diff --git a/src/ImageBuilder.Tests/BuildCommandTests.cs b/src/ImageBuilder.Tests/BuildCommandTests.cs index c34c17119..bb44fa1f4 100644 --- a/src/ImageBuilder.Tests/BuildCommandTests.cs +++ b/src/ImageBuilder.Tests/BuildCommandTests.cs @@ -7,21 +7,19 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; 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.Oras; 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; @@ -142,7 +140,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); @@ -345,7 +343,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); @@ -537,8 +535,195 @@ public async Task BuildCommand_Publish() } /// - /// Verifies that the manifest's platform architecture settings match the architecture of the base image. + /// 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.ImageInfo, + 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() { @@ -849,7 +1034,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); @@ -997,8 +1182,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()), Times.Never); dockerServiceMock.Verify( o => o.GetImageSize(It.IsAny(), false), @@ -1035,7 +1219,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); @@ -1286,7 +1470,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); @@ -1496,7 +1680,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); @@ -1810,7 +1994,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); @@ -2098,7 +2282,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); @@ -2306,7 +2490,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); @@ -2549,7 +2733,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); @@ -2702,8 +2886,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()), Times.Never); dockerServiceMock.Verify( o => o.GetImageSize(It.IsAny(), false), @@ -2771,7 +2954,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); @@ -3078,7 +3261,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); @@ -3423,7 +3606,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); @@ -3519,7 +3702,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); @@ -3641,7 +3824,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(), @@ -3653,7 +3837,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 +3871,26 @@ 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 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/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..8e9488c6d 100644 --- a/src/ImageBuilder/Commands/BuildCommand.cs +++ b/src/ImageBuilder/Commands/BuildCommand.cs @@ -7,9 +7,8 @@ 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; @@ -26,20 +25,10 @@ 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 _orasService; 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, @@ -50,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)); @@ -65,7 +55,10 @@ public BuildCommand( ArgumentNullException.ThrowIfNull(manifestServiceFactory); _manifestService = new Lazy(() => manifestServiceFactory.Create(Options.CredentialsOptions)); - _imageDigestCache = new ImageDigestCache(_manifestService); + + ArgumentNullException.ThrowIfNull(orasServiceFactory); + _orasService = new Lazy(() => + orasServiceFactory.Create(Options.CredentialsOptions)); _imageNameResolver = new Lazy(() => new ImageNameResolverForBuild( @@ -95,26 +88,251 @@ public override async Task ExecuteAsync() { Options.BaseImageOverrideOptions.Validate(); - if (Options.ImageInfoOutputPath != null) + bool isImageInfoOutputEnabled = !string.IsNullOrEmpty(Options.ImageInfoOutputPath); + if (isImageInfoOutputEnabled && string.IsNullOrEmpty(Options.SourceRepoUrl)) { - _imageArtifactDetails = new ImageArtifactDetails(); + throw new InvalidOperationException("Source repo URL must be provided when outputting to an image info file."); } - await ExecuteWithDockerCredentialsAsync(PullBaseImagesAsync); - await BuildImagesAsync(); + ImageDigestCache imageDigestCache = new ImageDigestCache(_manifestService); + + await ExecuteWithDockerCredentialsAsync(() => PullBaseImagesAsync(imageDigestCache)); - if (_processedTags.Count > 0 || _imageCacheService.HasAnyCachedPlatforms) + _logger.LogInformation("BUILDING IMAGES"); + + List builtTags = []; + HashSet builtTagNames = []; + HashSet builtPlatforms = []; + + // 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) { - PushImages(); - await PublishImageInfoAsync(); - }); + // 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) + { + foreach (TagInfo tag in platformData.PlatformInfo.Tags) + { + platformDataByTag.Add(tag.FullyQualifiedName, platformData); + } + } + + bool isCachedImage = false; + bool shouldCheckCache = !Options.NoCache; + if (shouldCheckCache && platform.FinalStageFromImage is not null) + { + 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); + } + + // 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.ImageInfo, annotations); + } + } + } + } + + 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 (repoData.Images.Any()) + { + imageArtifactDetails?.Repos.Add(repoData); + } } - WriteBuildSummary(); - WriteBuiltImagesToOutputVar(); + 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, builtPlatforms); + } } private async Task ExecuteWithDockerCredentialsAsync(Func action) @@ -135,91 +353,15 @@ 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))); - } - } - - private async Task PublishImageInfoAsync() - { - 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."); - } - - Dictionary platformDataByTag = new Dictionary(); - foreach (PlatformData platformData in GetProcessedPlatforms()) - { - if (platformData.PlatformInfo is not null) - { - foreach (TagInfo tag in platformData.PlatformInfo.Tags) - { - platformDataByTag.Add(tag.FullyQualifiedName, platformData); - } - } - } - - IEnumerable processedPlatforms = GetProcessedPlatforms(); - List platformsWithNoPushTags = new List(); - - foreach (PlatformData platform in processedPlatforms) - { - IEnumerable pushTags = platform.PlatformInfo?.Tags ?? []; - - foreach (TagInfo tag in pushTags) - { - if (Options.IsPushEnabled) - { - await SetPlatformDataDigestAsync(platform, tag.FullyQualifiedName); - 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 = processedPlatforms.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 imageInfoString = JsonHelper.SerializeObject(_imageArtifactDetails); - File.WriteAllText(Options.ImageInfoOutputPath, imageInfoString); + IEnumerable builtDigests = builtPlatforms + .Select(platform => DockerHelper.GetDigestString(platform.PlatformInfo!.RepoName, DockerHelper.GetDigestSha(platform.Digest))) + .Distinct(); + _logger.LogInformation( + PipelineHelper.FormatOutputVariable( + outputVariableName, + string.Join(',', builtDigests))); } private void SetPlatformDataCreatedDate(PlatformData platform, string tag) @@ -273,10 +415,9 @@ private async Task SetPlatformDataLayersAsync(PlatformData platform, string tag) } } - private async Task SetPlatformDataDigestAsync(PlatformData platform, string tag) + 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)); @@ -300,91 +441,6 @@ private async Task SetPlatformDataDigestAsync(PlatformData platform, string tag) platform.Digest = digest; } - private async Task BuildImagesAsync() - { - _logger.LogInformation("BUILDING IMAGES"); - - 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, cacheResult.Platform!.Digest); - } - } - - if (!isCachedImage) - { - _processedTags.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); - } - } - } - 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 @@ -558,7 +614,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 +646,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 +670,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 +697,7 @@ await _copyImageService.ImportImageAsync( return sourceDigest; } - private async Task PullBaseImagesAsync() + private async Task PullBaseImagesAsync(ImageDigestCache imageDigestCache) { _logger.LogInformation("PULLING LATEST BASE IMAGES"); @@ -694,7 +756,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,23 +770,40 @@ 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 async Task> PushTagsAsync( + IEnumerable tagsToPush, + IEnumerable tagsForDigest, + ImageDigestCache imageDigestCache) { - if (Options.IsPushEnabled) + _logger.LogInformation("PUSHING BUILT IMAGES"); + + HashSet digestTagNames = tagsForDigest + .Select(tag => tag.FullyQualifiedName) + .ToHashSet(); + Dictionary pushedDigestByTag = []; + + foreach (TagInfo tag in tagsToPush) { - _logger.LogInformation("PUSHING BUILT IMAGES"); + _dockerService.PushImage(tag.FullyQualifiedName, Options.IsDryRun); - foreach (TagInfo tag in _processedTags) + if (digestTagNames.Contains(tag.FullyQualifiedName)) { - _dockerService.PushImage(tag.FullyQualifiedName, Options.IsDryRun); + 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) @@ -761,13 +840,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 +858,6 @@ private void WriteBuildSummary() _logger.LogInformation(string.Empty); } + } } diff --git a/src/ImageBuilder/GitHelper.cs b/src/ImageBuilder/GitHelper.cs index aaf9893ae..e22170c88 100644 --- a/src/ImageBuilder/GitHelper.cs +++ b/src/ImageBuilder/GitHelper.cs @@ -24,31 +24,36 @@ 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."); - } - } - - 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}"); } + // Don't make the assumption that the current working directory is a Git repository. + // Walk up from the given path to find the root of the containing Git repository. + public static string GetRepoRoot(string path) + { + DirectoryInfo directory = Directory.Exists(path) ? new DirectoryInfo(path) : new FileInfo(path).Directory; + + // 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.FullName; + } + 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/ImageBuilderAnnotations.cs b/src/ImageBuilder/ImageBuilderAnnotations.cs new file mode 100644 index 000000000..ec9d27a56 --- /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 = "vnd.microsoft.imagebuilder.source"; + + /// + /// Source control revision (commit) the image was built from. + /// + 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 = "vnd.microsoft.imagebuilder.dockerfile"; + + /// + /// Image reference of the base image the image was built from. + /// + public const string BaseName = "vnd.microsoft.imagebuilder.base.name"; + + /// + /// Digest of the base image the image was built from. + /// + public const string BaseDigest = "vnd.microsoft.imagebuilder.base.digest"; +} diff --git a/src/ImageBuilder/Oras/OciArtifactType.cs b/src/ImageBuilder/Oras/OciArtifactType.cs index ec0836b71..bdf377d3d 100644 --- a/src/ImageBuilder/Oras/OciArtifactType.cs +++ b/src/ImageBuilder/Oras/OciArtifactType.cs @@ -9,6 +9,11 @@ namespace Microsoft.DotNet.ImageBuilder.Oras; /// public static class OciArtifactType { + /// + /// ImageBuilder metadata referrer for image build information stored in manifest annotations. + /// + public const string ImageInfo = "application/vnd.microsoft.imagebuilder.image-info.v1"; + /// /// Notary v2 signature envelope. ///