From 1c9bc53eae68f9b3e6c1d15f681438bd0f0c35b0 Mon Sep 17 00:00:00 2001 From: Joseph Petersen Date: Wed, 22 Jul 2026 19:28:42 +0200 Subject: [PATCH 1/3] Add OCI container annotations --- .../ImageBuilder.cs | 13 ++++- .../ImageIndexGenerator.cs | 24 +++++++-- .../ManifestListV2.cs | 10 +++- .../ManifestV2.cs | 7 +++ .../PublicAPI/net11.0/PublicAPI.Unshipped.txt | 10 ++++ .../Tasks/CreateImageIndex.Interface.cs | 8 ++- .../Tasks/CreateImageIndex.cs | 13 +++-- .../Tasks/CreateNewImage.Interface.cs | 6 +++ .../Tasks/CreateNewImage.cs | 6 +++ .../Microsoft.NET.Build.Containers.targets | 43 ++++++++++++++- .../TargetsTests.cs | 53 +++++++++++++++++++ .../ImageBuilderTests.cs | 23 ++++++++ .../ImageIndexGeneratorTests.cs | 48 +++++++++++++++++ 13 files changed, 250 insertions(+), 14 deletions(-) diff --git a/src/Containers/Microsoft.NET.Build.Containers/ImageBuilder.cs b/src/Containers/Microsoft.NET.Build.Containers/ImageBuilder.cs index 10e51d53666d..bd85d2f82906 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ImageBuilder.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ImageBuilder.cs @@ -21,6 +21,7 @@ internal sealed class ImageBuilder private readonly ManifestV2 _manifest; private readonly ImageConfig _baseImageConfig; private readonly ILogger _logger; + private Dictionary? _annotations; /// /// This is a parser for ASPNETCORE_URLS based on https://github.com/dotnet/aspnetcore/blob/main/src/Http/Http/src/BindingAddress.cs @@ -83,7 +84,8 @@ internal BuiltImage Build() Config = newManifestConfig, SchemaVersion = _manifest.SchemaVersion, MediaType = ManifestMediaType, - Layers = _manifest.Layers + Layers = _manifest.Layers, + Annotations = ManifestMediaType == SchemaTypes.OciManifestV1 ? _annotations : null }; return new BuiltImage() @@ -121,6 +123,15 @@ internal void AddLayer(Layer l) /// internal void AddLabel(string name, string value) => _baseImageConfig.AddLabel(name, value); + /// + /// Adds an annotation to the generated OCI image manifest. + /// + internal void AddAnnotation(string name, string value) + { + _annotations ??= new(StringComparer.Ordinal); + _annotations[name] = value; + } + /// /// Adds environment variables to a base image. /// diff --git a/src/Containers/Microsoft.NET.Build.Containers/ImageIndexGenerator.cs b/src/Containers/Microsoft.NET.Build.Containers/ImageIndexGenerator.cs index 69403dc72dae..94cabd7d24c2 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ImageIndexGenerator.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ImageIndexGenerator.cs @@ -17,7 +17,7 @@ internal static class ImageIndexGenerator /// Returns json string of image index and image index mediaType. /// /// - internal static (string, string) GenerateImageIndex(BuiltImage[] images) + internal static (string, string) GenerateImageIndex(BuiltImage[] images, IReadOnlyDictionary? annotations = null) { if (images.Length == 0) { @@ -37,7 +37,7 @@ internal static (string, string) GenerateImageIndex(BuiltImage[] images) } else if (manifestMediaType == SchemaTypes.OciManifestV1) { - return (GenerateImageIndex(images, SchemaTypes.OciManifestV1, SchemaTypes.OciImageIndexV1), SchemaTypes.OciImageIndexV1); + return (GenerateImageIndex(images, SchemaTypes.OciManifestV1, SchemaTypes.OciImageIndexV1, annotations), SchemaTypes.OciImageIndexV1); } else { @@ -54,7 +54,7 @@ internal static (string, string) GenerateImageIndex(BuiltImage[] images) /// Returns json string of image index and image index mediaType. /// /// - internal static string GenerateImageIndex(BuiltImage[] images, string manifestMediaType, string imageIndexMediaType) + internal static string GenerateImageIndex(BuiltImage[] images, string manifestMediaType, string imageIndexMediaType, IReadOnlyDictionary? annotations = null) { if (images.Length == 0) { @@ -84,12 +84,28 @@ internal static string GenerateImageIndex(BuiltImage[] images, string manifestMe { schemaVersion = 2, mediaType = imageIndexMediaType, - manifests = manifests + manifests = manifests, + annotations = CopyAnnotations(annotations) }; return GetJsonStringFromImageIndex(imageIndex); } + private static Dictionary? CopyAnnotations(IReadOnlyDictionary? annotations) + { + if (annotations is not { Count: > 0 }) + { + return null; + } + + Dictionary result = new(annotations.Count, StringComparer.Ordinal); + foreach ((string key, string value) in annotations) + { + result[key] = value; + } + return result; + } + internal static string GenerateImageIndexWithAnnotations( string manifestMediaType, string manifestDigest, diff --git a/src/Containers/Microsoft.NET.Build.Containers/ManifestListV2.cs b/src/Containers/Microsoft.NET.Build.Containers/ManifestListV2.cs index 471f450f42d6..a002b27a1937 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ManifestListV2.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ManifestListV2.cs @@ -5,11 +5,17 @@ namespace Microsoft.NET.Build.Containers; -public record struct ManifestListV2(int schemaVersion, string mediaType, PlatformSpecificManifest[] manifests); +public record struct ManifestListV2(int schemaVersion, string mediaType, PlatformSpecificManifest[] manifests) +{ + public Dictionary? annotations { get; init; } +} public record struct PlatformInformation(string architecture, string os, string? variant, string[] features, [property: JsonPropertyName("os.version")][field: JsonPropertyName("os.version")] string? version); public record struct PlatformSpecificManifest(string mediaType, long size, string digest, PlatformInformation platform); -public record struct ImageIndexV1(int schemaVersion, string mediaType, PlatformSpecificOciManifest[] manifests); +public record struct ImageIndexV1(int schemaVersion, string mediaType, PlatformSpecificOciManifest[] manifests) +{ + public Dictionary? annotations { get; init; } +} public record struct PlatformSpecificOciManifest(string mediaType, long size, string digest, PlatformInformation platform, Dictionary annotations); diff --git a/src/Containers/Microsoft.NET.Build.Containers/ManifestV2.cs b/src/Containers/Microsoft.NET.Build.Containers/ManifestV2.cs index a0403bdc035f..f158839e3c64 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ManifestV2.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ManifestV2.cs @@ -53,6 +53,13 @@ public class ManifestV2 [JsonPropertyName("layers")] public required List Layers { get; init; } + /// + /// Arbitrary metadata for this OCI image manifest. + /// + [JsonPropertyName("annotations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Annotations { get; init; } + /// /// Gets the digest for this manifest. /// diff --git a/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net11.0/PublicAPI.Unshipped.txt b/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net11.0/PublicAPI.Unshipped.txt index 5db830dd4f46..b4b87e2e5c43 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net11.0/PublicAPI.Unshipped.txt +++ b/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net11.0/PublicAPI.Unshipped.txt @@ -31,6 +31,8 @@ Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.BaseImageDigest.get -> str Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.BaseImageDigest.set -> void Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.ArchiveOutputPath.get -> string! Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.ArchiveOutputPath.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.Annotations.get -> Microsoft.Build.Framework.ITaskItem![]! +Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.Annotations.set -> void Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.LocalRegistry.get -> string! Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.LocalRegistry.set -> void Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.GeneratedArchiveOutputPath.get -> string! @@ -96,6 +98,8 @@ Microsoft.NET.Build.Containers.ManifestLayer.urls.get -> string![]? Microsoft.NET.Build.Containers.ManifestLayer.urls.set -> void Microsoft.NET.Build.Containers.ManifestListV2 Microsoft.NET.Build.Containers.ManifestListV2.ManifestListV2() -> void +Microsoft.NET.Build.Containers.ManifestListV2.annotations.get -> System.Collections.Generic.Dictionary? +Microsoft.NET.Build.Containers.ManifestListV2.annotations.init -> void Microsoft.NET.Build.Containers.ManifestListV2.ManifestListV2(int schemaVersion, string! mediaType, Microsoft.NET.Build.Containers.PlatformSpecificManifest[]! manifests) -> void Microsoft.NET.Build.Containers.ManifestListV2.manifests.get -> Microsoft.NET.Build.Containers.PlatformSpecificManifest[]! Microsoft.NET.Build.Containers.ManifestListV2.manifests.set -> void @@ -105,6 +109,8 @@ Microsoft.NET.Build.Containers.ManifestListV2.schemaVersion.get -> int Microsoft.NET.Build.Containers.ManifestListV2.schemaVersion.set -> void Microsoft.NET.Build.Containers.ImageIndexV1 Microsoft.NET.Build.Containers.ImageIndexV1.ImageIndexV1() -> void +Microsoft.NET.Build.Containers.ImageIndexV1.annotations.get -> System.Collections.Generic.Dictionary? +Microsoft.NET.Build.Containers.ImageIndexV1.annotations.init -> void Microsoft.NET.Build.Containers.ImageIndexV1.ImageIndexV1(int schemaVersion, string! mediaType, Microsoft.NET.Build.Containers.PlatformSpecificOciManifest[]! manifests) -> void Microsoft.NET.Build.Containers.ImageIndexV1.manifests.get -> Microsoft.NET.Build.Containers.PlatformSpecificOciManifest[]! Microsoft.NET.Build.Containers.ImageIndexV1.manifests.set -> void @@ -114,6 +120,8 @@ Microsoft.NET.Build.Containers.ImageIndexV1.schemaVersion.get -> int Microsoft.NET.Build.Containers.ImageIndexV1.schemaVersion.set -> void Microsoft.NET.Build.Containers.ManifestV2 Microsoft.NET.Build.Containers.ManifestV2.Config.get -> Microsoft.NET.Build.Containers.ManifestConfig +Microsoft.NET.Build.Containers.ManifestV2.Annotations.get -> System.Collections.Generic.Dictionary? +Microsoft.NET.Build.Containers.ManifestV2.Annotations.init -> void Microsoft.NET.Build.Containers.ManifestV2.Config.init -> void Microsoft.NET.Build.Containers.ManifestV2.GetDigest() -> string! Microsoft.NET.Build.Containers.ManifestV2.KnownDigest.get -> string? @@ -235,6 +243,8 @@ Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ImageTags.get -> string![]! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ImageTags.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.Labels.get -> Microsoft.Build.Framework.ITaskItem![]! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.Labels.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.Annotations.get -> Microsoft.Build.Framework.ITaskItem![]! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.Annotations.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.LocalRegistry.get -> string! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.LocalRegistry.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.OutputRegistry.get -> string! diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateImageIndex.Interface.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateImageIndex.Interface.cs index 538240841c6b..808d1e98a1b2 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateImageIndex.Interface.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateImageIndex.Interface.cs @@ -66,6 +66,11 @@ partial class CreateImageIndex [Required] public string[] ImageTags { get; set; } + /// + /// Annotations that the OCI image index will include in metadata. + /// + public ITaskItem[] Annotations { get; set; } + /// /// The generated archive output path. /// @@ -90,9 +95,10 @@ public CreateImageIndex() LocalRegistry = string.Empty; Repository = string.Empty; ImageTags = Array.Empty(); + Annotations = Array.Empty(); GeneratedArchiveOutputPath = string.Empty; GeneratedImageIndex = string.Empty; TaskResources = Resource.Manager; } -} \ No newline at end of file +} diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateImageIndex.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateImageIndex.cs index 8046790069b7..9f41fc0ccacb 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateImageIndex.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateImageIndex.cs @@ -68,7 +68,12 @@ internal async Task ExecuteAsync(CancellationToken cancellationToken) return false; } - var multiArchImage = CreateMultiArchImage(images, destinationImageReference.Kind); + Dictionary annotations = new(StringComparer.Ordinal); + foreach (ITaskItem annotation in Annotations) + { + annotations[annotation.ItemSpec] = annotation.GetMetadata("Value"); + } + var multiArchImage = CreateMultiArchImage(images, destinationImageReference.Kind, annotations); GeneratedImageIndex = multiArchImage.ImageIndex; GeneratedArchiveOutputPath = ArchiveOutputPath; @@ -163,7 +168,7 @@ private BuiltImage[] ParseImages(DestinationImageReferenceKind destinationKind) return (architecture, os); } - private static MultiArchImage CreateMultiArchImage(BuiltImage[] images, DestinationImageReferenceKind destinationImageKind) + private static MultiArchImage CreateMultiArchImage(BuiltImage[] images, DestinationImageReferenceKind destinationImageKind, IReadOnlyDictionary annotations) { switch (destinationImageKind) { @@ -171,12 +176,12 @@ private static MultiArchImage CreateMultiArchImage(BuiltImage[] images, Destinat return new MultiArchImage() { // For multi-arch we publish only oci-formatted image tarballs. - ImageIndex = ImageIndexGenerator.GenerateImageIndex(images, SchemaTypes.OciManifestV1, SchemaTypes.OciImageIndexV1), + ImageIndex = ImageIndexGenerator.GenerateImageIndex(images, SchemaTypes.OciManifestV1, SchemaTypes.OciImageIndexV1, annotations), ImageIndexMediaType = SchemaTypes.OciImageIndexV1, Images = images }; case DestinationImageReferenceKind.RemoteRegistry: - (string imageIndex, string mediaType) = ImageIndexGenerator.GenerateImageIndex(images); + (string imageIndex, string mediaType) = ImageIndexGenerator.GenerateImageIndex(images, annotations); return new MultiArchImage() { ImageIndex = imageIndex, diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs index d33273ee309f..56d9ab9b805a 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs @@ -116,6 +116,11 @@ partial class CreateNewImage /// public ITaskItem[] Labels { get; set; } + /// + /// Annotations that the OCI image manifest will include in metadata. + /// + public ITaskItem[] Annotations { get; set; } + /// /// Container environment variables to set. /// @@ -206,6 +211,7 @@ public CreateNewImage() AppCommandArgs = Array.Empty(); AppCommandInstruction = ""; Labels = Array.Empty(); + Annotations = Array.Empty(); ExposedPorts = Array.Empty(); ContainerEnvironmentVariables = Array.Empty(); ContainerRuntimeIdentifier = ""; diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs index 64506e88b352..59d1fec07790 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs @@ -185,6 +185,7 @@ private async Task ExecuteAsyncCore(ILogger logger, ILoggerFactory msbuild (baseImageLabel, baseImageDigest) = imageBuilder.AddBaseImageDigestLabel(); } } + else { if (GenerateDigestLabel) @@ -193,6 +194,11 @@ private async Task ExecuteAsyncCore(ILogger logger, ILoggerFactory msbuild } } + foreach (ITaskItem annotation in Annotations) + { + imageBuilder.AddAnnotation(annotation.ItemSpec, annotation.GetMetadata("Value")); + } + SetEnvironmentVariables(imageBuilder, ContainerEnvironmentVariables); SetPorts(imageBuilder, ExposedPorts); diff --git a/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets b/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets index af82a666253b..6700e5be4114 100644 --- a/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets +++ b/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets @@ -135,6 +135,19 @@ true true true + + false + $(ContainerGenerateLabelsImageCreated) + $(ContainerGenerateLabelsImageDescription) + $(ContainerGenerateLabelsImageAuthors) + $(ContainerGenerateLabelsImageUrl) + $(ContainerGenerateLabelsImageDocumentation) + $(ContainerGenerateLabelsImageSource) + $(ContainerGenerateLabelsImageVersion) + $(ContainerGenerateLabelsImageRevision) + $(ContainerGenerateLabelsImageVendor) + $(ContainerGenerateLabelsImageLicenses) + $(ContainerGenerateLabelsImageTitle) @@ -145,11 +158,12 @@ $(PackageVersion) $(PackageLicenseExpression) $(Title) + <_ContainerImageCreated>$([System.DateTime]::UtcNow.ToString('o')) - + @@ -165,9 +179,22 @@ + + + + + + + + + + + + + - + <_TrimmedRepositoryUrl Condition="'$(RepositoryType)' == 'git' and '$(PrivateRepositoryUrl)' != '' and $(PrivateRepositoryUrl.EndsWith('.git'))">$(PrivateRepositoryUrl.Substring(0, $(PrivateRepositoryUrl.LastIndexOf('.git')))) <_TrimmedRepositoryUrl Condition="'$(_TrimmedRepositoryUrl)' == '' and '$(PrivateRepositoryUrl)' != ''">$(PrivateRepositoryUrl) @@ -176,6 +203,16 @@ + + + + + + OCI + + @@ -271,6 +308,7 @@ AppCommandInstruction="$(ContainerAppCommandInstruction)" DefaultArgs="@(ContainerDefaultArgs)" Labels="@(ContainerLabel)" + Annotations="@(ContainerAnnotation)" ExposedPorts="@(ContainerPort)" ContainerEnvironmentVariables="@(ContainerEnvironmentVariables)" ContainerRuntimeIdentifier="$(ContainerRuntimeIdentifier)" @@ -356,6 +394,7 @@ ArchiveOutputPath="$(ContainerArchiveOutputPath)" Repository="$(ContainerRepository)" ImageTags="@(ContainerImageTags)" + Annotations="@(ContainerAnnotation)" BaseRegistry="$(ContainerBaseRegistry)" BaseImageName="$(ContainerBaseName)" BaseImageTag="$(ContainerBaseTag)" diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/TargetsTests.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/TargetsTests.cs index cbcfcadd84c3..f8f01e372718 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/TargetsTests.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/TargetsTests.cs @@ -140,6 +140,59 @@ public void GetsConventionalLabelsByDefault(bool shouldEvaluateLabels) private static bool LabelMatch(string label, string value, ProjectItemInstance item) => item.EvaluatedInclude == label && item.GetMetadata("Value") is { } v && v.EvaluatedValue == value; + private static bool AnnotationMatch(string annotation, string value, ProjectItemInstance item) => item.EvaluatedInclude == annotation && item.GetMetadata("Value") is { } v && v.EvaluatedValue == value; + + [TestMethod] + public void GetsConventionalAnnotationsFromAvailableMetadata() + { + const string repoUrl = "https://github.com/dotnet/sdk.git"; + var (project, logger, d) = ProjectInitializer.InitProject(new() + { + ["PublishRepositoryUrl"] = true.ToString(), + ["PrivateRepositoryUrl"] = repoUrl, + ["RepositoryType"] = "git", + ["SourceRevisionId"] = "abcdef", + ["PackageVersion"] = "1.2.3", + ["Description"] = "SDK container", + ["ContainerGenerateAnnotations"] = true.ToString() + }, projectName: nameof(GetsConventionalAnnotationsFromAvailableMetadata)); + using var _ = d; + var instance = project.CreateProjectInstance(ProjectInstanceSettings.None); + + instance.Build([ComputeContainerConfig], [logger]).Should().BeTrue(String.Join(Environment.NewLine, logger.AllMessages)); + + var annotations = instance.GetItems("ContainerAnnotation"); + annotations.Should() + .ContainSingle(annotation => AnnotationMatch("org.opencontainers.image.source", "https://github.com/dotnet/sdk", annotation)) + .And.ContainSingle(annotation => AnnotationMatch("org.opencontainers.image.revision", "abcdef", annotation)) + .And.ContainSingle(annotation => AnnotationMatch("org.opencontainers.image.version", "1.2.3", annotation)) + .And.ContainSingle(annotation => AnnotationMatch("org.opencontainers.image.description", "SDK container", annotation)); + annotations.Should().NotContain(annotation => annotation.EvaluatedInclude == "org.opencontainers.image.base.name" || annotation.EvaluatedInclude == "net.dot.sdk.version"); + instance.GetPropertyValue("ContainerImageFormat").Should().Be("OCI"); + } + + [DataRow("", true, "OCI")] + [DataRow("Docker", false, "Docker")] + [DataRow("OCI", true, "OCI")] + [TestMethod] + public void ContainerAnnotationsRequireOciFormat(string requestedFormat, bool shouldSucceed, string expectedFormat) + { + var annotation = new Microsoft.Build.Utilities.TaskItem("example.com/annotation"); + annotation.SetMetadata("Value", "value"); + var (project, logger, d) = ProjectInitializer.InitProject(new() + { + ["ContainerImageFormat"] = requestedFormat + }, bonusItems: new() + { + ["ContainerAnnotation"] = [annotation] + }, projectName: $"{nameof(ContainerAnnotationsRequireOciFormat)}_{requestedFormat}"); + using var _ = d; + var instance = project.CreateProjectInstance(ProjectInstanceSettings.None); + + instance.Build([ComputeContainerConfig], [logger]).Should().Be(shouldSucceed, String.Join(Environment.NewLine, logger.AllMessages)); + instance.GetPropertyValue("ContainerImageFormat").Should().Be(expectedFormat); + } + [DataRow(true)] [DataRow(false)] [TestMethod] diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs index 849826edab2f..5d64dc3e05e5 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs @@ -94,6 +94,29 @@ public void CanAddLabelsToImage() Assert.AreEqual("v2", resultLabels["testLabel2"]?.ToString()); } + [TestMethod] + public void CanAddAnnotationsToOciImageManifest() + { + var builder = FromBaseImageConfig( + """ + { + "architecture": "amd64", + "config": {}, + "os": "linux", + "rootfs": { "type": "layers", "diff_ids": [] } + } + """); + builder.ManifestMediaType = SchemaTypes.OciManifestV1; + builder.AddAnnotation("org.opencontainers.image.source", "https://github.com/dotnet/sdk"); + builder.AddAnnotation("org.opencontainers.image.revision", "abcdef"); + + JsonNode? manifest = JsonNode.Parse(builder.Build().Manifest); + + Assert.IsNotNull(manifest); + Assert.AreEqual("https://github.com/dotnet/sdk", manifest["annotations"]?["org.opencontainers.image.source"]?.GetValue()); + Assert.AreEqual("abcdef", manifest["annotations"]?["org.opencontainers.image.revision"]?.GetValue()); + } + [TestMethod] public void CanPreserveExistingLabels() { diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs index 8c64c3b8867e..35576768730b 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs @@ -135,6 +135,54 @@ public void GenerateOciImageIndex() Assert.AreEqual(SchemaTypes.OciImageIndexV1, mediaType); } + [TestMethod] + public void GenerateOciImageIndexWithAnnotations() + { + BuiltImage[] images = + [ + new BuiltImage + { + Config = "", + Manifest = "123", + ManifestDigest = "sha256:digest1", + ManifestMediaType = SchemaTypes.OciManifestV1, + Architecture = "arch1", + OS = "os1" + } + ]; + Dictionary annotations = new() + { + ["org.opencontainers.image.source"] = "https://github.com/dotnet/sdk", + ["org.opencontainers.image.revision"] = "abcdef" + }; + + var (imageIndex, mediaType) = ImageIndexGenerator.GenerateImageIndex(images, annotations); + + Assert.AreEqual("{\"schemaVersion\":2,\"mediaType\":\"application/vnd.oci.image.index.v1+json\",\"manifests\":[{\"mediaType\":\"application/vnd.oci.image.manifest.v1+json\",\"size\":3,\"digest\":\"sha256:digest1\",\"platform\":{\"architecture\":\"arch1\",\"os\":\"os1\"}}],\"annotations\":{\"org.opencontainers.image.source\":\"https://github.com/dotnet/sdk\",\"org.opencontainers.image.revision\":\"abcdef\"}}", imageIndex); + Assert.AreEqual(SchemaTypes.OciImageIndexV1, mediaType); + } + + [TestMethod] + public void DockerManifestListDoesNotIncludeOciAnnotations() + { + BuiltImage[] images = + [ + new BuiltImage + { + Config = "", + Manifest = "123", + ManifestDigest = "sha256:digest1", + ManifestMediaType = SchemaTypes.DockerManifestV2, + Architecture = "arch1", + OS = "os1" + } + ]; + + var (imageIndex, _) = ImageIndexGenerator.GenerateImageIndex(images, new Dictionary { ["example.com/key"] = "value" }); + + Assert.IsFalse(imageIndex.Contains("annotations", StringComparison.Ordinal)); + } + [TestMethod] public void GenerateImageIndexWithAnnotations() { From d29d88d8a8722f69c004e8be06fa344d3d1adcec Mon Sep 17 00:00:00 2001 From: Joseph Petersen Date: Wed, 22 Jul 2026 20:12:13 +0200 Subject: [PATCH 2/3] Address container annotation review feedback --- .../ImageIndexGenerator.cs | 2 +- .../ImageIndexGeneratorTests.cs | 26 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/Containers/Microsoft.NET.Build.Containers/ImageIndexGenerator.cs b/src/Containers/Microsoft.NET.Build.Containers/ImageIndexGenerator.cs index 94cabd7d24c2..e5c66250eaa3 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ImageIndexGenerator.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ImageIndexGenerator.cs @@ -99,7 +99,7 @@ internal static string GenerateImageIndex(BuiltImage[] images, string manifestMe } Dictionary result = new(annotations.Count, StringComparer.Ordinal); - foreach ((string key, string value) in annotations) + foreach ((string key, string value) in annotations.OrderBy(annotation => annotation.Key, StringComparer.Ordinal)) { result[key] = value; } diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs index 35576768730b..6a40c41f5b0f 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using Microsoft.NET.Build.Containers.Resources; namespace Microsoft.NET.Build.Containers.UnitTests; @@ -155,11 +156,34 @@ public void GenerateOciImageIndexWithAnnotations() ["org.opencontainers.image.source"] = "https://github.com/dotnet/sdk", ["org.opencontainers.image.revision"] = "abcdef" }; + Dictionary annotationsInReverseOrder = new() + { + ["org.opencontainers.image.revision"] = "abcdef", + ["org.opencontainers.image.source"] = "https://github.com/dotnet/sdk" + }; var (imageIndex, mediaType) = ImageIndexGenerator.GenerateImageIndex(images, annotations); + var (imageIndexFromReverseOrder, _) = ImageIndexGenerator.GenerateImageIndex(images, annotationsInReverseOrder); - Assert.AreEqual("{\"schemaVersion\":2,\"mediaType\":\"application/vnd.oci.image.index.v1+json\",\"manifests\":[{\"mediaType\":\"application/vnd.oci.image.manifest.v1+json\",\"size\":3,\"digest\":\"sha256:digest1\",\"platform\":{\"architecture\":\"arch1\",\"os\":\"os1\"}}],\"annotations\":{\"org.opencontainers.image.source\":\"https://github.com/dotnet/sdk\",\"org.opencontainers.image.revision\":\"abcdef\"}}", imageIndex); + Assert.AreEqual(imageIndex, imageIndexFromReverseOrder); Assert.AreEqual(SchemaTypes.OciImageIndexV1, mediaType); + + using JsonDocument document = JsonDocument.Parse(imageIndex); + JsonElement root = document.RootElement; + Assert.AreEqual(2, root.GetProperty("schemaVersion").GetInt32()); + Assert.AreEqual(SchemaTypes.OciImageIndexV1, root.GetProperty("mediaType").GetString()); + + JsonElement manifest = root.GetProperty("manifests")[0]; + Assert.AreEqual(SchemaTypes.OciManifestV1, manifest.GetProperty("mediaType").GetString()); + Assert.AreEqual(3, manifest.GetProperty("size").GetInt64()); + Assert.AreEqual("sha256:digest1", manifest.GetProperty("digest").GetString()); + Assert.AreEqual("arch1", manifest.GetProperty("platform").GetProperty("architecture").GetString()); + Assert.AreEqual("os1", manifest.GetProperty("platform").GetProperty("os").GetString()); + + JsonElement serializedAnnotations = root.GetProperty("annotations"); + Assert.HasCount(2, serializedAnnotations.EnumerateObject()); + Assert.AreEqual("https://github.com/dotnet/sdk", serializedAnnotations.GetProperty("org.opencontainers.image.source").GetString()); + Assert.AreEqual("abcdef", serializedAnnotations.GetProperty("org.opencontainers.image.revision").GetString()); } [TestMethod] From 621a6cf1bc00dbf94c5675b20da42764796ac4b6 Mon Sep 17 00:00:00 2001 From: Joseph Petersen Date: Thu, 23 Jul 2026 07:30:43 +0200 Subject: [PATCH 3/3] Fix copying container test item metadata --- .../ProjectInitializer.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/ProjectInitializer.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/ProjectInitializer.cs index 60a1737bcc01..c38c4c0d971e 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/ProjectInitializer.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/ProjectInitializer.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections; using System.Runtime.CompilerServices; using Microsoft.Build.Evaluation; using Microsoft.Build.Framework; @@ -88,10 +89,9 @@ public static (Project, CapturingLogger, IDisposable) InitProject(Dictionary