From 412ac167db8e297dd760e7ad8b0f03a424e20715 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Fri, 27 Mar 2026 15:53:23 -0700 Subject: [PATCH 1/7] Extend IOrasService with annotations and artifact attachment - Add Annotations property to ReferrerInfo record - Populate referrer annotations from OrasDotNet descriptors in GetReferrersAsync - Add AttachArtifactAsync method to IOrasService for creating referrer artifacts - Implement AttachArtifactAsync in OrasDotNetService using Packer.PackManifestAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ImageBuilder/Oras/IOrasService.cs | 14 +++++++ src/ImageBuilder/Oras/OrasDotNetService.cs | 43 +++++++++++++++++++++- src/ImageBuilder/Oras/ReferrerInfo.cs | 10 ++++- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/ImageBuilder/Oras/IOrasService.cs b/src/ImageBuilder/Oras/IOrasService.cs index cdefb1b75..c19e58443 100644 --- a/src/ImageBuilder/Oras/IOrasService.cs +++ b/src/ImageBuilder/Oras/IOrasService.cs @@ -47,4 +47,18 @@ Task PushSignatureAsync( Task> GetReferrersAsync( string reference, CancellationToken cancellationToken = default); + + /// + /// Creates and pushes a referrer artifact with the given type and annotations. + /// + /// Full registry reference of the subject image (e.g., "registry.io/repo@sha256:..."). + /// The OCI artifact type for the referrer. + /// Annotations to set on the referrer manifest. + /// Cancellation token. + /// The digest of the created referrer artifact. + Task AttachArtifactAsync( + string reference, + string artifactType, + IDictionary annotations, + CancellationToken cancellationToken = default); } diff --git a/src/ImageBuilder/Oras/OrasDotNetService.cs b/src/ImageBuilder/Oras/OrasDotNetService.cs index 53ed45bce..97fe1e619 100644 --- a/src/ImageBuilder/Oras/OrasDotNetService.cs +++ b/src/ImageBuilder/Oras/OrasDotNetService.cs @@ -132,7 +132,11 @@ public async Task> GetReferrersAsync( await foreach (Descriptor referrer in repository.FetchReferrersAsync(subjectDescriptor, cancellationToken)) { string referrerReference = $"{parsedRef.Registry}/{parsedRef.Repository}@{referrer.Digest}"; - referrers.Add(new ReferrerInfo(referrerReference, referrer.ArtifactType)); + referrers.Add(new ReferrerInfo(referrerReference, referrer.ArtifactType) + { + Annotations = referrer.Annotations as IReadOnlyDictionary + ?? referrer.Annotations?.AsReadOnly() + }); _logger.LogDebug("Found referrer: {Referrer} (artifactType={ArtifactType})", referrerReference, referrer.ArtifactType); } @@ -142,6 +146,43 @@ public async Task> GetReferrersAsync( return referrers; } + /// + public async Task AttachArtifactAsync( + string reference, + string artifactType, + IDictionary annotations, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(reference); + ArgumentException.ThrowIfNullOrWhiteSpace(artifactType); + ArgumentNullException.ThrowIfNull(annotations); + + _logger.LogDebug("Attaching artifact (type={ArtifactType}) to {Reference}", artifactType, reference); + + long startTime = Stopwatch.GetTimestamp(); + Repository repository = CreateRepository(reference); + Descriptor subjectDescriptor = await repository.ResolveAsync(reference, cancellationToken); + + PackManifestOptions options = new() + { + ManifestAnnotations = annotations, + Subject = subjectDescriptor + }; + + Descriptor artifactDescriptor = + await Packer.PackManifestAsync( + pusher: repository, + version: Packer.ManifestVersion.Version1_1, + artifactType: artifactType, + options: options, + cancellationToken); + + TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime); + _logger.LogDebug("Artifact attached: {Digest} in {Elapsed}", artifactDescriptor.Digest, elapsed); + + return artifactDescriptor.Digest; + } + /// /// Creates an authenticated ORAS repository client for the given reference. /// diff --git a/src/ImageBuilder/Oras/ReferrerInfo.cs b/src/ImageBuilder/Oras/ReferrerInfo.cs index 2b121e18b..4b24dbf39 100644 --- a/src/ImageBuilder/Oras/ReferrerInfo.cs +++ b/src/ImageBuilder/Oras/ReferrerInfo.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Generic; + namespace Microsoft.DotNet.ImageBuilder.Oras; /// @@ -9,4 +11,10 @@ namespace Microsoft.DotNet.ImageBuilder.Oras; /// /// Fully-qualified digest reference (e.g., "registry.io/repo@sha256:abc..."). /// The OCI artifact type (e.g., "application/vnd.cncf.notary.signature"), or null if not set. -public record ReferrerInfo(string Digest, string? ArtifactType); +public record ReferrerInfo(string Digest, string? ArtifactType) +{ + /// + /// Annotations from the referrer manifest, or null if not present. + /// + public IReadOnlyDictionary? Annotations { get; init; } +} From de93c84e4ad6d6d1361a366647a63bdea84c1b86 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Fri, 27 Mar 2026 15:56:28 -0700 Subject: [PATCH 2/7] Rewrite LifecycleMetadataService to use OrasDotNet library - Make ILifecycleMetadataService async with Task returns - Remove isDryRun and ILogger parameters (callers handle dry-run, logger injected via ctor) - Rewrite LifecycleMetadataService to use IOrasService instead of IOrasClient - Update AnnotateEolDigestsCommand for async (Parallel.ForEachAsync) - Update GenerateEolAnnotationDataCommandBase for async - Update CleanAcrImagesCommand.HasExpiredEol to async Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/AnnotateEolDigestsCommand.cs | 14 ++- .../Commands/CleanAcrImagesCommand.cs | 12 +- .../GenerateEolAnnotationDataCommandBase.cs | 15 ++- src/ImageBuilder/ILifecycleMetadataService.cs | 20 ++- src/ImageBuilder/LifecycleMetadataService.cs | 119 ++++++++---------- 5 files changed, 96 insertions(+), 84 deletions(-) diff --git a/src/ImageBuilder/Commands/AnnotateEolDigestsCommand.cs b/src/ImageBuilder/Commands/AnnotateEolDigestsCommand.cs index b0669624a..b61fdab53 100644 --- a/src/ImageBuilder/Commands/AnnotateEolDigestsCommand.cs +++ b/src/ImageBuilder/Commands/AnnotateEolDigestsCommand.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.DotNet.ImageBuilder.Models.Annotations; using Microsoft.DotNet.ImageBuilder.Models.MarBulkDeletion; @@ -51,9 +52,10 @@ public override async Task ExecuteAsync() await _registryCredentialsProvider.ExecuteWithCredentialsAsync( Options.IsDryRun, - () => + async () => { - Parallel.ForEach(eolAnnotations.EolDigests, digestData => AnnotateDigest(digestData, globalEolDate)); + await Parallel.ForEachAsync(eolAnnotations.EolDigests, CancellationToken.None, + async (digestData, ct) => await AnnotateDigestAsync(digestData, globalEolDate, ct)); }, Options.CredentialsOptions, registryName: Options.AcrName); @@ -103,7 +105,7 @@ private void WriteNonEmptySummary(object value, string message) _logger.LogInformation(string.Empty); } - private void AnnotateDigest(EolDigestData digestData, DateOnly? globalEolDate) + private async Task AnnotateDigestAsync(EolDigestData digestData, DateOnly? globalEolDate, CancellationToken cancellationToken) { if (Options.IsDryRun) { @@ -119,10 +121,12 @@ private void AnnotateDigest(EolDigestData digestData, DateOnly? globalEolDate) return; } - if (!_lifecycleMetadataService.IsDigestAnnotatedForEol(digestData.Digest, _logger, Options.IsDryRun, out Manifest? existingAnnotationManifest)) + Manifest? existingAnnotationManifest = await _lifecycleMetadataService.IsDigestAnnotatedForEolAsync(digestData.Digest, cancellationToken); + if (existingAnnotationManifest is null) { _logger.LogInformation($"Annotating EOL for digest '{digestData.Digest}', date '{eolDate}'"); - if (_lifecycleMetadataService.AnnotateEolDigest(digestData.Digest, eolDate.Value, _logger, Options.IsDryRun, out Manifest? createdAnnotationManifest)) + Manifest? createdAnnotationManifest = await _lifecycleMetadataService.AnnotateEolDigestAsync(digestData.Digest, eolDate.Value, cancellationToken); + if (createdAnnotationManifest is not null) { _createdAnnotationDigests.Add(createdAnnotationManifest.Reference); } diff --git a/src/ImageBuilder/Commands/CleanAcrImagesCommand.cs b/src/ImageBuilder/Commands/CleanAcrImagesCommand.cs index 7f57845fe..c2cf350ef 100644 --- a/src/ImageBuilder/Commands/CleanAcrImagesCommand.cs +++ b/src/ImageBuilder/Commands/CleanAcrImagesCommand.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using Azure.Containers.ContainerRegistry; using Microsoft.DotNet.ImageBuilder.Configuration; @@ -94,7 +95,7 @@ await ProcessManifestsAsync(acrClient, acrContentClient, deletedImages, deletedR break; case CleanAcrImagesAction.PruneEol: await ProcessManifestsAsync(acrClient, acrContentClient, deletedImages, deletedRepos, repository, - async manifest => !(await IsAnnotationManifestAsync(manifest, acrContentClient)) && HasExpiredEol(manifest, Options.Age)); + async manifest => !(await IsAnnotationManifestAsync(manifest, acrContentClient)) && await HasExpiredEolAsync(manifest, Options.Age)); break; case CleanAcrImagesAction.PruneAll: await ProcessManifestsAsync(acrClient, acrContentClient, deletedImages, deletedRepos, repository, @@ -274,10 +275,13 @@ private async Task IsAnnotationManifestAsync(ArtifactManifestProperties ma return manifestResult.Manifest["subject"] is not null; } - private bool HasExpiredEol(ArtifactManifestProperties manifest, int expirationDays) + private async Task HasExpiredEolAsync(ArtifactManifestProperties manifest, int expirationDays) { - if(_lifecycleMetadataService.IsDigestAnnotatedForEol(manifest.RegistryLoginServer + "/" + manifest.RepositoryName + "@" + manifest.Digest, _logger, isDryRun: false, out Manifest? lifecycleArtifactManifest) && - lifecycleArtifactManifest?.Annotations != null) + Manifest? lifecycleArtifactManifest = await _lifecycleMetadataService.IsDigestAnnotatedForEolAsync( + $"{manifest.RegistryLoginServer}/{manifest.RepositoryName}@{manifest.Digest}", + CancellationToken.None); + + if (lifecycleArtifactManifest?.Annotations != null) { return IsExpired(DateTimeOffset.Parse(lifecycleArtifactManifest.Annotations[LifecycleMetadataService.EndOfLifeAnnotation]), expirationDays); } diff --git a/src/ImageBuilder/Commands/GenerateEolAnnotationDataCommandBase.cs b/src/ImageBuilder/Commands/GenerateEolAnnotationDataCommandBase.cs index 9c2f1494e..0bb51244e 100644 --- a/src/ImageBuilder/Commands/GenerateEolAnnotationDataCommandBase.cs +++ b/src/ImageBuilder/Commands/GenerateEolAnnotationDataCommandBase.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Azure.Containers.ContainerRegistry; using Microsoft.DotNet.ImageBuilder.Configuration; @@ -46,7 +47,7 @@ public sealed override async Task ExecuteAsync() IEnumerable digestsToAnnotate = []; await _registryCredentialsProvider.ExecuteWithCredentialsAsync( Options.IsDryRun, - async () => digestsToAnnotate = GetDigestsWithoutExistingAnnotation(await GetDigestsToAnnotateAsync()), + async () => digestsToAnnotate = await GetDigestsWithoutExistingAnnotationAsync(await GetDigestsToAnnotateAsync()), Options.CredentialsOptions, registryName: Options.RegistryOptions.Registry); @@ -115,15 +116,19 @@ protected void WriteDigestDataJson(IEnumerable digestsToAnnotate) File.WriteAllText(Options.EolDigestsListPath, annotationsJson); } - private IEnumerable GetDigestsWithoutExistingAnnotation( + private async Task> GetDigestsWithoutExistingAnnotationAsync( IEnumerable unsupportedDigests) { - // Annotate digests that are not already annotated for EOL + if (Options.IsDryRun) + { + return unsupportedDigests.OrderBy(item => item.Digest).ToList(); + } + ConcurrentBag digestsToAnnotate = []; - Parallel.ForEach(unsupportedDigests, digest => + await Parallel.ForEachAsync(unsupportedDigests, CancellationToken.None, async (digest, ct) => { _logger.LogInformation($"Checking digest for existing annotation: {digest.Digest}"); - if (!_lifecycleMetadataService.IsDigestAnnotatedForEol(digest.Digest, _logger, Options.IsDryRun, out _)) + if (await _lifecycleMetadataService.IsDigestAnnotatedForEolAsync(digest.Digest, ct) is null) { digestsToAnnotate.Add(digest); } diff --git a/src/ImageBuilder/ILifecycleMetadataService.cs b/src/ImageBuilder/ILifecycleMetadataService.cs index 797ad0e25..573791b1d 100644 --- a/src/ImageBuilder/ILifecycleMetadataService.cs +++ b/src/ImageBuilder/ILifecycleMetadataService.cs @@ -3,14 +3,28 @@ // See the LICENSE file in the project root for more information. using System; -using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; using Microsoft.DotNet.ImageBuilder.Models.Oci; namespace Microsoft.DotNet.ImageBuilder; public interface ILifecycleMetadataService { - bool IsDigestAnnotatedForEol(string digest, ILogger logger, bool isDryRun, [MaybeNullWhen(false)] out Manifest lifecycleArtifactManifest); + /// + /// Checks whether the given digest has an existing lifecycle (EOL) annotation. + /// + /// Fully-qualified digest reference (e.g., "registry.io/repo@sha256:..."). + /// Cancellation token. + /// The lifecycle artifact manifest if annotated, or null if not. + Task IsDigestAnnotatedForEolAsync(string digest, CancellationToken cancellationToken = default); - bool AnnotateEolDigest(string digest, DateOnly date, ILogger logger, bool isDryRun, [MaybeNullWhen(false)] out Manifest lifecycleArtifactManifest); + /// + /// Annotates the given digest with an end-of-life date. + /// + /// Fully-qualified digest reference (e.g., "registry.io/repo@sha256:..."). + /// The end-of-life date to set. + /// Cancellation token. + /// The created lifecycle artifact manifest, or null on failure. + Task AnnotateEolDigestAsync(string digest, DateOnly date, CancellationToken cancellationToken = default); } diff --git a/src/ImageBuilder/LifecycleMetadataService.cs b/src/ImageBuilder/LifecycleMetadataService.cs index 04b6adf92..ac690d09d 100644 --- a/src/ImageBuilder/LifecycleMetadataService.cs +++ b/src/ImageBuilder/LifecycleMetadataService.cs @@ -4,105 +4,90 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Microsoft.DotNet.ImageBuilder.Models.Oci; -using Microsoft.DotNet.ImageBuilder.Models.Oras; using Microsoft.DotNet.ImageBuilder.Oras; -using Newtonsoft.Json; +using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.ImageBuilder; + public class LifecycleMetadataService : ILifecycleMetadataService { public const string EndOfLifeAnnotation = "vnd.microsoft.artifact.lifecycle.end-of-life.date"; public const string EolDateFormat = "yyyy-MM-dd"; - private readonly IOrasClient _orasClient; + private readonly IOrasService _orasService; + private readonly ILogger _logger; - public LifecycleMetadataService(IOrasClient orasClient) + public LifecycleMetadataService(IOrasService orasService, ILogger logger) { - _orasClient = orasClient; - } - - public bool IsDigestAnnotatedForEol(string digest, ILogger logger, bool isDryRun, [MaybeNullWhen(false)] out Manifest lifecycleArtifactManifest) - { - string stdOut = _orasClient.RunOrasCommand( - args: [ - "discover", - $"--artifact-type {OciArtifactType.Lifecycle}", - $"--format json", - digest - ], - isDryRun: isDryRun); - - if (LifecycleAnnotationExists(stdOut, logger, out lifecycleArtifactManifest)) - { - return true; - } - - lifecycleArtifactManifest = null; - return false; + _orasService = orasService; + _logger = logger; } - public bool AnnotateEolDigest(string digest, DateOnly date, ILogger logger, bool isDryRun, [MaybeNullWhen(false)] out Manifest lifecycleArtifactManifest) + public async Task IsDigestAnnotatedForEolAsync(string digest, CancellationToken cancellationToken = default) { try { - string output = _orasClient.RunOrasCommand( - args: [ - "attach", - $"--artifact-type {OciArtifactType.Lifecycle}", - $"--annotation \"{EndOfLifeAnnotation}={date.ToString(EolDateFormat)}\"", - $"--format json", - digest - ], - isDryRun: isDryRun); + IReadOnlyList referrers = await _orasService.GetReferrersAsync(digest, cancellationToken); - if (isDryRun) + ReferrerInfo? lifecycleReferrer = referrers.FirstOrDefault( + r => r.ArtifactType == OciArtifactType.Lifecycle); + + if (lifecycleReferrer is null) { - lifecycleArtifactManifest = null; - return false; + return null; } - lifecycleArtifactManifest = JsonConvert.DeserializeObject(output ?? string.Empty) - ?? throw new Exception( - $""" - Unable to deserialize lifecycle metadata manifest from 'oras' output: - - {output} - - """ - ); + return new Manifest + { + ArtifactType = lifecycleReferrer.ArtifactType ?? string.Empty, + Reference = lifecycleReferrer.Digest, + Annotations = lifecycleReferrer.Annotations is not null + ? new Dictionary(lifecycleReferrer.Annotations) + : [] + }; } - catch (InvalidOperationException ex) + catch (Exception ex) { - logger.LogError(ex, "Failed to annotate EOL for digest '{Digest}'", digest); - lifecycleArtifactManifest = null; - return false; + _logger.LogError(ex, "Failed to check EOL annotation for digest '{Digest}'", digest); + return null; } - - return true; } - private static bool LifecycleAnnotationExists(string json, ILogger logger, [MaybeNullWhen(false)] out Manifest lifecycleArtifactManifest) + public async Task AnnotateEolDigestAsync(string digest, DateOnly date, CancellationToken cancellationToken = default) { try { - OrasDiscoverData? orasDiscoverData = JsonConvert.DeserializeObject(json); - List? manifests = orasDiscoverData?.Manifests ?? orasDiscoverData?.Referrers; - if (manifests != null) + Dictionary annotations = new() { - lifecycleArtifactManifest = manifests.FirstOrDefault(m => m.ArtifactType == OciArtifactType.Lifecycle); - return lifecycleArtifactManifest is not null; - } + [EndOfLifeAnnotation] = date.ToString(EolDateFormat) + }; + + string artifactDigest = await _orasService.AttachArtifactAsync( + digest, + OciArtifactType.Lifecycle, + annotations, + cancellationToken); + + // Construct the fully-qualified reference from the subject reference and the artifact digest. + string registry = digest[..digest.IndexOf('/')]; + string repository = digest[(digest.IndexOf('/') + 1)..digest.IndexOf('@')]; + string artifactReference = $"{registry}/{repository}@{artifactDigest}"; + + return new Manifest + { + ArtifactType = OciArtifactType.Lifecycle, + Reference = artifactReference, + Annotations = annotations + }; } - catch (JsonException ex) + catch (Exception ex) { - logger.LogError(ex, "Failed to deserialize 'oras discover' json"); + _logger.LogError(ex, "Failed to annotate EOL for digest '{Digest}'", digest); + return null; } - - lifecycleArtifactManifest = null; - return false; } - } From c3a72ce8c526e6a65cc5ef6df06ffda24de2f4f5 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Fri, 27 Mar 2026 15:59:55 -0700 Subject: [PATCH 3/7] Update tests for async ILifecycleMetadataService - Convert mock setups from out-param pattern to ReturnsAsync pattern - Update method names: IsDigestAnnotatedForEol -> IsDigestAnnotatedForEolAsync - Update method names: AnnotateEolDigest -> AnnotateEolDigestAsync - Remove ILogger from mock parameters - All 451 tests passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AnnotateEolDigestsCommandTests.cs | 25 ++++++++----------- .../CleanAcrImagesCommandTest.cs | 4 +-- ...EolAnnotationDataForPublishCommandTests.cs | 21 +++++++++------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/ImageBuilder.Tests/AnnotateEolDigestsCommandTests.cs b/src/ImageBuilder.Tests/AnnotateEolDigestsCommandTests.cs index d842a6658..44fa7d695 100644 --- a/src/ImageBuilder.Tests/AnnotateEolDigestsCommandTests.cs +++ b/src/ImageBuilder.Tests/AnnotateEolDigestsCommandTests.cs @@ -11,7 +11,7 @@ using Microsoft.DotNet.ImageBuilder.Models.Annotations; using Microsoft.DotNet.ImageBuilder.Models.Oci; using Microsoft.DotNet.ImageBuilder.Tests.Helpers; -using Microsoft.Extensions.Logging; +using System.Threading; using Moq; using Newtonsoft.Json; using Xunit.Abstractions; @@ -50,11 +50,10 @@ public async Task AnnotateEolDigestsCommand_AnnotationSuccess() digestAnnotationIsSuccessful: true); await command.ExecuteAsync(); - Manifest manifest; lifecycleMetadataServiceMock.Verify( - o => o.AnnotateEolDigest("digest1", _globalDate, It.IsAny(), It.IsAny(), out manifest)); + o => o.AnnotateEolDigestAsync("digest1", _globalDate, It.IsAny())); lifecycleMetadataServiceMock.Verify( - o => o.AnnotateEolDigest("digest2", _specificDigestDate, It.IsAny(), It.IsAny(), out manifest)); + o => o.AnnotateEolDigestAsync("digest2", _specificDigestDate, It.IsAny())); string[] expectedAnnotationDigests = [ @@ -105,9 +104,8 @@ public async Task AnnotateEolDigestsCommand_CheckAnnotations_AlreadyAnnotated_No $"(failed: 0, skipped: 2)", ex.Message); - Manifest manifest; lifecycleMetadataServiceMock.Verify( - o => o.AnnotateEolDigest(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), out manifest), + o => o.AnnotateEolDigestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); } @@ -127,9 +125,8 @@ public async Task AnnotateEolDigestsCommand_CheckAnnotations_AlreadyAnnotated_Ma await command.ExecuteAsync(); - Manifest manifest; lifecycleMetadataServiceMock.Verify( - o => o.AnnotateEolDigest(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), out manifest), + o => o.AnnotateEolDigestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); } @@ -178,8 +175,8 @@ private Mock CreateLifecycleMetadataServiceMock(bool }; lifecycleMetadataServiceMock - .Setup(o => o.AnnotateEolDigest(It.Is(digest => digest.Contains("digest1")), It.IsAny(), It.IsAny(), It.IsAny(), out digest1Annotation)) - .Returns(digestAnnotationIsSuccessful); + .Setup(o => o.AnnotateEolDigestAsync(It.Is(digest => digest.Contains("digest1")), It.IsAny(), It.IsAny())) + .ReturnsAsync(digestAnnotationIsSuccessful ? digest1Annotation : null); Manifest digest2Annotation = new() { @@ -187,8 +184,8 @@ private Mock CreateLifecycleMetadataServiceMock(bool }; lifecycleMetadataServiceMock - .Setup(o => o.AnnotateEolDigest(It.Is(digest => digest.Contains("digest2")), It.IsAny(), It.IsAny(), It.IsAny(), out digest2Annotation)) - .Returns(digestAnnotationIsSuccessful); + .Setup(o => o.AnnotateEolDigestAsync(It.Is(digest => digest.Contains("digest2")), It.IsAny(), It.IsAny())) + .ReturnsAsync(digestAnnotationIsSuccessful ? digest2Annotation : null); return lifecycleMetadataServiceMock; } @@ -214,8 +211,8 @@ private static void SetupIsDigestAnnotatedForEolMethod(Mock o.IsDigestAnnotatedForEol(digest, It.IsAny(), It.IsAny(), out manifest)) - .Returns(digestAlreadyAnnotated); + .Setup(o => o.IsDigestAnnotatedForEolAsync(digest, It.IsAny())) + .ReturnsAsync(manifest); } } } diff --git a/src/ImageBuilder.Tests/CleanAcrImagesCommandTest.cs b/src/ImageBuilder.Tests/CleanAcrImagesCommandTest.cs index 47b413590..dcf93ff11 100644 --- a/src/ImageBuilder.Tests/CleanAcrImagesCommandTest.cs +++ b/src/ImageBuilder.Tests/CleanAcrImagesCommandTest.cs @@ -414,8 +414,8 @@ private static void SetupIsDigestAnnotatedForEolMethod(Mock o.IsDigestAnnotatedForEol(reference, It.IsAny(), It.IsAny(), out manifest)) - .Returns(digestAlreadyAnnotated); + .Setup(o => o.IsDigestAnnotatedForEolAsync(reference, It.IsAny())) + .ReturnsAsync(manifest); } } } diff --git a/src/ImageBuilder.Tests/GenerateEolAnnotationDataForPublishCommandTests.cs b/src/ImageBuilder.Tests/GenerateEolAnnotationDataForPublishCommandTests.cs index 106aa7b38..a08c2ffa0 100644 --- a/src/ImageBuilder.Tests/GenerateEolAnnotationDataForPublishCommandTests.cs +++ b/src/ImageBuilder.Tests/GenerateEolAnnotationDataForPublishCommandTests.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.IO; using System.Text.Json.Nodes; +using System.Threading; using System.Threading.Tasks; using Microsoft.DotNet.ImageBuilder.Commands; using Microsoft.DotNet.ImageBuilder.Models.Annotations; @@ -430,11 +431,11 @@ public async Task GenerateEolAnnotationData_ExcludeDigestsThatAreAlreadyAnnotate string armDigest = DockerHelper.GetImageName(AcrName, $"{DefaultRepoPrefix}repo1", digest: "platformdigest102-arm64"); // Set the Arm64 digest as already annotated. This should exclude it from the list of digests to annotate. - Manifest lifecycleArtifactManifest; + Manifest lifecycleArtifactManifest = new(); Mock lifecycleMetadataServiceMock = new(); lifecycleMetadataServiceMock - .Setup(o => o.IsDigestAnnotatedForEol(armDigest, It.IsAny(), It.IsAny(), out lifecycleArtifactManifest)) - .Returns(true); + .Setup(o => o.IsDigestAnnotatedForEolAsync(armDigest, It.IsAny())) + .ReturnsAsync(lifecycleArtifactManifest); IAcrContentClientFactory registryContentClientFactory = CreateAcrContentClientFactory(AcrName, [ @@ -1058,16 +1059,18 @@ private static GenerateEolAnnotationDataForPublishCommand InitializeCommand( private static ILifecycleMetadataService CreateLifecycleMetadataService(Dictionary digestAnnotatedMapping) { Mock lifecycleMetadataServiceMock = new(); - Manifest lifecycleArtifactManifest; lifecycleMetadataServiceMock - .Setup(o => o.IsDigestAnnotatedForEol(It.IsAny(), It.IsAny(), It.IsAny(), out lifecycleArtifactManifest)) - .Returns(false); + .Setup(o => o.IsDigestAnnotatedForEolAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Manifest)null); foreach (KeyValuePair digestAnnotated in digestAnnotatedMapping) { - lifecycleMetadataServiceMock - .Setup(o => o.IsDigestAnnotatedForEol(digestAnnotated.Key, It.IsAny(), It.IsAny(), out lifecycleArtifactManifest)) - .Returns(digestAnnotated.Value); + if (digestAnnotated.Value) + { + lifecycleMetadataServiceMock + .Setup(o => o.IsDigestAnnotatedForEolAsync(digestAnnotated.Key, It.IsAny())) + .ReturnsAsync(new Manifest()); + } } return lifecycleMetadataServiceMock.Object; From d45d55980a76181e4f0d794d6a109ffd3aab5222 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Fri, 27 Mar 2026 16:00:53 -0700 Subject: [PATCH 4/7] Remove legacy ORAS CLI wrapper code - Delete OrasClient.cs (IOrasClient interface and OrasClient class) - Delete Models/Oras/OrasDiscoverData.cs (CLI JSON output model) - Remove IOrasClient DI registration from ImageBuilder.cs - Remove empty Models/Oras/ directory All ORAS operations now use the OrasDotNet library via IOrasService. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ImageBuilder/ImageBuilder.cs | 1 - .../Models/Oras/OrasDiscoverData.cs | 14 ------- src/ImageBuilder/OrasClient.cs | 39 ------------------- 3 files changed, 54 deletions(-) delete mode 100644 src/ImageBuilder/Models/Oras/OrasDiscoverData.cs delete mode 100644 src/ImageBuilder/OrasClient.cs diff --git a/src/ImageBuilder/ImageBuilder.cs b/src/ImageBuilder/ImageBuilder.cs index aa4f2cebb..29a5a7abc 100644 --- a/src/ImageBuilder/ImageBuilder.cs +++ b/src/ImageBuilder/ImageBuilder.cs @@ -63,7 +63,6 @@ public static class ImageBuilder builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/ImageBuilder/Models/Oras/OrasDiscoverData.cs b/src/ImageBuilder/Models/Oras/OrasDiscoverData.cs deleted file mode 100644 index 52bc760c0..000000000 --- a/src/ImageBuilder/Models/Oras/OrasDiscoverData.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.Generic; - -namespace Microsoft.DotNet.ImageBuilder.Models.Oras; - -public class OrasDiscoverData -{ - public List? Manifests { get; set; } - - public List? Referrers { get; set; } -} diff --git a/src/ImageBuilder/OrasClient.cs b/src/ImageBuilder/OrasClient.cs deleted file mode 100644 index 17cec6094..000000000 --- a/src/ImageBuilder/OrasClient.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.Generic; -using Microsoft.DotNet.ImageBuilder.Models.Oci; - -namespace Microsoft.DotNet.ImageBuilder; - -public interface IOrasClient -{ - string RunOrasCommand(IEnumerable args, bool isDryRun); - - public Descriptor GetDescriptor(string digest, bool isDryRun) - { - string output = RunOrasCommand( - args: [ - "manifest", - "fetch", - "--descriptor", - digest - ], - isDryRun: isDryRun); - - return Descriptor.FromJson(output); - } -} -public class OrasClient : IOrasClient -{ - private const string OrasExecutable = "oras"; - - public string RunOrasCommand(IEnumerable args, bool isDryRun = false) - { - return ExecuteHelper.Execute( - fileName: OrasExecutable, - args: string.Join(' ', args), - isDryRun: isDryRun); - } -} From 05914da793c0675f2dc1c338b02626c20ebe0a0a Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Fri, 27 Mar 2026 16:10:35 -0700 Subject: [PATCH 5/7] Remove oras CLI from ImageBuilder container image The oras CLI executable is no longer needed since all ORAS operations now use the OrasDotNet library directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Dockerfile.linux | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Dockerfile.linux b/src/Dockerfile.linux index ae829141a..ee8ce4112 100644 --- a/src/Dockerfile.linux +++ b/src/Dockerfile.linux @@ -39,7 +39,6 @@ RUN tdnf install -y \ git \ moby-engine \ notation \ - oras \ && tdnf clean all # install notation trust materials (root CA certs + trust policies) From c191e3eebfb9674d63d1f1e0a064252b589f6329 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Fri, 27 Mar 2026 16:19:00 -0700 Subject: [PATCH 6/7] Consolidate AttachArtifactAsync log messages Combine the before/after log calls into a single post-operation message with all details: reference, digest, artifactType, annotations, elapsed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ImageBuilder/Oras/OrasDotNetService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ImageBuilder/Oras/OrasDotNetService.cs b/src/ImageBuilder/Oras/OrasDotNetService.cs index 97fe1e619..131b6d09a 100644 --- a/src/ImageBuilder/Oras/OrasDotNetService.cs +++ b/src/ImageBuilder/Oras/OrasDotNetService.cs @@ -157,8 +157,6 @@ public async Task AttachArtifactAsync( ArgumentException.ThrowIfNullOrWhiteSpace(artifactType); ArgumentNullException.ThrowIfNull(annotations); - _logger.LogDebug("Attaching artifact (type={ArtifactType}) to {Reference}", artifactType, reference); - long startTime = Stopwatch.GetTimestamp(); Repository repository = CreateRepository(reference); Descriptor subjectDescriptor = await repository.ResolveAsync(reference, cancellationToken); @@ -178,7 +176,9 @@ await Packer.PackManifestAsync( cancellationToken); TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime); - _logger.LogDebug("Artifact attached: {Digest} in {Elapsed}", artifactDescriptor.Digest, elapsed); + _logger.LogDebug( + "Attached artifact to {Reference}: digest={Digest}, artifactType={ArtifactType}, annotations={Annotations}, elapsed={Elapsed}", + reference, artifactDescriptor.Digest, artifactType, annotations, elapsed); return artifactDescriptor.Digest; } From a8c60a8f1c4e75059c7f3c4542fadcec9ff7a9bf Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Fri, 27 Mar 2026 16:22:19 -0700 Subject: [PATCH 7/7] Address PR review feedback - Use TryGetValue for EndOfLifeAnnotation lookups in HasExpiredEolAsync and AnnotateDigestAsync to avoid KeyNotFoundException - Use TryParse for DateTimeOffset in HasExpiredEolAsync for robustness - Add ArgumentException.ThrowIfNullOrWhiteSpace(digest) guards to both LifecycleMetadataService public methods The (Manifest)null cast in tests is kept as-is because the file has #nullable disable, making (Manifest?)null a compile error (CS8632). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ImageBuilder/Commands/AnnotateEolDigestsCommand.cs | 3 ++- src/ImageBuilder/Commands/CleanAcrImagesCommand.cs | 6 ++++-- src/ImageBuilder/LifecycleMetadataService.cs | 4 ++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/ImageBuilder/Commands/AnnotateEolDigestsCommand.cs b/src/ImageBuilder/Commands/AnnotateEolDigestsCommand.cs index b61fdab53..e32f755db 100644 --- a/src/ImageBuilder/Commands/AnnotateEolDigestsCommand.cs +++ b/src/ImageBuilder/Commands/AnnotateEolDigestsCommand.cs @@ -139,7 +139,8 @@ private async Task AnnotateDigestAsync(EolDigestData digestData, DateOnly? globa } else { - if (existingAnnotationManifest.Annotations[LifecycleMetadataService.EndOfLifeAnnotation] == eolDate?.ToString(LifecycleMetadataService.EolDateFormat)) + if (existingAnnotationManifest.Annotations.TryGetValue(LifecycleMetadataService.EndOfLifeAnnotation, out string? existingEolValue) && + existingEolValue == eolDate?.ToString(LifecycleMetadataService.EolDateFormat)) { _logger.LogInformation($"Skipping digest '{digestData.Digest}' because it is already annotated with a matching EOL date."); _skippedAnnotationImageDigests.Add(digestData); diff --git a/src/ImageBuilder/Commands/CleanAcrImagesCommand.cs b/src/ImageBuilder/Commands/CleanAcrImagesCommand.cs index c2cf350ef..09e6e2d18 100644 --- a/src/ImageBuilder/Commands/CleanAcrImagesCommand.cs +++ b/src/ImageBuilder/Commands/CleanAcrImagesCommand.cs @@ -281,9 +281,11 @@ private async Task HasExpiredEolAsync(ArtifactManifestProperties manifest, $"{manifest.RegistryLoginServer}/{manifest.RepositoryName}@{manifest.Digest}", CancellationToken.None); - if (lifecycleArtifactManifest?.Annotations != null) + if (lifecycleArtifactManifest?.Annotations != null && + lifecycleArtifactManifest.Annotations.TryGetValue(LifecycleMetadataService.EndOfLifeAnnotation, out string? endOfLifeValue) && + DateTimeOffset.TryParse(endOfLifeValue, out DateTimeOffset endOfLifeDateTime)) { - return IsExpired(DateTimeOffset.Parse(lifecycleArtifactManifest.Annotations[LifecycleMetadataService.EndOfLifeAnnotation]), expirationDays); + return IsExpired(endOfLifeDateTime, expirationDays); } return false; diff --git a/src/ImageBuilder/LifecycleMetadataService.cs b/src/ImageBuilder/LifecycleMetadataService.cs index ac690d09d..804aa1c7f 100644 --- a/src/ImageBuilder/LifecycleMetadataService.cs +++ b/src/ImageBuilder/LifecycleMetadataService.cs @@ -29,6 +29,8 @@ public LifecycleMetadataService(IOrasService orasService, ILogger IsDigestAnnotatedForEolAsync(string digest, CancellationToken cancellationToken = default) { + ArgumentException.ThrowIfNullOrWhiteSpace(digest); + try { IReadOnlyList referrers = await _orasService.GetReferrersAsync(digest, cancellationToken); @@ -59,6 +61,8 @@ public LifecycleMetadataService(IOrasService orasService, ILogger AnnotateEolDigestAsync(string digest, DateOnly date, CancellationToken cancellationToken = default) { + ArgumentException.ThrowIfNullOrWhiteSpace(digest); + try { Dictionary annotations = new()