diff --git a/eng/docker-tools/CHANGELOG.md b/eng/docker-tools/CHANGELOG.md index 99dbceb69..5ef04a0d6 100644 --- a/eng/docker-tools/CHANGELOG.md +++ b/eng/docker-tools/CHANGELOG.md @@ -4,6 +4,15 @@ All breaking changes and new features in `eng/docker-tools` will be documented i --- +## 2026-06-01: Post_Build validates manifest list platform completeness + +- Pull request: [#2126](https://github.com/dotnet/docker-tools/pull/2126) + +The `Create Manifest Lists` step in the `Post_Build` stage now fails if a generated multi-arch manifest tag would omit platforms expected by `manifest.json`. +This is evaluated only after attempting to import missing platform images into staging in the case of a path-filtered build. + +--- + ## 2026-04-02: Extra Docker build options can be passed through ImageBuilder - Pull request: [#2063](https://github.com/dotnet/docker-tools/pull/2063) diff --git a/eng/docker-tools/DEV-GUIDE.md b/eng/docker-tools/DEV-GUIDE.md index 28ddc471b..035116fc4 100644 --- a/eng/docker-tools/DEV-GUIDE.md +++ b/eng/docker-tools/DEV-GUIDE.md @@ -143,6 +143,7 @@ Build Stage ▼ Post_Build Stage ├── Merge image info files + ├── Create multi-arch manifests └── Consolidate SBOMs │ ▼ @@ -191,7 +192,7 @@ Common patterns: - `"publish"` - Publish only (when re-running a failed publish from a previous build) - `"build,test,sign,publish"` - Full pipeline -**Note:** The `Post_Build` stage is implicitly included whenever `build` is in the stages list. You don't need to specify it separately—it automatically runs after Build to merge image info files and consolidate SBOMs. +**Note:** The `Post_Build` stage is implicitly included whenever `build` is in the stages list. You don't need to specify it separately—it automatically runs after Build to merge image info files, create and validate multi-arch manifests, and consolidate SBOMs. The stages variable is useful for: - Re-running just the publish stage after fixing a transient failure diff --git a/src/ImageBuilder.Tests/CreateManifestListCommandTests.cs b/src/ImageBuilder.Tests/CreateManifestListCommandTests.cs index 0164637b8..3fee0d7aa 100644 --- a/src/ImageBuilder.Tests/CreateManifestListCommandTests.cs +++ b/src/ImageBuilder.Tests/CreateManifestListCommandTests.cs @@ -496,6 +496,64 @@ public async Task ExecuteAsync_DoesNotPortImagesThatAreAbsentFromImageInfo() Times.Never); } + /// + /// Verifies that a missing manifest platform is reported when the old-build + /// import path has no platform to port forward. + /// + [Fact] + public async Task ExecuteAsync_ThrowsMissingPlatform_WhenNoOldBuildImportExists() + { + Mock manifestServiceMock = new(MockBehavior.Strict); + Mock dockerServiceMock = new(); + Mock copyImageServiceMock = new(); + + CreateManifestListCommand command = CreateCommand( + CreateManifestServiceFactoryMock(manifestServiceMock), + dockerServiceMock, + copyImageServiceMock, + Mock.Of()); + + using TempFolderContext tempFolderContext = TestHelper.UseTempFolder(); + + string dockerfileAmd64 = CreateDockerfile("1.0/repo/linux-amd64", tempFolderContext); + string dockerfileArm64 = CreateDockerfile("1.0/repo/linux-arm64", tempFolderContext); + + Manifest manifest = CreateManifest( + CreateRepo("repo", + CreateImage( + ["sharedtag"], + CreatePlatform(dockerfileAmd64, ["tag-amd64"]), + CreatePlatform(dockerfileArm64, ["tag-arm64"], architecture: Architecture.ARM64)))); + + ImageArtifactDetails imageArtifactDetails = CreateImageArtifactDetails( + CreateRepoData("repo", + CreateImageData( + CreatePlatform(dockerfileAmd64, simpleTags: ["tag-amd64"])))); + + SetupCommand(command, manifest, imageArtifactDetails, tempFolderContext); + + InvalidOperationException exception = await Should.ThrowAsync( + () => command.ExecuteAsync()); + + exception.Message.ShouldContain("Generated manifest list tags are missing expected platforms defined in the manifest"); + exception.Message.ShouldContain("repo:sharedtag"); + exception.Message.ShouldContain("linux/arm64"); + exception.Message.ShouldContain(dockerfileArm64); + + manifestServiceMock.Verify( + o => o.GetManifestAsync(It.IsAny(), It.IsAny()), + Times.Never); + copyImageServiceMock.Verify(o => o.ImportImageAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + dockerServiceMock.Verify(o => o.CreateManifestList( + It.IsAny(), It.IsAny>(), It.IsAny()), + Times.Never); + } + /// /// Verifies that when a manifest-declared platform is missing from image-info AND /// the source registry returns 404 for its tag (e.g. a misconfigured --path diff --git a/src/ImageBuilder.Tests/ManifestListHelperTests.cs b/src/ImageBuilder.Tests/ManifestListHelperTests.cs index 6e8bd4593..b902d26e6 100644 --- a/src/ImageBuilder.Tests/ManifestListHelperTests.cs +++ b/src/ImageBuilder.Tests/ManifestListHelperTests.cs @@ -102,6 +102,79 @@ public void GetManifestListsForImages_OnlyIncludesBuiltPlatforms() results[0].PlatformTags.ShouldNotContain("repo:tag-windows"); } + /// + /// Verifies that validation reports generated manifest lists that are missing expected platforms. + /// + [Fact] + public void GetManifestListPlatformValidationIssues_MissingPlatform() + { + using TempFolderContext tempFolderContext = TestHelper.UseTempFolder(); + + string dockerfileAmd64 = CreateDockerfile("1.0/repo/linux-amd64", tempFolderContext); + string dockerfileArm64 = CreateDockerfile("1.0/repo/linux-arm64", tempFolderContext); + string dockerfileWindows = CreateDockerfile("1.0/repo/windows", tempFolderContext); + + Manifest manifest = CreateManifest( + CreateRepo("repo", + CreateImage( + ["sharedtag"], + CreatePlatform(dockerfileAmd64, ["tag-amd64"]), + CreatePlatform(dockerfileArm64, ["tag-arm64"], architecture: Architecture.ARM64), + CreatePlatform(dockerfileWindows, ["tag-windows"], os: OS.Windows, osVersion: "ltsc2022")))); + + ImageArtifactDetails imageArtifactDetails = CreateImageArtifactDetails( + CreateRepoData("repo", + CreateImageData( + ["sharedtag"], + CreatePlatform(dockerfileAmd64, simpleTags: ["tag-amd64"]), + CreatePlatform(dockerfileArm64, simpleTags: ["tag-arm64"], architecture: "arm64")))); + + ManifestInfo manifestInfo = LoadManifest(manifest, tempFolderContext); + ImageArtifactDetails linkedImageInfo = LoadImageInfo(imageArtifactDetails, manifestInfo, tempFolderContext); + + IReadOnlyList issues = + ManifestListHelper.GetManifestListPlatformValidationIssues( + manifestInfo, linkedImageInfo, repoPrefix: null); + + issues.Count.ShouldBe(1); + issues[0].ManifestListTag.ShouldBe("repo:sharedtag"); + issues[0].MissingPlatforms.Count.ShouldBe(1); + issues[0].MissingPlatforms[0].ShouldContain("windows/amd64"); + issues[0].MissingPlatforms[0].ShouldContain(dockerfileWindows); + } + + /// + /// Verifies that validation succeeds when every generated manifest list has all expected platforms. + /// + [Fact] + public void ValidateManifestListPlatforms_AllPlatformsPresent() + { + using TempFolderContext tempFolderContext = TestHelper.UseTempFolder(); + + string dockerfileAmd64 = CreateDockerfile("1.0/repo/linux-amd64", tempFolderContext); + string dockerfileArm64 = CreateDockerfile("1.0/repo/linux-arm64", tempFolderContext); + + Manifest manifest = CreateManifest( + CreateRepo("repo", + CreateImage( + ["sharedtag"], + CreatePlatform(dockerfileAmd64, ["tag-amd64"]), + CreatePlatform(dockerfileArm64, ["tag-arm64"], architecture: Architecture.ARM64)))); + + ImageArtifactDetails imageArtifactDetails = CreateImageArtifactDetails( + CreateRepoData("repo", + CreateImageData( + ["sharedtag"], + CreatePlatform(dockerfileAmd64, simpleTags: ["tag-amd64"]), + CreatePlatform(dockerfileArm64, simpleTags: ["tag-arm64"], architecture: "arm64")))); + + ManifestInfo manifestInfo = LoadManifest(manifest, tempFolderContext); + ImageArtifactDetails linkedImageInfo = LoadImageInfo(imageArtifactDetails, manifestInfo, tempFolderContext); + + Should.NotThrow(() => ManifestListHelper.ValidateManifestListPlatforms( + manifestInfo, linkedImageInfo, repoPrefix: null)); + } + /// /// Verifies that no manifest list is returned when an image has shared tags /// but no platforms exist in image-info. diff --git a/src/ImageBuilder/Commands/CreateManifestListCommand.cs b/src/ImageBuilder/Commands/CreateManifestListCommand.cs index 22ae8c061..213941dd1 100644 --- a/src/ImageBuilder/Commands/CreateManifestListCommand.cs +++ b/src/ImageBuilder/Commands/CreateManifestListCommand.cs @@ -90,6 +90,9 @@ await _registryCredentialsProvider.ExecuteWithCredentialsAsync( AddPlatformToImageInfo(platformToImport); } + ManifestListHelper.ValidateManifestListPlatforms( + Manifest, imageArtifactDetails, Options.RepoPrefix); + // Build the manifest-list definitions from the now-complete image-info. IReadOnlyList manifestLists = ManifestListHelper.GetManifestListsForImages(Manifest, imageArtifactDetails, Options.RepoPrefix); @@ -192,7 +195,7 @@ imageData.Manifest is not null ManifestQueryResult result = await _manifestService.Value.GetManifestAsync(sourceName, isDryRun: false); - var platformImportData = new PlatformImportData( + PlatformImportData platformImportData = new( Repo: imageData.ManifestRepo, Image: imageData.ManifestImage, Platform: platform, diff --git a/src/ImageBuilder/ManifestListHelper.cs b/src/ImageBuilder/ManifestListHelper.cs index 97ef8128b..91ff86ac5 100644 --- a/src/ImageBuilder/ManifestListHelper.cs +++ b/src/ImageBuilder/ManifestListHelper.cs @@ -18,6 +18,13 @@ namespace Microsoft.DotNet.ImageBuilder; /// The fully-qualified platform image tags included in this manifest list. public record ManifestListInfo(string Tag, IReadOnlyList PlatformTags); +/// +/// Describes platforms that are expected by the manifest but missing from a generated manifest list. +/// +/// The fully-qualified manifest list tag. +/// Descriptions of the expected platforms missing from the tag. +public record ManifestListPlatformValidationIssue(string ManifestListTag, IReadOnlyList MissingPlatforms); + /// /// Determines which Docker manifest lists should be created based on /// the manifest definition and which platforms were actually built. @@ -35,7 +42,65 @@ public static IReadOnlyList GetManifestListsForImages( ImageArtifactDetails imageArtifactDetails, string? repoPrefix) { - IEnumerable<(RepoInfo Repo, ImageInfo Image)> imagesWithBuiltPlatforms = manifest.FilteredRepos + IEnumerable<(RepoInfo Repo, ImageInfo Image)> imagesWithBuiltPlatforms = + GetImagesWithBuiltPlatforms(manifest, imageArtifactDetails); + + return imagesWithBuiltPlatforms + .SelectMany(pair => GetManifestListsForImage(pair.Repo, pair.Image, manifest, imageArtifactDetails, repoPrefix)) + .ToList() + .AsReadOnly(); + } + + /// + /// Validates that each generated manifest list contains every platform expected by the manifest. + /// + /// + /// Thrown when one or more generated manifest list tags would omit expected platforms. + /// + public static void ValidateManifestListPlatforms( + ManifestInfo manifest, + ImageArtifactDetails imageArtifactDetails, + string? repoPrefix) + { + IReadOnlyList issues = GetManifestListPlatformValidationIssues( + manifest, imageArtifactDetails, repoPrefix); + + if (issues.Count == 0) + { + return; + } + + string details = string.Join( + Environment.NewLine, + issues.Select(issue => + $"- {issue.ManifestListTag}: {string.Join(", ", issue.MissingPlatforms)}")); + + throw new InvalidOperationException( + $"Generated manifest list tags are missing expected platforms defined in the manifest:{Environment.NewLine}{details}"); + } + + /// + /// Gets validation issues for generated manifest lists that would omit expected platforms. + /// + public static IReadOnlyList GetManifestListPlatformValidationIssues( + ManifestInfo manifest, + ImageArtifactDetails imageArtifactDetails, + string? repoPrefix) + { + IEnumerable<(RepoInfo Repo, ImageInfo Image)> imagesWithBuiltPlatforms = + GetImagesWithBuiltPlatforms(manifest, imageArtifactDetails); + + return imagesWithBuiltPlatforms + .SelectMany(pair => GetManifestListPlatformValidationIssuesForImage( + pair.Repo, pair.Image, manifest, imageArtifactDetails, repoPrefix)) + .ToList() + .AsReadOnly(); + } + + private static IEnumerable<(RepoInfo Repo, ImageInfo Image)> GetImagesWithBuiltPlatforms( + ManifestInfo manifest, + ImageArtifactDetails imageArtifactDetails) => + manifest.FilteredRepos .SelectMany(repo => repo.FilteredImages .Where(image => image.SharedTags.Any()) @@ -45,12 +110,6 @@ public static IReadOnlyList GetManifestListsForImages( .Select(image => (repo, image))) .ToList(); - return imagesWithBuiltPlatforms - .SelectMany(pair => GetManifestListsForImage(pair.Repo, pair.Image, manifest, imageArtifactDetails, repoPrefix)) - .ToList() - .AsReadOnly(); - } - private static IEnumerable GetManifestListsForImage( RepoInfo repo, ImageInfo image, @@ -86,6 +145,39 @@ private static IEnumerable GetManifestListsForImage( return primaryManifestLists.Concat(syndicatedManifestLists); } + private static IEnumerable GetManifestListPlatformValidationIssuesForImage( + RepoInfo repo, + ImageInfo image, + ManifestInfo manifest, + ImageArtifactDetails imageArtifactDetails, + string? repoPrefix) + { + IEnumerable primaryManifestListIssues = GetManifestListPlatformValidationIssuesForTags( + repo, image, imageArtifactDetails, + image.SharedTags.Select(tag => tag.Name), + tag => DockerHelper.GetImageName(manifest.Registry, repoPrefix + repo.Name, tag), + platform => platform.Tags.First()); + + IEnumerable> syndicatedTagGroups = image.SharedTags + .Where(tag => tag.SyndicatedRepo != null) + .GroupBy(tag => tag.SyndicatedRepo); + + IEnumerable syndicatedManifestListIssues = syndicatedTagGroups + .SelectMany(syndicatedTags => + { + string syndicatedRepo = syndicatedTags.Key; + IEnumerable destinationTags = syndicatedTags.SelectMany(tag => tag.SyndicatedDestinationTags); + + return GetManifestListPlatformValidationIssuesForTags( + repo, image, imageArtifactDetails, + destinationTags, + tag => DockerHelper.GetImageName(manifest.Registry, repoPrefix + syndicatedRepo, tag), + platform => platform.Tags.FirstOrDefault(tag => tag.SyndicatedRepo == syndicatedRepo)); + }); + + return primaryManifestListIssues.Concat(syndicatedManifestListIssues); + } + private static IEnumerable GetManifestListsForTags( RepoInfo repo, ImageInfo image, @@ -99,6 +191,20 @@ private static IEnumerable GetManifestListsForTags( .OfType(); } + private static IEnumerable GetManifestListPlatformValidationIssuesForTags( + RepoInfo repo, + ImageInfo image, + ImageArtifactDetails imageArtifactDetails, + IEnumerable tags, + Func getImageName, + Func getTagRepresentative) + { + return tags + .Select(tag => BuildManifestListPlatformValidationIssue( + repo, image, imageArtifactDetails, tag, getImageName, getTagRepresentative)) + .OfType(); + } + private static ManifestListInfo? BuildManifestListInfo( RepoInfo repo, ImageInfo image, @@ -122,56 +228,101 @@ private static IEnumerable GetManifestListsForTags( } TagInfo? imageTag; - if (platform.Tags.Any()) + imageTag = GetPlatformTagRepresentative(repo, image, platform, getTagRepresentative, throwIfMissing: true); + + if (imageTag is not null) { - imageTag = getTagRepresentative(platform); + platformTags.Add(getImageName(imageTag.Name)); } - else + } + + if (platformTags.Count == 0) + { + return null; + } + + return new ManifestListInfo(manifestListTag, platformTags.AsReadOnly()); + } + + private static ManifestListPlatformValidationIssue? BuildManifestListPlatformValidationIssue( + RepoInfo repo, + ImageInfo image, + ImageArtifactDetails imageArtifactDetails, + string tag, + Func getImageName, + Func getTagRepresentative) + { + string manifestListTag = getImageName(tag); + List missingPlatforms = []; + bool hasExpectedPlatform = false; + + foreach (PlatformInfo platform in image.AllPlatforms) + { + TagInfo? imageTag = GetPlatformTagRepresentative(repo, image, platform, getTagRepresentative, throwIfMissing: false); + if (imageTag is null) { - // This platform has no tags of its own (it's a "tagless" platform included - // only via shared tags). To reference it in the manifest list, we need a tag. - // Search all images in the repo for a different platform that builds the same - // Dockerfile/OS/arch and does have tags, then borrow one of its tags. - (ImageInfo Image, PlatformInfo Platform)? matchingPlatform = - repo.AllImages - .SelectMany(candidateImage => - candidateImage.AllPlatforms + continue; + } + + hasExpectedPlatform = true; + + if (ImageInfoHelper.GetMatchingPlatformData(platform, repo, imageArtifactDetails) is null) + { + missingPlatforms.Add(GetPlatformDescription(platform)); + } + } + + if (!hasExpectedPlatform || missingPlatforms.Count == 0) + { + return null; + } + + return new ManifestListPlatformValidationIssue(manifestListTag, missingPlatforms.AsReadOnly()); + } + + private static TagInfo? GetPlatformTagRepresentative( + RepoInfo repo, + ImageInfo image, + PlatformInfo platform, + Func getTagRepresentative, + bool throwIfMissing) + { + if (platform.Tags.Any()) + { + return getTagRepresentative(platform); + } + + // Tagless platforms included by shared tags need a matching concrete tag to reference in the manifest list. + (ImageInfo Image, PlatformInfo Platform)? matchingPlatform = + repo.AllImages + .SelectMany(candidateImage => + candidateImage.AllPlatforms .Select(candidatePlatform => (Image: candidateImage, Platform: candidatePlatform)) .Where(candidate => - // Exclude the current platform itself platform != candidate.Platform - // Must be the same Dockerfile, OS, and architecture && PlatformInfo.AreMatchingPlatforms( - image1: candidateImage, + image1: image, platform1: platform, image2: candidate.Image, platform2: candidate.Platform) - // Must actually have tags we can borrow - && candidate.Platform.Tags.Any() - ) - ) - // Cast to nullable so FirstOrDefault returns null (not a default struct) - // when no match is found, allowing the ?? to throw. - .Cast<(ImageInfo Image, PlatformInfo Platform)?>() - .FirstOrDefault() - ?? throw new InvalidOperationException( - $"Could not find a platform with concrete tags for" - + $" '{platform.DockerfilePathRelativeToManifest}'."); - - imageTag = getTagRepresentative(matchingPlatform.Value.Platform); - } + && candidate.Platform.Tags.Any())) + .Cast<(ImageInfo Image, PlatformInfo Platform)?>() + .FirstOrDefault(); - if (imageTag is not null) - { - platformTags.Add(getImageName(imageTag.Name)); - } + if (matchingPlatform is not null) + { + return getTagRepresentative(matchingPlatform.Value.Platform); } - if (platformTags.Count == 0) + if (throwIfMissing) { - return null; + throw new InvalidOperationException( + $"Could not find a platform with concrete tags for '{platform.DockerfilePathRelativeToManifest}'."); } - return new ManifestListInfo(manifestListTag, platformTags.AsReadOnly()); + return null; } + + private static string GetPlatformDescription(PlatformInfo platform) => + $"{platform.PlatformLabel} ({platform.DockerfilePathRelativeToManifest})"; }