diff --git a/README.md b/README.md
index 290fe97..3d4da83 100644
--- a/README.md
+++ b/README.md
@@ -150,7 +150,7 @@ versions. Override them to route the lookups through an internal mirror or proxy
| `CleanupIntervalMinutes` | `720` | Interval for cleaning old scan data |
| `RetryCount` | `2` | Retry count for transient background failures |
| `RetainScanRunsDays` | `30` | Retention period for completed scan history |
-| `MajorUpgradeMinimumAgeDays` | `30` | Waiting period before a new major version line is recommended; `0` disables the waiting period |
+| `MajorUpgradeMinimumAgeDays` | `30` | Waiting period before a new major version line is recommended; the upgrade also stays deferred while the current major version line is still served next to the new line or while its first release is unknown; `0` disables the waiting periods |
| `MajorUpgradeMinimumReleaseCount` | `2` | Number of releases a new major version line must have before it is recommended; `0` disables the requirement |
### `DockerUpdateGuard:DockerInstances[]`
diff --git a/src/DockerUpdateGuard/Configuration/ScanningOptions.cs b/src/DockerUpdateGuard/Configuration/ScanningOptions.cs
index 6800ddb..b54456a 100644
--- a/src/DockerUpdateGuard/Configuration/ScanningOptions.cs
+++ b/src/DockerUpdateGuard/Configuration/ScanningOptions.cs
@@ -70,7 +70,9 @@ public class ScanningOptions
///
/// Number of days a new major version line must be established before a major upgrade is
/// recommended, and number of days the current major version line must have been without a
- /// release before the major upgrade takes over; a value of zero disables both waiting periods
+ /// release before the major upgrade takes over; the upgrade additionally stays deferred while
+ /// the current major version line is still served next to the new line and while the start of
+ /// the new line is unknown; a value of zero disables all of these waiting periods
///
public int MajorUpgradeMinimumAgeDays { get; set; } = 30;
diff --git a/src/DockerUpdateGuard/Images/UpdateDetectionService.cs b/src/DockerUpdateGuard/Images/UpdateDetectionService.cs
index d3723d4..e7b3109 100644
--- a/src/DockerUpdateGuard/Images/UpdateDetectionService.cs
+++ b/src/DockerUpdateGuard/Images/UpdateDetectionService.cs
@@ -410,6 +410,28 @@ private static bool IsCandidatePublishedAfterBaseline(DateTimeOffset? candidateP
|| candidatePublishedAtUtc > baselinePublishedAtUtc;
}
+ ///
+ /// Get all known tags of a major version line that are comparable to the current tag
+ ///
+ /// Ordered available tags
+ /// Current concrete version tag
+ /// Major version of the requested version line
+ /// Tags of the requested major version line that carry a publication timestamp
+ private static List<(DockerHubTagData Tag, Version Version)> GetMajorLineTags(IReadOnlyList orderedTags,
+ string currentTag,
+ int major)
+ {
+ var currentIsPreRelease = VersionTagResolutionHelper.IsPreReleaseVersionTag(currentTag);
+
+ return orderedTags.Where(tag => tag.PublishedAtUtc is not null
+ && (currentIsPreRelease
+ || VersionTagResolutionHelper.IsPreReleaseVersionTag(tag.Tag) == false)
+ && VersionTagResolutionHelper.TryCompareVersionTags(tag.Tag, currentTag, out _)
+ && ParseVersion(tag.Tag).Major == major)
+ .Select(tag => (Tag: tag, Version: ParseVersion(tag.Tag)))
+ .ToList();
+ }
+
///
/// Get the newest publication timestamp of the current major version line
///
@@ -421,14 +443,47 @@ private static bool IsCandidatePublishedAfterBaseline(DateTimeOffset? candidateP
string currentTag,
Version currentVersion)
{
- var currentIsPreRelease = VersionTagResolutionHelper.IsPreReleaseVersionTag(currentTag);
+ var currentLineTags = GetMajorLineTags(orderedTags,
+ currentTag,
+ currentVersion.Major);
- return orderedTags.Where(tag => tag.PublishedAtUtc is not null
- && (currentIsPreRelease
- || VersionTagResolutionHelper.IsPreReleaseVersionTag(tag.Tag) == false)
- && VersionTagResolutionHelper.TryCompareVersionTags(tag.Tag, currentTag, out _)
- && ParseVersion(tag.Tag).Major == currentVersion.Major)
- .Max(tag => tag.PublishedAtUtc);
+ return currentLineTags.Max(entity => entity.Tag.PublishedAtUtc);
+ }
+
+ ///
+ /// Get the oldest known release of a major version line
+ /// The lowest version of the line is used instead of the oldest publication timestamp so that
+ /// a truncated tag scan can be detected reliably
+ ///
+ /// Ordered available tags
+ /// Current concrete version tag
+ /// Major version of the requested version line
+ /// Lowest known release of the version line, or null when no release is known
+ private static (DockerHubTagData Tag, Version Version)? GetMajorLineFirstRelease(IReadOnlyList orderedTags,
+ string currentTag,
+ int major)
+ {
+ var lineTags = GetMajorLineTags(orderedTags, currentTag, major);
+
+ if (lineTags.Count == 0)
+ {
+ return null;
+ }
+
+ return lineTags.OrderBy(entity => entity.Version)
+ .ThenBy(entity => entity.Tag.PublishedAtUtc)
+ .First();
+ }
+
+ ///
+ /// Determine whether a version is the initial release of its major version line
+ ///
+ /// Version to inspect
+ /// True when the version starts a major version line
+ private static bool IsMajorLineInitialRelease(Version version)
+ {
+ return version.Minor == 0
+ && version.Build <= 0;
}
///
@@ -526,6 +581,9 @@ private static bool IsCandidatePublishedAfterBaseline(DateTimeOffset? candidateP
///
/// Determine whether the next major version line may already be recommended
+ /// The line must have published enough releases, its initial release must be known and old
+ /// enough, and the current major version line must have gone dormant before that initial
+ /// release; an unknown or truncated release history defers the upgrade
///
/// Candidates of the next major version line
/// Current concrete version tag
@@ -554,10 +612,19 @@ private bool IsMajorUpgradeRecommendable(List<(DockerHubTagData Tag, Version Ver
}
var establishedBeforeUtc = DateTimeOffset.UtcNow.AddDays(-scanningOptions.MajorUpgradeMinimumAgeDays);
- var firstMajorReleaseAtUtc = nextMajorCandidates.Where(entity => entity.Tag.PublishedAtUtc is not null)
- .Min(entity => entity.Tag.PublishedAtUtc);
+ var firstMajorRelease = GetMajorLineFirstRelease(orderedTags,
+ currentTag,
+ nextMajorCandidates[0].Version.Major);
+
+ if (firstMajorRelease is null
+ || IsMajorLineInitialRelease(firstMajorRelease.Value.Version) == false)
+ {
+ return false;
+ }
+
+ var firstMajorReleaseAtUtc = firstMajorRelease.Value.Tag.PublishedAtUtc;
- if (firstMajorReleaseAtUtc is not null && firstMajorReleaseAtUtc > establishedBeforeUtc)
+ if (firstMajorReleaseAtUtc is null || firstMajorReleaseAtUtc > establishedBeforeUtc)
{
return false;
}
@@ -566,8 +633,9 @@ private bool IsMajorUpgradeRecommendable(List<(DockerHubTagData Tag, Version Ver
currentTag,
currentVersion);
- return currentMajorLinePublishedAtUtc is null
- || currentMajorLinePublishedAtUtc <= establishedBeforeUtc;
+ return currentMajorLinePublishedAtUtc is not null
+ && currentMajorLinePublishedAtUtc <= establishedBeforeUtc
+ && currentMajorLinePublishedAtUtc <= firstMajorReleaseAtUtc;
}
///
diff --git a/src/Tests/DockerUpdateGuard.Tests/UpdateDetectionServiceTests.cs b/src/Tests/DockerUpdateGuard.Tests/UpdateDetectionServiceTests.cs
index c846684..f03fa7f 100644
--- a/src/Tests/DockerUpdateGuard.Tests/UpdateDetectionServiceTests.cs
+++ b/src/Tests/DockerUpdateGuard.Tests/UpdateDetectionServiceTests.cs
@@ -991,6 +991,153 @@ public void UpdateDetectionServiceSemverRecommendsNextMajorLineFirst()
"Higher major lines must remain selectable behind the recommended next major line");
}
+ ///
+ /// Verify a major upgrade is deferred while the current major line is still served next to the new line
+ ///
+ [TestMethod]
+ public void UpdateDetectionServiceSemverDefersMajorUpgradeForParallelMaintainedLine()
+ {
+ var service = CreateService();
+
+ var evaluation = service.Evaluate(new ImageReference
+ {
+ Registry = "docker.io",
+ Repository = "grafana/mimir",
+ Tag = "2.17.11",
+ Digest = "sha256:21711",
+ },
+ [
+ new DockerHubTagData
+ {
+ Tag = "2.17.10",
+ Digest = "sha256:21710",
+ PublishedAtUtc = CreateRelativeTimestamp(-98),
+ },
+ new DockerHubTagData
+ {
+ Tag = "2.17.11",
+ Digest = "sha256:21711",
+ PublishedAtUtc = CreateRelativeTimestamp(-74),
+ },
+ new DockerHubTagData
+ {
+ Tag = "3.0.0",
+ Digest = "sha256:300",
+ PublishedAtUtc = CreateRelativeTimestamp(-270),
+ },
+ new DockerHubTagData
+ {
+ Tag = "3.1.0",
+ Digest = "sha256:310",
+ PublishedAtUtc = CreateRelativeTimestamp(-56),
+ },
+ new DockerHubTagData
+ {
+ Tag = "3.1.4",
+ Digest = "sha256:314",
+ PublishedAtUtc = CreateRelativeTimestamp(-6),
+ },
+ ]);
+
+ Assert.AreEqual(UpdateEvaluationStatus.UpToDate,
+ evaluation.Status,
+ "A major line that is served next to the current major line must not be recommended");
+ Assert.AreEqual("Tag '3.1.4' introduces a new major version and is not recommended yet",
+ evaluation.Details,
+ "The details must name the major version that is held back");
+ }
+
+ ///
+ /// Verify a major upgrade is deferred when the start of the new major line is outside the scanned tags
+ ///
+ [TestMethod]
+ public void UpdateDetectionServiceSemverDefersMajorUpgradeWithTruncatedMajorLineHistory()
+ {
+ var service = CreateService();
+
+ var evaluation = service.Evaluate(new ImageReference
+ {
+ Registry = "docker.io",
+ Repository = "grafana/mimir",
+ Tag = "2.17.11",
+ Digest = "sha256:21711",
+ },
+ [
+ new DockerHubTagData
+ {
+ Tag = "2.17.11",
+ Digest = "sha256:21711",
+ PublishedAtUtc = CreateRelativeTimestamp(-74),
+ },
+ new DockerHubTagData
+ {
+ Tag = "3.0.7",
+ Digest = "sha256:307",
+ PublishedAtUtc = CreateRelativeTimestamp(-41),
+ },
+ new DockerHubTagData
+ {
+ Tag = "3.1.0",
+ Digest = "sha256:310",
+ PublishedAtUtc = CreateRelativeTimestamp(-56),
+ },
+ new DockerHubTagData
+ {
+ Tag = "3.1.4",
+ Digest = "sha256:314",
+ PublishedAtUtc = CreateRelativeTimestamp(-6),
+ },
+ ]);
+
+ Assert.AreEqual(UpdateEvaluationStatus.UpToDate,
+ evaluation.Status,
+ "A major line whose start is unknown must not be recommended");
+ Assert.AreEqual("Tag '3.1.4' introduces a new major version and is not recommended yet",
+ evaluation.Details,
+ "The details must name the major version that is held back");
+ }
+
+ ///
+ /// Verify a major upgrade is deferred when the tags carry no publication timestamps
+ ///
+ [TestMethod]
+ public void UpdateDetectionServiceSemverDefersMajorUpgradeWithoutPublicationTimestamps()
+ {
+ var service = CreateService();
+
+ var evaluation = service.Evaluate(new ImageReference
+ {
+ Registry = "ghcr.io",
+ Repository = "grafana/mimir",
+ Tag = "2.17.11",
+ Digest = "sha256:21711",
+ },
+ [
+ new DockerHubTagData
+ {
+ Tag = "2.17.11",
+ Digest = "sha256:21711",
+ },
+ new DockerHubTagData
+ {
+ Tag = "3.0.0",
+ Digest = "sha256:300",
+ },
+ new DockerHubTagData
+ {
+ Tag = "3.1.4",
+ Digest = "sha256:314",
+ },
+ ]);
+
+ Assert.AreEqual(UpdateEvaluationStatus.UpToDate,
+ evaluation.Status,
+ "A major upgrade must not be recommended when the release dates are unknown");
+ Assert.AreEqual("Tag '3.1.4' introduces a new major version and is not recommended yet",
+ evaluation.Details,
+ "The details must name the major version that is held back");
+ }
+
#endregion // Methods
#region Static methods