Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion eng/docker-tools/DEV-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ Build Stage
Post_Build Stage
├── Merge image info files
├── Create multi-arch manifests
└── Consolidate SBOMs
Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions src/ImageBuilder.Tests/CreateManifestListCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,64 @@ public async Task ExecuteAsync_DoesNotPortImagesThatAreAbsentFromImageInfo()
Times.Never);
}

/// <summary>
/// Verifies that a missing manifest platform is reported when the old-build
/// import path has no platform to port forward.
/// </summary>
[TestMethod]
public async Task ExecuteAsync_ThrowsMissingPlatform_WhenNoOldBuildImportExists()
{
Mock<IManifestService> manifestServiceMock = new(MockBehavior.Strict);
Mock<IDockerService> dockerServiceMock = new();
Mock<ICopyImageService> copyImageServiceMock = new();

CreateManifestListCommand command = CreateCommand(
CreateManifestServiceFactoryMock(manifestServiceMock),
dockerServiceMock,
copyImageServiceMock,
Mock.Of<IDateTimeService>());

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<InvalidOperationException>(
() => 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<ImageName>(), It.IsAny<bool>()),
Times.Never);
copyImageServiceMock.Verify(o => o.ImportImageAsync(
It.IsAny<string[]>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<bool>(), It.IsAny<string>(),
It.IsAny<ContainerRegistryImportSourceCredentials>(),
It.IsAny<bool>()),
Times.Never);
dockerServiceMock.Verify(o => o.CreateManifestList(
It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<bool>()),
Times.Never);
}

/// <summary>
/// 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 <c>--path</c>
Expand Down
187 changes: 187 additions & 0 deletions src/ImageBuilder.Tests/ManifestListHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,193 @@ public void GetManifestListsForImages_OnlyIncludesBuiltPlatforms()
results[0].PlatformTags.ShouldNotContain("repo:tag-windows");
}

// Regression test for search ordering bug.
// A tagless platform whose shared tag is syndicated must borrow a representative tag from a
// sibling that actually has a tag syndicated to the target repo, even when an earlier-declared
// sibling has tags but none are syndicated.
//
// This is a super-niche edge case that is unlikely to ever be hit. However, in the original
// implementation of GetManifestListsForImages, the ordering of images in the manifest impacted
// the outcome. The ordering in the manifest should not matter, so therefore it's worth fixing
// and creating this regression test.
//
// Test scenario: 3 images, all with the same dockerfile.
//
// no platform specific tags.
// one shared tag, syndicated.
// image A:
// - dockerfile: 1.0/repo/os
// tags: []
// sharedTags:
// sharedtag:
// syndication:
// repo: syndicated-repo
// destinationTags: [syn-sharedtag]
//
// one platform specific tag, not syndicated.
// no shared tags.
// image B:
// - dockerfile: 1.0/repo/os
// tags: tagB
//
// one platform specific tag, syndicated.
// no shared tags.
// image C:
// - dockerfile: 1.0/repo/os
// tags:
// tagC:
// syndication:
// repo: syndicated-repo
// destinationTags: [syn-tagC]
//
// build: only image A's single platform.
//
// expected manifest lists:
// mcr.microsoft.com/repo:sharedtag -> [ mcr.microsoft.com/repo:tagB ]
// mcr.microsoft.com/syndicated-repo:syn-sharedtag -> [ mcr.microsoft.com/syndicated-repo:tagC ]
[TestMethod]
public void GetManifestListsForImages_SyndicatedTaglessPlatform_SkipsSiblingWithoutSyndicatedTag()
{
using TempFolderContext tempFolderContext = TestHelper.UseTempFolder();

string dockerfile = CreateDockerfile("1.0/repo/os", tempFolderContext);

// Image A: a tagless platform whose shared tag is syndicated.
Image imageA = CreateImage(
[CreatePlatform(dockerfile, Array.Empty<string>())],
new Dictionary<string, Tag>
{
{
"sharedtag",
new Tag
{
Syndication = new TagSyndication
{
Repo = "syndicated-repo",
DestinationTags = ["syn-sharedtag"]
}
}
}
});

// Image B: matching sibling (same Dockerfile) with tags but NO syndicated tag.
// Declared first, so it is the first donor encountered.
Image imageB = CreateImage(CreatePlatform(dockerfile, ["tagB"]));

// Image C: matching sibling with a tag syndicated to "syndicated-repo".
// Declared second; this is the only donor that can supply the syndicated representative.
Platform siblingWithSyndication = CreatePlatform(dockerfile, ["tagC"]);
siblingWithSyndication.Tags["tagC"].Syndication = new TagSyndication
{
Repo = "syndicated-repo",
DestinationTags = ["syn-tagC"]
};
Image imageC = CreateImage(siblingWithSyndication);

Manifest manifest = CreateManifest(CreateRepo("repo", imageA, imageB, imageC));
manifest.Registry = "mcr.microsoft.com";

// Only the tagless platform was built.
ImageArtifactDetails imageArtifactDetails = CreateImageArtifactDetails(
CreateRepoData("repo",
CreateImageData(
["sharedtag"],
CreatePlatform(dockerfile, simpleTags: []))));

ManifestInfo manifestInfo = LoadManifest(manifest, tempFolderContext);
ImageArtifactDetails linkedImageInfo = LoadImageInfo(imageArtifactDetails, manifestInfo, tempFolderContext);

IReadOnlyList<ManifestListInfo> lists = ManifestListHelper.GetManifestListsForImages(
manifestInfo, linkedImageInfo, repoPrefix: null);

// The primary manifest list borrows donor B's tag (first matching sibling with tags).
ManifestListInfo primaryList = lists.Single(l => l.Tag == "mcr.microsoft.com/repo:sharedtag");
primaryList.PlatformTags.ShouldBe(["mcr.microsoft.com/repo:tagB"]);

// The syndicated manifest list must be created and must borrow donor C's tag, since C is
// the only sibling with a tag syndicated to "syndicated-repo".
ManifestListInfo syndicatedList =
lists.Single(l => l.Tag == "mcr.microsoft.com/syndicated-repo:syn-sharedtag");
syndicatedList.PlatformTags.ShouldBe(["mcr.microsoft.com/syndicated-repo:tagC"]);

// Validation should report no missing platforms now that the syndicated list is complete.
ManifestListHelper.GetManifestListPlatformValidationIssues(
manifestInfo, linkedImageInfo, repoPrefix: null).ShouldBeEmpty();
}

/// <summary>
/// Verifies that validation reports generated manifest lists that are missing expected platforms.
/// </summary>
[TestMethod]
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<ManifestListPlatformValidationIssue> 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);
}

/// <summary>
/// Verifies that validation succeeds when every generated manifest list has all expected platforms.
/// </summary>
[TestMethod]
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));
}

/// <summary>
/// Verifies that no manifest list is returned when an image has shared tags
/// but no platforms exist in image-info.
Expand Down
3 changes: 3 additions & 0 deletions src/ImageBuilder/Commands/CreateManifestListCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ManifestListInfo> manifestLists =
ManifestListHelper.GetManifestListsForImages(Manifest, imageArtifactDetails, Options.RepoPrefix);
Expand Down
Loading
Loading