Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[]`
Expand Down
4 changes: 3 additions & 1 deletion src/DockerUpdateGuard/Configuration/ScanningOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ public class ScanningOptions
/// <summary>
/// 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
/// </summary>
public int MajorUpgradeMinimumAgeDays { get; set; } = 30;

Expand Down
92 changes: 80 additions & 12 deletions src/DockerUpdateGuard/Images/UpdateDetectionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,28 @@ private static bool IsCandidatePublishedAfterBaseline(DateTimeOffset? candidateP
|| candidatePublishedAtUtc > baselinePublishedAtUtc;
}

/// <summary>
/// Get all known tags of a major version line that are comparable to the current tag
/// </summary>
/// <param name="orderedTags">Ordered available tags</param>
/// <param name="currentTag">Current concrete version tag</param>
/// <param name="major">Major version of the requested version line</param>
/// <returns>Tags of the requested major version line that carry a publication timestamp</returns>
private static List<(DockerHubTagData Tag, Version Version)> GetMajorLineTags(IReadOnlyList<DockerHubTagData> 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();
}

/// <summary>
/// Get the newest publication timestamp of the current major version line
/// </summary>
Expand All @@ -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);
}

/// <summary>
/// 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
/// </summary>
/// <param name="orderedTags">Ordered available tags</param>
/// <param name="currentTag">Current concrete version tag</param>
/// <param name="major">Major version of the requested version line</param>
/// <returns>Lowest known release of the version line, or null when no release is known</returns>
private static (DockerHubTagData Tag, Version Version)? GetMajorLineFirstRelease(IReadOnlyList<DockerHubTagData> 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();
}

/// <summary>
/// Determine whether a version is the initial release of its major version line
/// </summary>
/// <param name="version">Version to inspect</param>
/// <returns>True when the version starts a major version line</returns>
private static bool IsMajorLineInitialRelease(Version version)
{
return version.Minor == 0
&& version.Build <= 0;
}

/// <summary>
Expand Down Expand Up @@ -526,6 +581,9 @@ private static bool IsCandidatePublishedAfterBaseline(DateTimeOffset? candidateP

/// <summary>
/// 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
/// </summary>
/// <param name="nextMajorCandidates">Candidates of the next major version line</param>
/// <param name="currentTag">Current concrete version tag</param>
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}

/// <summary>
Expand Down
147 changes: 147 additions & 0 deletions src/Tests/DockerUpdateGuard.Tests/UpdateDetectionServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,153 @@ public void UpdateDetectionServiceSemverRecommendsNextMajorLineFirst()
"Higher major lines must remain selectable behind the recommended next major line");
}

/// <summary>
/// Verify a major upgrade is deferred while the current major line is still served next to the new line
/// </summary>
[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");
}

/// <summary>
/// Verify a major upgrade is deferred when the start of the new major line is outside the scanned tags
/// </summary>
[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");
}

/// <summary>
/// Verify a major upgrade is deferred when the tags carry no publication timestamps
/// </summary>
[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
Expand Down
Loading