From 9c06663b3ec15cc28d6a174b12720aabfc6a656a Mon Sep 17 00:00:00 2001 From: Davis Goodin Date: Tue, 26 May 2026 22:00:34 +0000 Subject: [PATCH 1/3] Add platform validation for multi-arch manifests in Post_Build stage --- eng/docker-tools/CHANGELOG.md | 8 + eng/docker-tools/DEV-GUIDE.md | 5 +- .../templates/jobs/post-build.yml | 6 + .../templates/stages/build-and-test.yml | 2 + .../stages/dotnet/build-and-test.yml | 2 + .../stages/dotnet/build-test-publish-repo.yml | 2 + .../CreateManifestListCommandTests.cs | 44 ++++ .../ManifestListHelperTests.cs | 73 ++++++ .../Commands/CreateManifestListCommand.cs | 14 +- .../Commands/CreateManifestListOptions.cs | 8 + src/ImageBuilder/ManifestListHelper.cs | 235 ++++++++++++++---- 11 files changed, 352 insertions(+), 47 deletions(-) diff --git a/eng/docker-tools/CHANGELOG.md b/eng/docker-tools/CHANGELOG.md index 99dbceb69..87598e9ed 100644 --- a/eng/docker-tools/CHANGELOG.md +++ b/eng/docker-tools/CHANGELOG.md @@ -4,6 +4,14 @@ All breaking changes and new features in `eng/docker-tools` will be documented i --- +## 2026-05-26: Post_Build can validate manifest list platform completeness + +The build/test templates now accept `validateManifestListPlatforms` (boolean, default `false`). When enabled, Post_Build passes `--validate-manifest-list-platforms` to ImageBuilder's `createManifestList` command and fails if a generated multi-arch manifest tag would omit platforms expected by `manifest.json`. + +Enable this for normal official production builds where manifest tags should represent the full manifest-defined platform set. Leave it disabled for intentionally partial runs such as PR validation, filtered builds, platform bring-up, or temporary infrastructure recovery. + +--- + ## 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..dc59182dd 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,9 @@ 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 multi-arch manifests, and consolidate SBOMs. + +Set `validateManifestListPlatforms: true` on the build/test template to make Post_Build fail if any generated multi-arch manifest tag would omit platforms that are expected by `manifest.json`. This is recommended for normal official production builds, where `manifest.json` is the source of truth for the image platform surface. Leave it disabled for intentionally partial runs, such as PR validation, filtered builds, platform bring-up, or temporary infrastructure recovery where producing a partial manifest tag is expected. The stages variable is useful for: - Re-running just the publish stage after fixing a transient failure diff --git a/eng/docker-tools/templates/jobs/post-build.yml b/eng/docker-tools/templates/jobs/post-build.yml index 32b9c7999..30e6828f4 100644 --- a/eng/docker-tools/templates/jobs/post-build.yml +++ b/eng/docker-tools/templates/jobs/post-build.yml @@ -4,6 +4,7 @@ parameters: publicProjectName: null customInitSteps: [] publishConfig: null + validateManifestListPlatforms: false jobs: - job: Build @@ -14,6 +15,10 @@ jobs: imageInfosContainerDir: "$(artifactsPath)$(imageInfosSubDir)" imageInfosOutputSubDir: "/output" sbomOutputDir: "$(Build.ArtifactStagingDirectory)/sbom" + ${{ if eq(parameters.validateManifestListPlatforms, true) }}: + validateManifestListPlatformsArg: "--validate-manifest-list-platforms" + ${{ else }}: + validateManifestListPlatformsArg: "" steps: - template: /eng/docker-tools/templates/steps/init-common.yml@self parameters: @@ -95,6 +100,7 @@ jobs: --architecture '*' --manifest '$(manifest)' --registry-override '${{ parameters.publishConfig.BuildRegistry.server }}' + $(validateManifestListPlatformsArg) $(manifestVariables) - template: /eng/docker-tools/templates/steps/publish-artifact.yml@self parameters: diff --git a/eng/docker-tools/templates/stages/build-and-test.yml b/eng/docker-tools/templates/stages/build-and-test.yml index d21e8de90..4cd8195be 100644 --- a/eng/docker-tools/templates/stages/build-and-test.yml +++ b/eng/docker-tools/templates/stages/build-and-test.yml @@ -24,6 +24,7 @@ parameters: linuxArmTestJobTimeout: 60 windowsAmdTestJobTimeout: 60 noCache: false + validateManifestListPlatforms: false publishConfig: null internalProjectName: null @@ -221,6 +222,7 @@ stages: publicProjectName: ${{ parameters.publicProjectName }} customInitSteps: ${{ parameters.customInitSteps }} publishConfig: ${{ parameters.publishConfig }} + validateManifestListPlatforms: ${{ parameters.validateManifestListPlatforms }} ################################################################################ # Sign Images diff --git a/eng/docker-tools/templates/stages/dotnet/build-and-test.yml b/eng/docker-tools/templates/stages/dotnet/build-and-test.yml index a9e8f05fd..aec2a8551 100644 --- a/eng/docker-tools/templates/stages/dotnet/build-and-test.yml +++ b/eng/docker-tools/templates/stages/dotnet/build-and-test.yml @@ -20,6 +20,7 @@ parameters: # Build parameters noCache: false + validateManifestListPlatforms: false publishConfig: null buildMatrixType: platformDependencyGraph buildMatrixCustomBuildLegGroupArgs: "" @@ -46,6 +47,7 @@ stages: - template: /eng/docker-tools/templates/stages/build-and-test.yml@self parameters: noCache: ${{ parameters.noCache }} + validateManifestListPlatforms: ${{ parameters.validateManifestListPlatforms }} publishConfig: ${{ parameters.publishConfig }} internalProjectName: ${{ parameters.internalProjectName }} publicProjectName: ${{ parameters.publicProjectName }} diff --git a/eng/docker-tools/templates/stages/dotnet/build-test-publish-repo.yml b/eng/docker-tools/templates/stages/dotnet/build-test-publish-repo.yml index c590a5287..199afcbc5 100644 --- a/eng/docker-tools/templates/stages/dotnet/build-test-publish-repo.yml +++ b/eng/docker-tools/templates/stages/dotnet/build-test-publish-repo.yml @@ -12,6 +12,7 @@ parameters: # Build parameters noCache: false + validateManifestListPlatforms: false publishConfig: null buildMatrixType: platformDependencyGraph buildMatrixCustomBuildLegGroupArgs: "" @@ -50,6 +51,7 @@ stages: customCopyBaseImagesInitSteps: ${{ parameters.customCopyBaseImagesInitSteps }} # Build noCache: ${{ parameters.noCache }} + validateManifestListPlatforms: ${{ parameters.validateManifestListPlatforms }} publishConfig: ${{ parameters.publishConfig }} buildMatrixType: ${{ parameters.buildMatrixType }} buildMatrixCustomBuildLegGroupArgs: ${{ parameters.buildMatrixCustomBuildLegGroupArgs }} diff --git a/src/ImageBuilder.Tests/CreateManifestListCommandTests.cs b/src/ImageBuilder.Tests/CreateManifestListCommandTests.cs index 1504b57ef..c46f4f40f 100644 --- a/src/ImageBuilder.Tests/CreateManifestListCommandTests.cs +++ b/src/ImageBuilder.Tests/CreateManifestListCommandTests.cs @@ -297,6 +297,50 @@ public async Task ExecuteAsync_OnlyCreatesForBuiltPlatforms() false)); } + /// + /// Verifies that platform validation fails before creating partial manifest lists when enabled. + /// + [Fact] + public async Task ExecuteAsync_ValidateManifestListPlatforms_MissingPlatformThrows() + { + Mock manifestServiceMock = CreateManifestServiceMock(); + Mock manifestServiceFactory = CreateManifestServiceFactoryMock(manifestServiceMock); + Mock dockerServiceMock = new(); + + CreateManifestListCommand command = CreateCommand( + manifestServiceFactory, dockerServiceMock, Mock.Of()); + command.Options.ValidateManifestListPlatforms = true; + + 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"])))); + + SetupCommand(command, manifest, imageArtifactDetails, tempFolderContext); + + InvalidOperationException exception = await Should.ThrowAsync(command.ExecuteAsync); + + exception.Message.ShouldContain("repo:sharedtag"); + exception.Message.ShouldContain("linux/arm64"); + exception.Message.ShouldContain(dockerfileArm64); + dockerServiceMock.Verify( + o => o.CreateManifestList(It.IsAny(), It.IsAny>(), It.IsAny()), + Times.Never); + } + private static CreateManifestListCommand CreateCommand( Mock manifestServiceFactory, Mock dockerServiceMock, 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 62825906a..8f85cf47e 100644 --- a/src/ImageBuilder/Commands/CreateManifestListCommand.cs +++ b/src/ImageBuilder/Commands/CreateManifestListCommand.cs @@ -57,14 +57,20 @@ public override async Task ExecuteAsync() ImageArtifactDetails imageArtifactDetails = ImageInfoHelper.LoadFromFile(Options.ImageInfoPath, Manifest); + IReadOnlyList manifestLists = + ManifestListHelper.GetManifestListsForImages( + Manifest, imageArtifactDetails, Options.RepoPrefix); + + if (Options.ValidateManifestListPlatforms) + { + ManifestListHelper.ValidateManifestListPlatforms( + Manifest, imageArtifactDetails, Options.RepoPrefix); + } + await _registryCredentialsProvider.ExecuteWithCredentialsAsync( Options.IsDryRun, async () => { - IReadOnlyList manifestLists = - ManifestListHelper.GetManifestListsForImages( - Manifest, imageArtifactDetails, Options.RepoPrefix); - foreach (ManifestListInfo manifestListInfo in manifestLists) { _dockerService.CreateManifestList(manifestListInfo.Tag, manifestListInfo.PlatformTags, Options.IsDryRun); diff --git a/src/ImageBuilder/Commands/CreateManifestListOptions.cs b/src/ImageBuilder/Commands/CreateManifestListOptions.cs index d03d1cc8f..ee30b9685 100644 --- a/src/ImageBuilder/Commands/CreateManifestListOptions.cs +++ b/src/ImageBuilder/Commands/CreateManifestListOptions.cs @@ -13,17 +13,24 @@ public class CreateManifestListOptions : ManifestOptions, IFilterableOptions public ManifestFilterOptions FilterOptions { get; set; } = new ManifestFilterOptions(); public RegistryCredentialsOptions CredentialsOptions { get; set; } = new RegistryCredentialsOptions(); public string ImageInfoPath { get; set; } = string.Empty; + public bool ValidateManifestListPlatforms { get; set; } private static readonly Argument ImageInfoPathArgument = new(nameof(ImageInfoPath)) { Description = "Path to the image info file to read and update with manifest list digests" }; + private static readonly Option ValidateManifestListPlatformsOption = new("--validate-manifest-list-platforms") + { + Description = "Validate that generated manifest list tags include every expected platform defined in the manifest" + }; + public override IEnumerable