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