From f77ba54d097f5295a43ac63ffcef2c374db5ff49 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Tue, 30 Jun 2026 11:38:51 -0700 Subject: [PATCH 01/13] Make BuildCommand less stateful and lift some control flow into ExecuteAsync --- .../Build/ImageArtifactDetailsExtensions.cs | 26 +++ src/ImageBuilder/Commands/BuildCommand.cs | 194 ++++++++++-------- 2 files changed, 137 insertions(+), 83 deletions(-) create mode 100644 src/ImageBuilder/Commands/Build/ImageArtifactDetailsExtensions.cs diff --git a/src/ImageBuilder/Commands/Build/ImageArtifactDetailsExtensions.cs b/src/ImageBuilder/Commands/Build/ImageArtifactDetailsExtensions.cs new file mode 100644 index 000000000..f347d6fc5 --- /dev/null +++ b/src/ImageBuilder/Commands/Build/ImageArtifactDetailsExtensions.cs @@ -0,0 +1,26 @@ +// 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 System.Linq; +using Microsoft.DotNet.ImageBuilder.Models.Image; + +namespace Microsoft.DotNet.ImageBuilder.Commands.Build; + +/// +/// Extension methods for image artifact details used during build command processing. +/// +internal static class ImageArtifactDetailsExtensions +{ + /// + /// Enumerates all platform data entries from image-info repo and image groups. + /// + /// The image artifact details to enumerate. + /// All platform data entries contained in the image artifact details. + internal static IEnumerable EnumeratePlatforms(this ImageArtifactDetails imageArtifactDetails) => + imageArtifactDetails.Repos + .Where(repoData => repoData.Images != null) + .SelectMany(repoData => repoData.Images) + .SelectMany(imageData => imageData.Platforms); +} diff --git a/src/ImageBuilder/Commands/BuildCommand.cs b/src/ImageBuilder/Commands/BuildCommand.cs index 5ae85c30c..78c4fc734 100644 --- a/src/ImageBuilder/Commands/BuildCommand.cs +++ b/src/ImageBuilder/Commands/BuildCommand.cs @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using Azure.Core; +using Microsoft.DotNet.ImageBuilder.Commands.Build; using Microsoft.DotNet.ImageBuilder.Models.Image; using Microsoft.DotNet.ImageBuilder.ViewModel; @@ -26,20 +27,9 @@ public class BuildCommand : ManifestCommand private readonly IRegistryCredentialsProvider _registryCredentialsProvider; private readonly IAzureTokenCredentialProvider _tokenCredentialProvider; private readonly IImageCacheService _imageCacheService; - private readonly ImageDigestCache _imageDigestCache; - private readonly List _processedTags = new List(); - private readonly HashSet _builtPlatforms = new(); private readonly Lazy _imageNameResolver; private readonly Lazy _storageAccountToken; - /// - /// Maps a source digest from the image info file to the corresponding digest in the copied location for image caching. - /// This is specifically needed to support shared Dockerfile scenarios. - /// - private readonly Dictionary _sourceDigestCopyLocationMapping = new(); - - private ImageArtifactDetails? _imageArtifactDetails; - public BuildCommand( IManifestJsonService manifestJsonService, IDockerService dockerService, @@ -65,7 +55,6 @@ public BuildCommand( ArgumentNullException.ThrowIfNull(manifestServiceFactory); _manifestService = new Lazy(() => manifestServiceFactory.Create(Options.CredentialsOptions)); - _imageDigestCache = new ImageDigestCache(_manifestService); _imageNameResolver = new Lazy(() => new ImageNameResolverForBuild( @@ -95,26 +84,46 @@ public override async Task ExecuteAsync() { Options.BaseImageOverrideOptions.Validate(); - if (Options.ImageInfoOutputPath != null) - { - _imageArtifactDetails = new ImageArtifactDetails(); - } + ImageDigestCache imageDigestCache = new ImageDigestCache(_manifestService); - await ExecuteWithDockerCredentialsAsync(PullBaseImagesAsync); - await BuildImagesAsync(); + await ExecuteWithDockerCredentialsAsync(() => PullBaseImagesAsync(imageDigestCache)); - if (_processedTags.Count > 0 || _imageCacheService.HasAnyCachedPlatforms) + BuildResult buildResult = await BuildImagesAsync(imageDigestCache); + + bool shouldProcessBuildOutputs = + buildResult.BuiltTags.Count > 0 + || _imageCacheService.HasAnyCachedPlatforms; + + if (shouldProcessBuildOutputs) { // Log in again to refresh token as it may have expired from a long build - await ExecuteWithDockerCredentialsAsync(async () => + await ExecuteWithDockerCredentialsAsync( + async () => { - PushImages(); - await PublishImageInfoAsync(); - }); + if (Options.IsPushEnabled) + { + PushTags(buildResult.BuiltTags); + } + + if (!string.IsNullOrEmpty(Options.ImageInfoOutputPath) && buildResult.ImageArtifactDetails is not null) + { + await PopulateImageInfoAsync(buildResult.ImageArtifactDetails, imageDigestCache); + } + } + ); + + if (!string.IsNullOrEmpty(Options.ImageInfoOutputPath) && buildResult.ImageArtifactDetails is not null) + { + string imageInfoContent = JsonHelper.SerializeObject(buildResult.ImageArtifactDetails); + File.WriteAllText(Options.ImageInfoOutputPath, imageInfoContent); + } } - WriteBuildSummary(); - WriteBuiltImagesToOutputVar(); + WriteBuildSummary(buildResult.BuiltTags); + if (!string.IsNullOrEmpty(Options.OutputVariableName)) + { + WriteBuiltImagesToOutputVar(Options.OutputVariableName, buildResult.BuiltPlatforms); + } } private async Task ExecuteWithDockerCredentialsAsync(Func action) @@ -135,34 +144,30 @@ await _registryCredentialsProvider.ExecuteWithCredentialsAsync( registryName: Manifest.Registry); } - private void WriteBuiltImagesToOutputVar() + private void WriteBuiltImagesToOutputVar(string outputVariableName, IEnumerable builtPlatforms) { - if (!string.IsNullOrEmpty(Options.OutputVariableName)) - { - IEnumerable builtDigests = _builtPlatforms - .Select(platform => DockerHelper.GetDigestString(platform.PlatformInfo!.RepoName, DockerHelper.GetDigestSha(platform.Digest))) - .Distinct(); - _logger.LogInformation( - PipelineHelper.FormatOutputVariable( - Options.OutputVariableName, - string.Join(',', builtDigests))); - } + IEnumerable builtDigests = builtPlatforms + .Select(platform => DockerHelper.GetDigestString(platform.PlatformInfo!.RepoName, DockerHelper.GetDigestSha(platform.Digest))) + .Distinct(); + _logger.LogInformation( + PipelineHelper.FormatOutputVariable( + outputVariableName, + string.Join(',', builtDigests))); } - private async Task PublishImageInfoAsync() + private async Task PopulateImageInfoAsync( + ImageArtifactDetails imageArtifactDetails, + ImageDigestCache imageDigestCache) { - if (string.IsNullOrEmpty(Options.ImageInfoOutputPath)) - { - return; - } - if (string.IsNullOrEmpty(Options.SourceRepoUrl)) { throw new InvalidOperationException("Source repo URL must be provided when outputting to an image info file."); } + List allPlatforms = imageArtifactDetails.EnumeratePlatforms().ToList(); + Dictionary platformDataByTag = new Dictionary(); - foreach (PlatformData platformData in GetProcessedPlatforms()) + foreach (PlatformData platformData in allPlatforms) { if (platformData.PlatformInfo is not null) { @@ -173,10 +178,9 @@ private async Task PublishImageInfoAsync() } } - IEnumerable processedPlatforms = GetProcessedPlatforms(); - List platformsWithNoPushTags = new List(); + List platformsWithNoPushTags = []; - foreach (PlatformData platform in processedPlatforms) + foreach (PlatformData platform in allPlatforms) { IEnumerable pushTags = platform.PlatformInfo?.Tags ?? []; @@ -184,7 +188,7 @@ private async Task PublishImageInfoAsync() { if (Options.IsPushEnabled) { - await SetPlatformDataDigestAsync(platform, tag.FullyQualifiedName); + await SetPlatformDataDigestAsync(platform, tag.FullyQualifiedName, imageDigestCache); SetPlatformDataBaseDigest(platform, platformDataByTag); await SetPlatformDataLayersAsync(platform, tag.FullyQualifiedName); } @@ -206,7 +210,7 @@ private async Task PublishImageInfoAsync() // set (as a result of having a concrete tag) and copy its values. foreach (PlatformData platform in platformsWithNoPushTags) { - PlatformData matchingBuiltPlatform = processedPlatforms.First(builtPlatform => + PlatformData matchingBuiltPlatform = allPlatforms.First(builtPlatform => (builtPlatform.PlatformInfo?.Tags ?? []).Any() && platform.ImageInfo is not null && platform.PlatformInfo is not null && @@ -217,9 +221,6 @@ builtPlatform.PlatformInfo is not null && platform.Digest = matchingBuiltPlatform.Digest; platform.Created = matchingBuiltPlatform.Created; } - - string imageInfoString = JsonHelper.SerializeObject(_imageArtifactDetails); - File.WriteAllText(Options.ImageInfoOutputPath, imageInfoString); } private void SetPlatformDataCreatedDate(PlatformData platform, string tag) @@ -273,10 +274,10 @@ private async Task SetPlatformDataLayersAsync(PlatformData platform, string tag) } } - private async Task SetPlatformDataDigestAsync(PlatformData platform, string tag) + private async Task SetPlatformDataDigestAsync(PlatformData platform, string tag, ImageDigestCache imageDigestCache) { // The digest of an image that is pushed to ACR is guaranteed to be the same when transferred to MCR. - string? digest = await _imageDigestCache.GetLocalImageDigestAsync(tag, Options.IsDryRun); + string? digest = await imageDigestCache.GetLocalImageDigestAsync(tag, Options.IsDryRun); if (digest is not null && platform.PlatformInfo is not null) { digest = DockerHelper.GetDigestString(platform.PlatformInfo.FullRepoModelName, DockerHelper.GetDigestSha(digest)); @@ -300,10 +301,19 @@ private async Task SetPlatformDataDigestAsync(PlatformData platform, string tag) platform.Digest = digest; } - private async Task BuildImagesAsync() + private async Task BuildImagesAsync(ImageDigestCache imageDigestCache) { _logger.LogInformation("BUILDING IMAGES"); + List builtTags = []; + HashSet builtPlatforms = []; + + // Maps source image-info digests to copied locations so shared Dockerfile cache hits + // can resolve per-repo digests. + Dictionary sourceDigestCopyLocationMapping = []; + ImageArtifactDetails? imageArtifactDetails = + Options.ImageInfoOutputPath is null ? null : new ImageArtifactDetails(); + ImageArtifactDetails? srcImageArtifactDetails = null; if (Options.ImageInfoSourcePath != null) { @@ -344,7 +354,7 @@ private async Task BuildImagesAsync() ImageCacheResult cacheResult = await _imageCacheService.CheckForCachedImageAsync( srcImageData, platformData, - _imageDigestCache, + imageDigestCache, _imageNameResolver.Value, sourceRepoUrl: Options.SourceRepoUrl, isLocalBaseImageExpected: true, @@ -357,21 +367,27 @@ private async Task BuildImagesAsync() CopyPlatformDataFromCachedPlatform(platformData, cacheResult.Platform!); platformData.IsUnchanged = cacheResult.State != ImageCacheState.CachedWithMissingTags; - await OnCacheHitAsync(repoInfo, allTagInfos, pullImage: cacheResult.IsNewCacheHit, cacheResult.Platform!.Digest); + await OnCacheHitAsync( + repoInfo, + allTagInfos, + pullImage: cacheResult.IsNewCacheHit, + sourceDigest: cacheResult.Platform!.Digest, + imageDigestCache, + sourceDigestCopyLocationMapping); } } if (!isCachedImage) { - _processedTags.AddRange(allTagInfos); + builtTags.AddRange(allTagInfos); BuildImage(platform, allTags); - _builtPlatforms.Add(platformData); + builtPlatforms.Add(platformData); if (Options.IsPushEnabled && platform.FinalStageFromImage is not null) { platformData.BaseImageDigest = - await _imageDigestCache.GetLocalImageDigestAsync( + await imageDigestCache.GetLocalImageDigestAsync( _imageNameResolver.Value.GetFromImageLocalTag(platform.FinalStageFromImage), Options.IsDryRun); } } @@ -380,9 +396,11 @@ await _imageDigestCache.GetLocalImageDigestAsync( if (repoData?.Images.Any() == true) { - _imageArtifactDetails?.Repos.Add(repoData); + imageArtifactDetails?.Repos.Add(repoData); } } + + return new BuildResult(builtTags, builtPlatforms, imageArtifactDetails); } private void CopyPlatformDataFromCachedPlatform(PlatformData dstPlatform, PlatformData srcPlatform) @@ -558,7 +576,13 @@ private void BuildImage(PlatformInfo platform, IEnumerable allTags) private IEnumerable GetDockerBuildOptions() => Options.DockerBuildOptions.Where(option => !string.IsNullOrWhiteSpace(option)); - private async Task OnCacheHitAsync(RepoInfo repo, IEnumerable allTags, bool pullImage, string sourceDigest) + private async Task OnCacheHitAsync( + RepoInfo repo, + IEnumerable allTags, + bool pullImage, + string sourceDigest, + ImageDigestCache imageDigestCache, + Dictionary sourceDigestCopyLocationMapping) { _logger.LogInformation(string.Empty); _logger.LogInformation("CACHE HIT"); @@ -584,14 +608,14 @@ await ExecuteWithDockerCredentialsAsync(() => { // Don't need to provide the platform because we're pulling by digest. No need to worry about multi-arch tags. _dockerService.PullImage(copiedSourceDigest, null, Options.IsDryRun); - _sourceDigestCopyLocationMapping[sourceDigest] = copiedSourceDigest; + sourceDigestCopyLocationMapping[sourceDigest] = copiedSourceDigest; }); } // Tag the image as if it were locally built so that subsequent built images can reference it foreach (TagInfo tag in allTags) { - if (!_sourceDigestCopyLocationMapping.TryGetValue(sourceDigest, out string? resolvedSourceDigest)) + if (!sourceDigestCopyLocationMapping.TryGetValue(sourceDigest, out string? resolvedSourceDigest)) { throw new InvalidOperationException("Digest should be mapped by this point"); } @@ -608,7 +632,7 @@ await ExecuteWithDockerCredentialsAsync(() => // Populate the digest cache with the known digest value for the tags assigned to the image. // This is needed in order to prevent a call to the manifest tool to get the digest for these tags // because they haven't yet been pushed to staging by that time. - _imageDigestCache.AddDigest(tag.FullyQualifiedName, newDigest); + imageDigestCache.AddDigest(tag.FullyQualifiedName, newDigest); } } @@ -635,7 +659,7 @@ await _copyImageService.ImportImageAsync( return sourceDigest; } - private async Task PullBaseImagesAsync() + private async Task PullBaseImagesAsync(ImageDigestCache imageDigestCache) { _logger.LogInformation("PULLING LATEST BASE IMAGES"); @@ -694,7 +718,7 @@ await Parallel.ForEachAsync(finalStageExternalFromImages, async (fromImage, canc // the DockerServiceCache for later use. The longer we wait to get the digest after pulling, the // greater chance the tag could be updated resulting in a different digest returned than what was // originally pulled. - await _imageDigestCache.GetLocalImageDigestAsync(fromImage, Options.IsDryRun); + await imageDigestCache.GetLocalImageDigestAsync(fromImage, Options.IsDryRun); }); // Tag the images that were pulled from the mirror as they are referenced in the Dockerfiles @@ -708,22 +732,13 @@ await Parallel.ForEachAsync(finalStageExternalFromImages, async (fromImage, canc }); } - private IEnumerable GetProcessedPlatforms() => _imageArtifactDetails?.Repos - .Where(repoData => repoData.Images != null) - .SelectMany(repoData => repoData.Images) - .SelectMany(imageData => imageData.Platforms) - ?? Enumerable.Empty(); - - private void PushImages() + private void PushTags(IEnumerable builtTags) { - if (Options.IsPushEnabled) - { - _logger.LogInformation("PUSHING BUILT IMAGES"); + _logger.LogInformation("PUSHING BUILT IMAGES"); - foreach (TagInfo tag in _processedTags) - { - _dockerService.PushImage(tag.FullyQualifiedName, Options.IsDryRun); - } + foreach (TagInfo tag in builtTags) + { + _dockerService.PushImage(tag.FullyQualifiedName, Options.IsDryRun); } } @@ -761,13 +776,13 @@ private bool UpdateDockerfileFromCommands(PlatformInfo platform, out string dock return updateDockerfile; } - private void WriteBuildSummary() + private void WriteBuildSummary(IReadOnlyCollection builtTags) { _logger.LogInformation("IMAGES BUILT"); - if (_processedTags.Any()) + if (builtTags.Any()) { - foreach (TagInfo tag in _processedTags) + foreach (TagInfo tag in builtTags) { _logger.LogInformation(tag.FullyQualifiedName); } @@ -779,5 +794,18 @@ private void WriteBuildSummary() _logger.LogInformation(string.Empty); } + + /// + /// Contains the build outputs needed by the remaining command flow. + /// + /// Tags for images built locally during this command execution. + /// Platforms built locally during this command execution. + /// + /// Image-info details to populate and write when is set; otherwise null. + /// + private sealed record BuildResult( + IReadOnlyCollection BuiltTags, + IReadOnlyCollection BuiltPlatforms, + ImageArtifactDetails? ImageArtifactDetails); } } From 6a5bf5b22f4f607f44cb526a150b15fe13935cc6 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Tue, 30 Jun 2026 12:30:39 -0700 Subject: [PATCH 02/13] Push images immediately after building --- src/ImageBuilder/Commands/BuildCommand.cs | 419 +++++++++++----------- 1 file changed, 215 insertions(+), 204 deletions(-) diff --git a/src/ImageBuilder/Commands/BuildCommand.cs b/src/ImageBuilder/Commands/BuildCommand.cs index 78c4fc734..9df4a8e39 100644 --- a/src/ImageBuilder/Commands/BuildCommand.cs +++ b/src/ImageBuilder/Commands/BuildCommand.cs @@ -84,45 +84,213 @@ public override async Task ExecuteAsync() { Options.BaseImageOverrideOptions.Validate(); + bool isImageInfoOutputEnabled = !string.IsNullOrEmpty(Options.ImageInfoOutputPath); + if (isImageInfoOutputEnabled && string.IsNullOrEmpty(Options.SourceRepoUrl)) + { + throw new InvalidOperationException("Source repo URL must be provided when outputting to an image info file."); + } + ImageDigestCache imageDigestCache = new ImageDigestCache(_manifestService); await ExecuteWithDockerCredentialsAsync(() => PullBaseImagesAsync(imageDigestCache)); - BuildResult buildResult = await BuildImagesAsync(imageDigestCache); + _logger.LogInformation("BUILDING IMAGES"); - bool shouldProcessBuildOutputs = - buildResult.BuiltTags.Count > 0 - || _imageCacheService.HasAnyCachedPlatforms; + List builtTags = []; + HashSet builtTagNames = []; + HashSet builtPlatforms = []; - if (shouldProcessBuildOutputs) + // Maps source image-info digests to copied locations so shared Dockerfile cache hits + // can resolve per-repo digests. + Dictionary sourceDigestCopyLocationMapping = []; + Dictionary platformDataByTag = []; + List platformsWithNoPushTags = []; + ImageArtifactDetails? imageArtifactDetails = + isImageInfoOutputEnabled ? new ImageArtifactDetails() : null; + + ImageArtifactDetails? srcImageArtifactDetails = null; + if (!string.IsNullOrWhiteSpace(Options.ImageInfoSourcePath)) + { + srcImageArtifactDetails = ImageInfoHelper.LoadFromFile( + Options.ImageInfoSourcePath, + Manifest, + skipManifestValidation: true); + } + foreach (RepoInfo repoInfo in Manifest.FilteredRepos) { - // Log in again to refresh token as it may have expired from a long build - await ExecuteWithDockerCredentialsAsync( - async () => + RepoData repoData = CreateRepoData(repoInfo); + RepoData? srcRepoData = srcImageArtifactDetails?.Repos.FirstOrDefault(srcRepo => srcRepo.Repo == repoInfo.Name); + + foreach (ImageInfo image in repoInfo.FilteredImages) + { + ImageData imageData = CreateImageData(image); + repoData.Images.Add(imageData); + + ImageData? srcImageData = srcRepoData?.Images.FirstOrDefault(srcImage => srcImage.ManifestImage == image); + + foreach (PlatformInfo platform in image.FilteredPlatforms) { - if (Options.IsPushEnabled) + // Tag the built images with the shared tags as well as the platform tags. + // Some tests and image FROM instructions depend on these tags. + + List allTagInfos = platform.Tags + .Concat(image.SharedTags) + .ToList(); + + List allTags = allTagInfos + .Select(tag => tag.FullyQualifiedName) + .ToList(); + + List concreteTags = platform.Tags.ToList(); + PlatformData platformData = CreatePlatformData(image, platform); + imageData.Platforms.Add(platformData); + + if (platformData.PlatformInfo is not null) { - PushTags(buildResult.BuiltTags); + foreach (TagInfo tag in platformData.PlatformInfo.Tags) + { + platformDataByTag.Add(tag.FullyQualifiedName, platformData); + } } - if (!string.IsNullOrEmpty(Options.ImageInfoOutputPath) && buildResult.ImageArtifactDetails is not null) + bool isCachedImage = false; + bool shouldCheckCache = !Options.NoCache; + if (shouldCheckCache && platform.FinalStageFromImage is not null) { - await PopulateImageInfoAsync(buildResult.ImageArtifactDetails, imageDigestCache); + string finalStageLocalTag = + _imageNameResolver.Value.GetFromImageLocalTag(platform.FinalStageFromImage); + shouldCheckCache = !builtTagNames.Contains(finalStageLocalTag); + } + + if (shouldCheckCache) + { + ImageCacheResult cacheResult = await _imageCacheService.CheckForCachedImageAsync( + srcImageData, + platformData, + imageDigestCache, + _imageNameResolver.Value, + sourceRepoUrl: Options.SourceRepoUrl, + isLocalBaseImageExpected: true, + isDryRun: Options.IsDryRun); + + if (cacheResult.State.HasFlag(ImageCacheState.Cached)) + { + isCachedImage = true; + + CopyPlatformDataFromCachedPlatform(platformData, cacheResult.Platform!); + platformData.IsUnchanged = cacheResult.State != ImageCacheState.CachedWithMissingTags; + + await OnCacheHitAsync( + repoInfo, + allTagInfos, + pullImage: cacheResult.IsNewCacheHit, + sourceDigest: cacheResult.Platform!.Digest, + imageDigestCache, + sourceDigestCopyLocationMapping); + } + } + + Dictionary pushedDigestByTag = []; + if (!isCachedImage) + { + builtTags.AddRange(allTagInfos); + builtTagNames.UnionWith(allTags); + + BuildImage(platform, allTags); + builtPlatforms.Add(platformData); + + if (Options.IsPushEnabled) + { + IEnumerable tagsForDigest = imageArtifactDetails is null ? [] : concreteTags; + + // Log in again to refresh token as it may have expired from a long build + await ExecuteWithDockerCredentialsAsync( + async () => + { + pushedDigestByTag = await PushTagsAsync(allTagInfos, tagsForDigest, imageDigestCache); + }); + + if (platform.FinalStageFromImage is not null) + { + platformData.BaseImageDigest = + await imageDigestCache.GetLocalImageDigestAsync( + _imageNameResolver.Value.GetFromImageLocalTag(platform.FinalStageFromImage), Options.IsDryRun); + } + } + } + + if (imageArtifactDetails is not null) + { + // Multiple concrete tags for the same platform should all resolve to the same + // digest and created date; validate each tag before preserving the shared values. + foreach (TagInfo tag in concreteTags) + { + if (Options.IsPushEnabled) + { + string? digest = + isCachedImage + ? await imageDigestCache.GetLocalImageDigestAsync(tag.FullyQualifiedName, Options.IsDryRun) + : pushedDigestByTag[tag.FullyQualifiedName]; + + SetPlatformDataDigest(platformData, tag.FullyQualifiedName, digest); + SetPlatformDataBaseDigest(platformData, platformDataByTag); + await SetPlatformDataLayersAsync(platformData, tag.FullyQualifiedName); + } + + SetPlatformDataCreatedDate(platformData, tag.FullyQualifiedName); + } + + if (!concreteTags.Any()) + { + platformsWithNoPushTags.Add(platformData); + } + + if (!string.IsNullOrEmpty(Options.SourceRepoUrl)) + { + platformData.CommitUrl = _gitService.GetDockerfileCommitUrl(platformData.PlatformInfo, Options.SourceRepoUrl); + } } } - ); + } - if (!string.IsNullOrEmpty(Options.ImageInfoOutputPath) && buildResult.ImageArtifactDetails is not null) + if (repoData.Images.Any()) { - string imageInfoContent = JsonHelper.SerializeObject(buildResult.ImageArtifactDetails); - File.WriteAllText(Options.ImageInfoOutputPath, imageInfoContent); + imageArtifactDetails?.Repos.Add(repoData); } } - WriteBuildSummary(buildResult.BuiltTags); + if ((builtTags.Count > 0 || _imageCacheService.HasAnyCachedPlatforms) + && !string.IsNullOrEmpty(Options.ImageInfoOutputPath) + && imageArtifactDetails is not null) + { + List allPlatforms = imageArtifactDetails.EnumeratePlatforms().ToList(); + + // Some platforms do not have concrete tags. In such cases, they must be duplicates of a platform in a different + // image which does have a concrete tag. For these platforms that do not have concrete tags, we are unable to + // lookup digest/created info based on their tag. Instead, we find the matching platform which does have that info + // set (as a result of having a concrete tag) and copy its values. + foreach (PlatformData platform in platformsWithNoPushTags) + { + PlatformData matchingBuiltPlatform = allPlatforms.First(builtPlatform => + (builtPlatform.PlatformInfo?.Tags ?? []).Any() && + platform.ImageInfo is not null && + platform.PlatformInfo is not null && + builtPlatform.ImageInfo is not null && + builtPlatform.PlatformInfo is not null && + PlatformInfo.AreMatchingPlatforms(platform.ImageInfo, platform.PlatformInfo, builtPlatform.ImageInfo, builtPlatform.PlatformInfo)); + + platform.Digest = matchingBuiltPlatform.Digest; + platform.Created = matchingBuiltPlatform.Created; + } + + string imageInfoContent = JsonHelper.SerializeObject(imageArtifactDetails); + File.WriteAllText(Options.ImageInfoOutputPath, imageInfoContent); + } + + WriteBuildSummary(builtTags); if (!string.IsNullOrEmpty(Options.OutputVariableName)) { - WriteBuiltImagesToOutputVar(Options.OutputVariableName, buildResult.BuiltPlatforms); + WriteBuiltImagesToOutputVar(Options.OutputVariableName, builtPlatforms); } } @@ -155,74 +323,6 @@ private void WriteBuiltImagesToOutputVar(string outputVariableName, IEnumerable< string.Join(',', builtDigests))); } - private async Task PopulateImageInfoAsync( - ImageArtifactDetails imageArtifactDetails, - ImageDigestCache imageDigestCache) - { - if (string.IsNullOrEmpty(Options.SourceRepoUrl)) - { - throw new InvalidOperationException("Source repo URL must be provided when outputting to an image info file."); - } - - List allPlatforms = imageArtifactDetails.EnumeratePlatforms().ToList(); - - Dictionary platformDataByTag = new Dictionary(); - foreach (PlatformData platformData in allPlatforms) - { - if (platformData.PlatformInfo is not null) - { - foreach (TagInfo tag in platformData.PlatformInfo.Tags) - { - platformDataByTag.Add(tag.FullyQualifiedName, platformData); - } - } - } - - List platformsWithNoPushTags = []; - - foreach (PlatformData platform in allPlatforms) - { - IEnumerable pushTags = platform.PlatformInfo?.Tags ?? []; - - foreach (TagInfo tag in pushTags) - { - if (Options.IsPushEnabled) - { - await SetPlatformDataDigestAsync(platform, tag.FullyQualifiedName, imageDigestCache); - SetPlatformDataBaseDigest(platform, platformDataByTag); - await SetPlatformDataLayersAsync(platform, tag.FullyQualifiedName); - } - - SetPlatformDataCreatedDate(platform, tag.FullyQualifiedName); - } - - if (!pushTags.Any()) - { - platformsWithNoPushTags.Add(platform); - } - - platform.CommitUrl = _gitService.GetDockerfileCommitUrl(platform.PlatformInfo, Options.SourceRepoUrl); - } - - // Some platforms do not have concrete tags. In such cases, they must be duplicates of a platform in a different - // image which does have a concrete tag. For these platforms that do not have concrete tags, we are unable to - // lookup digest/created info based on their tag. Instead, we find the matching platform which does have that info - // set (as a result of having a concrete tag) and copy its values. - foreach (PlatformData platform in platformsWithNoPushTags) - { - PlatformData matchingBuiltPlatform = allPlatforms.First(builtPlatform => - (builtPlatform.PlatformInfo?.Tags ?? []).Any() && - platform.ImageInfo is not null && - platform.PlatformInfo is not null && - builtPlatform.ImageInfo is not null && - builtPlatform.PlatformInfo is not null && - PlatformInfo.AreMatchingPlatforms(platform.ImageInfo, platform.PlatformInfo, builtPlatform.ImageInfo, builtPlatform.PlatformInfo)); - - platform.Digest = matchingBuiltPlatform.Digest; - platform.Created = matchingBuiltPlatform.Created; - } - } - private void SetPlatformDataCreatedDate(PlatformData platform, string tag) { DateTime createdDate = _dockerService.GetCreatedDate(tag, Options.IsDryRun).ToUniversalTime(); @@ -274,10 +374,9 @@ private async Task SetPlatformDataLayersAsync(PlatformData platform, string tag) } } - private async Task SetPlatformDataDigestAsync(PlatformData platform, string tag, ImageDigestCache imageDigestCache) + private void SetPlatformDataDigest(PlatformData platform, string tag, string? digest) { // The digest of an image that is pushed to ACR is guaranteed to be the same when transferred to MCR. - string? digest = await imageDigestCache.GetLocalImageDigestAsync(tag, Options.IsDryRun); if (digest is not null && platform.PlatformInfo is not null) { digest = DockerHelper.GetDigestString(platform.PlatformInfo.FullRepoModelName, DockerHelper.GetDigestSha(digest)); @@ -301,108 +400,6 @@ private async Task SetPlatformDataDigestAsync(PlatformData platform, string tag, platform.Digest = digest; } - private async Task BuildImagesAsync(ImageDigestCache imageDigestCache) - { - _logger.LogInformation("BUILDING IMAGES"); - - List builtTags = []; - HashSet builtPlatforms = []; - - // Maps source image-info digests to copied locations so shared Dockerfile cache hits - // can resolve per-repo digests. - Dictionary sourceDigestCopyLocationMapping = []; - ImageArtifactDetails? imageArtifactDetails = - Options.ImageInfoOutputPath is null ? null : new ImageArtifactDetails(); - - ImageArtifactDetails? srcImageArtifactDetails = null; - if (Options.ImageInfoSourcePath != null) - { - srcImageArtifactDetails = ImageInfoHelper.LoadFromFile(Options.ImageInfoSourcePath, Manifest, skipManifestValidation: true); - } - - foreach (RepoInfo repoInfo in Manifest.FilteredRepos) - { - RepoData repoData = CreateRepoData(repoInfo); - RepoData? srcRepoData = srcImageArtifactDetails?.Repos.FirstOrDefault(srcRepo => srcRepo.Repo == repoInfo.Name); - - foreach (ImageInfo image in repoInfo.FilteredImages) - { - ImageData imageData = CreateImageData(image); - repoData.Images.Add(imageData); - - ImageData? srcImageData = srcRepoData?.Images.FirstOrDefault(srcImage => srcImage.ManifestImage == image); - - foreach (PlatformInfo platform in image.FilteredPlatforms) - { - // Tag the built images with the shared tags as well as the platform tags. - // Some tests and image FROM instructions depend on these tags. - - IEnumerable allTagInfos = platform.Tags - .Concat(image.SharedTags) - .ToList(); - - IEnumerable allTags = allTagInfos - .Select(tag => tag.FullyQualifiedName) - .ToList(); - - PlatformData platformData = CreatePlatformData(image, platform); - imageData.Platforms.Add(platformData); - - bool isCachedImage = false; - if (!Options.NoCache) - { - ImageCacheResult cacheResult = await _imageCacheService.CheckForCachedImageAsync( - srcImageData, - platformData, - imageDigestCache, - _imageNameResolver.Value, - sourceRepoUrl: Options.SourceRepoUrl, - isLocalBaseImageExpected: true, - isDryRun: Options.IsDryRun); - - if (cacheResult.State.HasFlag(ImageCacheState.Cached)) - { - isCachedImage = true; - - CopyPlatformDataFromCachedPlatform(platformData, cacheResult.Platform!); - platformData.IsUnchanged = cacheResult.State != ImageCacheState.CachedWithMissingTags; - - await OnCacheHitAsync( - repoInfo, - allTagInfos, - pullImage: cacheResult.IsNewCacheHit, - sourceDigest: cacheResult.Platform!.Digest, - imageDigestCache, - sourceDigestCopyLocationMapping); - } - } - - if (!isCachedImage) - { - builtTags.AddRange(allTagInfos); - - BuildImage(platform, allTags); - builtPlatforms.Add(platformData); - - if (Options.IsPushEnabled && platform.FinalStageFromImage is not null) - { - platformData.BaseImageDigest = - await imageDigestCache.GetLocalImageDigestAsync( - _imageNameResolver.Value.GetFromImageLocalTag(platform.FinalStageFromImage), Options.IsDryRun); - } - } - } - } - - if (repoData?.Images.Any() == true) - { - imageArtifactDetails?.Repos.Add(repoData); - } - } - - return new BuildResult(builtTags, builtPlatforms, imageArtifactDetails); - } - private void CopyPlatformDataFromCachedPlatform(PlatformData dstPlatform, PlatformData srcPlatform) { // When a cache hit occurs for a Dockerfile, we want to transfer some of the metadata about the previously @@ -732,14 +729,40 @@ await Parallel.ForEachAsync(finalStageExternalFromImages, async (fromImage, canc }); } - private void PushTags(IEnumerable builtTags) + private async Task> PushTagsAsync( + IEnumerable tagsToPush, + IEnumerable tagsForDigest, + ImageDigestCache imageDigestCache) { _logger.LogInformation("PUSHING BUILT IMAGES"); - foreach (TagInfo tag in builtTags) + HashSet digestTagNames = tagsForDigest + .Select(tag => tag.FullyQualifiedName) + .ToHashSet(); + Dictionary pushedDigestByTag = []; + + foreach (TagInfo tag in tagsToPush) { _dockerService.PushImage(tag.FullyQualifiedName, Options.IsDryRun); + + if (digestTagNames.Contains(tag.FullyQualifiedName)) + { + string? digest = null; + for (int attempt = 0; attempt <= RetryHelper.MaxRetries && digest is null; attempt++) + { + digest = await imageDigestCache.GetLocalImageDigestAsync(tag.FullyQualifiedName, Options.IsDryRun); + } + + if (digest is null) + { + throw new InvalidOperationException($"Unable to retrieve digest for pushed tag '{tag.FullyQualifiedName}'."); + } + + pushedDigestByTag.Add(tag.FullyQualifiedName, digest); + } } + + return pushedDigestByTag; } private bool UpdateDockerfileFromCommands(PlatformInfo platform, out string dockerfilePath) @@ -795,17 +818,5 @@ private void WriteBuildSummary(IReadOnlyCollection builtTags) _logger.LogInformation(string.Empty); } - /// - /// Contains the build outputs needed by the remaining command flow. - /// - /// Tags for images built locally during this command execution. - /// Platforms built locally during this command execution. - /// - /// Image-info details to populate and write when is set; otherwise null. - /// - private sealed record BuildResult( - IReadOnlyCollection BuiltTags, - IReadOnlyCollection BuiltPlatforms, - ImageArtifactDetails? ImageArtifactDetails); } } From 61621ec4e7fceb56e061edc4539af296e8cd1fc9 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Tue, 30 Jun 2026 15:10:10 -0700 Subject: [PATCH 03/13] Add labels to images --- src/ImageBuilder.Tests/BuildCommandTests.cs | 140 ++++++++++++++++++-- src/ImageBuilder/Commands/BuildCommand.cs | 27 +++- src/ImageBuilder/DockerService.cs | 7 +- src/ImageBuilder/DockerServiceCache.cs | 28 +++- src/ImageBuilder/IDockerService.cs | 9 +- src/ImageBuilder/OciAnnotations.cs | 32 +++++ 6 files changed, 221 insertions(+), 22 deletions(-) create mode 100644 src/ImageBuilder/OciAnnotations.cs diff --git a/src/ImageBuilder.Tests/BuildCommandTests.cs b/src/ImageBuilder.Tests/BuildCommandTests.cs index c34c17119..f2040d966 100644 --- a/src/ImageBuilder.Tests/BuildCommandTests.cs +++ b/src/ImageBuilder.Tests/BuildCommandTests.cs @@ -11,17 +11,13 @@ using Azure.ResourceManager.ContainerRegistry.Models; using FluentAssertions; using Microsoft.DotNet.ImageBuilder.Commands; -using Microsoft.DotNet.ImageBuilder.Configuration; using Microsoft.DotNet.ImageBuilder.Models.Image; using Microsoft.DotNet.ImageBuilder.Models.Manifest; using Microsoft.DotNet.ImageBuilder.Tests.Helpers; using Microsoft.DotNet.ImageBuilder.ViewModel; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Moq; using Newtonsoft.Json; using Shouldly; -using static Microsoft.DotNet.ImageBuilder.Tests.Helpers.ConfigurationHelper; using static Microsoft.DotNet.ImageBuilder.Tests.Helpers.ImageInfoHelper; using static Microsoft.DotNet.ImageBuilder.Tests.Helpers.ManifestHelper; using static Microsoft.DotNet.ImageBuilder.Tests.Helpers.ManifestServiceHelper; @@ -521,6 +517,7 @@ public async Task BuildCommand_Publish() TagInfo.GetFullyQualifiedName(repoName, sharedTag) }, It.IsAny>(), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -537,8 +534,123 @@ public async Task BuildCommand_Publish() } /// - /// Verifies that the manifest's platform architecture settings match the architecture of the base image. + /// Verifies that the OCI source and base image labels are applied to the built image. /// + [TestMethod] + public async Task BuildCommand_AppliesOciLabels() + { + const string repoName = "runtime"; + const string tag = "tag"; + const string baseImageRepo = "baserepo"; + string baseImageTag = $"{baseImageRepo}:basetag"; + string baseImageDigest = $"{baseImageRepo}@sha256:baseImageDigestSha"; + const string sourceRepoUrl = "https://github.com/dotnet/test"; + const string commitSha = "c0ff33c0ff33c0ff33c0ff33c0ff33c0ff33c0ff"; + + using TempFolderContext tempFolderContext = TestHelper.UseTempFolder(); + Mock dockerServiceMock = CreateDockerServiceMock(); + + Mock gitServiceMock = new(); + gitServiceMock + .Setup(o => o.GetCommitSha(It.IsAny(), true)) + .Returns(commitSha); + + BuildCommand command = CreateBuildCommand( + dockerService: dockerServiceMock.Object, + gitService: gitServiceMock.Object, + copyImageService: Mock.Of(), + manifestServiceFactory: CreateManifestServiceFactoryMock( + localImageDigestResults: [new(baseImageTag, baseImageDigest)]).Object, + imageCacheService: new ImageCacheService(Mock.Of>(), Mock.Of())); + command.Options.Manifest = Path.Combine(tempFolderContext.Path, "manifest.json"); + command.Options.SourceRepoUrl = sourceRepoUrl; + + const string runtimeRelativeDir = "1.0/runtime/os"; + Directory.CreateDirectory(Path.Combine(tempFolderContext.Path, runtimeRelativeDir)); + string dockerfileRelativePath = Path.Combine(runtimeRelativeDir, "Dockerfile"); + string dockerfileAbsolutePath = PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, dockerfileRelativePath)); + File.WriteAllText(dockerfileAbsolutePath, $"FROM {baseImageTag}"); + + Platform platform = CreatePlatform(dockerfileRelativePath, new string[] { tag }); + + Manifest manifest = CreateManifest( + CreateRepo(repoName, + CreateImage( + new Platform[] { platform }))); + + File.WriteAllText(Path.Combine(tempFolderContext.Path, command.Options.Manifest), JsonConvert.SerializeObject(manifest)); + + command.LoadManifest(); + await command.ExecuteAsync(); + + dockerServiceMock.Verify( + o => o.BuildImage( + dockerfileAbsolutePath, + PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeRelativeDir)), + "linux/amd64", + It.IsAny>(), + It.IsAny>(), + It.Is>(labels => + labels.Count == 4 && + labels[OciAnnotations.Source] == sourceRepoUrl && + labels[OciAnnotations.Revision] == commitSha && + labels[OciAnnotations.BaseName] == baseImageTag && + labels[OciAnnotations.BaseDigest] == "sha256:baseImageDigestSha"), + It.IsAny>(), + It.IsAny(), + It.IsAny())); + } + + /// + /// Verifies that OCI labels whose source values are unavailable are omitted rather than applied with empty values. + /// + [TestMethod] + public async Task BuildCommand_OmitsOciLabelsWhenDataUnavailable() + { + const string repoName = "runtime"; + const string tag = "tag"; + + using TempFolderContext tempFolderContext = TestHelper.UseTempFolder(); + Mock dockerServiceMock = CreateDockerServiceMock(); + + BuildCommand command = CreateBuildCommand( + dockerService: dockerServiceMock.Object, + copyImageService: Mock.Of(), + manifestServiceFactory: CreateManifestServiceFactoryMock().Object, + imageCacheService: new ImageCacheService(Mock.Of>(), Mock.Of())); + command.Options.Manifest = Path.Combine(tempFolderContext.Path, "manifest.json"); + + const string runtimeRelativeDir = "1.0/runtime/os"; + Directory.CreateDirectory(Path.Combine(tempFolderContext.Path, runtimeRelativeDir)); + string dockerfileRelativePath = Path.Combine(runtimeRelativeDir, "Dockerfile"); + string dockerfileAbsolutePath = PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, dockerfileRelativePath)); + File.WriteAllText(dockerfileAbsolutePath, "FROM scratch"); + + Platform platform = CreatePlatform(dockerfileRelativePath, new string[] { tag }); + + Manifest manifest = CreateManifest( + CreateRepo(repoName, + CreateImage( + new Platform[] { platform }))); + + File.WriteAllText(Path.Combine(tempFolderContext.Path, command.Options.Manifest), JsonConvert.SerializeObject(manifest)); + + command.LoadManifest(); + await command.ExecuteAsync(); + + dockerServiceMock.Verify( + o => o.BuildImage( + dockerfileAbsolutePath, + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny>(), + It.Is>(labels => labels.Count == 0), + It.IsAny>(), + It.IsAny(), + It.IsAny())); + } + [TestMethod] public async Task BuildCommand_VerifyOnBaseImageArchMismatch() { @@ -687,6 +799,7 @@ public async Task BuildCommand_BuildArgs() It.IsAny>(), It.Is>( args => args.Count == 3 && args["arg1"] == "val1" && args["arg2"] == "val2b" && args["arg3"] == "val3"), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -741,6 +854,7 @@ public async Task BuildCommand_DockerBuildOptions() It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny>(), It.Is>(args => args.SequenceEqual(command.Options.DockerBuildOptions)), It.IsAny(), It.IsAny())); @@ -805,6 +919,7 @@ public async Task BuildCommand_NoBaseImage_Build() TagInfo.GetFullyQualifiedName(repoName, sharedTag) }, It.IsAny>(), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -998,7 +1113,7 @@ public async Task BuildCommand_NoBaseImage_Cached() o.BuildImage( PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDepsLinuxDockerfileRelativePath)), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), - It.IsAny>(), It.IsAny(), It.IsAny()), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); dockerServiceMock.Verify( o => o.GetImageSize(It.IsAny(), false), @@ -1726,7 +1841,7 @@ public async Task BuildCommand_Caching_SharedDockerfile_MissingSourceImageInfoEn dockerServiceMock.Verify(o => o.BuildImage( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), + It.IsAny>(), It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); dockerServiceMock.VerifyNoOtherCalls(); @@ -2034,6 +2149,7 @@ public async Task BuildCommand_Caching_SharedDockerfile_MissingSourceImageInfoEn It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -2241,6 +2357,7 @@ public async Task BuildCommand_Caching_SharedDockerfile_NoExistingImageInfoEntri It.IsAny(), new string[] { expectedTag }, It.IsAny>(), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), @@ -2485,6 +2602,7 @@ public async Task BuildCommand_SharedDockerfile() It.IsAny(), new string[] { expectedTag }, It.IsAny>(), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), @@ -2703,7 +2821,7 @@ public async Task BuildCommand_Caching_TagUpdate() o.BuildImage( PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDepsLinuxDockerfileRelativePath)), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), - It.IsAny>(), It.IsAny(), It.IsAny()), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); dockerServiceMock.Verify( o => o.GetImageSize(It.IsAny(), false), @@ -2962,7 +3080,7 @@ public async Task BuildCommand_Caching_SharedDockerfile_TagUpdate() dockerServiceMock.Verify(o => o.BuildImage( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), + It.IsAny>(), It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); dockerServiceMock.Verify(o => o.GetCreatedDate(It.IsAny(), false)); @@ -3322,6 +3440,7 @@ public async Task BuildCommand_MirroredImages(bool hasCachedImage, string srcBas It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -3468,6 +3587,7 @@ public async Task BuildCommand_MirroredImages_External(string baseImageRegistry, It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -3610,6 +3730,7 @@ public async Task BuildCommand_MirroredImages_BaseImageTagOverride() It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -3674,6 +3795,7 @@ private static Mock CreateDockerServiceMock(string buildOutput = It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())) diff --git a/src/ImageBuilder/Commands/BuildCommand.cs b/src/ImageBuilder/Commands/BuildCommand.cs index 9df4a8e39..f668e8ffd 100644 --- a/src/ImageBuilder/Commands/BuildCommand.cs +++ b/src/ImageBuilder/Commands/BuildCommand.cs @@ -7,9 +7,7 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; -using System.Threading; using System.Threading.Tasks; -using Azure.Core; using Microsoft.DotNet.ImageBuilder.Commands.Build; using Microsoft.DotNet.ImageBuilder.Models.Image; using Microsoft.DotNet.ImageBuilder.ViewModel; @@ -196,7 +194,27 @@ await OnCacheHitAsync( builtTags.AddRange(allTagInfos); builtTagNames.UnionWith(allTags); - BuildImage(platform, allTags); + Dictionary labels = []; + if (!string.IsNullOrEmpty(Options.SourceRepoUrl)) + { + labels[OciAnnotations.Source] = Options.SourceRepoUrl; + labels[OciAnnotations.Revision] = _gitService.GetCommitSha(platform.DockerfilePath, useFullHash: true); + } + + if (platform.FinalStageFromImage is not null) + { + labels[OciAnnotations.BaseName] = + _imageNameResolver.Value.GetFromImagePublicTag(platform.FinalStageFromImage); + + string? baseImageDigest = await imageDigestCache.GetLocalImageDigestAsync( + _imageNameResolver.Value.GetFromImageLocalTag(platform.FinalStageFromImage), Options.IsDryRun); + if (!string.IsNullOrEmpty(baseImageDigest)) + { + labels[OciAnnotations.BaseDigest] = DockerHelper.GetDigestSha(baseImageDigest); + } + } + + BuildImage(platform, allTags, labels); builtPlatforms.Add(platformData); if (Options.IsPushEnabled) @@ -494,7 +512,7 @@ private void ValidatePlatformIsCompatibleWithBaseImage(PlatformInfo platform) } } - private void BuildImage(PlatformInfo platform, IEnumerable allTags) + private void BuildImage(PlatformInfo platform, IEnumerable allTags, IDictionary labels) { ValidatePlatformIsCompatibleWithBaseImage(platform); @@ -508,6 +526,7 @@ private void BuildImage(PlatformInfo platform, IEnumerable allTags) platform.PlatformLabel, allTags, GetBuildArgs(platform), + labels, GetDockerBuildOptions(), Options.IsRetryEnabled, Options.IsDryRun); diff --git a/src/ImageBuilder/DockerService.cs b/src/ImageBuilder/DockerService.cs index d24dbe9a3..250344449 100644 --- a/src/ImageBuilder/DockerService.cs +++ b/src/ImageBuilder/DockerService.cs @@ -34,6 +34,7 @@ public void CreateManifestList(string manifestListTag, IEnumerable image string platform, IEnumerable tags, IDictionary buildArgs, + IDictionary labels, IEnumerable dockerBuildOptions, bool isRetryEnabled, bool isDryRun) @@ -44,12 +45,16 @@ public void CreateManifestList(string manifestListTag, IEnumerable image .Select(buildArg => $" --build-arg {buildArg.Key}={buildArg.Value}"); string buildArgsString = string.Join(string.Empty, buildArgList); + IEnumerable labelList = labels + .Select(label => $" --label {label.Key}={label.Value}"); + string labelsString = string.Join(string.Empty, labelList); + IEnumerable dockerBuildOptionList = dockerBuildOptions .Where(option => !string.IsNullOrWhiteSpace(option)) .Select(option => $" {option}"); string dockerBuildOptionsString = string.Join(string.Empty, dockerBuildOptionList); - string dockerArgs = $"build --platform {platform} {tagArgs} -f {dockerfilePath}{buildArgsString}{dockerBuildOptionsString} {buildContextPath}"; + string dockerArgs = $"build --platform {platform} {tagArgs} -f {dockerfilePath}{buildArgsString}{labelsString}{dockerBuildOptionsString} {buildContextPath}"; if (isRetryEnabled) { diff --git a/src/ImageBuilder/DockerServiceCache.cs b/src/ImageBuilder/DockerServiceCache.cs index 7878c025b..5ff9f8046 100644 --- a/src/ImageBuilder/DockerServiceCache.cs +++ b/src/ImageBuilder/DockerServiceCache.cs @@ -5,8 +5,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using Microsoft.DotNet.ImageBuilder.Models.Manifest; namespace Microsoft.DotNet.ImageBuilder @@ -31,9 +29,25 @@ public DockerServiceCache(IDockerService inner) public Architecture Architecture => _inner.Architecture; public string? BuildImage( - string dockerfilePath, string buildContextPath, string platform, IEnumerable tags, - IDictionary buildArgs, IEnumerable dockerBuildOptions, bool isRetryEnabled, bool isDryRun) => - _inner.BuildImage(dockerfilePath, buildContextPath, platform, tags, buildArgs, dockerBuildOptions, isRetryEnabled, isDryRun); + string dockerfilePath, + string buildContextPath, + string platform, + IEnumerable tags, + IDictionary buildArgs, + IDictionary labels, + IEnumerable dockerBuildOptions, + bool isRetryEnabled, + bool isDryRun) => + _inner.BuildImage( + dockerfilePath, + buildContextPath, + platform, + tags, + buildArgs, + labels, + dockerBuildOptions, + isRetryEnabled, + isDryRun); public (Architecture Arch, string? Variant) GetImageArch(string image, bool isDryRun) => _architectureCache.GetOrAdd(image, _ =>_inner.GetImageArch(image, isDryRun)); @@ -49,10 +63,10 @@ public DateTime GetCreatedDate(string image, bool isDryRun) => public long GetImageSize(string image, bool isDryRun) => _imageSizeCache.GetOrAdd(image, _ => _inner.GetImageSize(image, isDryRun)); - + public bool LocalImageExists(string tag, bool isDryRun) => _localImageExistsCache.GetOrAdd(tag, _ => _inner.LocalImageExists(tag, isDryRun)); - + public void PullImage(string image, string? platform, bool isDryRun) { _pulledImages.GetOrAdd(image, _ => diff --git a/src/ImageBuilder/IDockerService.cs b/src/ImageBuilder/IDockerService.cs index 8eb379f71..6285adf65 100644 --- a/src/ImageBuilder/IDockerService.cs +++ b/src/ImageBuilder/IDockerService.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Threading.Tasks; using Microsoft.DotNet.ImageBuilder.Models.Manifest; namespace Microsoft.DotNet.ImageBuilder @@ -23,12 +22,20 @@ public interface IDockerService void CreateManifestList(string manifestListTag, IEnumerable images, bool isDryRun); + /// + /// Builds a Docker image. + /// + /// + /// Labels to apply to the image. Each entry translates to a --label key=value option on the + /// docker build command. + /// string? BuildImage( string dockerfilePath, string buildContextPath, string platform, IEnumerable tags, IDictionary buildArgs, + IDictionary labels, IEnumerable dockerBuildOptions, bool isRetryEnabled, bool isDryRun); diff --git a/src/ImageBuilder/OciAnnotations.cs b/src/ImageBuilder/OciAnnotations.cs new file mode 100644 index 000000000..f3e2c493f --- /dev/null +++ b/src/ImageBuilder/OciAnnotations.cs @@ -0,0 +1,32 @@ +// 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. + +namespace Microsoft.DotNet.ImageBuilder; + +/// +/// Well-known OCI image annotation keys applied to built images as Docker labels. +/// See https://github.com/opencontainers/image-spec/blob/main/annotations.md. +/// +public static class OciAnnotations +{ + /// + /// URL of the source code repository the image was built from. + /// + public const string Source = "org.opencontainers.image.source"; + + /// + /// Source control revision (commit) the image was built from. + /// + public const string Revision = "org.opencontainers.image.revision"; + + /// + /// Image reference of the base image the image was built from. + /// + public const string BaseName = "org.opencontainers.image.base.name"; + + /// + /// Digest of the base image the image was built from. + /// + public const string BaseDigest = "org.opencontainers.image.base.digest"; +} From 7bff2272a88a40fd2f020c29aae5487d70171ab8 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Tue, 30 Jun 2026 17:18:15 -0700 Subject: [PATCH 04/13] Add custom Dockerfile label --- src/ImageBuilder.Tests/BuildCommandTests.cs | 49 +++++++++++++-------- src/ImageBuilder/Commands/BuildCommand.cs | 6 +++ src/ImageBuilder/GitHelper.cs | 34 ++++++++------ src/ImageBuilder/GitService.cs | 3 ++ src/ImageBuilder/IGitService.cs | 9 ++++ src/ImageBuilder/ImageBuilderLabels.cs | 16 +++++++ 6 files changed, 85 insertions(+), 32 deletions(-) create mode 100644 src/ImageBuilder/ImageBuilderLabels.cs diff --git a/src/ImageBuilder.Tests/BuildCommandTests.cs b/src/ImageBuilder.Tests/BuildCommandTests.cs index f2040d966..514ef6169 100644 --- a/src/ImageBuilder.Tests/BuildCommandTests.cs +++ b/src/ImageBuilder.Tests/BuildCommandTests.cs @@ -138,7 +138,7 @@ public async Task BuildCommand_ImageInfoOutput_Basic() "1.0/aspnet/os", tempFolderContext, $"{runtimeRepo}:{tag}"); const string dockerfileCommitSha = "mycommit"; - Mock gitServiceMock = new(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDepsDockerfileRelativePath)), It.IsAny())) .Returns(dockerfileCommitSha); @@ -341,7 +341,7 @@ public async Task BuildCommand_ImageInfoOutput_DuplicatedPlatform() "1.0/runtime/os", tempFolderContext, baseImageTag); const string dockerfileCommitSha = "mycommit"; - Mock gitServiceMock = new Mock(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDockerfileRelativePath)), It.IsAny())) .Returns(dockerfileCommitSha); @@ -534,7 +534,7 @@ public async Task BuildCommand_Publish() } /// - /// Verifies that the OCI source and base image labels are applied to the built image. + /// Verifies that the OCI source/base image labels and the Dockerfile path label are applied to the built image. /// [TestMethod] public async Task BuildCommand_AppliesOciLabels() @@ -546,11 +546,12 @@ public async Task BuildCommand_AppliesOciLabels() string baseImageDigest = $"{baseImageRepo}@sha256:baseImageDigestSha"; const string sourceRepoUrl = "https://github.com/dotnet/test"; const string commitSha = "c0ff33c0ff33c0ff33c0ff33c0ff33c0ff33c0ff"; + const string dockerfileRepoRootPath = "1.0/runtime/os/Dockerfile"; using TempFolderContext tempFolderContext = TestHelper.UseTempFolder(); Mock dockerServiceMock = CreateDockerServiceMock(); - Mock gitServiceMock = new(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(It.IsAny(), true)) .Returns(commitSha); @@ -591,18 +592,19 @@ public async Task BuildCommand_AppliesOciLabels() It.IsAny>(), It.IsAny>(), It.Is>(labels => - labels.Count == 4 && + labels.Count == 5 && labels[OciAnnotations.Source] == sourceRepoUrl && labels[OciAnnotations.Revision] == commitSha && labels[OciAnnotations.BaseName] == baseImageTag && - labels[OciAnnotations.BaseDigest] == "sha256:baseImageDigestSha"), + labels[OciAnnotations.BaseDigest] == "sha256:baseImageDigestSha" && + labels[ImageBuilderLabels.Dockerfile] == dockerfileRepoRootPath), It.IsAny>(), It.IsAny(), It.IsAny())); } /// - /// Verifies that OCI labels whose source values are unavailable are omitted rather than applied with empty values. + /// Verifies that all labels are omitted when their source data (source repo, base image) is unavailable. /// [TestMethod] public async Task BuildCommand_OmitsOciLabelsWhenDataUnavailable() @@ -964,7 +966,7 @@ public async Task BuildCommand_NoBaseImage_Cached() string runtimeDepsLinuxDockerfileRelativePath = DockerfileHelper.CreateDockerfile( "1.0/runtime-deps/linux", tempFolderContext, "scratch"); - Mock gitServiceMock = new(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDepsLinuxDockerfileRelativePath)), It.IsAny())) .Returns(currentRuntimeDepsCommitSha); @@ -1150,7 +1152,7 @@ public async Task BuildCommand_ImageInfoOutput_CustomDockerfile() File.WriteAllText(Path.Combine(tempFolderContext.Path, dockerfileRelativePath), "FROM repo:tag"); const string dockerfileCommitSha = "mycommit"; - Mock gitServiceMock = new Mock(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(dockerfileRelativePath, It.IsAny())) .Returns(dockerfileCommitSha); @@ -1401,7 +1403,7 @@ public async Task BuildCommand_Caching( string runtimeDockerfileRelativePath = DockerfileHelper.CreateDockerfile( "1.0/runtime/os", tempFolderContext, $"$REPO:{tag}"); - Mock gitServiceMock = new(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDepsDockerfileRelativePath)), It.IsAny())) .Returns(currentRuntimeDepsCommitSha); @@ -1611,7 +1613,7 @@ public async Task BuildCommand_Caching_SharedDockerfile_MissingSourceImageInfoEn string runtimeDepsWindowsDockerfileRelativePath = DockerfileHelper.CreateDockerfile( "1.0/runtime-deps/windows", tempFolderContext, windowsBaseImageTag); - Mock gitServiceMock = new Mock(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDepsLinuxDockerfileRelativePath)), It.IsAny())) .Returns(currentRuntimeDepsCommitSha); @@ -1925,7 +1927,7 @@ public async Task BuildCommand_Caching_SharedDockerfile_MissingSourceImageInfoEn string runtimeDepsDockerfileRelativePath = DockerfileHelper.CreateDockerfile( "1.0/runtime-deps/os", tempFolderContext, baseImageTag); - Mock gitServiceMock = new Mock(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDepsDockerfileRelativePath)), It.IsAny())) .Returns(currentRuntimeDepsCommitSha); @@ -2214,7 +2216,7 @@ public async Task BuildCommand_Caching_SharedDockerfile_NoExistingImageInfoEntri string runtimeDepsDockerfileRelativePath = DockerfileHelper.CreateDockerfile( "1.0/runtime-deps/os", tempFolderContext, baseImageTag); - Mock gitServiceMock = new Mock(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDepsDockerfileRelativePath)), It.IsAny())) .Returns(currentRuntimeDepsCommitSha); @@ -2423,7 +2425,7 @@ public async Task BuildCommand_SharedDockerfile() string runtimeDepsDockerfileRelativePath = DockerfileHelper.CreateDockerfile( "1.0/runtime-deps/os", tempFolderContext, baseImageTag); - Mock gitServiceMock = new(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDepsDockerfileRelativePath)), It.IsAny())) .Returns(currentRuntimeDepsCommitSha); @@ -2667,7 +2669,7 @@ public async Task BuildCommand_Caching_TagUpdate() string runtimeDepsLinuxDockerfileRelativePath = DockerfileHelper.CreateDockerfile( "1.0/runtime-deps/linux", tempFolderContext, baseImageTag); - Mock gitServiceMock = new Mock(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDepsLinuxDockerfileRelativePath)), It.IsAny())) .Returns(currentRuntimeDepsCommitSha); @@ -2889,7 +2891,7 @@ public async Task BuildCommand_Caching_SharedDockerfile_TagUpdate() string runtimeDepsLinuxDockerfileRelativePath = DockerfileHelper.CreateDockerfile( "1.0/runtime-deps/linux", tempFolderContext, baseImageTag); - Mock gitServiceMock = new Mock(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDepsLinuxDockerfileRelativePath)), It.IsAny())) .Returns(currentRuntimeDepsCommitSha); @@ -3196,7 +3198,7 @@ public async Task BuildCommand_MirroredImages(bool hasCachedImage, string srcBas "1.0/aspnet/os", tempFolderContext, $"$REPO:{Tag}"); const string dockerfileCommitSha = "mycommit"; - Mock gitServiceMock = new Mock(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(It.IsAny(), It.IsAny())) .Returns(dockerfileCommitSha); @@ -3542,7 +3544,7 @@ public async Task BuildCommand_MirroredImages_External(string baseImageRegistry, "1.0/samples/os", tempFolderContext, $"{baseImageRegistry}/{RuntimeRepo}:{Tag}"); const string dockerfileCommitSha = "mycommit"; - Mock gitServiceMock = new Mock(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(It.IsAny(), It.IsAny())) .Returns(dockerfileCommitSha); @@ -3639,7 +3641,7 @@ public async Task BuildCommand_MirroredImages_BaseImageTagOverride() ], []); const string dockerfileCommitSha = "mycommit"; - Mock gitServiceMock = new(); + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); gitServiceMock .Setup(o => o.GetCommitSha(It.IsAny(), It.IsAny())) .Returns(dockerfileCommitSha); @@ -3808,6 +3810,15 @@ private static Mock CreateDockerServiceMock(string buildOutput = return dockerServiceMock; } + private static Mock CreateGitServiceMock(TempFolderContext tempFolderContext) + { + Mock mock = new(); + mock + .Setup(o => o.GetRepoRoot(It.IsAny())) + .Returns(tempFolderContext.Path); + return mock; + } + private static void VerifyImportImage(Mock copyImageServiceMock, BuildCommand command, string[] destTagNames, string srcTagName, string destRegistryName, string srcRegistryName) { diff --git a/src/ImageBuilder/Commands/BuildCommand.cs b/src/ImageBuilder/Commands/BuildCommand.cs index f668e8ffd..e36dc7ecd 100644 --- a/src/ImageBuilder/Commands/BuildCommand.cs +++ b/src/ImageBuilder/Commands/BuildCommand.cs @@ -199,6 +199,12 @@ await OnCacheHitAsync( { labels[OciAnnotations.Source] = Options.SourceRepoUrl; labels[OciAnnotations.Revision] = _gitService.GetCommitSha(platform.DockerfilePath, useFullHash: true); + + // Record which Dockerfile the image was built from, relative to the repo root. + // The repo root must be discovered via Git rather than assuming it's the manifest's directory. + string repoRoot = _gitService.GetRepoRoot(platform.DockerfilePath); + labels[ImageBuilderLabels.Dockerfile] = + PathHelper.NormalizePath(Path.GetRelativePath(repoRoot, platform.DockerfilePath)); } if (platform.FinalStageFromImage is not null) diff --git a/src/ImageBuilder/GitHelper.cs b/src/ImageBuilder/GitHelper.cs index aaf9893ae..6321f39bd 100644 --- a/src/ImageBuilder/GitHelper.cs +++ b/src/ImageBuilder/GitHelper.cs @@ -24,19 +24,7 @@ public static class GitHelper public static string GetCommitSha(string filePath, bool useFullHash = false) { - // Don't make the assumption that the current working directory is a Git repository - // Find the Git repo that contains the file being checked. - DirectoryInfo directory = new FileInfo(filePath).Directory; - while (!directory.GetDirectories(".git").Any()) - { - directory = directory.Parent; - - if (directory is null) - { - throw new InvalidOperationException($"File '{filePath}' is not contained within a Git repository."); - } - } - + DirectoryInfo directory = GetContainingRepoRoot(filePath); filePath = Path.GetRelativePath(directory.FullName, filePath); string format = useFullHash ? "H" : "h"; @@ -49,6 +37,26 @@ public static string GetCommitSha(string filePath, bool useFullHash = false) $"Unable to retrieve the latest commit SHA for {filePath}"); } + public static string GetRepoRoot(string path) => GetContainingRepoRoot(path).FullName; + + // Don't make the assumption that the current working directory is a Git repository. + // Walk up from the given path to find the Git repo that contains it. + private static DirectoryInfo GetContainingRepoRoot(string path) + { + DirectoryInfo directory = new FileInfo(path).Directory; + while (!directory.GetDirectories(".git").Any()) + { + directory = directory.Parent; + + if (directory is null) + { + throw new InvalidOperationException($"File '{path}' is not contained within a Git repository."); + } + } + + return directory; + } + public static Uri GetArchiveUrl(IGitHubBranchRef branchRef) => new Uri($"https://github.com/{branchRef.Owner}/{branchRef.Repo}/archive/{branchRef.Branch}.zip"); diff --git a/src/ImageBuilder/GitService.cs b/src/ImageBuilder/GitService.cs index e062a1dd7..1fe2d7704 100644 --- a/src/ImageBuilder/GitService.cs +++ b/src/ImageBuilder/GitService.cs @@ -16,6 +16,9 @@ public string GetCommitSha(string filePath, bool useFullHash = false) return GitHelper.GetCommitSha(filePath, useFullHash); } + /// + public string GetRepoRoot(string path) => GitHelper.GetRepoRoot(path); + public IRepository CloneRepository(string sourceUrl, string workdirPath, CloneOptions options) { _logger.LogInformation($"Cloning repository {sourceUrl} to {workdirPath}"); diff --git a/src/ImageBuilder/IGitService.cs b/src/ImageBuilder/IGitService.cs index 0765ddc69..fdde000ef 100644 --- a/src/ImageBuilder/IGitService.cs +++ b/src/ImageBuilder/IGitService.cs @@ -10,6 +10,15 @@ public interface IGitService { string GetCommitSha(string filePath, bool useFullHash = false); + /// + /// Gets the absolute path to the root of the Git repository that contains the given path. + /// + /// + /// An absolute path to a file or directory that resides within a Git repository's working tree. + /// + /// The absolute path to the containing repository's root directory. + string GetRepoRoot(string path); + IRepository CloneRepository(string sourceUrl, string workdirPath, CloneOptions options); void Stage(IRepository repository, string path); diff --git a/src/ImageBuilder/ImageBuilderLabels.cs b/src/ImageBuilder/ImageBuilderLabels.cs new file mode 100644 index 000000000..655fcce13 --- /dev/null +++ b/src/ImageBuilder/ImageBuilderLabels.cs @@ -0,0 +1,16 @@ +// 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. + +namespace Microsoft.DotNet.ImageBuilder; + +/// +/// Custom (non-OCI) image label keys applied to built images. +/// +public static class ImageBuilderLabels +{ + /// + /// Path of the Dockerfile the image was built from, relative to the root of the source repository. + /// + public const string Dockerfile = "com.microsoft.imagebuilder.dockerfile"; +} From 9ade0d0ca289f2cefd82b297014f820e8e9a4d18 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Tue, 30 Jun 2026 17:51:42 -0700 Subject: [PATCH 05/13] Handle worktrees in GetRepoRoot --- src/ImageBuilder/GitHelper.cs | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/ImageBuilder/GitHelper.cs b/src/ImageBuilder/GitHelper.cs index 6321f39bd..e22170c88 100644 --- a/src/ImageBuilder/GitHelper.cs +++ b/src/ImageBuilder/GitHelper.cs @@ -24,37 +24,34 @@ public static class GitHelper public static string GetCommitSha(string filePath, bool useFullHash = false) { - DirectoryInfo directory = GetContainingRepoRoot(filePath); - filePath = Path.GetRelativePath(directory.FullName, filePath); + string repoRoot = GetRepoRoot(filePath); + filePath = Path.GetRelativePath(repoRoot, filePath); string format = useFullHash ? "H" : "h"; return ExecuteHelper.Execute( new ProcessStartInfo("git", $"log -1 --format=format:%{format} {filePath}") { - WorkingDirectory = directory.FullName + WorkingDirectory = repoRoot }, false, $"Unable to retrieve the latest commit SHA for {filePath}"); } - public static string GetRepoRoot(string path) => GetContainingRepoRoot(path).FullName; - // Don't make the assumption that the current working directory is a Git repository. - // Walk up from the given path to find the Git repo that contains it. - private static DirectoryInfo GetContainingRepoRoot(string path) + // Walk up from the given path to find the root of the containing Git repository. + public static string GetRepoRoot(string path) { - DirectoryInfo directory = new FileInfo(path).Directory; - while (!directory.GetDirectories(".git").Any()) - { - directory = directory.Parent; + DirectoryInfo directory = Directory.Exists(path) ? new DirectoryInfo(path) : new FileInfo(path).Directory; - if (directory is null) - { - throw new InvalidOperationException($"File '{path}' is not contained within a Git repository."); - } + // The repository root is marked by a ".git" entry. It's a directory in a normal + // checkout, but a file (a gitdir pointer) in linked worktrees and submodules. + while (!directory.EnumerateFileSystemInfos(".git").Any()) + { + directory = directory.Parent + ?? throw new InvalidOperationException($"'{path}' is not contained within a Git repository."); } - return directory; + return directory.FullName; } public static Uri GetArchiveUrl(IGitHubBranchRef branchRef) => From 4ad51b9a9218dd57c0ee8afc769fc401692b6342 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Wed, 8 Jul 2026 12:09:38 -0700 Subject: [PATCH 06/13] Remove Docker --label metadata plumbing from build command Removes the `--label` build-metadata mechanism from the image build path in preparation for attaching the same metadata as an OCI referrer artifact. The `labels` parameter is dropped from `IDockerService.BuildImage` (and its `DockerService`/`DockerServiceCache` implementations) along with the label-building block in `BuildCommand`. The corresponding label-verification tests are removed and the `BuildImage` mock verifications are updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ImageBuilder.Tests/BuildCommandTests.cs | 143 +------------------- src/ImageBuilder/Commands/BuildCommand.cs | 31 +---- src/ImageBuilder/DockerService.cs | 7 +- src/ImageBuilder/DockerServiceCache.cs | 2 - src/ImageBuilder/IDockerService.cs | 8 -- 5 files changed, 8 insertions(+), 183 deletions(-) diff --git a/src/ImageBuilder.Tests/BuildCommandTests.cs b/src/ImageBuilder.Tests/BuildCommandTests.cs index 514ef6169..19227c3b1 100644 --- a/src/ImageBuilder.Tests/BuildCommandTests.cs +++ b/src/ImageBuilder.Tests/BuildCommandTests.cs @@ -1,4 +1,4 @@ -#nullable disable +#nullable disable // 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. @@ -517,7 +517,6 @@ public async Task BuildCommand_Publish() TagInfo.GetFullyQualifiedName(repoName, sharedTag) }, It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -533,126 +532,6 @@ public async Task BuildCommand_Publish() copyImageServiceMock.VerifyNoOtherCalls(); } - /// - /// Verifies that the OCI source/base image labels and the Dockerfile path label are applied to the built image. - /// - [TestMethod] - public async Task BuildCommand_AppliesOciLabels() - { - const string repoName = "runtime"; - const string tag = "tag"; - const string baseImageRepo = "baserepo"; - string baseImageTag = $"{baseImageRepo}:basetag"; - string baseImageDigest = $"{baseImageRepo}@sha256:baseImageDigestSha"; - const string sourceRepoUrl = "https://github.com/dotnet/test"; - const string commitSha = "c0ff33c0ff33c0ff33c0ff33c0ff33c0ff33c0ff"; - const string dockerfileRepoRootPath = "1.0/runtime/os/Dockerfile"; - - using TempFolderContext tempFolderContext = TestHelper.UseTempFolder(); - Mock dockerServiceMock = CreateDockerServiceMock(); - - Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); - gitServiceMock - .Setup(o => o.GetCommitSha(It.IsAny(), true)) - .Returns(commitSha); - - BuildCommand command = CreateBuildCommand( - dockerService: dockerServiceMock.Object, - gitService: gitServiceMock.Object, - copyImageService: Mock.Of(), - manifestServiceFactory: CreateManifestServiceFactoryMock( - localImageDigestResults: [new(baseImageTag, baseImageDigest)]).Object, - imageCacheService: new ImageCacheService(Mock.Of>(), Mock.Of())); - command.Options.Manifest = Path.Combine(tempFolderContext.Path, "manifest.json"); - command.Options.SourceRepoUrl = sourceRepoUrl; - - const string runtimeRelativeDir = "1.0/runtime/os"; - Directory.CreateDirectory(Path.Combine(tempFolderContext.Path, runtimeRelativeDir)); - string dockerfileRelativePath = Path.Combine(runtimeRelativeDir, "Dockerfile"); - string dockerfileAbsolutePath = PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, dockerfileRelativePath)); - File.WriteAllText(dockerfileAbsolutePath, $"FROM {baseImageTag}"); - - Platform platform = CreatePlatform(dockerfileRelativePath, new string[] { tag }); - - Manifest manifest = CreateManifest( - CreateRepo(repoName, - CreateImage( - new Platform[] { platform }))); - - File.WriteAllText(Path.Combine(tempFolderContext.Path, command.Options.Manifest), JsonConvert.SerializeObject(manifest)); - - command.LoadManifest(); - await command.ExecuteAsync(); - - dockerServiceMock.Verify( - o => o.BuildImage( - dockerfileAbsolutePath, - PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeRelativeDir)), - "linux/amd64", - It.IsAny>(), - It.IsAny>(), - It.Is>(labels => - labels.Count == 5 && - labels[OciAnnotations.Source] == sourceRepoUrl && - labels[OciAnnotations.Revision] == commitSha && - labels[OciAnnotations.BaseName] == baseImageTag && - labels[OciAnnotations.BaseDigest] == "sha256:baseImageDigestSha" && - labels[ImageBuilderLabels.Dockerfile] == dockerfileRepoRootPath), - It.IsAny>(), - It.IsAny(), - It.IsAny())); - } - - /// - /// Verifies that all labels are omitted when their source data (source repo, base image) is unavailable. - /// - [TestMethod] - public async Task BuildCommand_OmitsOciLabelsWhenDataUnavailable() - { - const string repoName = "runtime"; - const string tag = "tag"; - - using TempFolderContext tempFolderContext = TestHelper.UseTempFolder(); - Mock dockerServiceMock = CreateDockerServiceMock(); - - BuildCommand command = CreateBuildCommand( - dockerService: dockerServiceMock.Object, - copyImageService: Mock.Of(), - manifestServiceFactory: CreateManifestServiceFactoryMock().Object, - imageCacheService: new ImageCacheService(Mock.Of>(), Mock.Of())); - command.Options.Manifest = Path.Combine(tempFolderContext.Path, "manifest.json"); - - const string runtimeRelativeDir = "1.0/runtime/os"; - Directory.CreateDirectory(Path.Combine(tempFolderContext.Path, runtimeRelativeDir)); - string dockerfileRelativePath = Path.Combine(runtimeRelativeDir, "Dockerfile"); - string dockerfileAbsolutePath = PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, dockerfileRelativePath)); - File.WriteAllText(dockerfileAbsolutePath, "FROM scratch"); - - Platform platform = CreatePlatform(dockerfileRelativePath, new string[] { tag }); - - Manifest manifest = CreateManifest( - CreateRepo(repoName, - CreateImage( - new Platform[] { platform }))); - - File.WriteAllText(Path.Combine(tempFolderContext.Path, command.Options.Manifest), JsonConvert.SerializeObject(manifest)); - - command.LoadManifest(); - await command.ExecuteAsync(); - - dockerServiceMock.Verify( - o => o.BuildImage( - dockerfileAbsolutePath, - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny>(), - It.Is>(labels => labels.Count == 0), - It.IsAny>(), - It.IsAny(), - It.IsAny())); - } - [TestMethod] public async Task BuildCommand_VerifyOnBaseImageArchMismatch() { @@ -801,7 +680,6 @@ public async Task BuildCommand_BuildArgs() It.IsAny>(), It.Is>( args => args.Count == 3 && args["arg1"] == "val1" && args["arg2"] == "val2b" && args["arg3"] == "val3"), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -856,7 +734,6 @@ public async Task BuildCommand_DockerBuildOptions() It.IsAny(), It.IsAny>(), It.IsAny>(), - It.IsAny>(), It.Is>(args => args.SequenceEqual(command.Options.DockerBuildOptions)), It.IsAny(), It.IsAny())); @@ -921,7 +798,6 @@ public async Task BuildCommand_NoBaseImage_Build() TagInfo.GetFullyQualifiedName(repoName, sharedTag) }, It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -1114,8 +990,7 @@ public async Task BuildCommand_NoBaseImage_Cached() dockerServiceMock.Verify(o => o.BuildImage( PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDepsLinuxDockerfileRelativePath)), - It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); dockerServiceMock.Verify( o => o.GetImageSize(It.IsAny(), false), @@ -1843,7 +1718,7 @@ public async Task BuildCommand_Caching_SharedDockerfile_MissingSourceImageInfoEn dockerServiceMock.Verify(o => o.BuildImage( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); dockerServiceMock.VerifyNoOtherCalls(); @@ -2151,7 +2026,6 @@ public async Task BuildCommand_Caching_SharedDockerfile_MissingSourceImageInfoEn It.IsAny(), It.IsAny>(), It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -2359,7 +2233,6 @@ public async Task BuildCommand_Caching_SharedDockerfile_NoExistingImageInfoEntri It.IsAny(), new string[] { expectedTag }, It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), @@ -2604,7 +2477,6 @@ public async Task BuildCommand_SharedDockerfile() It.IsAny(), new string[] { expectedTag }, It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), @@ -2822,8 +2694,7 @@ public async Task BuildCommand_Caching_TagUpdate() dockerServiceMock.Verify(o => o.BuildImage( PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, runtimeDepsLinuxDockerfileRelativePath)), - It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); dockerServiceMock.Verify( o => o.GetImageSize(It.IsAny(), false), @@ -3082,7 +2953,7 @@ public async Task BuildCommand_Caching_SharedDockerfile_TagUpdate() dockerServiceMock.Verify(o => o.BuildImage( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), + It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); dockerServiceMock.Verify(o => o.GetCreatedDate(It.IsAny(), false)); @@ -3442,7 +3313,6 @@ public async Task BuildCommand_MirroredImages(bool hasCachedImage, string srcBas It.IsAny(), It.IsAny>(), It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -3589,7 +3459,6 @@ public async Task BuildCommand_MirroredImages_External(string baseImageRegistry, It.IsAny(), It.IsAny>(), It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -3732,7 +3601,6 @@ public async Task BuildCommand_MirroredImages_BaseImageTagOverride() It.IsAny(), It.IsAny>(), It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())); @@ -3797,7 +3665,6 @@ private static Mock CreateDockerServiceMock(string buildOutput = It.IsAny(), It.IsAny>(), It.IsAny>(), - It.IsAny>(), It.IsAny>(), It.IsAny(), It.IsAny())) diff --git a/src/ImageBuilder/Commands/BuildCommand.cs b/src/ImageBuilder/Commands/BuildCommand.cs index e36dc7ecd..7c4f7d543 100644 --- a/src/ImageBuilder/Commands/BuildCommand.cs +++ b/src/ImageBuilder/Commands/BuildCommand.cs @@ -194,33 +194,7 @@ await OnCacheHitAsync( builtTags.AddRange(allTagInfos); builtTagNames.UnionWith(allTags); - Dictionary labels = []; - if (!string.IsNullOrEmpty(Options.SourceRepoUrl)) - { - labels[OciAnnotations.Source] = Options.SourceRepoUrl; - labels[OciAnnotations.Revision] = _gitService.GetCommitSha(platform.DockerfilePath, useFullHash: true); - - // Record which Dockerfile the image was built from, relative to the repo root. - // The repo root must be discovered via Git rather than assuming it's the manifest's directory. - string repoRoot = _gitService.GetRepoRoot(platform.DockerfilePath); - labels[ImageBuilderLabels.Dockerfile] = - PathHelper.NormalizePath(Path.GetRelativePath(repoRoot, platform.DockerfilePath)); - } - - if (platform.FinalStageFromImage is not null) - { - labels[OciAnnotations.BaseName] = - _imageNameResolver.Value.GetFromImagePublicTag(platform.FinalStageFromImage); - - string? baseImageDigest = await imageDigestCache.GetLocalImageDigestAsync( - _imageNameResolver.Value.GetFromImageLocalTag(platform.FinalStageFromImage), Options.IsDryRun); - if (!string.IsNullOrEmpty(baseImageDigest)) - { - labels[OciAnnotations.BaseDigest] = DockerHelper.GetDigestSha(baseImageDigest); - } - } - - BuildImage(platform, allTags, labels); + BuildImage(platform, allTags); builtPlatforms.Add(platformData); if (Options.IsPushEnabled) @@ -518,7 +492,7 @@ private void ValidatePlatformIsCompatibleWithBaseImage(PlatformInfo platform) } } - private void BuildImage(PlatformInfo platform, IEnumerable allTags, IDictionary labels) + private void BuildImage(PlatformInfo platform, IEnumerable allTags) { ValidatePlatformIsCompatibleWithBaseImage(platform); @@ -532,7 +506,6 @@ private void BuildImage(PlatformInfo platform, IEnumerable allTags, IDic platform.PlatformLabel, allTags, GetBuildArgs(platform), - labels, GetDockerBuildOptions(), Options.IsRetryEnabled, Options.IsDryRun); diff --git a/src/ImageBuilder/DockerService.cs b/src/ImageBuilder/DockerService.cs index 250344449..d24dbe9a3 100644 --- a/src/ImageBuilder/DockerService.cs +++ b/src/ImageBuilder/DockerService.cs @@ -34,7 +34,6 @@ public void CreateManifestList(string manifestListTag, IEnumerable image string platform, IEnumerable tags, IDictionary buildArgs, - IDictionary labels, IEnumerable dockerBuildOptions, bool isRetryEnabled, bool isDryRun) @@ -45,16 +44,12 @@ public void CreateManifestList(string manifestListTag, IEnumerable image .Select(buildArg => $" --build-arg {buildArg.Key}={buildArg.Value}"); string buildArgsString = string.Join(string.Empty, buildArgList); - IEnumerable labelList = labels - .Select(label => $" --label {label.Key}={label.Value}"); - string labelsString = string.Join(string.Empty, labelList); - IEnumerable dockerBuildOptionList = dockerBuildOptions .Where(option => !string.IsNullOrWhiteSpace(option)) .Select(option => $" {option}"); string dockerBuildOptionsString = string.Join(string.Empty, dockerBuildOptionList); - string dockerArgs = $"build --platform {platform} {tagArgs} -f {dockerfilePath}{buildArgsString}{labelsString}{dockerBuildOptionsString} {buildContextPath}"; + string dockerArgs = $"build --platform {platform} {tagArgs} -f {dockerfilePath}{buildArgsString}{dockerBuildOptionsString} {buildContextPath}"; if (isRetryEnabled) { diff --git a/src/ImageBuilder/DockerServiceCache.cs b/src/ImageBuilder/DockerServiceCache.cs index 5ff9f8046..1869bd4e7 100644 --- a/src/ImageBuilder/DockerServiceCache.cs +++ b/src/ImageBuilder/DockerServiceCache.cs @@ -34,7 +34,6 @@ public DockerServiceCache(IDockerService inner) string platform, IEnumerable tags, IDictionary buildArgs, - IDictionary labels, IEnumerable dockerBuildOptions, bool isRetryEnabled, bool isDryRun) => @@ -44,7 +43,6 @@ public DockerServiceCache(IDockerService inner) platform, tags, buildArgs, - labels, dockerBuildOptions, isRetryEnabled, isDryRun); diff --git a/src/ImageBuilder/IDockerService.cs b/src/ImageBuilder/IDockerService.cs index 6285adf65..287758163 100644 --- a/src/ImageBuilder/IDockerService.cs +++ b/src/ImageBuilder/IDockerService.cs @@ -22,20 +22,12 @@ public interface IDockerService void CreateManifestList(string manifestListTag, IEnumerable images, bool isDryRun); - /// - /// Builds a Docker image. - /// - /// - /// Labels to apply to the image. Each entry translates to a --label key=value option on the - /// docker build command. - /// string? BuildImage( string dockerfilePath, string buildContextPath, string platform, IEnumerable tags, IDictionary buildArgs, - IDictionary labels, IEnumerable dockerBuildOptions, bool isRetryEnabled, bool isDryRun); From 7524cf65a01e06858da95884507559c72d7104e4 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Wed, 8 Jul 2026 12:10:37 -0700 Subject: [PATCH 07/13] Attach build metadata as an OCI referrer artifact Attaches image build metadata (source repo, revision, Dockerfile path, and base image name/digest) to each freshly built and pushed platform image as an OCI referrer artifact whose annotations carry the metadata, rather than baking it into the image as Docker labels. Referrer annotations do not propagate to downstream images and can evolve independently of the image, per dotnet/docker-tools#2166. The metadata is recorded using annotation keys in ImageBuilder's own `com.microsoft.imagebuilder.*` namespace rather than the standard `org.opencontainers.image.*` keys. OCI annotations describe the artifact they are placed on; on a referrer artifact the standard keys would describe the referrer itself rather than the subject image, so a custom namespace is used. After pushing a platform, `BuildCommand` builds the annotations inline and attaches them to the pushed image by digest via an `IOrasService` (created from the command's registry credentials) using the new `OciArtifactType.ImageInfoReferrer` artifact type. The `ImageBuilderLabels` constants type is replaced by `ImageBuilderAnnotations`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ImageBuilder.Tests/BuildCommandTests.cs | 208 +++++++++++++++++++- src/ImageBuilder/Commands/BuildCommand.cs | 45 ++++- src/ImageBuilder/ImageBuilderAnnotations.cs | 42 ++++ src/ImageBuilder/ImageBuilderLabels.cs | 16 -- src/ImageBuilder/OciAnnotations.cs | 32 --- src/ImageBuilder/Oras/OciArtifactType.cs | 7 + 6 files changed, 299 insertions(+), 51 deletions(-) create mode 100644 src/ImageBuilder/ImageBuilderAnnotations.cs delete mode 100644 src/ImageBuilder/ImageBuilderLabels.cs delete mode 100644 src/ImageBuilder/OciAnnotations.cs diff --git a/src/ImageBuilder.Tests/BuildCommandTests.cs b/src/ImageBuilder.Tests/BuildCommandTests.cs index 19227c3b1..49f4e19cf 100644 --- a/src/ImageBuilder.Tests/BuildCommandTests.cs +++ b/src/ImageBuilder.Tests/BuildCommandTests.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Azure.ResourceManager.ContainerRegistry.Models; using FluentAssertions; @@ -532,6 +533,196 @@ public async Task BuildCommand_Publish() copyImageServiceMock.VerifyNoOtherCalls(); } + /// + /// Verifies that image build metadata (source, revision, Dockerfile, and base image) is attached to the + /// pushed image as an OCI referrer artifact, using the image digest as the subject. + /// + [TestMethod] + public async Task BuildCommand_AttachesImageMetadataReferrer() + { + const string repoName = "runtime"; + const string tag = "tag"; + const string baseImageRepo = "baserepo"; + string baseImageTag = $"{baseImageRepo}:basetag"; + string baseImageDigest = $"{baseImageRepo}@sha256:baseImageDigestSha"; + string imageDigest = $"{repoName}@sha256:builtImageDigestSha"; + const string sourceRepoUrl = "https://github.com/dotnet/test"; + const string commitSha = "c0ff33c0ff33c0ff33c0ff33c0ff33c0ff33c0ff"; + const string dockerfileRepoRootPath = "1.0/runtime/os/Dockerfile"; + + using TempFolderContext tempFolderContext = TestHelper.UseTempFolder(); + Mock dockerServiceMock = CreateDockerServiceMock(); + + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); + gitServiceMock + .Setup(o => o.GetCommitSha(It.IsAny(), true)) + .Returns(commitSha); + + Mock orasServiceFactoryMock = CreateOrasServiceFactoryMock(out Mock orasServiceMock); + + BuildCommand command = CreateBuildCommand( + dockerService: dockerServiceMock.Object, + gitService: gitServiceMock.Object, + copyImageService: Mock.Of(), + manifestServiceFactory: CreateManifestServiceFactoryMock( + localImageDigestResults: + [ + new(baseImageTag, baseImageDigest), + new($"{repoName}:{tag}", imageDigest) + ]).Object, + imageCacheService: new ImageCacheService(Mock.Of>(), Mock.Of()), + orasServiceFactory: orasServiceFactoryMock.Object); + command.Options.Manifest = Path.Combine(tempFolderContext.Path, "manifest.json"); + command.Options.SourceRepoUrl = sourceRepoUrl; + command.Options.IsPushEnabled = true; + + const string runtimeRelativeDir = "1.0/runtime/os"; + Directory.CreateDirectory(Path.Combine(tempFolderContext.Path, runtimeRelativeDir)); + string dockerfileRelativePath = Path.Combine(runtimeRelativeDir, "Dockerfile"); + string dockerfileAbsolutePath = PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, dockerfileRelativePath)); + File.WriteAllText(dockerfileAbsolutePath, $"FROM {baseImageTag}"); + + Platform platform = CreatePlatform(dockerfileRelativePath, [tag]); + + Manifest manifest = CreateManifest( + CreateRepo(repoName, + CreateImage( + [platform]))); + + File.WriteAllText(Path.Combine(tempFolderContext.Path, command.Options.Manifest), JsonConvert.SerializeObject(manifest)); + + command.LoadManifest(); + await command.ExecuteAsync(); + + orasServiceMock.Verify( + o => o.AttachArtifactAsync( + imageDigest, + OciArtifactType.ImageInfoReferrer, + It.Is>(annotations => + annotations.Count == 5 && + annotations[ImageBuilderAnnotations.Source] == sourceRepoUrl && + annotations[ImageBuilderAnnotations.Revision] == commitSha && + annotations[ImageBuilderAnnotations.BaseName] == baseImageTag && + annotations[ImageBuilderAnnotations.BaseDigest] == "sha256:baseImageDigestSha" && + annotations[ImageBuilderAnnotations.Dockerfile] == dockerfileRepoRootPath), + It.IsAny())); + + // The ORAS service must be created with the command's registry credentials so it can push to ACR. + orasServiceFactoryMock.Verify(o => o.Create(command.Options.CredentialsOptions)); + } + + /// + /// Verifies that no referrer artifact is attached when there is no metadata to record (no source repo + /// and no base image). + /// + [TestMethod] + public async Task BuildCommand_SkipsMetadataReferrerWhenDataUnavailable() + { + const string repoName = "runtime"; + const string tag = "tag"; + + using TempFolderContext tempFolderContext = TestHelper.UseTempFolder(); + Mock dockerServiceMock = CreateDockerServiceMock(); + + Mock orasServiceFactoryMock = CreateOrasServiceFactoryMock(out Mock orasServiceMock); + + BuildCommand command = CreateBuildCommand( + dockerService: dockerServiceMock.Object, + copyImageService: Mock.Of(), + manifestServiceFactory: CreateManifestServiceFactoryMock().Object, + imageCacheService: new ImageCacheService(Mock.Of>(), Mock.Of()), + orasServiceFactory: orasServiceFactoryMock.Object); + command.Options.Manifest = Path.Combine(tempFolderContext.Path, "manifest.json"); + command.Options.IsPushEnabled = true; + + const string runtimeRelativeDir = "1.0/runtime/os"; + Directory.CreateDirectory(Path.Combine(tempFolderContext.Path, runtimeRelativeDir)); + string dockerfileRelativePath = Path.Combine(runtimeRelativeDir, "Dockerfile"); + string dockerfileAbsolutePath = PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, dockerfileRelativePath)); + File.WriteAllText(dockerfileAbsolutePath, "FROM scratch"); + + Platform platform = CreatePlatform(dockerfileRelativePath, [tag]); + + Manifest manifest = CreateManifest( + CreateRepo(repoName, + CreateImage( + [platform]))); + + File.WriteAllText(Path.Combine(tempFolderContext.Path, command.Options.Manifest), JsonConvert.SerializeObject(manifest)); + + command.LoadManifest(); + await command.ExecuteAsync(); + + orasServiceMock.Verify( + o => o.AttachArtifactAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + /// + /// Verifies that no referrer artifact is attached when pushing is disabled, since a referrer can only be + /// attached to an image that exists in the registry. + /// + [TestMethod] + public async Task BuildCommand_SkipsMetadataReferrerWhenPushDisabled() + { + const string repoName = "runtime"; + const string tag = "tag"; + const string baseImageRepo = "baserepo"; + string baseImageTag = $"{baseImageRepo}:basetag"; + const string sourceRepoUrl = "https://github.com/dotnet/test"; + + using TempFolderContext tempFolderContext = TestHelper.UseTempFolder(); + Mock dockerServiceMock = CreateDockerServiceMock(); + + Mock gitServiceMock = CreateGitServiceMock(tempFolderContext); + gitServiceMock + .Setup(o => o.GetCommitSha(It.IsAny(), true)) + .Returns("c0ff33"); + + Mock orasServiceFactoryMock = CreateOrasServiceFactoryMock(out Mock orasServiceMock); + + BuildCommand command = CreateBuildCommand( + dockerService: dockerServiceMock.Object, + gitService: gitServiceMock.Object, + copyImageService: Mock.Of(), + manifestServiceFactory: CreateManifestServiceFactoryMock().Object, + imageCacheService: new ImageCacheService(Mock.Of>(), Mock.Of()), + orasServiceFactory: orasServiceFactoryMock.Object); + command.Options.Manifest = Path.Combine(tempFolderContext.Path, "manifest.json"); + command.Options.SourceRepoUrl = sourceRepoUrl; + command.Options.IsPushEnabled = false; + + const string runtimeRelativeDir = "1.0/runtime/os"; + Directory.CreateDirectory(Path.Combine(tempFolderContext.Path, runtimeRelativeDir)); + string dockerfileRelativePath = Path.Combine(runtimeRelativeDir, "Dockerfile"); + string dockerfileAbsolutePath = PathHelper.NormalizePath(Path.Combine(tempFolderContext.Path, dockerfileRelativePath)); + File.WriteAllText(dockerfileAbsolutePath, $"FROM {baseImageTag}"); + + Platform platform = CreatePlatform(dockerfileRelativePath, [tag]); + + Manifest manifest = CreateManifest( + CreateRepo(repoName, + CreateImage( + [platform]))); + + File.WriteAllText(Path.Combine(tempFolderContext.Path, command.Options.Manifest), JsonConvert.SerializeObject(manifest)); + + command.LoadManifest(); + await command.ExecuteAsync(); + + orasServiceMock.Verify( + o => o.AttachArtifactAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + [TestMethod] public async Task BuildCommand_VerifyOnBaseImageArchMismatch() { @@ -3632,7 +3823,8 @@ private static BuildCommand CreateBuildCommand( IManifestServiceFactory? manifestServiceFactory = null, IRegistryCredentialsProvider? registryCredentialsProvider = null, IAzureTokenCredentialProvider? azureTokenCredentialProvider = null, - IImageCacheService? imageCacheService = null) + IImageCacheService? imageCacheService = null, + IOrasServiceFactory? orasServiceFactory = null) { BuildCommand command = new( manifestJsonService ?? TestHelper.CreateManifestJsonService(), @@ -3644,7 +3836,8 @@ private static BuildCommand CreateBuildCommand( manifestServiceFactory ?? Mock.Of(), registryCredentialsProvider ?? Mock.Of(), azureTokenCredentialProvider ?? Mock.Of(), - imageCacheService ?? Mock.Of()); + imageCacheService ?? Mock.Of(), + orasServiceFactory ?? CreateOrasServiceFactoryMock(out _).Object); return command; } @@ -3686,6 +3879,17 @@ private static Mock CreateGitServiceMock(TempFolderContext tempFold return mock; } + private static Mock CreateOrasServiceFactoryMock(out Mock orasServiceMock) + { + orasServiceMock = new Mock(); + Mock capturedMock = orasServiceMock; + Mock factoryMock = new(); + factoryMock + .Setup(o => o.Create(It.IsAny())) + .Returns(() => capturedMock.Object); + return factoryMock; + } + private static void VerifyImportImage(Mock copyImageServiceMock, BuildCommand command, string[] destTagNames, string srcTagName, string destRegistryName, string srcRegistryName) { diff --git a/src/ImageBuilder/Commands/BuildCommand.cs b/src/ImageBuilder/Commands/BuildCommand.cs index 7c4f7d543..7d64e2d83 100644 --- a/src/ImageBuilder/Commands/BuildCommand.cs +++ b/src/ImageBuilder/Commands/BuildCommand.cs @@ -26,6 +26,7 @@ public class BuildCommand : ManifestCommand private readonly IAzureTokenCredentialProvider _tokenCredentialProvider; private readonly IImageCacheService _imageCacheService; private readonly Lazy _imageNameResolver; + private readonly Lazy _orasService; private readonly Lazy _storageAccountToken; public BuildCommand( @@ -38,7 +39,8 @@ public BuildCommand( IManifestServiceFactory manifestServiceFactory, IRegistryCredentialsProvider registryCredentialsProvider, IAzureTokenCredentialProvider tokenCredentialProvider, - IImageCacheService imageCacheService) : base(manifestJsonService) + IImageCacheService imageCacheService, + Oras.IOrasServiceFactory orasServiceFactory) : base(manifestJsonService) { _dockerService = new DockerServiceCache(dockerService ?? throw new ArgumentNullException(nameof(dockerService))); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -54,6 +56,10 @@ public BuildCommand( _manifestService = new Lazy(() => manifestServiceFactory.Create(Options.CredentialsOptions)); + ArgumentNullException.ThrowIfNull(orasServiceFactory); + _orasService = new Lazy(() => + orasServiceFactory.Create(Options.CredentialsOptions)); + _imageNameResolver = new Lazy(() => new ImageNameResolverForBuild( Options.BaseImageOverrideOptions, @@ -214,6 +220,43 @@ await ExecuteWithDockerCredentialsAsync( await imageDigestCache.GetLocalImageDigestAsync( _imageNameResolver.Value.GetFromImageLocalTag(platform.FinalStageFromImage), Options.IsDryRun); } + + // Attach build metadata to the pushed image as an OCI referrer artifact rather than + // as image labels, which would propagate to downstream images. Referrers require the + // subject to exist in the registry, so this happens after the push. + Dictionary annotations = []; + if (!string.IsNullOrEmpty(Options.SourceRepoUrl)) + { + annotations[ImageBuilderAnnotations.Source] = Options.SourceRepoUrl; + annotations[ImageBuilderAnnotations.Revision] = _gitService.GetCommitSha(platform.DockerfilePath, useFullHash: true); + + // The Dockerfile path is relative to the repo root, which must be discovered via + // Git rather than assumed to be the manifest's directory. + string repoRoot = _gitService.GetRepoRoot(platform.DockerfilePath); + annotations[ImageBuilderAnnotations.Dockerfile] = + PathHelper.NormalizePath(Path.GetRelativePath(repoRoot, platform.DockerfilePath)); + } + + if (platform.FinalStageFromImage is not null) + { + annotations[ImageBuilderAnnotations.BaseName] = _imageNameResolver.Value.GetFromImagePublicTag(platform.FinalStageFromImage); + if (!string.IsNullOrEmpty(platformData.BaseImageDigest)) + { + annotations[ImageBuilderAnnotations.BaseDigest] = DockerHelper.GetDigestSha(platformData.BaseImageDigest); + } + } + + if (annotations.Count > 0 && concreteTags.Count > 0) + { + // Attach by digest so the metadata binds to the exact manifest that was built. + string? subjectDigest = await imageDigestCache.GetLocalImageDigestAsync( + concreteTags[0].FullyQualifiedName, Options.IsDryRun); + if (!string.IsNullOrEmpty(subjectDigest)) + { + await _orasService.Value.AttachArtifactAsync( + subjectDigest, Oras.OciArtifactType.ImageInfoReferrer, annotations); + } + } } } diff --git a/src/ImageBuilder/ImageBuilderAnnotations.cs b/src/ImageBuilder/ImageBuilderAnnotations.cs new file mode 100644 index 000000000..a45824de1 --- /dev/null +++ b/src/ImageBuilder/ImageBuilderAnnotations.cs @@ -0,0 +1,42 @@ +// 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. + +namespace Microsoft.DotNet.ImageBuilder; + +/// +/// Annotation keys, in ImageBuilder's own namespace, that describe how the subject image was built. +/// These are recorded on the image's referrer artifact. +/// +/// +/// Standard org.opencontainers.image.* annotations are deliberately not used here because OCI +/// annotations describe the artifact they are placed on. On a referrer artifact they would describe the +/// referrer itself rather than the subject image, so a custom namespace is used to describe the subject. +/// +public static class ImageBuilderAnnotations +{ + /// + /// URL of the source code repository the image was built from. + /// + public const string Source = "com.microsoft.imagebuilder.source"; + + /// + /// Source control revision (commit) the image was built from. + /// + public const string Revision = "com.microsoft.imagebuilder.revision"; + + /// + /// Path of the Dockerfile the image was built from, relative to the root of the source repository. + /// + public const string Dockerfile = "com.microsoft.imagebuilder.dockerfile"; + + /// + /// Image reference of the base image the image was built from. + /// + public const string BaseName = "com.microsoft.imagebuilder.base.name"; + + /// + /// Digest of the base image the image was built from. + /// + public const string BaseDigest = "com.microsoft.imagebuilder.base.digest"; +} diff --git a/src/ImageBuilder/ImageBuilderLabels.cs b/src/ImageBuilder/ImageBuilderLabels.cs deleted file mode 100644 index 655fcce13..000000000 --- a/src/ImageBuilder/ImageBuilderLabels.cs +++ /dev/null @@ -1,16 +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. - -namespace Microsoft.DotNet.ImageBuilder; - -/// -/// Custom (non-OCI) image label keys applied to built images. -/// -public static class ImageBuilderLabels -{ - /// - /// Path of the Dockerfile the image was built from, relative to the root of the source repository. - /// - public const string Dockerfile = "com.microsoft.imagebuilder.dockerfile"; -} diff --git a/src/ImageBuilder/OciAnnotations.cs b/src/ImageBuilder/OciAnnotations.cs deleted file mode 100644 index f3e2c493f..000000000 --- a/src/ImageBuilder/OciAnnotations.cs +++ /dev/null @@ -1,32 +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. - -namespace Microsoft.DotNet.ImageBuilder; - -/// -/// Well-known OCI image annotation keys applied to built images as Docker labels. -/// See https://github.com/opencontainers/image-spec/blob/main/annotations.md. -/// -public static class OciAnnotations -{ - /// - /// URL of the source code repository the image was built from. - /// - public const string Source = "org.opencontainers.image.source"; - - /// - /// Source control revision (commit) the image was built from. - /// - public const string Revision = "org.opencontainers.image.revision"; - - /// - /// Image reference of the base image the image was built from. - /// - public const string BaseName = "org.opencontainers.image.base.name"; - - /// - /// Digest of the base image the image was built from. - /// - public const string BaseDigest = "org.opencontainers.image.base.digest"; -} diff --git a/src/ImageBuilder/Oras/OciArtifactType.cs b/src/ImageBuilder/Oras/OciArtifactType.cs index ec0836b71..aaf20f038 100644 --- a/src/ImageBuilder/Oras/OciArtifactType.cs +++ b/src/ImageBuilder/Oras/OciArtifactType.cs @@ -18,4 +18,11 @@ public static class OciArtifactType /// Microsoft artifact lifecycle metadata. /// public const string Lifecycle = "application/vnd.microsoft.artifact.lifecycle"; + + /// + /// Referrer artifact that records build metadata (source repo, revision, base image, Dockerfile) + /// for a single image as OCI annotations on the referrer manifest. Attached to each built image + /// so that rebuild decisions can be made from the registry without an external image-info store. + /// + public const string ImageInfoReferrer = "application/vnd.microsoft.imagebuilder.image-info.v1+json"; } From 7f060fd8e330e68a04a6b0736e80c12e2e0c455c Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Wed, 8 Jul 2026 13:29:54 -0700 Subject: [PATCH 08/13] Use vnd.microsoft namespace for image metadata annotations Aligns the image build metadata annotation keys with the existing `vnd.microsoft.artifact.lifecycle.*` annotations used for end-of-life metadata, moving them from `com.microsoft.imagebuilder.*` to `vnd.microsoft.imagebuilder.*`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ImageBuilder/ImageBuilderAnnotations.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ImageBuilder/ImageBuilderAnnotations.cs b/src/ImageBuilder/ImageBuilderAnnotations.cs index a45824de1..ec9d27a56 100644 --- a/src/ImageBuilder/ImageBuilderAnnotations.cs +++ b/src/ImageBuilder/ImageBuilderAnnotations.cs @@ -18,25 +18,25 @@ public static class ImageBuilderAnnotations /// /// URL of the source code repository the image was built from. /// - public const string Source = "com.microsoft.imagebuilder.source"; + public const string Source = "vnd.microsoft.imagebuilder.source"; /// /// Source control revision (commit) the image was built from. /// - public const string Revision = "com.microsoft.imagebuilder.revision"; + public const string Revision = "vnd.microsoft.imagebuilder.revision"; /// /// Path of the Dockerfile the image was built from, relative to the root of the source repository. /// - public const string Dockerfile = "com.microsoft.imagebuilder.dockerfile"; + public const string Dockerfile = "vnd.microsoft.imagebuilder.dockerfile"; /// /// Image reference of the base image the image was built from. /// - public const string BaseName = "com.microsoft.imagebuilder.base.name"; + public const string BaseName = "vnd.microsoft.imagebuilder.base.name"; /// /// Digest of the base image the image was built from. /// - public const string BaseDigest = "com.microsoft.imagebuilder.base.digest"; + public const string BaseDigest = "vnd.microsoft.imagebuilder.base.digest"; } From 1ff0914447c969d9d5dd24a4d1e39e37ce7389e5 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Wed, 8 Jul 2026 17:20:20 -0700 Subject: [PATCH 09/13] Use empty OCI artifact type for the metadata referrer The build-metadata referrer carries all of its data in manifest annotations and has no content blob. Using a custom '+json' artifact type wrongly implies a JSON payload, so use the empty OCI artifact type (application/vnd.oci.empty.v1+json) instead, matching the behavior of 'oras attach' with no files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ImageBuilder.Tests/BuildCommandTests.cs | 2 +- src/ImageBuilder/Commands/BuildCommand.cs | 2 +- src/ImageBuilder/Oras/OciArtifactType.cs | 15 +++++++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/ImageBuilder.Tests/BuildCommandTests.cs b/src/ImageBuilder.Tests/BuildCommandTests.cs index 49f4e19cf..3452b2d61 100644 --- a/src/ImageBuilder.Tests/BuildCommandTests.cs +++ b/src/ImageBuilder.Tests/BuildCommandTests.cs @@ -597,7 +597,7 @@ public async Task BuildCommand_AttachesImageMetadataReferrer() orasServiceMock.Verify( o => o.AttachArtifactAsync( imageDigest, - OciArtifactType.ImageInfoReferrer, + OciArtifactType.Empty, It.Is>(annotations => annotations.Count == 5 && annotations[ImageBuilderAnnotations.Source] == sourceRepoUrl && diff --git a/src/ImageBuilder/Commands/BuildCommand.cs b/src/ImageBuilder/Commands/BuildCommand.cs index 7d64e2d83..913030a2f 100644 --- a/src/ImageBuilder/Commands/BuildCommand.cs +++ b/src/ImageBuilder/Commands/BuildCommand.cs @@ -254,7 +254,7 @@ await imageDigestCache.GetLocalImageDigestAsync( if (!string.IsNullOrEmpty(subjectDigest)) { await _orasService.Value.AttachArtifactAsync( - subjectDigest, Oras.OciArtifactType.ImageInfoReferrer, annotations); + subjectDigest, Oras.OciArtifactType.Empty, annotations); } } } diff --git a/src/ImageBuilder/Oras/OciArtifactType.cs b/src/ImageBuilder/Oras/OciArtifactType.cs index aaf20f038..9b7153317 100644 --- a/src/ImageBuilder/Oras/OciArtifactType.cs +++ b/src/ImageBuilder/Oras/OciArtifactType.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 OrasProject.Oras.Oci; + namespace Microsoft.DotNet.ImageBuilder.Oras; /// @@ -9,6 +11,13 @@ namespace Microsoft.DotNet.ImageBuilder.Oras; /// public static class OciArtifactType { + /// + /// The "empty" OCI artifact type. Used for referrer artifacts whose data is carried entirely in + /// the manifest's annotations rather than in a content blob, matching the behavior of + /// oras attach when no files are provided. + /// + public const string Empty = MediaType.EmptyJson; + /// /// Notary v2 signature envelope. /// @@ -19,10 +28,4 @@ public static class OciArtifactType /// public const string Lifecycle = "application/vnd.microsoft.artifact.lifecycle"; - /// - /// Referrer artifact that records build metadata (source repo, revision, base image, Dockerfile) - /// for a single image as OCI annotations on the referrer manifest. Attached to each built image - /// so that rebuild decisions can be made from the registry without an external image-info store. - /// - public const string ImageInfoReferrer = "application/vnd.microsoft.imagebuilder.image-info.v1+json"; } From 27b987a762271fbc2f503fcd21a5b6c3493e4a98 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Wed, 8 Jul 2026 19:44:04 -0700 Subject: [PATCH 10/13] Fix BuildCommandTests ORAS namespace after rebase Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ImageBuilder.Tests/BuildCommandTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ImageBuilder.Tests/BuildCommandTests.cs b/src/ImageBuilder.Tests/BuildCommandTests.cs index 3452b2d61..d6bce05b6 100644 --- a/src/ImageBuilder.Tests/BuildCommandTests.cs +++ b/src/ImageBuilder.Tests/BuildCommandTests.cs @@ -14,6 +14,7 @@ using Microsoft.DotNet.ImageBuilder.Commands; using Microsoft.DotNet.ImageBuilder.Models.Image; using Microsoft.DotNet.ImageBuilder.Models.Manifest; +using Microsoft.DotNet.ImageBuilder.Oras; using Microsoft.DotNet.ImageBuilder.Tests.Helpers; using Microsoft.DotNet.ImageBuilder.ViewModel; using Moq; From ac9c07a74441ec838abd829dcd40c7d6d687ab09 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Wed, 8 Jul 2026 19:49:48 -0700 Subject: [PATCH 11/13] Restore BuildCommandTests BOM Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ImageBuilder.Tests/BuildCommandTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ImageBuilder.Tests/BuildCommandTests.cs b/src/ImageBuilder.Tests/BuildCommandTests.cs index d6bce05b6..5d23ccb3b 100644 --- a/src/ImageBuilder.Tests/BuildCommandTests.cs +++ b/src/ImageBuilder.Tests/BuildCommandTests.cs @@ -1,4 +1,4 @@ -#nullable disable +#nullable disable // 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. From 42b229504cc377b3077c3547a0f8dea6ba8b3e83 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Wed, 8 Jul 2026 19:55:44 -0700 Subject: [PATCH 12/13] Clean up unnecessary post-rebase diffs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ImageBuilder/DockerServiceCache.cs | 26 +++++++----------------- src/ImageBuilder/IDockerService.cs | 1 + src/ImageBuilder/Oras/OciArtifactType.cs | 1 - 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/ImageBuilder/DockerServiceCache.cs b/src/ImageBuilder/DockerServiceCache.cs index 1869bd4e7..7878c025b 100644 --- a/src/ImageBuilder/DockerServiceCache.cs +++ b/src/ImageBuilder/DockerServiceCache.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Microsoft.DotNet.ImageBuilder.Models.Manifest; namespace Microsoft.DotNet.ImageBuilder @@ -29,23 +31,9 @@ public DockerServiceCache(IDockerService inner) public Architecture Architecture => _inner.Architecture; public string? BuildImage( - string dockerfilePath, - string buildContextPath, - string platform, - IEnumerable tags, - IDictionary buildArgs, - IEnumerable dockerBuildOptions, - bool isRetryEnabled, - bool isDryRun) => - _inner.BuildImage( - dockerfilePath, - buildContextPath, - platform, - tags, - buildArgs, - dockerBuildOptions, - isRetryEnabled, - isDryRun); + string dockerfilePath, string buildContextPath, string platform, IEnumerable tags, + IDictionary buildArgs, IEnumerable dockerBuildOptions, bool isRetryEnabled, bool isDryRun) => + _inner.BuildImage(dockerfilePath, buildContextPath, platform, tags, buildArgs, dockerBuildOptions, isRetryEnabled, isDryRun); public (Architecture Arch, string? Variant) GetImageArch(string image, bool isDryRun) => _architectureCache.GetOrAdd(image, _ =>_inner.GetImageArch(image, isDryRun)); @@ -61,10 +49,10 @@ public DateTime GetCreatedDate(string image, bool isDryRun) => public long GetImageSize(string image, bool isDryRun) => _imageSizeCache.GetOrAdd(image, _ => _inner.GetImageSize(image, isDryRun)); - + public bool LocalImageExists(string tag, bool isDryRun) => _localImageExistsCache.GetOrAdd(tag, _ => _inner.LocalImageExists(tag, isDryRun)); - + public void PullImage(string image, string? platform, bool isDryRun) { _pulledImages.GetOrAdd(image, _ => diff --git a/src/ImageBuilder/IDockerService.cs b/src/ImageBuilder/IDockerService.cs index 287758163..8eb379f71 100644 --- a/src/ImageBuilder/IDockerService.cs +++ b/src/ImageBuilder/IDockerService.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using Microsoft.DotNet.ImageBuilder.Models.Manifest; namespace Microsoft.DotNet.ImageBuilder diff --git a/src/ImageBuilder/Oras/OciArtifactType.cs b/src/ImageBuilder/Oras/OciArtifactType.cs index 9b7153317..387fbdf1f 100644 --- a/src/ImageBuilder/Oras/OciArtifactType.cs +++ b/src/ImageBuilder/Oras/OciArtifactType.cs @@ -27,5 +27,4 @@ public static class OciArtifactType /// Microsoft artifact lifecycle metadata. /// public const string Lifecycle = "application/vnd.microsoft.artifact.lifecycle"; - } From 11c4adc38737cd5ed560383d44192573b3402a9b Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Wed, 8 Jul 2026 20:15:24 -0700 Subject: [PATCH 13/13] Use ImageBuilder artifact type for metadata referrer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ImageBuilder.Tests/BuildCommandTests.cs | 2 +- src/ImageBuilder/Commands/BuildCommand.cs | 2 +- src/ImageBuilder/Oras/OciArtifactType.cs | 8 ++------ 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/ImageBuilder.Tests/BuildCommandTests.cs b/src/ImageBuilder.Tests/BuildCommandTests.cs index 5d23ccb3b..bb44fa1f4 100644 --- a/src/ImageBuilder.Tests/BuildCommandTests.cs +++ b/src/ImageBuilder.Tests/BuildCommandTests.cs @@ -598,7 +598,7 @@ public async Task BuildCommand_AttachesImageMetadataReferrer() orasServiceMock.Verify( o => o.AttachArtifactAsync( imageDigest, - OciArtifactType.Empty, + OciArtifactType.ImageInfo, It.Is>(annotations => annotations.Count == 5 && annotations[ImageBuilderAnnotations.Source] == sourceRepoUrl && diff --git a/src/ImageBuilder/Commands/BuildCommand.cs b/src/ImageBuilder/Commands/BuildCommand.cs index 913030a2f..8e9488c6d 100644 --- a/src/ImageBuilder/Commands/BuildCommand.cs +++ b/src/ImageBuilder/Commands/BuildCommand.cs @@ -254,7 +254,7 @@ await imageDigestCache.GetLocalImageDigestAsync( if (!string.IsNullOrEmpty(subjectDigest)) { await _orasService.Value.AttachArtifactAsync( - subjectDigest, Oras.OciArtifactType.Empty, annotations); + subjectDigest, Oras.OciArtifactType.ImageInfo, annotations); } } } diff --git a/src/ImageBuilder/Oras/OciArtifactType.cs b/src/ImageBuilder/Oras/OciArtifactType.cs index 387fbdf1f..bdf377d3d 100644 --- a/src/ImageBuilder/Oras/OciArtifactType.cs +++ b/src/ImageBuilder/Oras/OciArtifactType.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using OrasProject.Oras.Oci; - namespace Microsoft.DotNet.ImageBuilder.Oras; /// @@ -12,11 +10,9 @@ namespace Microsoft.DotNet.ImageBuilder.Oras; public static class OciArtifactType { /// - /// The "empty" OCI artifact type. Used for referrer artifacts whose data is carried entirely in - /// the manifest's annotations rather than in a content blob, matching the behavior of - /// oras attach when no files are provided. + /// ImageBuilder metadata referrer for image build information stored in manifest annotations. /// - public const string Empty = MediaType.EmptyJson; + public const string ImageInfo = "application/vnd.microsoft.imagebuilder.image-info.v1"; /// /// Notary v2 signature envelope.