From 4aa810b420f94a828e44336fd5365bcecfb0b94d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 18:18:06 +0000 Subject: [PATCH] Defer major upgrades while the current version line is still maintained The major upgrade policy read the start of the new major line from the candidate list, which is pre-filtered to tags published after the running tag, so a line that began long ago looked young enough to pass the waiting period. The first release is now taken as the lowest known version of that line, a line whose lowest known version is not its initial release counts as truncated, and the upgrade additionally waits until the current major line went dormant before that initial release. Missing publication timestamps no longer skip the age checks but defer the upgrade instead. --- README.md | 2 +- .../Configuration/ScanningOptions.cs | 4 +- .../Images/UpdateDetectionService.cs | 92 +++++++++-- .../UpdateDetectionServiceTests.cs | 147 ++++++++++++++++++ 4 files changed, 231 insertions(+), 14 deletions(-) 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