diff --git a/src/Containers/Microsoft.NET.Build.Containers/BuiltImage.cs b/src/Containers/Microsoft.NET.Build.Containers/BuiltImage.cs index 91f47193ce66..ea0694eea097 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/BuiltImage.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/BuiltImage.cs @@ -1,6 +1,9 @@ // 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 System.Text.Json.Nodes; + namespace Microsoft.NET.Build.Containers; /// @@ -11,32 +14,22 @@ internal readonly struct BuiltImage /// /// Gets image configuration in JSON format. /// - internal required string Config { get; init; } - - /// - /// Gets image digest. - /// - internal string? ImageDigest { get; init; } - - /// - /// Gets image SHA. - /// - internal string? ImageSha { get; init; } + internal required JsonObject Config { get; init; } /// /// Gets image manifest. /// - internal required string Manifest { get; init; } + internal required ManifestV2 Manifest { get; init; } /// /// Gets manifest digest. /// - internal required string ManifestDigest { get; init; } + internal string ManifestDigest => Manifest.GetDigest(); /// /// Gets manifest mediaType. /// - internal required string ManifestMediaType { get; init; } + internal string ManifestMediaType => Manifest.MediaType!; /// /// Gets image layers. diff --git a/src/Containers/Microsoft.NET.Build.Containers/ContainerBuilder.cs b/src/Containers/Microsoft.NET.Build.Containers/ContainerBuilder.cs index ff496c844e70..4ce4ef82a199 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ContainerBuilder.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ContainerBuilder.cs @@ -1,6 +1,8 @@ // 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 System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using Microsoft.NET.Build.Containers.Resources; @@ -15,7 +17,7 @@ internal enum KnownImageFormats internal static class ContainerBuilder { internal static async Task ContainerizeAsync( - DirectoryInfo publishDirectory, + (string absolutePath, string relativePath)[] inputFiles, string workingDir, string baseRegistry, string baseImageName, @@ -33,22 +35,22 @@ internal static async Task ContainerizeAsync( Dictionary labels, Port[]? exposedPorts, Dictionary envVars, - string containerRuntimeIdentifier, - string ridGraphPath, string localRegistry, string? containerUser, string? archiveOutputPath, bool generateLabels, bool generateDigestLabel, KnownImageFormats? imageFormat, + string contentStoreRoot, + FileInfo baseImageManifestPath, + FileInfo baseImageConfigPath, + FileInfo generatedConfigPath, + FileInfo generatedManifestPath, + FileInfo generatedLayerPath, ILoggerFactory loggerFactory, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (!publishDirectory.Exists) - { - throw new ArgumentException(string.Format(Resource.GetString(nameof(Strings.PublishDirectoryDoesntExist)), nameof(publishDirectory), publishDirectory.FullName)); - } ILogger logger = loggerFactory.CreateLogger("Containerize"); logger.LogTrace("Trace logging: enabled."); @@ -70,13 +72,7 @@ internal static async Task ContainerizeAsync( { try { - var ridGraphPicker = new RidGraphManifestPicker(ridGraphPath); - imageBuilder = await registry.GetImageManifestAsync( - baseImageName, - sourceImageReference.Reference, - containerRuntimeIdentifier, - ridGraphPicker, - cancellationToken).ConfigureAwait(false); + imageBuilder = await LoadFromManifestAndConfig(baseImageManifestPath.FullName, imageFormat, baseImageConfigPath.FullName, logger); } catch (RepositoryNotFoundException) { @@ -98,11 +94,6 @@ internal static async Task ContainerizeAsync( { throw new NotSupportedException(Resource.GetString(nameof(Strings.ImagePullNotSupported))); } - if (imageBuilder is null) - { - Console.WriteLine(Resource.GetString(nameof(Strings.BaseImageNotFound)), sourceImageReference, containerRuntimeIdentifier); - return 1; - } logger.LogInformation(Strings.ContainerBuilder_StartBuildingImage, imageName, string.Join(",", imageName), sourceImageReference); cancellationToken.ThrowIfCancellationRequested(); @@ -115,7 +106,14 @@ internal static async Task ContainerizeAsync( _ => imageBuilder.ManifestMediaType // should be impossible unless we add to the enum }; - Layer newLayer = Layer.FromDirectory(publishDirectory.FullName, workingDir, imageBuilder.IsWindows, imageBuilder.ManifestMediaType); + var storePath = new DirectoryInfo(contentStoreRoot); + if (!storePath.Exists) + { + throw new ArgumentException($"The content store path '{contentStoreRoot}' does not exist."); + } + var store = new ContentStore(storePath); + + Layer newLayer = await Layer.FromFiles(inputFiles, workingDir, imageBuilder.IsWindows, imageBuilder.ManifestMediaType, store, generatedLayerPath, cancellationToken); imageBuilder.AddLayer(newLayer); imageBuilder.SetWorkingDirectory(workingDir); @@ -174,6 +172,16 @@ internal static async Task ContainerizeAsync( BuiltImage builtImage = imageBuilder.Build(); cancellationToken.ThrowIfCancellationRequested(); + // at this point we're done with modifications and are just pushing the data other places + + var serializedManifest = JsonSerializer.Serialize(builtImage.Manifest); + var manifestWriteTask = File.WriteAllTextAsync(generatedManifestPath.FullName, serializedManifest, DigestUtils.UTF8); + + var serializedConfig = JsonSerializer.Serialize(builtImage.Config); + var configWriteTask = File.WriteAllTextAsync(generatedConfigPath.FullName, serializedConfig, DigestUtils.UTF8); + + await Task.WhenAll(manifestWriteTask, configWriteTask).ConfigureAwait(false); + int exitCode; switch (destinationImageReference.Kind) { @@ -256,4 +264,24 @@ private static async Task PushToRemoteRegistryAsync(ILogger logger, BuiltIm return 0; } + + + public static async Task LoadFromManifestAndConfig(string manifestPath, KnownImageFormats? desiredImageFormat, string configPath, ILogger logger) + { + var baseImageManifest = await JsonSerializer.DeserializeAsync(File.OpenRead(manifestPath)); + var baseImageConfig = await JsonNode.ParseAsync(File.OpenRead(configPath)); + if (baseImageConfig is null || baseImageManifest is null) throw new ArgumentException($"Expected to load manifest from {manifestPath} and config from {configPath}"); + // forcibly change the media type if required from that of the base image + var mediaType = baseImageManifest.MediaType; + if (desiredImageFormat is not null) + { + mediaType = desiredImageFormat switch + { + KnownImageFormats.Docker => SchemaTypes.DockerManifestV2, + KnownImageFormats.OCI => SchemaTypes.OciManifestV1, + _ => mediaType // should be impossible unless we add to the enum + }; + } + return new ImageBuilder(baseImageManifest, mediaType!, new ImageConfig(baseImageConfig), logger); + } } diff --git a/src/Containers/Microsoft.NET.Build.Containers/ContentStore.cs b/src/Containers/Microsoft.NET.Build.Containers/ContentStore.cs index 2fb16be3ec5d..46f3263c7f04 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ContentStore.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ContentStore.cs @@ -6,34 +6,67 @@ namespace Microsoft.NET.Build.Containers; -internal static class ContentStore +/// +/// Structured access to the content store for manifests and blobs at a given root path. +/// +/// +internal class ContentStore(DirectoryInfo root) { - public static string ArtifactRoot { get; set; } = Path.Combine(Path.GetTempPath(), "Containers"); - public static string ContentRoot + public string ArtifactRoot + { + get + { + string artifactPath = Path.Join(root.FullName, "Containers"); + Directory.CreateDirectory(artifactPath); + return artifactPath; + } + } + + /// + /// Where all the blobs are stored in this ContentStore - these will be addressed purely by digest. The contents may be JSON blobs, + /// layer tarballs, or other - you need to know the media type to interpret the contents. + /// + public string ContentRoot { get { string contentPath = Path.Join(ArtifactRoot, "Content"); - Directory.CreateDirectory(contentPath); - return contentPath; } } - public static string TempPath + /// + /// Where all the reference pointers are stored in this ContentStore. These will be addressed by logical reference - registry, repository, tag. + /// The contents will be a digest and media type, which can then be looked up in the . + /// + public string ReferenceRoot { get { - string tempPath = Path.Join(ArtifactRoot, "Temp"); + string referencePath = Path.Combine(ArtifactRoot, "Manifests"); + Directory.CreateDirectory(referencePath); + return referencePath; + } + } + public string TempPath + { + get + { + string tempPath = Path.Join(ArtifactRoot, "Temp"); Directory.CreateDirectory(tempPath); - return tempPath; } } - public static string PathForDescriptor(Descriptor descriptor) + /// + /// A safety valve on top of that also validates that we know/understand the media type of the Descriptor + /// + /// + /// + /// If the Descriptor isn't a layer mediatype + public string PathForDescriptor(Descriptor descriptor) { string digest = descriptor.Digest; @@ -50,20 +83,32 @@ public static string PathForDescriptor(Descriptor descriptor) "application/vnd.docker.image.rootfs.diff.tar" or "application/vnd.oci.image.layer.v1.tar" => ".tar", + SchemaTypes.DockerManifestListV2 + or SchemaTypes.DockerManifestV2 + or SchemaTypes.OciImageIndexV1 + or SchemaTypes.OciManifestV1 + or SchemaTypes.DockerContainerV1 => string.Empty, _ => throw new ArgumentException(Resource.FormatString(nameof(Strings.UnrecognizedMediaType), descriptor.MediaType)) }; return GetPathForHash(contentHash) + extension; } - - public static string GetPathForHash(string contentHash) - { - return Path.Combine(ContentRoot, contentHash); - } - - public static string GetTempFile() - { - return Path.Join(TempPath, Path.GetRandomFileName()); - } + /// + /// Returns the path in the for the manifest reference for this registry/repository/tag. + /// + /// + /// + /// + /// + public string PathForManifestByReferenceOrDigest(string registry, string repository, string tag) => Path.Combine(ReferenceRoot, registry, repository, tag); + + /// + /// Returns the path to the content store for a given content hash (algo:digest) pair. + /// + /// + /// + public string GetPathForHash(string contentHash) => Path.Combine(ContentRoot, contentHash); + + public string GetTempFile() => Path.Join(TempPath, Path.GetRandomFileName()); } diff --git a/src/Containers/Microsoft.NET.Build.Containers/DigestUtils.cs b/src/Containers/Microsoft.NET.Build.Containers/DigestUtils.cs index c0c7e16e6793..db01b3ef6f28 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/DigestUtils.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/DigestUtils.cs @@ -2,20 +2,27 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Microsoft.NET.Build.Containers; internal sealed class DigestUtils { + /// + /// UTF8 encoding without BOM. + /// + internal static Encoding UTF8 = new UTF8Encoding(false); + /// /// Gets digest for string . /// - internal static string GetDigest(string str) => GetDigestFromSha(GetSha(str)); + internal static string GetDigest(T content) => GetDigestFromSha(GetSha(content)); /// - /// Formats digest based on ready SHA . + /// Formats digest based on ready SHA . /// - internal static string GetDigestFromSha(string sha) => $"sha256:{sha}"; + internal static string GetDigestFromSha(string sha256) => $"sha256:{sha256}"; internal static string GetShaFromDigest(string digest) { @@ -30,11 +37,20 @@ internal static string GetShaFromDigest(string digest) /// /// Gets the SHA of . /// - internal static string GetSha(string str) + internal static (long size, string sha256) GetSha(string str) { Span hash = stackalloc byte[SHA256.HashSizeInBytes]; - SHA256.HashData(Encoding.UTF8.GetBytes(str), hash); + var bytes = UTF8.GetBytes(str); + SHA256.HashData(bytes, hash); - return Convert.ToHexStringLower(hash); + return (bytes.LongLength, Convert.ToHexStringLower(hash)); } + + internal static string GetSha(T content) + { + var jsonstring = JsonSerializer.Serialize(content); + return GetSha(jsonstring).sha256; + } + + internal static long GetUtf8Length(string content) => UTF8.GetBytes(content).LongLength; } diff --git a/src/Containers/Microsoft.NET.Build.Containers/Exceptions/UnableToDownloadFromRepositoryException.cs b/src/Containers/Microsoft.NET.Build.Containers/Exceptions/UnableToDownloadFromRepositoryException.cs index 30718be88ed6..bff2fc364dd4 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Exceptions/UnableToDownloadFromRepositoryException.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Exceptions/UnableToDownloadFromRepositoryException.cs @@ -5,8 +5,8 @@ namespace Microsoft.NET.Build.Containers; internal sealed class UnableToDownloadFromRepositoryException : Exception { - public UnableToDownloadFromRepositoryException(string repository) - : base($"The download of the image from repository { repository } has failed.") + public UnableToDownloadFromRepositoryException(string repository, Exception? innerException = null) + : base($"The download of the image from repository {repository} has failed.", innerException) { } } diff --git a/src/Containers/Microsoft.NET.Build.Containers/GlobalSuppressions.cs b/src/Containers/Microsoft.NET.Build.Containers/GlobalSuppressions.cs new file mode 100644 index 000000000000..08292a366ee6 --- /dev/null +++ b/src/Containers/Microsoft.NET.Build.Containers/GlobalSuppressions.cs @@ -0,0 +1,39 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "type", Target = "~T:Microsoft.NET.Build.Containers.Tasks.DownloadContainerManifest")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "type", Target = "~T:Microsoft.NET.Build.Containers.IMultiImageManifest")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "type", Target = "~T:Microsoft.NET.Build.Containers.ISingleImageManifest")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.IPlatformSpecificManifestData.digest")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.IPlatformSpecificManifestData.mediaType")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.IPlatformSpecificManifestData.platform")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.IPlatformSpecificManifestData.size")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "type", Target = "~T:Microsoft.NET.Build.Containers.IPlatformSpecificManifestData")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "type", Target = "~T:Microsoft.NET.Build.Containers.ImageIndexV1")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "type", Target = "~T:Microsoft.NET.Build.Containers.ManifestListV2")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "type", Target = "~T:Microsoft.NET.Build.Containers.IManifest")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~M:Microsoft.NET.Build.Containers.RidMapping.CreateRidForPlatform(Microsoft.NET.Build.Containers.PlatformInformation)~System.String")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~M:Microsoft.NET.Build.Containers.RidMapping.GetManifestsByRid(Microsoft.NET.Build.Containers.PlatformSpecificManifest[])~System.Collections.Generic.IReadOnlyDictionary{System.String,Microsoft.NET.Build.Containers.PlatformSpecificManifest}")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~M:Microsoft.NET.Build.Containers.RidMapping.GetManifestsByRid(Microsoft.NET.Build.Containers.PlatformSpecificOciManifest[])~System.Collections.Generic.IReadOnlyDictionary{System.String,Microsoft.NET.Build.Containers.PlatformSpecificOciManifest}")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "type", Target = "~T:Microsoft.NET.Build.Containers.RidMapping")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~M:Microsoft.NET.Build.Containers.Tasks.DownloadLayers.Cancel")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~M:Microsoft.NET.Build.Containers.Tasks.DownloadLayers.Execute~System.Boolean")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~M:Microsoft.NET.Build.Containers.Tasks.DownloadLayers.ExecuteAsync~System.Threading.Tasks.Task{System.Boolean}")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.Tasks.DownloadLayers.ContentStore")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.Tasks.DownloadLayers.Registry")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.Tasks.DownloadLayers.Repository")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "type", Target = "~T:Microsoft.NET.Build.Containers.Tasks.DownloadLayers")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~M:Microsoft.NET.Build.Containers.Tasks.SelectRuntimeIdentifierSpecificItems.Cancel")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~M:Microsoft.NET.Build.Containers.Tasks.SelectRuntimeIdentifierSpecificItems.Execute~System.Boolean")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~M:Microsoft.NET.Build.Containers.Tasks.SelectRuntimeIdentifierSpecificItems.ExecuteAsync~System.Threading.Tasks.Task{System.Boolean}")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.Tasks.SelectRuntimeIdentifierSpecificItems.Items")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.Tasks.SelectRuntimeIdentifierSpecificItems.RuntimeIdentifierGraphPath")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.Tasks.SelectRuntimeIdentifierSpecificItems.SelectedItems")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.Tasks.SelectRuntimeIdentifierSpecificItems.TargetRuntimeIdentifier")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "type", Target = "~T:Microsoft.NET.Build.Containers.Tasks.SelectRuntimeIdentifierSpecificItems")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseImageConfigurationPath")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseImageManifestPath")] diff --git a/src/Containers/Microsoft.NET.Build.Containers/GlobalSuppressions2.cs b/src/Containers/Microsoft.NET.Build.Containers/GlobalSuppressions2.cs new file mode 100644 index 000000000000..d49df66f7808 --- /dev/null +++ b/src/Containers/Microsoft.NET.Build.Containers/GlobalSuppressions2.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContentStoreRoot")] diff --git a/src/Containers/Microsoft.NET.Build.Containers/GlobalSuppressions3.cs b/src/Containers/Microsoft.NET.Build.Containers/GlobalSuppressions3.cs new file mode 100644 index 000000000000..9c78f67755fd --- /dev/null +++ b/src/Containers/Microsoft.NET.Build.Containers/GlobalSuppressions3.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "", Scope = "member", Target = "~P:Microsoft.NET.Build.Containers.Tasks.CreateNewImage.PublishFiles")] diff --git a/src/Containers/Microsoft.NET.Build.Containers/ImageBuilder.cs b/src/Containers/Microsoft.NET.Build.Containers/ImageBuilder.cs index e97a8151a897..1ceb2caa5542 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ImageBuilder.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ImageBuilder.cs @@ -6,6 +6,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.NET.Build.Containers.Resources; +using System.Text.Json.Nodes; namespace Microsoft.NET.Build.Containers; @@ -61,22 +62,23 @@ internal BuiltImage Build() AssignUserFromEnvironment(); AssignPortsFromEnvironment(); - string imageJsonStr = _baseImageConfig.BuildConfig(); - string imageSha = DigestUtils.GetSha(imageJsonStr); + JsonObject config = _baseImageConfig.BuildConfig(); + string configAsString = JsonSerializer.Serialize(config); + (long length, string imageSha) = DigestUtils.GetSha(configAsString); string imageDigest = DigestUtils.GetDigestFromSha(imageSha); - long imageSize = Encoding.UTF8.GetBytes(imageJsonStr).Length; - ManifestConfig newManifestConfig = _manifest.Config with - { - digest = imageDigest, - size = imageSize, - mediaType = ManifestMediaType switch + ManifestConfig newManifestConfig = + new() { - SchemaTypes.OciManifestV1 => SchemaTypes.OciImageConfigV1, - SchemaTypes.DockerManifestV2 => SchemaTypes.DockerContainerV1, - _ => SchemaTypes.OciImageConfigV1 // opinion - defaulting to modern here, but really this should never happen - } - }; + digest = imageDigest, + size = length, + mediaType = ManifestMediaType switch + { + SchemaTypes.OciManifestV1 => SchemaTypes.OciImageConfigV1, + SchemaTypes.DockerManifestV2 => SchemaTypes.DockerContainerV1, + _ => SchemaTypes.OciImageConfigV1 // opinion - defaulting to modern here, but really this should never happen + } + }; ManifestV2 newManifest = new ManifestV2() { @@ -88,12 +90,8 @@ internal BuiltImage Build() return new BuiltImage() { - Config = imageJsonStr, - ImageDigest = imageDigest, - ImageSha = imageSha, - Manifest = JsonSerializer.SerializeToNode(newManifest)?.ToJsonString() ?? "", - ManifestDigest = newManifest.GetDigest(), - ManifestMediaType = ManifestMediaType, + Config = config, + Manifest = newManifest, Layers = _manifest.Layers }; } diff --git a/src/Containers/Microsoft.NET.Build.Containers/ImageConfig.cs b/src/Containers/Microsoft.NET.Build.Containers/ImageConfig.cs index d16563c052db..1dcdaae9c221 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ImageConfig.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ImageConfig.cs @@ -73,7 +73,7 @@ internal ImageConfig(JsonNode config) /// /// Builds in additional configuration and returns updated image configuration in JSON format as string. /// - internal string BuildConfig() + internal JsonObject BuildConfig() { var newConfig = new JsonObject(); @@ -151,7 +151,7 @@ internal string BuildConfig() ["history"] = new JsonArray(_history.Select(CreateHistory).ToArray()) }; - return configContainer.ToJsonString(); + return configContainer; static JsonArray ToJsonArray(IEnumerable items) => new(items.Where(s => !string.IsNullOrEmpty(s)).Select(s => JsonValue.Create(s)).ToArray()); } @@ -217,7 +217,8 @@ internal void AddLayer(Layer l) _rootFsLayers.Add(l.Descriptor.UncompressedDigest!); } - internal void SetUser(string user, bool isUserInteraction = false) { + internal void SetUser(string user, bool isUserInteraction = false) + { // we don't let automatic/inferred user settings overwrite an explicit user request if (_userHasBeenExplicitlySet && !isUserInteraction) { diff --git a/src/Containers/Microsoft.NET.Build.Containers/ImageIndexGenerator.cs b/src/Containers/Microsoft.NET.Build.Containers/ImageIndexGenerator.cs index 64a00b4236cb..2451285ec4de 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 IMultiImageManifest GenerateImageIndex(BuiltImage[] images) { if (images.Length == 0) { @@ -33,11 +33,11 @@ internal static (string, string) GenerateImageIndex(BuiltImage[] images) if (manifestMediaType == SchemaTypes.DockerManifestV2) { - return (GenerateImageIndex(images, SchemaTypes.DockerManifestV2, SchemaTypes.DockerManifestListV2), SchemaTypes.DockerManifestListV2); + return GenerateDockerManifestList(images, SchemaTypes.DockerManifestV2, SchemaTypes.DockerManifestListV2); } else if (manifestMediaType == SchemaTypes.OciManifestV1) { - return (GenerateImageIndex(images, SchemaTypes.OciManifestV1, SchemaTypes.OciImageIndexV1), SchemaTypes.OciImageIndexV1); + return GenerateDockerManifestList(images, SchemaTypes.OciManifestV1, 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 ManifestListV2 GenerateDockerManifestList(BuiltImage[] images, string manifestMediaType, string imageIndexMediaType) { if (images.Length == 0) { @@ -70,7 +70,7 @@ internal static string GenerateImageIndex(BuiltImage[] images, string manifestMe manifests[i] = new PlatformSpecificManifest { mediaType = manifestMediaType, - size = images[i].Manifest.Length, + size = JsonSerializer.SerializeToNode(images[i].Manifest)!.ToJsonString().Length, digest = images[i].ManifestDigest, platform = new PlatformInformation { @@ -87,10 +87,10 @@ internal static string GenerateImageIndex(BuiltImage[] images, string manifestMe manifests = manifests }; - return GetJsonStringFromImageIndex(imageIndex); + return imageIndex; } - internal static string GenerateImageIndexWithAnnotations(string manifestMediaType, string manifestDigest, long manifestSize, string repository, string[] tags) + internal static ImageIndexV1 GenerateImageIndexWithAnnotations(string manifestMediaType, string manifestDigest, long manifestSize, string repository, string[] tags) { string containerdImageNamePrefix = repository.Contains('/') ? "docker.io/" : "docker.io/library/"; @@ -118,21 +118,6 @@ internal static string GenerateImageIndexWithAnnotations(string manifestMediaTyp manifests = manifests }; - return GetJsonStringFromImageIndex(index); - } - - private static string GetJsonStringFromImageIndex(T imageIndex) - { - var nullIgnoreOptions = new JsonSerializerOptions - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - // To avoid things like \u002B for '+' especially in media types ("application/vnd.oci.image.manifest.v1\u002Bjson"), we use UnsafeRelaxedJsonEscaping. - var escapeOptions = new JsonSerializerOptions - { - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - - return JsonSerializer.SerializeToNode(imageIndex, nullIgnoreOptions)?.ToJsonString(escapeOptions) ?? ""; + return index; } } diff --git a/src/Containers/Microsoft.NET.Build.Containers/Layer.cs b/src/Containers/Microsoft.NET.Build.Containers/Layer.cs index fc97038017c2..4c25377b8313 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Layer.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Layer.cs @@ -1,11 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; using System.Diagnostics; using System.Formats.Tar; using System.IO.Compression; using System.IO.Enumeration; using System.Security.Cryptography; +using System.Threading.Tasks; using Microsoft.NET.Build.Containers.Resources; namespace Microsoft.NET.Build.Containers; @@ -32,29 +34,35 @@ internal class Layer public virtual Descriptor Descriptor { get; } - public string BackingFile { get; } + public FileInfo BackingFile { get; } internal Layer() { Descriptor = new Descriptor(); - BackingFile = ""; + BackingFile = null!; } - internal Layer(string backingFile, Descriptor descriptor) + internal Layer(FileInfo backingFile, Descriptor descriptor) { BackingFile = backingFile; Descriptor = descriptor; } - public static Layer FromDescriptor(Descriptor descriptor) + public static Layer FromDescriptor(Descriptor descriptor, ContentStore store) { - return new(ContentStore.PathForDescriptor(descriptor), descriptor); + FileInfo path = new(store.PathForDescriptor(descriptor)); + return FromBackingFile(path, descriptor); } - public static Layer FromDirectory(string directory, string containerPath, bool isWindowsLayer, string manifestMediaType) + public static Layer FromBackingFile(FileInfo backingFile, Descriptor descriptor) + { + return new(backingFile, descriptor); + } + + public static async Task FromFiles((string absPath, string relPath)[] inputFiles, string containerPath, bool isWindowsLayer, string manifestMediaType, ContentStore store, FileInfo layerWritePath, CancellationToken ct) { long fileSize; - Span hash = stackalloc byte[SHA256.HashSizeInBytes]; - Span uncompressedHash = stackalloc byte[SHA256.HashSizeInBytes]; + var hash = MemoryPool.Shared.Rent(SHA256.HashSizeInBytes); + var uncompressedHash = MemoryPool.Shared.Rent(SHA256.HashSizeInBytes); // Docker treats a COPY instruction that copies to a path like `/app` by // including `app/` as a directory, with no leading slash. Emulate that here. @@ -86,7 +94,7 @@ public static Layer FromDirectory(string directory, string containerPath, bool i entryAttributes["MSWINDOWS.rawsd"] = BuiltinUsersSecurityDescriptor; } - string tempTarballPath = ContentStore.GetTempFile(); + string tempTarballPath = store.GetTempFile(); using (FileStream fs = File.Create(tempTarballPath)) { using (HashDigestGZipStream gz = new(fs, leaveOpen: true)) @@ -97,58 +105,49 @@ public static Layer FromDirectory(string directory, string containerPath, bool i if (isWindowsLayer) { var entry = new PaxTarEntry(TarEntryType.Directory, "Files", entryAttributes); - writer.WriteEntry(entry); + await writer.WriteEntryAsync(entry, ct); } - // Write an entry for the application directory. - WriteTarEntryForFile(writer, new DirectoryInfo(directory), containerPath, entryAttributes); + // Write an entry for the container working directory. + await writer.WriteEntryAsync( + new PaxTarEntry(TarEntryType.Directory, containerPath, entryAttributes) + { + Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute + }, ct); // Write entries for the application directory contents. - var fileList = new FileSystemEnumerable<(FileSystemInfo file, string containerPath)>( - directory: directory, - transform: (ref FileSystemEntry entry) => - { - FileSystemInfo fsi = entry.ToFileSystemInfo(); - string relativePath = Path.GetRelativePath(directory, fsi.FullName); - if (OperatingSystem.IsWindows()) - { - // Use only '/' directory separators. - relativePath = relativePath.Replace('\\', '/'); - } - return (fsi, $"{containerPath}/{relativePath}"); - }, - options: new EnumerationOptions() - { - AttributesToSkip = FileAttributes.System, // Include hidden files - RecurseSubdirectories = true - }); - foreach (var item in fileList) + foreach ((string absolutePath, string containerRelativePath) in inputFiles) { - WriteTarEntryForFile(writer, item.file, item.containerPath, entryAttributes); + var file = new FileInfo(absolutePath); + var adjustedRelativePath = OperatingSystem.IsWindows() ? containerRelativePath.Replace('\\', '/') : containerRelativePath; + var finalRelativePath = $"{containerPath}/{adjustedRelativePath}"; + await WriteTarEntryForFile(writer, file, finalRelativePath, entryAttributes, ct); } // Windows layers need a Hives folder, we do not need to create any Registry Hive deltas inside if (isWindowsLayer) { var entry = new PaxTarEntry(TarEntryType.Directory, "Hives", entryAttributes); - writer.WriteEntry(entry); + await writer.WriteEntryAsync(entry, ct); } } // Dispose of the TarWriter before getting the hash so the final data get written to the tar stream - int bytesWritten = gz.GetCurrentUncompressedHash(uncompressedHash); - Debug.Assert(bytesWritten == uncompressedHash.Length); + int bytesWritten = gz.GetCurrentUncompressedHash(uncompressedHash.Memory); + Debug.Assert(bytesWritten == uncompressedHash.Memory.Length); } fileSize = fs.Length; fs.Position = 0; - int bW = SHA256.HashData(fs, hash); - Debug.Assert(bW == hash.Length); + int bW = await SHA256.HashDataAsync(fs, hash.Memory, ct); + Debug.Assert(bW == hash.Memory.Length); // Writes a tar entry corresponding to the file system item. - static void WriteTarEntryForFile(TarWriter writer, FileSystemInfo file, string containerPath, IEnumerable> entryAttributes) + static async Task WriteTarEntryForFile(TarWriter writer, FileSystemInfo file, string containerPath, IEnumerable> entryAttributes, CancellationToken ct) { UnixFileMode mode = DetermineFileMode(file); @@ -160,7 +159,7 @@ static void WriteTarEntryForFile(TarWriter writer, FileSystemInfo file, string c Mode = mode, DataStream = fileStream }; - writer.WriteEntry(entry); + await writer.WriteEntryAsync(entry, ct); } else { @@ -168,7 +167,7 @@ static void WriteTarEntryForFile(TarWriter writer, FileSystemInfo file, string c { Mode = mode }; - writer.WriteEntry(entry); + await writer.WriteEntryAsync(entry, ct); } static UnixFileMode DetermineFileMode(FileSystemInfo file) @@ -185,12 +184,12 @@ static UnixFileMode DetermineFileMode(FileSystemInfo file) } } - string contentHash = Convert.ToHexStringLower(hash); - string uncompressedContentHash = Convert.ToHexStringLower(uncompressedHash); + string contentHash = Convert.ToHexStringLower(hash.Memory.Span); + string uncompressedContentHash = Convert.ToHexStringLower(uncompressedHash.Memory.Span); string layerMediaType = manifestMediaType switch { - // TODO: configurable? gzip always? + // TODO: configurable? gzip always? SchemaTypes.DockerManifestV2 => SchemaTypes.DockerLayerGzip, SchemaTypes.OciManifestV1 => SchemaTypes.OciLayerGzipV1, _ => throw new ArgumentException(Resource.FormatString(nameof(Strings.UnrecognizedMediaType), manifestMediaType)) @@ -204,18 +203,19 @@ static UnixFileMode DetermineFileMode(FileSystemInfo file) UncompressedDigest = $"sha256:{uncompressedContentHash}", }; - string storedContent = ContentStore.PathForDescriptor(descriptor); - - Directory.CreateDirectory(ContentStore.ContentRoot); - - File.Move(tempTarballPath, storedContent, overwrite: true); + string storedContent = store.PathForDescriptor(descriptor); + var _ = store.ContentRoot; + // TODO: the publish side of things requires that the layer exists in the content root (because we look it up by digest), + // but we should ideally store these in the msbuild intermediate path so that we can clean it nicely. + File.Copy(tempTarballPath, storedContent, overwrite: true); + File.Move(tempTarballPath, layerWritePath.FullName, overwrite: true); - return new(storedContent, descriptor); + return new(layerWritePath, descriptor); } - internal virtual Stream OpenBackingFile() => File.OpenRead(BackingFile); + internal virtual Stream OpenBackingFile() => BackingFile.OpenRead(); - private static readonly char[] PathSeparators = new char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }; + private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; /// /// A stream capable of computing the hash digest of raw uncompressed data while also compressing it. @@ -225,13 +225,16 @@ private sealed class HashDigestGZipStream : Stream private readonly IncrementalHash sha256Hash; private readonly GZipStream compressionStream; - public HashDigestGZipStream(Stream writeStream, bool leaveOpen) + public HashDigestGZipStream(Stream writeStream, bool leaveOpen, CompressionMode compressionMode = CompressionMode.Compress) + : base() { sha256Hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - compressionStream = new GZipStream(writeStream, CompressionMode.Compress, leaveOpen); + compressionStream = new GZipStream(writeStream, compressionMode, leaveOpen); } - public override bool CanWrite => true; + public override bool CanWrite => compressionStream.CanWrite; + public override bool CanRead => compressionStream.CanRead; + public override bool CanSeek => compressionStream.CanSeek; public override void Write(byte[] buffer, int offset, int count) { @@ -251,6 +254,7 @@ public override void Flush() } internal int GetCurrentUncompressedHash(Span buffer) => sha256Hash.GetCurrentHash(buffer); + internal int GetCurrentUncompressedHash(Memory buffer) => sha256Hash.GetCurrentHash(buffer.Span); protected override void Dispose(bool disposing) { @@ -265,18 +269,41 @@ protected override void Dispose(bool disposing) } } - // This class is never used with async writes, but if it ever is, implement these overrides public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - => throw new NotImplementedException(); + { + sha256Hash.AppendData(buffer, offset, count); + return compressionStream.WriteAsync(buffer, offset, count, cancellationToken); + } public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken) - => throw new NotImplementedException(); + { + sha256Hash.AppendData(buffer.Span); + return compressionStream.WriteAsync(buffer, cancellationToken); + } - public override bool CanRead => false; - public override bool CanSeek => false; public override long Length => throw new NotImplementedException(); public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } - public override int Read(byte[] buffer, int offset, int count) => throw new NotImplementedException(); + public override int Read(byte[] buffer, int offset, int count) + { + var read = compressionStream.Read(buffer, offset, count); + sha256Hash.AppendData(buffer.AsSpan(offset, read)); + return read; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var read = compressionStream.ReadAsync(buffer, offset, count, cancellationToken); + sha256Hash.AppendData(buffer.AsSpan(offset, read.Result)); + return read; + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var read = compressionStream.ReadAsync(buffer, cancellationToken); + sha256Hash.AppendData(buffer.Span.Slice(0, read.Result)); + return read; + } + public override long Seek(long offset, SeekOrigin origin) => throw new NotImplementedException(); public override void SetLength(long value) => throw new NotImplementedException(); } diff --git a/src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/ArchiveFileRegistry.cs b/src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/ArchiveFileRegistry.cs index d127635ba584..78a67fb22724 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/ArchiveFileRegistry.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/ArchiveFileRegistry.cs @@ -14,9 +14,9 @@ public ArchiveFileRegistry(string archiveOutputPath) ArchiveOutputPath = archiveOutputPath; } - internal async Task LoadAsync(T image, SourceImageReference sourceReference, + internal async Task LoadAsync(T pushData, SourceImageReference sourceReference, DestinationImageReference destinationReference, CancellationToken cancellationToken, - Func writeStreamFunc) + Func<(T, SourceImageReference, DestinationImageReference), Stream, CancellationToken, Task> writeStreamFunc) { var fullPath = Path.GetFullPath(ArchiveOutputPath); @@ -25,7 +25,7 @@ internal async Task LoadAsync(T image, SourceImageReference sourceReference, // if doesn't end with a file extension, assume it's a directory if (!Path.HasExtension(fullPath)) { - fullPath += Path.DirectorySeparatorChar; + fullPath += Path.DirectorySeparatorChar; } // pointing to a directory? -> append default name @@ -45,18 +45,18 @@ internal async Task LoadAsync(T image, SourceImageReference sourceReference, await using var fileStream = File.Create(fullPath); // Call the delegate to write the image to the stream - await writeStreamFunc(image, sourceReference, destinationReference, fileStream, cancellationToken).ConfigureAwait(false); + await writeStreamFunc((pushData, sourceReference, destinationReference), fileStream, cancellationToken).ConfigureAwait(false); } public async Task LoadAsync(BuiltImage image, SourceImageReference sourceReference, DestinationImageReference destinationReference, - CancellationToken cancellationToken) + CancellationToken cancellationToken) => await LoadAsync(image, sourceReference, destinationReference, cancellationToken, - DockerCli.WriteImageToStreamAsync); + (tup, a, b) => DockerCli.WriteImageToStreamAsync(tup.Item1, tup.Item2, tup.Item3, a, b)); public async Task LoadAsync(MultiArchImage multiArchImage, SourceImageReference sourceReference, DestinationImageReference destinationReference, - CancellationToken cancellationToken) + CancellationToken cancellationToken) => await LoadAsync(multiArchImage, sourceReference, destinationReference, cancellationToken, DockerCli.WriteMultiArchOciImageToStreamAsync); diff --git a/src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/DockerCli.cs b/src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/DockerCli.cs index ebc8064f8e41..2bc2f37fda4a 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/DockerCli.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/DockerCli.cs @@ -51,6 +51,9 @@ public DockerCli(string? command, ILoggerFactory loggerFactory) public DockerCli(ILoggerFactory loggerFactory) : this(null, loggerFactory) { } + public bool IsDocker => _command == DockerCommand; + public bool IsPodman => _command == PodmanCommand; + private static string FindFullPathFromPath(string command) { foreach (string directory in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty).Split(Path.PathSeparator)) @@ -85,10 +88,8 @@ private async ValueTask FindFullCommandPath(CancellationToken cancellati } private async Task LoadAsync( - T image, - SourceImageReference sourceReference, - DestinationImageReference destinationReference, - Func writeStreamFunc, + T pushData, + Func writeStreamFunc, CancellationToken cancellationToken, bool checkContainerdStore = false) { @@ -100,7 +101,7 @@ private async Task LoadAsync( } string commandPath = await FindFullCommandPath(cancellationToken); - + _logger.LogInformation($"Streaming image to local {commandPath}..."); // call `docker load` and get it ready to receive input ProcessStartInfo loadInfo = new(commandPath, $"load") { @@ -113,7 +114,7 @@ private async Task LoadAsync( throw new NotImplementedException(Resource.FormatString(Strings.ContainerRuntimeProcessCreationFailed, commandPath)); // Call the delegate to write the image to the stream - await writeStreamFunc(image, sourceReference, destinationReference, loadProcess.StandardInput.BaseStream, cancellationToken) + await writeStreamFunc(pushData, loadProcess.StandardInput.BaseStream, cancellationToken) .ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); @@ -122,6 +123,8 @@ await writeStreamFunc(image, sourceReference, destinationReference, loadProcess. await loadProcess.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + _logger.LogInformation($"Image streaming completed"); + cancellationToken.ThrowIfCancellationRequested(); if (loadProcess.ExitCode != 0) @@ -130,13 +133,19 @@ await writeStreamFunc(image, sourceReference, destinationReference, loadProcess. } } - public async Task LoadAsync(BuiltImage image, SourceImageReference sourceReference, DestinationImageReference destinationReference, CancellationToken cancellationToken) + public async Task LoadAsync(BuiltImage image, SourceImageReference sourceReference, DestinationImageReference destinationReference, CancellationToken cancellationToken) // For loading to the local registry, we use the Docker format. Two reasons: one - compatibility with previous behavior before oci formatted publishing was available, two - Podman cannot load multi tag oci image tarball. - => await LoadAsync(image, sourceReference, destinationReference, WriteDockerImageToStreamAsync, cancellationToken); + => await LoadAsync((image, sourceReference, destinationReference), WriteDockerImageToStreamAsync, cancellationToken); + + public async Task LoadAsync( + (string repository, string[] tags, string configDigest, JsonObject config, Layer[] layers) imageData, + Func<(string repository, string[] tags, string configDigest, JsonObject config, Layer[] layers), Stream, CancellationToken, Task> writeStreamFunc, + CancellationToken cancellationToken) + => await LoadAsync(imageData, writeStreamFunc, cancellationToken, checkContainerdStore: false); + + public async Task LoadAsync(MultiArchImage multiArchImage, SourceImageReference sourceReference, DestinationImageReference destinationReference, CancellationToken cancellationToken) + => await LoadAsync((multiArchImage, sourceReference, destinationReference), WriteMultiArchOciImageToStreamAsync, cancellationToken, checkContainerdStore: true); - public async Task LoadAsync(MultiArchImage multiArchImage, SourceImageReference sourceReference, DestinationImageReference destinationReference, CancellationToken cancellationToken) - => await LoadAsync(multiArchImage, sourceReference, destinationReference, WriteMultiArchOciImageToStreamAsync, cancellationToken, checkContainerdStore: true); - public async Task IsAvailableAsync(CancellationToken cancellationToken) { bool commandPathWasUnknown = _command is null; // avoid running the version command twice. @@ -289,11 +298,11 @@ public static async Task WriteImageToStreamAsync(BuiltImage image, SourceImageRe { if (image.ManifestMediaType == SchemaTypes.DockerManifestV2) { - await WriteDockerImageToStreamAsync(image, sourceReference, destinationReference, imageStream, cancellationToken); + await WriteDockerImageToStreamAsync((image, sourceReference, destinationReference), imageStream, cancellationToken); } else if (image.ManifestMediaType == SchemaTypes.OciManifestV1) { - await WriteOciImageToStreamAsync(image, sourceReference, destinationReference, imageStream, cancellationToken); + await WriteOciImageToStreamAsync((image, sourceReference, destinationReference), imageStream, cancellationToken); } else { @@ -301,10 +310,26 @@ public static async Task WriteImageToStreamAsync(BuiltImage image, SourceImageRe } } + public static async Task WriteImageToStreamAsync(string repository, string[] tags, JsonObject config, Layer[] layers, ManifestV2 manifest, Stream imageStream, CancellationToken cancellationToken) + { + if (manifest.MediaType == SchemaTypes.DockerManifestV2) + { + await WriteDockerImageToStreamAsync((repository, tags, DigestUtils.GetDigest(config), config, layers), imageStream, cancellationToken); + } + else if (manifest.MediaType == SchemaTypes.OciManifestV1) + { + await WriteOciImageToStreamAsync((repository, tags, DigestUtils.GetDigest(config), config, layers, manifest), imageStream, cancellationToken); + } + else + { + throw new ArgumentException(Resource.FormatString(nameof(Strings.UnsupportedMediaTypeForTarball), manifest.MediaType)); + } + } + private static async Task WriteDockerImageToStreamAsync( - BuiltImage image, + (BuiltImage image, SourceImageReference sourceReference, - DestinationImageReference destinationReference, + DestinationImageReference destinationReference) pushData, Stream imageStream, CancellationToken cancellationToken) { @@ -313,15 +338,42 @@ private static async Task WriteDockerImageToStreamAsync( // Feed each layer tarball into the stream JsonArray layerTarballPaths = new(); - await WriteImageLayers(writer, image, sourceReference, d => $"{d.Substring("sha256:".Length)}/layer.tar", cancellationToken, layerTarballPaths) + await WriteImageLayers(writer, pushData.image, pushData.sourceReference, d => $"{d.Substring("sha256:".Length)}/layer.tar", cancellationToken, layerTarballPaths) + .ConfigureAwait(false); + + string configTarballPath = $"{pushData.image.Manifest.Config.digest.Split(':')[1]!}.json"; + await WriteImageConfig(writer, pushData.image, configTarballPath, cancellationToken) + .ConfigureAwait(false); + + // Add manifest + await WriteManifestForDockerImage(writer, pushData.destinationReference.Repository, pushData.destinationReference.Tags, configTarballPath, layerTarballPaths, cancellationToken) + .ConfigureAwait(false); + } + + public static async Task WriteDockerImageToStreamAsync( + (string repository, + string[] tags, + string configDigest, + JsonObject imageConfig, + Layer[] layers) pushData, + Stream imageStream, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + using TarWriter writer = new(imageStream, TarEntryFormat.Pax, leaveOpen: true); + // Feed each layer tarball into the stream + var layerTarballPathFunc = (Layer l) => $"{l.Descriptor.Digest.Substring("sha256:".Length)}/layer.tar"; + await WriteImageLayers(writer, pushData.layers, layerTarballPathFunc, cancellationToken) .ConfigureAwait(false); - string configTarballPath = $"{image.ImageSha!}.json"; - await WriteImageConfig(writer, image, configTarballPath, cancellationToken) + string configTarballPath = $"{pushData.configDigest.Split(':')[1]!}.json"; + await WriteImageConfig(writer, pushData.imageConfig, configTarballPath, cancellationToken) .ConfigureAwait(false); // Add manifest - await WriteManifestForDockerImage(writer, destinationReference, configTarballPath, layerTarballPaths, cancellationToken) + JsonArray layerTarballPaths = new JsonArray([.. pushData.layers.Select(layerTarballPathFunc).Select(s => JsonValue.Create(s))]); + await WriteManifestForDockerImage(writer, pushData.repository, pushData.tags, configTarballPath, layerTarballPaths, cancellationToken) .ConfigureAwait(false); } @@ -358,6 +410,22 @@ private static async Task WriteImageLayers( } } + private static async Task WriteImageLayers( + TarWriter writer, + Layer[] layers, + Func layerTarballPathFunc, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + foreach (var layer in layers) + { + string layerTarballPath = layerTarballPathFunc(layer); + await writer.WriteEntryAsync(layer.BackingFile.FullName, layerTarballPath, cancellationToken).ConfigureAwait(false); + } + } + private static async Task WriteImageConfig( TarWriter writer, BuiltImage image, @@ -365,7 +433,24 @@ private static async Task WriteImageConfig( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - using (MemoryStream configStream = new(Encoding.UTF8.GetBytes(image.Config))) + using (MemoryStream configStream = new(DigestUtils.UTF8.GetBytes(image.Config.ToJsonString()))) + { + PaxTarEntry configEntry = new(TarEntryType.RegularFile, configPath) + { + DataStream = configStream + }; + await writer.WriteEntryAsync(configEntry, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task WriteImageConfig( + TarWriter writer, + JsonObject config, + string configPath, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using (MemoryStream configStream = new(DigestUtils.UTF8.GetBytes(config.ToJsonString()))) { PaxTarEntry configEntry = new(TarEntryType.RegularFile, configPath) { @@ -377,15 +462,16 @@ private static async Task WriteImageConfig( private static async Task WriteManifestForDockerImage( TarWriter writer, - DestinationImageReference destinationReference, + string repository, + string[] tags, string configTarballPath, JsonArray layerTarballPaths, CancellationToken cancellationToken) { JsonArray tagsNode = new(); - foreach (string tag in destinationReference.Tags) + foreach (string tag in tags) { - tagsNode.Add($"{destinationReference.Repository}:{tag}"); + tagsNode.Add($"{repository}:{tag}"); } JsonNode manifestNode = new JsonArray(new JsonObject @@ -396,7 +482,7 @@ private static async Task WriteManifestForDockerImage( }); cancellationToken.ThrowIfCancellationRequested(); - using (MemoryStream manifestStream = new(Encoding.UTF8.GetBytes(manifestNode.ToJsonString()))) + using (MemoryStream manifestStream = new(DigestUtils.UTF8.GetBytes(manifestNode.ToJsonString()))) { PaxTarEntry manifestEntry = new(TarEntryType.RegularFile, "manifest.json") { @@ -408,9 +494,9 @@ private static async Task WriteManifestForDockerImage( } private static async Task WriteOciImageToStreamAsync( - BuiltImage image, + (BuiltImage image, SourceImageReference sourceReference, - DestinationImageReference destinationReference, + DestinationImageReference destinationReference) pushData, Stream imageStream, CancellationToken cancellationToken) { @@ -418,10 +504,34 @@ private static async Task WriteOciImageToStreamAsync( using TarWriter writer = new(imageStream, TarEntryFormat.Pax, leaveOpen: true); - await WriteOciImageToBlobs(writer, image, sourceReference, cancellationToken) + await WriteOciImageToBlobs(writer, pushData.image, pushData.sourceReference, cancellationToken) .ConfigureAwait(false); - await WriteIndexJsonForOciImage(writer, image, destinationReference, cancellationToken) + await WriteIndexJsonForOciImage(writer, pushData.image, pushData.destinationReference, cancellationToken) + .ConfigureAwait(false); + + await WriteOciLayout(writer, cancellationToken) + .ConfigureAwait(false); + } + + private static async Task WriteOciImageToStreamAsync( + (string repository, + string[] tags, + string configDigest, + JsonObject imageConfig, + Layer[] layers, + ManifestV2 manifest) pushData, + Stream imageStream, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + using TarWriter writer = new(imageStream, TarEntryFormat.Pax, leaveOpen: true); + + await WriteOciImageToBlobs(writer, pushData.configDigest, pushData.imageConfig, pushData.layers, pushData.manifest, cancellationToken) + .ConfigureAwait(false); + + await WriteIndexJsonForOciImage(writer, pushData.repository, pushData.tags, pushData.manifest, cancellationToken) .ConfigureAwait(false); await WriteOciLayout(writer, cancellationToken) @@ -434,7 +544,7 @@ private static async Task WriteOciLayout(TarWriter writer, CancellationToken can string ociLayoutPath = "oci-layout"; var ociLayoutContent = "{\"imageLayoutVersion\": \"1.0.0\"}"; - using (MemoryStream ociLayoutStream = new MemoryStream(Encoding.UTF8.GetBytes(ociLayoutContent))) + using (MemoryStream ociLayoutStream = new MemoryStream(DigestUtils.UTF8.GetBytes(ociLayoutContent))) { PaxTarEntry layoutEntry = new(TarEntryType.RegularFile, ociLayoutPath) { @@ -452,7 +562,25 @@ private static async Task WriteManifestForOciImage( cancellationToken.ThrowIfCancellationRequested(); string manifestPath = $"{_blobsPath}/{image.ManifestDigest.Substring("sha256:".Length)}"; - using (MemoryStream manifestStream = new MemoryStream(Encoding.UTF8.GetBytes(image.Manifest))) + using (MemoryStream manifestStream = new MemoryStream(DigestUtils.UTF8.GetBytes(JsonSerializer.Serialize(image.Manifest)))) + { + PaxTarEntry manifestEntry = new(TarEntryType.RegularFile, manifestPath) + { + DataStream = manifestStream + }; + await writer.WriteEntryAsync(manifestEntry, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task WriteManifestForOciImage( + TarWriter writer, + ManifestV2 manifest, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + string manifestPath = $"{_blobsPath}/{manifest.GetDigest().Substring("sha256:".Length)}"; + using (MemoryStream manifestStream = new MemoryStream(DigestUtils.UTF8.GetBytes(JsonSerializer.Serialize(manifest)))) { PaxTarEntry manifestEntry = new(TarEntryType.RegularFile, manifestPath) { @@ -470,14 +598,41 @@ private static async Task WriteIndexJsonForOciImage( { cancellationToken.ThrowIfCancellationRequested(); - string indexJson = ImageIndexGenerator.GenerateImageIndexWithAnnotations( + var index = ImageIndexGenerator.GenerateImageIndexWithAnnotations( SchemaTypes.OciManifestV1, image.ManifestDigest, - image.Manifest.Length, + (DigestUtils.UTF8.GetBytes(JsonSerializer.Serialize(image.Manifest))).Length, destinationReference.Repository, destinationReference.Tags); - using (MemoryStream indexStream = new(Encoding.UTF8.GetBytes(indexJson))) + using (MemoryStream indexStream = new(DigestUtils.UTF8.GetBytes(JsonSerializer.Serialize(index)))) + { + PaxTarEntry indexEntry = new(TarEntryType.RegularFile, "index.json") + { + DataStream = indexStream + }; + await writer.WriteEntryAsync(indexEntry, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task WriteIndexJsonForOciImage( + TarWriter writer, + string repository, + string[] tags, + ManifestV2 manifest, + + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var index = ImageIndexGenerator.GenerateImageIndexWithAnnotations( + SchemaTypes.OciManifestV1, + manifest.GetDigest(), + DigestUtils.UTF8.GetBytes(JsonSerializer.Serialize(manifest)).Length, + repository, + tags); + + using (MemoryStream indexStream = new(DigestUtils.UTF8.GetBytes(JsonSerializer.Serialize(index)))) { PaxTarEntry indexEntry = new(TarEntryType.RegularFile, "index.json") { @@ -496,17 +651,35 @@ private static async Task WriteOciImageToBlobs( await WriteImageLayers(writer, image, sourceReference, d => $"{_blobsPath}/{d.Substring("sha256:".Length)}", cancellationToken) .ConfigureAwait(false); - await WriteImageConfig(writer, image, $"{_blobsPath}/{image.ImageSha!}", cancellationToken) + await WriteImageConfig(writer, image, $"{_blobsPath}/{image.Manifest.Config.digest.Split(':')[1]!}", cancellationToken) .ConfigureAwait(false); await WriteManifestForOciImage(writer, image, cancellationToken) .ConfigureAwait(false); } + private static async Task WriteOciImageToBlobs( + TarWriter writer, + string configDigest, + JsonObject imageConfig, + Layer[] layers, + ManifestV2 manifest, + CancellationToken cancellationToken) + { + await WriteImageLayers(writer, layers, l => $"{_blobsPath}/{l.Descriptor.Digest.Substring("sha256:".Length)}", cancellationToken) + .ConfigureAwait(false); + + await WriteImageConfig(writer, imageConfig, $"{_blobsPath}/{configDigest.Split(':')[1]!}", cancellationToken) + .ConfigureAwait(false); + + await WriteManifestForOciImage(writer, manifest, cancellationToken) + .ConfigureAwait(false); + } + public static async Task WriteMultiArchOciImageToStreamAsync( - MultiArchImage multiArchImage, + (MultiArchImage multiArchImage, SourceImageReference sourceReference, - DestinationImageReference destinationReference, + DestinationImageReference destinationReference) pushData, Stream imageStream, CancellationToken cancellationToken) { @@ -514,13 +687,13 @@ public static async Task WriteMultiArchOciImageToStreamAsync( using TarWriter writer = new(imageStream, TarEntryFormat.Pax, leaveOpen: true); - foreach (var image in multiArchImage.Images!) + foreach (var image in pushData.multiArchImage.Images!) { - await WriteOciImageToBlobs(writer, image, sourceReference, cancellationToken) + await WriteOciImageToBlobs(writer, image, pushData.sourceReference, cancellationToken) .ConfigureAwait(false); } - await WriteIndexJsonForMultiArchOciImage(writer, multiArchImage, destinationReference, cancellationToken) + await WriteIndexJsonForMultiArchOciImage(writer, pushData.multiArchImage, pushData.destinationReference, cancellationToken) .ConfigureAwait(false); await WriteOciLayout(writer, cancellationToken) @@ -539,8 +712,8 @@ private static async Task WriteIndexJsonForMultiArchOciImage( var manifestListDigest = DigestUtils.GetDigest(multiArchImage.ImageIndex); var manifestListSha = DigestUtils.GetShaFromDigest(manifestListDigest); var manifestListPath = $"{_blobsPath}/{manifestListSha}"; - - using (MemoryStream indexStream = new(Encoding.UTF8.GetBytes(multiArchImage.ImageIndex))) + var manifestBytes = DigestUtils.UTF8.GetBytes(JsonSerializer.Serialize(multiArchImage.ImageIndex)); + using (MemoryStream indexStream = new(manifestBytes)) { PaxTarEntry indexEntry = new(TarEntryType.RegularFile, manifestListPath) { @@ -552,14 +725,14 @@ private static async Task WriteIndexJsonForMultiArchOciImage( // 2. create index.json that points to manifest list in the blobs cancellationToken.ThrowIfCancellationRequested(); - string indexJson = ImageIndexGenerator.GenerateImageIndexWithAnnotations( - multiArchImage.ImageIndexMediaType, - manifestListDigest, - multiArchImage.ImageIndex.Length, - destinationReference.Repository, + var index = ImageIndexGenerator.GenerateImageIndexWithAnnotations( + multiArchImage.ImageIndex.MediaType!, + manifestListDigest, + manifestBytes.Length, + destinationReference.Repository, destinationReference.Tags); - using (MemoryStream indexStream = new(Encoding.UTF8.GetBytes(indexJson))) + using (MemoryStream indexStream = new(DigestUtils.UTF8.GetBytes(JsonSerializer.Serialize(index)))) { PaxTarEntry indexEntry = new(TarEntryType.RegularFile, "index.json") { diff --git a/src/Containers/Microsoft.NET.Build.Containers/Logging/MSBuildLogger.cs b/src/Containers/Microsoft.NET.Build.Containers/Logging/MSBuildLogger.cs index e6b96093f53b..44d05b0c6ace 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Logging/MSBuildLogger.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Logging/MSBuildLogger.cs @@ -5,45 +5,94 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using Microsoft.Extensions.Logging; +using Microsoft.NET.StringTools; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Microsoft.NET.Build.Containers.Logging; +/// +/// a struct that maps to the parameters of the MSBuild LogX methods. We'll extract this from M.E.ILogger state/scope information so that we can be maximally compatible with the MSBuild logging system. +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +internal record struct MSBuildMessageParameters(string? subcategory, + string? code, + string? helpKeyword, + string? file, + int? lineNumber, + int? columnNumber, + int? endLineNumber, + int? endColumnNumber, + string message); + /// /// Implements an ILogger that passes the logs to the wrapped TaskLoggingHelper. /// +/// +/// This logger is designed to be used with MSBuild tasks, allowing logs to be written in a way that integrates with the MSBuild logging system. +/// It looks for specific property names in the state/scope parts of the message and maps them to the parameters of the MSBuild LogX methods. +/// Those specific keys are: +/// +/// Subcategory +/// Code +/// HelpKeyword +/// File +/// LineNumber +/// ColumnNumber +/// EndLineNumber +/// EndColumnNumber +/// {OriginalFormat}(usually provided by the underlying logging framework) +/// +/// +/// So if you add these to the scope (e.g. via _logger.BeginScope(new Dictionary{ ... })) or on the message format itself, +/// they will be extracted and used to format the message correctly for MSBuild. +/// internal sealed class MSBuildLogger : ILogger { private static readonly IDisposable Scope = new DummyDisposable(); private readonly TaskLoggingHelper _loggingHelper; + private readonly string _category; + private IExternalScopeProvider? _scopeProvider; - public MSBuildLogger(string category, TaskLoggingHelper loggingHelperToWrap) + public MSBuildLogger(string category, TaskLoggingHelper loggingHelperToWrap, IExternalScopeProvider? scopeProvider = null) { + _category = category; _loggingHelper = loggingHelperToWrap; + _scopeProvider = scopeProvider; } - IDisposable ILogger.BeginScope(TState state) => Scope; + IDisposable ILogger.BeginScope(TState state) => _scopeProvider?.Push(state) ?? Scope; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { + var message = FormatMessage(_category, state, exception, formatter, _scopeProvider); switch (logLevel) { case LogLevel.Trace: - _loggingHelper.LogMessage(MessageImportance.Low, formatter(state, exception)); + _loggingHelper.LogMessage(message.subcategory, message.code, message.helpKeyword, message.file, message.lineNumber ?? 0, message.columnNumber ?? 0, message.endLineNumber ?? 0, message.endColumnNumber ?? 0, MessageImportance.Low, message.message); break; case LogLevel.Debug: + _loggingHelper.LogMessage(message.subcategory, message.code, message.helpKeyword, message.file, message.lineNumber ?? 0, message.columnNumber ?? 0, message.endLineNumber ?? 0, message.endColumnNumber ?? 0, MessageImportance.Normal, message.message); + break; case LogLevel.Information: - _loggingHelper.LogMessage(MessageImportance.High, formatter(state, exception)); + _loggingHelper.LogMessage(message.subcategory, message.code, message.helpKeyword, message.file, message.lineNumber ?? 0, message.columnNumber ?? 0, message.endLineNumber ?? 0, message.endColumnNumber ?? 0, MessageImportance.High, message.message); break; case LogLevel.Warning: - _loggingHelper.LogWarning(formatter(state, exception)); + _loggingHelper.LogWarning(message.subcategory, message.code, message.helpKeyword, message.file, message.lineNumber ?? 0, message.columnNumber ?? 0, message.endLineNumber ?? 0, message.endColumnNumber ?? 0, message.message); break; case LogLevel.Error: case LogLevel.Critical: - _loggingHelper.LogError(formatter(state, exception)); + _loggingHelper.LogError(message.subcategory, message.code, message.helpKeyword, message.file, message.lineNumber ?? 0, message.columnNumber ?? 0, message.endLineNumber ?? 0, message.endColumnNumber ?? 0, message.message); break; case LogLevel.None: break; @@ -52,6 +101,148 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except } } + public static MSBuildMessageParameters FormatMessage(string category, TState state, Exception? exception, Func formatter, IExternalScopeProvider? scopeProvider) + { + MSBuildMessageParameters message = default; + using var builder = new SpanBasedStringBuilder(); + var categoryBlock = string.Concat("[".AsSpan(), category.AsSpan(), "] ".AsSpan()); + builder.Append(categoryBlock); + var formatted = formatter(state, exception); + builder.Append(formatted); + + if (scopeProvider is not null) + { + // state will be a FormattedLogValues instance + // scope will be our dictionary thing we need to probe into + scopeProvider.ForEachScope((scope, state) => + { + var stateItems = (state as IReadOnlyList>)!; + string originalFormat = null!; + + foreach (var kvp in stateItems) + { + switch (kvp.Key) + { + case "{OriginalFormat}": + // If the key is {OriginalFormat}, we will use it to set the originalFormat variable. + // This is used to avoid appending the same key again in the message. + if (kvp.Value is string format) + { + originalFormat = format; + } + continue; + case "Subcategory": + message.subcategory = kvp.Value as string; + continue; + case "Code": + message.code = kvp.Value as string; + continue; + case "HelpKeyword": + message.helpKeyword = kvp.Value as string; + continue; + case "File": + message.file = kvp.Value as string; + continue; + case "LineNumber": + if (kvp.Value is int lineNumber) + message.lineNumber = lineNumber; + continue; + case "ColumnNumber": + if (kvp.Value is int columnNumber) + message.columnNumber = columnNumber; + continue; + case "EndLineNumber": + if (kvp.Value is int endLineNumber) + message.endLineNumber = endLineNumber; + continue; + case "EndColumnNumber": + if (kvp.Value is int endColumnNumber) + message.endColumnNumber = endColumnNumber; + continue; + default: + var wrappedKey = "{" + kvp.Key + "}"; + if (originalFormat.Contains(wrappedKey)) + { + // If the key is part of the format string of the original format, we don't need to append it again. + continue; + } + + // Otherwise, append the key and value to the message. + // if MSbuild had a property bag concept on the message APIs, + // we could use that instead of appending to the message. + + builder.Append($" {kvp.Key}={kvp.Value}"); + continue; + } + } + + if (scope is IDictionary dict) + { + foreach (var kvp in dict) + { + switch (kvp.Key) + { + // map all of the keys we decide are special and map to MSbuild message concepts + case "{OriginalFormat}": + continue; + case "Subcategory": + message.subcategory = kvp.Value as string; + continue; + case "Code": + message.code = kvp.Value as string; + continue; + case "HelpKeyword": + message.helpKeyword = kvp.Value as string; + continue; + case "File": + message.file = kvp.Value as string; + continue; + case "LineNumber": + if (kvp.Value is int lineNumber) + message.lineNumber = lineNumber; + continue; + case "ColumnNumber": + if (kvp.Value is int columnNumber) + message.columnNumber = columnNumber; + continue; + case "EndLineNumber": + if (kvp.Value is int endLineNumber) + message.endLineNumber = endLineNumber; + continue; + case "EndColumnNumber": + if (kvp.Value is int endColumnNumber) + message.endColumnNumber = endColumnNumber; + continue; + default: + var wrappedKey = "{" + kvp.Key + "}"; + if (originalFormat.Contains(wrappedKey)) + { + // If the key is part of the format string of the original format, we don't need to append it again. + continue; + } + + // Otherwise, append the key and value to the message. + // if MSbuild had a property bag concept on the message APIs, + // we could use that instead of appending to the message. + + builder.Append($" {kvp.Key}={kvp.Value}"); + continue; + } + } + } + else if (scope is string s) + { + builder.Append($" {s}"); + } + + + }, state); + } + + message.message = builder.ToString(); + return message; + } + /// /// A simple disposable to describe scopes with . /// @@ -59,4 +250,9 @@ private sealed class DummyDisposable : IDisposable { public void Dispose() { } } + + internal void SetScopeProvider(IExternalScopeProvider scopeProvider) + { + _scopeProvider = scopeProvider; + } } diff --git a/src/Containers/Microsoft.NET.Build.Containers/Logging/MSBuildLoggerProvider.cs b/src/Containers/Microsoft.NET.Build.Containers/Logging/MSBuildLoggerProvider.cs index 8db4a7b2ae51..2991b1f5119d 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Logging/MSBuildLoggerProvider.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Logging/MSBuildLoggerProvider.cs @@ -11,9 +11,11 @@ namespace Microsoft.NET.Build.Containers.Logging; /// An that creates s which passes /// all the logs to MSBuild's . /// -internal class MSBuildLoggerProvider : ILoggerProvider +internal class MSBuildLoggerProvider : ILoggerProvider, ISupportExternalScope { private readonly TaskLoggingHelper _loggingHelper; + private List _loggers = new List(); + private IExternalScopeProvider? _scopeProvider; public MSBuildLoggerProvider(TaskLoggingHelper loggingHelperToWrap) { @@ -22,8 +24,19 @@ public MSBuildLoggerProvider(TaskLoggingHelper loggingHelperToWrap) public ILogger CreateLogger(string categoryName) { - return new MSBuildLogger(categoryName, _loggingHelper); + var logger = new MSBuildLogger(categoryName, _loggingHelper, _scopeProvider); + _loggers.Add(logger); + return logger; } public void Dispose() { } + + public void SetScopeProvider(IExternalScopeProvider scopeProvider) + { + _scopeProvider = scopeProvider; + foreach (var logger in _loggers) + { + logger.SetScopeProvider(scopeProvider); + } + } } diff --git a/src/Containers/Microsoft.NET.Build.Containers/ManifestListV2.cs b/src/Containers/Microsoft.NET.Build.Containers/ManifestListV2.cs index 471f450f42d6..7e47b11aa21e 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ManifestListV2.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ManifestListV2.cs @@ -5,11 +5,22 @@ namespace Microsoft.NET.Build.Containers; -public record struct ManifestListV2(int schemaVersion, string mediaType, PlatformSpecificManifest[] manifests); +/// +/// Marker interface that signals that this contains sub-manifests +/// +public interface IMultiImageManifest: IManifest; + +public record struct ManifestListV2(int schemaVersion, string mediaType, PlatformSpecificManifest[] manifests) : IMultiImageManifest +{ + public string? MediaType => mediaType; +} 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) : IMultiImageManifest +{ + public string? MediaType => mediaType; +} 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 b4334c5e26db..8c17699511aa 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ManifestV2.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ManifestV2.cs @@ -6,13 +6,21 @@ namespace Microsoft.NET.Build.Containers; +/// +/// Marker interface so we can return polymorphic manifests of all kinds +/// +public interface IManifest +{ + public string? MediaType { get; } +}; + /// /// The struct represents image manifest specification. /// /// /// https://github.com/opencontainers/image-spec/blob/main/manifest.md /// -public class ManifestV2 +public class ManifestV2 : IManifest { [JsonIgnore] public string? KnownDigest { get; set; } @@ -50,7 +58,7 @@ public class ManifestV2 /// /// Gets the digest for this manifest. /// - public string GetDigest() => KnownDigest ??= DigestUtils.GetDigest(JsonSerializer.SerializeToNode(this)?.ToJsonString() ?? string.Empty); + public string GetDigest() => KnownDigest ??= DigestUtils.GetDigest(this); } public record struct ManifestConfig(string mediaType, long size, string digest); diff --git a/src/Containers/Microsoft.NET.Build.Containers/MultiArchImage.cs b/src/Containers/Microsoft.NET.Build.Containers/MultiArchImage.cs index b24fbf65e87f..99c2e014f14b 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/MultiArchImage.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/MultiArchImage.cs @@ -8,9 +8,7 @@ namespace Microsoft.NET.Build.Containers; /// internal readonly struct MultiArchImage { - internal required string ImageIndex { get; init; } - - internal required string ImageIndexMediaType { get; init; } + internal required IMultiImageManifest ImageIndex { get; init; } internal BuiltImage[]? Images { get; init; } } \ No newline at end of file diff --git a/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 4d92cb103d8d..726f0395ec29 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -29,6 +29,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.GeneratedManifestPath.get -> string! +Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.GeneratedManifestPath.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! @@ -46,7 +48,72 @@ Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.OutputRegistry.get -> stri Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.OutputRegistry.set -> void Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.Repository.get -> string! Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.Repository.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedAppContainerLayer.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedAppContainerLayer.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedAppContainerConfig.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedAppContainerConfig.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedAppContainerManifest.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedAppContainerManifest.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedConfigurationPath.get -> string! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedConfigurationPath.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedLayerPath.get -> string! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedLayerPath.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedManifestPath.get -> string! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedManifestPath.set -> void +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.ArchivePath.get -> string! +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.ArchivePath.set -> void +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.Cancel() -> void +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.Configuration.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.Configuration.set -> void +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.ExecuteAsync() -> System.Threading.Tasks.Task! +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.GeneratedArchiveFilePath.get -> string! +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.GeneratedArchiveFilePath.set -> void +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.Layers.get -> Microsoft.Build.Framework.ITaskItem![]! +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.Layers.set -> void +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.MakeContainerTarball() -> void +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.Manifest.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.Manifest.set -> void +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.Repository.get -> string! +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.Repository.set -> void +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.Tags.get -> string![]! +Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.Tags.set -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.Cancel() -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.Configuration.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.Configuration.set -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.ExecuteAsync() -> System.Threading.Tasks.Task! +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.Layers.get -> Microsoft.Build.Framework.ITaskItem![]! +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.Layers.set -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.LocalRegistry.get -> string? +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.LocalRegistry.set -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.Manifest.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.Manifest.set -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.PushContainerToLocal() -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.Repository.get -> string! +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.Repository.set -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.Tags.get -> string![]! +Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.Tags.set -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Cancel() -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Configuration.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Configuration.set -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.ExecuteAsync() -> System.Threading.Tasks.Task! +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Layers.get -> Microsoft.Build.Framework.ITaskItem![]! +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Layers.set -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Manifest.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Manifest.set -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.PushContainerToRemoteRegistry() -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Registry.get -> string! +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Registry.set -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Repository.get -> string! +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Repository.set -> void +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Tags.get -> string![]! +Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Tags.set -> void override Microsoft.NET.Build.Containers.Tasks.CreateImageIndex.Execute() -> bool +override Microsoft.NET.Build.Containers.Tasks.MakeContainerTarball.Execute() -> bool +override Microsoft.NET.Build.Containers.Tasks.PushContainerToLocal.Execute() -> bool +override Microsoft.NET.Build.Containers.Tasks.PushContainerToRemoteRegistry.Execute() -> bool static readonly Microsoft.NET.Build.Containers.Constants.Version -> string! Microsoft.NET.Build.Containers.ContainerHelpers Microsoft.NET.Build.Containers.ContainerHelpers.ParsePortError @@ -194,15 +261,19 @@ Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseImageDigest.get -> strin Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseImageDigest.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseRegistry.get -> string! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseRegistry.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseImageConfigurationPath.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseImageConfigurationPath.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseImageManifestPath.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseImageManifestPath.get -> Microsoft.Build.Framework.ITaskItem! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.Cancel() -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerEnvironmentVariables.get -> Microsoft.Build.Framework.ITaskItem![]! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerEnvironmentVariables.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerizeDirectory.get -> string! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerizeDirectory.set -> void -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerRuntimeIdentifier.get -> string! -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerRuntimeIdentifier.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerUser.get -> string! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerUser.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContentStoreRoot.get -> string! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContentStoreRoot.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.CreateNewImage() -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.Dispose() -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.Entrypoint.get -> Microsoft.Build.Framework.ITaskItem![]! @@ -241,10 +312,6 @@ Microsoft.NET.Build.Containers.Tasks.CreateNewImage.OutputRegistry.get -> string Microsoft.NET.Build.Containers.Tasks.CreateNewImage.OutputRegistry.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ArchiveOutputPath.get -> string! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ArchiveOutputPath.set -> void -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.PublishDirectory.get -> string! -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.PublishDirectory.set -> void -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.RuntimeIdentifierGraphPath.get -> string! -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.RuntimeIdentifierGraphPath.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ToolExe.get -> string! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ToolExe.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ToolPath.get -> string! @@ -255,8 +322,6 @@ Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateLabels.get -> bool Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateLabels.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateDigestLabel.get -> bool Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateDigestLabel.set -> void -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.SkipPublishing.get -> bool -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.SkipPublishing.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedContainerNames.get -> Microsoft.Build.Framework.ITaskItem![]! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedContainerNames.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ImageFormat.get -> string? diff --git a/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net472/PublicAPI.Unshipped.txt b/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net472/PublicAPI.Unshipped.txt index 607f906e2676..f5732d45457d 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -16,6 +16,14 @@ Microsoft.NET.Build.Containers.Port.Type.set -> void Microsoft.NET.Build.Containers.Tasks.ComputeDotnetBaseImageAndTag.ContainerFamily.get -> string? Microsoft.NET.Build.Containers.Tasks.ComputeDotnetBaseImageAndTag.UserBaseImage.get -> string? Microsoft.NET.Build.Containers.Tasks.ComputeDotnetBaseImageAndTag.UserBaseImage.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContentStoreRoot.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContentStoreRoot.get -> string! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.PublishFiles.get -> Microsoft.Build.Framework.ITaskItem![]! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.PublishFiles.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseImageConfigurationPath.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseImageConfigurationPath.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseImageManifestPath.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.BaseImageManifestPath.get -> Microsoft.Build.Framework.ITaskItem! ~override Microsoft.NET.Build.Containers.Port.ToString() -> string static Microsoft.NET.Build.Containers.Port.operator !=(Microsoft.NET.Build.Containers.Port left, Microsoft.NET.Build.Containers.Port right) -> bool static Microsoft.NET.Build.Containers.Port.operator ==(Microsoft.NET.Build.Containers.Port left, Microsoft.NET.Build.Containers.Port right) -> bool @@ -37,8 +45,6 @@ Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerEnvironmentVariable Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerEnvironmentVariables.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerizeDirectory.get -> string! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerizeDirectory.set -> void -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerRuntimeIdentifier.get -> string! -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerRuntimeIdentifier.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerUser.get -> string! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ContainerUser.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.CreateNewImage() -> void @@ -78,24 +84,30 @@ Microsoft.NET.Build.Containers.Tasks.CreateNewImage.OutputRegistry.get -> string Microsoft.NET.Build.Containers.Tasks.CreateNewImage.OutputRegistry.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ArchiveOutputPath.get -> string! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ArchiveOutputPath.set -> void -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.PublishDirectory.get -> string! -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.PublishDirectory.set -> void -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.RuntimeIdentifierGraphPath.get -> string! -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.RuntimeIdentifierGraphPath.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.WorkingDirectory.get -> string! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.WorkingDirectory.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateLabels.get -> bool Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateLabels.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateDigestLabel.get -> bool Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateDigestLabel.set -> void -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.SkipPublishing.get -> bool -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.SkipPublishing.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedContainerNames.get -> Microsoft.Build.Framework.ITaskItem![]! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedContainerNames.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ImageFormat.get -> string? Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ImageFormat.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedDigestLabel.get -> Microsoft.Build.Framework.ITaskItem? Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedDigestLabel.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedConfigurationPath.get -> string! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedConfigurationPath.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedLayerPath.get -> string! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedLayerPath.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedManifestPath.get -> string! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedManifestPath.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedAppContainerLayer.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedAppContainerLayer.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedAppContainerConfig.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedAppContainerConfig.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedAppContainerManifest.get -> Microsoft.Build.Framework.ITaskItem! +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedAppContainerManifest.set -> void override Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ToolName.get -> string! override Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateCommandLineCommands() -> string! override Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateFullPathToTool() -> string! diff --git a/src/Containers/Microsoft.NET.Build.Containers/Registry/DefaultManifestOperations.cs b/src/Containers/Microsoft.NET.Build.Containers/Registry/DefaultManifestOperations.cs index a8b2248d8996..119aacabb1ea 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Registry/DefaultManifestOperations.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Registry/DefaultManifestOperations.cs @@ -3,6 +3,7 @@ using System.Net; using System.Net.Http.Headers; +using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.NET.Build.Containers.Resources; @@ -38,11 +39,9 @@ public async Task GetAsync(string repositoryName, string re }; } - public async Task PutAsync(string repositoryName, string reference, string manifestJson, string mediaType, CancellationToken cancellationToken) + public async Task PutAsync(string repositoryName, string reference, T manifest, CancellationToken cancellationToken) where T : IManifest { - HttpContent manifestUploadContent = new StringContent(manifestJson); - manifestUploadContent.Headers.ContentType = new MediaTypeHeaderValue(mediaType); - + JsonContent manifestUploadContent = JsonContent.Create(manifest, mediaType: new MediaTypeHeaderValue(manifest.MediaType!)); HttpResponseMessage putResponse = await _client.PutAsync(new Uri(_baseUri, $"/v2/{repositoryName}/manifests/{reference}"), manifestUploadContent, cancellationToken).ConfigureAwait(false); if (!putResponse.IsSuccessStatusCode) diff --git a/src/Containers/Microsoft.NET.Build.Containers/Registry/IManifestOperations.cs b/src/Containers/Microsoft.NET.Build.Containers/Registry/IManifestOperations.cs index 23381179403d..86256fd9a8db 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Registry/IManifestOperations.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Registry/IManifestOperations.cs @@ -14,5 +14,5 @@ internal interface IManifestOperations { public Task GetAsync(string repositoryName, string reference, CancellationToken cancellationToken); - public Task PutAsync(string repositoryName, string reference, string manifestListJson, string mediaType, CancellationToken cancellationToken); + public Task PutAsync(string repositoryName, string reference, T manifest, CancellationToken cancellationToken) where T : IManifest; } diff --git a/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs b/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs index 100ff11d24cb..b87a425e98b6 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs @@ -8,61 +8,12 @@ using Microsoft.Extensions.Logging; using Microsoft.NET.Build.Containers.Resources; using NuGet.RuntimeModel; +using System.Windows.Markup; +using System.Text.RegularExpressions; +using System.Text.Json; namespace Microsoft.NET.Build.Containers; -internal interface IManifestPicker -{ - public PlatformSpecificManifest? PickBestManifestForRid(IReadOnlyDictionary manifestList, string runtimeIdentifier); - public PlatformSpecificOciManifest? PickBestManifestForRid(IReadOnlyDictionary manifestList, string runtimeIdentifier); -} - -internal sealed class RidGraphManifestPicker : IManifestPicker -{ - private readonly RuntimeGraph _runtimeGraph; - - public RidGraphManifestPicker(string runtimeIdentifierGraphPath) - { - _runtimeGraph = GetRuntimeGraphForDotNet(runtimeIdentifierGraphPath); - } - public PlatformSpecificManifest? PickBestManifestForRid(IReadOnlyDictionary ridManifestDict, string runtimeIdentifier) - { - var bestManifestRid = GetBestMatchingRid(_runtimeGraph, runtimeIdentifier, ridManifestDict.Keys); - if (bestManifestRid is null) - { - return null; - } - return ridManifestDict[bestManifestRid]; - } - - public PlatformSpecificOciManifest? PickBestManifestForRid(IReadOnlyDictionary ridManifestDict, string runtimeIdentifier) - { - var bestManifestRid = GetBestMatchingRid(_runtimeGraph, runtimeIdentifier, ridManifestDict.Keys); - if (bestManifestRid is null) - { - return null; - } - return ridManifestDict[bestManifestRid]; - } - - private static string? GetBestMatchingRid(RuntimeGraph runtimeGraph, string runtimeIdentifier, IEnumerable availableRuntimeIdentifiers) - { - HashSet availableRids = new HashSet(availableRuntimeIdentifiers, StringComparer.Ordinal); - foreach (var candidateRuntimeIdentifier in runtimeGraph.ExpandRuntime(runtimeIdentifier)) - { - if (availableRids.Contains(candidateRuntimeIdentifier)) - { - return candidateRuntimeIdentifier; - } - } - - return null; - } - - private static RuntimeGraph GetRuntimeGraphForDotNet(string ridGraphPath) => JsonRuntimeFormat.ReadRuntimeGraph(ridGraphPath); - -} - internal enum RegistryMode { Push, @@ -79,6 +30,7 @@ internal sealed class Registry private readonly ILogger _logger; private readonly IRegistryAPI _registryAPI; private readonly RegistrySettings _settings; + private readonly ContentStore _store; /// /// The name of the registry, which is the host name, optionally followed by a colon and the port number. @@ -87,24 +39,24 @@ internal sealed class Registry /// public string RegistryName { get; } - internal Registry(string registryName, ILogger logger, IRegistryAPI registryAPI, RegistrySettings? settings = null) : - this(new Uri($"https://{registryName}"), logger, registryAPI, settings) + internal Registry(string registryName, ILogger logger, IRegistryAPI registryAPI, RegistrySettings? settings = null, ContentStore? store = null) : + this(new Uri($"https://{registryName}"), logger, registryAPI, settings, store) { } - internal Registry(string registryName, ILogger logger, RegistryMode mode, RegistrySettings? settings = null) : - this(new Uri($"https://{registryName}"), logger, new RegistryApiFactory(mode), settings) + internal Registry(string registryName, ILogger logger, RegistryMode mode, RegistrySettings? settings = null, ContentStore? store = null) : + this(new Uri($"https://{registryName}"), logger, new RegistryApiFactory(mode), settings, store) { } - internal Registry(Uri baseUri, ILogger logger, IRegistryAPI registryAPI, RegistrySettings? settings = null) : - this(baseUri, logger, new RegistryApiFactory(registryAPI), settings) + internal Registry(Uri baseUri, ILogger logger, IRegistryAPI registryAPI, RegistrySettings? settings = null, ContentStore? store = null) : + this(baseUri, logger, new RegistryApiFactory(registryAPI), settings, store) { } - internal Registry(Uri baseUri, ILogger logger, RegistryMode mode, RegistrySettings? settings = null) : - this(baseUri, logger, new RegistryApiFactory(mode), settings) + internal Registry(Uri baseUri, ILogger logger, RegistryMode mode, RegistrySettings? settings = null, ContentStore? store = null) : + this(baseUri, logger, new RegistryApiFactory(mode), settings, store) { } - private Registry(Uri baseUri, ILogger logger, RegistryApiFactory factory, RegistrySettings? settings = null) + private Registry(Uri baseUri, ILogger logger, RegistryApiFactory factory, RegistrySettings? settings = null, ContentStore? store = null) { RegistryName = DeriveRegistryName(baseUri); @@ -118,6 +70,7 @@ private Registry(Uri baseUri, ILogger logger, RegistryApiFactory factory, Regist _logger = logger; _settings = settings ?? new RegistrySettings(RegistryName); _registryAPI = factory.Create(RegistryName, BaseUri, logger, _settings.IsInsecure); + _store = store ?? new ContentStore(new(Path.GetTempPath())); } private static string DeriveRegistryName(Uri baseUri) @@ -183,30 +136,109 @@ public bool IsGoogleArtifactRegistry /// private bool SupportsParallelUploads => !IsAmazonECRRegistry && _settings.ParallelUploadEnabled; + /// + /// Fetches the data for a given manifest tag or digest from the local content store if present. + /// If not present, fetches it from the remote and caches it in the local content store. + /// + /// + /// + /// + /// + /// + public async Task GetManifestCore(string repositoryName, string referenceOrDigest, CancellationToken cTok, bool skipCache = true) + { + cTok.ThrowIfCancellationRequested(); + // check if we have the reference in the ContentStore's reference area already. + // if so, read it from there. + var referencePath = _store.PathForManifestByReferenceOrDigest(RegistryName, repositoryName, referenceOrDigest); + if (!skipCache && File.Exists(referencePath)) + { + var lines = await File.ReadAllLinesAsync(referencePath, cTok); + (var digest, var mediaType, var size) = (lines[0], lines[1], lines[2]); + using var contentStream = File.OpenRead(_store.PathForDescriptor(new(mediaType, digest, long.Parse(size)))); + return await ParseManifest(mediaType, contentStream, digest); + } + // if not, make a remote call and add it to the ContentStore's reference area. + else + { + using var response = await _registryAPI.Manifest.GetAsync(repositoryName, referenceOrDigest, cTok).ConfigureAwait(false); + response.Headers.TryGetValues("Docker-Content-Digest", out var knownDigests); + var digest = knownDigests?.FirstOrDefault()!; + var mediaType = response.Content.Headers.ContentType?.MediaType!; + long size = response.Content.Headers.ContentLength ?? 0; + var descriptor = new Descriptor(mediaType, digest, size); + // write the manifest contents to the durable store + var storagePath = _store.PathForDescriptor(descriptor); + // if the file already exists at this digest then we can skip the download + if (File.Exists(storagePath)) + { + using var fs = File.OpenRead(storagePath); + return await ParseManifest(mediaType, fs, digest); + } + else + { + using var storageStream = File.OpenWrite(storagePath); + using var responseStream = await response.Content.ReadAsStreamAsync(cTok); + await responseStream.CopyToAsync(storageStream); + responseStream.Position = 0; + // write the marker file for the reference + // IMPORTANT: must stay in sync with the lines read in the if block above + var parentDir = Path.GetDirectoryName(referencePath)!; + // we're creating a multi-level directory structure, so ensure the parent directories exist before writing + Directory.CreateDirectory(parentDir); + await File.WriteAllLinesAsync(referencePath, [ + digest, + mediaType, + size.ToString() + ], DigestUtils.UTF8, cTok); + // now that the data is all set for next time, return the manifest + return await ParseManifest(mediaType, responseStream, digest); + } + } + + async Task ParseManifest(string? mediaType, Stream content, string? digest) + { + IManifest? manifest = mediaType switch + { + SchemaTypes.DockerManifestV2 or SchemaTypes.OciManifestV1 => await JsonSerializer.DeserializeAsync(content, cancellationToken: cTok), + SchemaTypes.DockerManifestListV2 => await JsonSerializer.DeserializeAsync(content, cancellationToken: cTok), + SchemaTypes.OciImageIndexV1 => await JsonSerializer.DeserializeAsync(content, cancellationToken: cTok), + null => throw new ArgumentException($"No media type found for manifest {RegistryName}/{repositoryName}@{referenceOrDigest}"), + _ => throw new ArgumentException($"Unknown manifest media type {mediaType}") + }; + if (manifest is ManifestV2 v) + { + v.KnownDigest = digest; + return v; + } + else + { + return manifest!; + } + } + } + public async Task GetImageManifestAsync(string repositoryName, string reference, string runtimeIdentifier, IManifestPicker manifestPicker, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - using HttpResponseMessage initialManifestResponse = await _registryAPI.Manifest.GetAsync(repositoryName, reference, cancellationToken).ConfigureAwait(false); - return initialManifestResponse.Content.Headers.ContentType?.MediaType switch + var manifest = await GetManifestCore(repositoryName, reference, cancellationToken); + + return manifest switch { - SchemaTypes.DockerManifestV2 or SchemaTypes.OciManifestV1 => await ReadSingleImageAsync( - repositoryName, - await ReadManifest().ConfigureAwait(false), - initialManifestResponse.Content.Headers.ContentType.MediaType, - cancellationToken).ConfigureAwait(false), - SchemaTypes.DockerManifestListV2 => await PickBestImageFromManifestListAsync( + ManifestV2 singleArchManifest => await ReadSingleImageAsync(repositoryName, singleArchManifest, cancellationToken), + ManifestListV2 multiArchDockerManifest => await PickBestImageFromManifestListAsync( repositoryName, reference, - await initialManifestResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken).ConfigureAwait(false), + multiArchDockerManifest, runtimeIdentifier, manifestPicker, cancellationToken).ConfigureAwait(false), - SchemaTypes.OciImageIndexV1 => + ImageIndexV1 multiArchOciIndex => await PickBestImageFromImageIndexAsync( repositoryName, reference, - await initialManifestResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken).ConfigureAwait(false), + multiArchOciIndex, runtimeIdentifier, manifestPicker, cancellationToken).ConfigureAwait(false), @@ -217,17 +249,6 @@ await initialManifestResponse.Content.ReadFromJsonAsync(cancellati BaseUri, unknownMediaType)) }; - - async Task ReadManifest() - { - initialManifestResponse.Headers.TryGetValues("Docker-Content-Digest", out var knownDigest); - var manifest = (await initialManifestResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken).ConfigureAwait(false))!; - if (knownDigest?.FirstOrDefault() is string knownDigestValue) - { - manifest.KnownDigest = knownDigestValue; - } - return manifest; - } } internal async Task GetManifestListAsync(string repositoryName, string reference, CancellationToken cancellationToken) @@ -242,79 +263,40 @@ async Task ReadManifest() }; } - private async Task ReadSingleImageAsync(string repositoryName, ManifestV2 manifest, string manifestMediaType, CancellationToken cancellationToken) + public async Task GetJsonBlobCore(string repositoryName, string digest, string mediaType, CancellationToken cancellationToken) { + // check if digest is available locally and serialize it, otherwise download from registry and store locally cancellationToken.ThrowIfCancellationRequested(); - ManifestConfig config = manifest.Config; - string configSha = config.digest; - - JsonNode configDoc = await _registryAPI.Blob.GetJsonAsync(repositoryName, configSha, cancellationToken).ConfigureAwait(false); - - cancellationToken.ThrowIfCancellationRequested(); - // ManifestV2.MediaType can be null, so we also provide manifest mediaType from http response - return new ImageBuilder(manifest, manifest.MediaType ?? manifestMediaType, new ImageConfig(configDoc), _logger); - } - - - private static IReadOnlyDictionary GetManifestsByRid(PlatformSpecificManifest[] manifestList) - { - var ridDict = new Dictionary(); - foreach (var manifest in manifestList) + var descriptor = new Descriptor(mediaType, digest, 0); + var storagePath = _store.PathForDescriptor(descriptor); + if (File.Exists(storagePath)) { - if (CreateRidForPlatform(manifest.platform) is { } rid) - { - ridDict.TryAdd(rid, manifest); - } + using var fs = File.OpenRead(storagePath); + return (await JsonNode.ParseAsync(fs, cancellationToken: cancellationToken))!; } - - return ridDict; - } - - private static IReadOnlyDictionary GetManifestsByRid(PlatformSpecificOciManifest[] manifestList) - { - var ridDict = new Dictionary(); - foreach (var manifest in manifestList) + else { - if (CreateRidForPlatform(manifest.platform) is { } rid) - { - ridDict.TryAdd(rid, manifest); - } - } - return ridDict; + using var jsonStream = await _registryAPI.Blob.GetStreamAsync(repositoryName, digest, cancellationToken); + using var fsStream = File.OpenWrite(storagePath); + await jsonStream.CopyToAsync(fsStream, cancellationToken); + // note: cannot just use the jsonStream here, as it may not be seekable + fsStream.Position = 0; + return (await JsonNode.ParseAsync(fsStream, cancellationToken: cancellationToken))!; + } } - private static string? CreateRidForPlatform(PlatformInformation platform) + private async Task ReadSingleImageAsync(string repositoryName, ManifestV2 manifest, CancellationToken cancellationToken) { - // we only support linux and windows containers explicitly, so anything else we should skip past. - var osPart = platform.os switch - { - "linux" => "linux", - "windows" => "win", - _ => null - }; - // TODO: this part needs a lot of work, the RID graph isn't super precise here and version numbers (especially on windows) are _whack_ - // TODO: we _may_ need OS-specific version parsing. Need to do more research on what the field looks like across more manifest lists. - var versionPart = platform.version?.Split('.') switch - { - [var major, ..] => major, - _ => null - }; - var platformPart = platform.architecture switch - { - "amd64" => "x64", - "x386" => "x86", - "arm" => $"arm{(platform.variant != "v7" ? platform.variant : "")}", - "arm64" => "arm64", - "ppc64le" => "ppc64le", - "s390x" => "s390x", - "riscv64" => "riscv64", - "loongarch64" => "loongarch64", - _ => null - }; + cancellationToken.ThrowIfCancellationRequested(); + ManifestConfig config = manifest.Config; + string configSha = config.digest; + + JsonNode configDoc = await GetJsonBlobCore(repositoryName, configSha, manifest.Config.mediaType, cancellationToken); - if (osPart is null || platformPart is null) return null; - return $"{osPart}{versionPart ?? ""}-{platformPart}"; + cancellationToken.ThrowIfCancellationRequested(); + // ManifestV2.MediaType can be null, so we also provide manifest mediaType from http response + return new ImageBuilder(manifest, manifest.MediaType ?? SchemaTypes.DockerManifestV2, new ImageConfig(configDoc), _logger); } @@ -327,14 +309,13 @@ private async Task PickBestImageFromManifestListAsync( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var ridManifestDict = GetManifestsByRid(manifestList.manifests); + var ridManifestDict = RidMapping.GetManifestsByRid(manifestList.manifests); if (manifestPicker.PickBestManifestForRid(ridManifestDict, runtimeIdentifier) is PlatformSpecificManifest matchingManifest) { - return await ReadImageFromManifest( + return await ReadSingleImageFromManifest( repositoryName, reference, matchingManifest.digest, - matchingManifest.mediaType, runtimeIdentifier, ridManifestDict.Keys, cancellationToken); @@ -354,14 +335,13 @@ private async Task PickBestImageFromImageIndexAsync( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var ridManifestDict = GetManifestsByRid(index.manifests); + var ridManifestDict = RidMapping.GetManifestsByRid(index.manifests); if (manifestPicker.PickBestManifestForRid(ridManifestDict, runtimeIdentifier) is PlatformSpecificOciManifest matchingManifest) { - return await ReadImageFromManifest( + return await ReadSingleImageFromManifest( repositoryName, reference, matchingManifest.digest, - matchingManifest.mediaType, runtimeIdentifier, ridManifestDict.Keys, cancellationToken); @@ -372,25 +352,32 @@ private async Task PickBestImageFromImageIndexAsync( } } - private async Task ReadImageFromManifest( + /// + /// Reads a manifest at the given digest, assuming it is a single-arch manifest + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + private async Task ReadSingleImageFromManifest( string repositoryName, string reference, string manifestDigest, - string mediaType, string runtimeIdentifier, IEnumerable rids, CancellationToken cancellationToken) { - using HttpResponseMessage manifestResponse = await _registryAPI.Manifest.GetAsync(repositoryName, manifestDigest, cancellationToken).ConfigureAwait(false); - - cancellationToken.ThrowIfCancellationRequested(); - var manifest = await manifestResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + var manifest = await GetManifestCore(repositoryName, manifestDigest, cancellationToken); if (manifest is null) throw new BaseImageNotFoundException(runtimeIdentifier, repositoryName, reference, rids); - manifest.KnownDigest = manifestDigest; + if (manifest is not ManifestV2 singleArchManifest) throw new ArgumentException("Only supports single-arch manifests in this pathway"); return await ReadSingleImageAsync( repositoryName, - manifest, - mediaType, + singleArchManifest, cancellationToken).ConfigureAwait(false); } @@ -403,7 +390,7 @@ private async Task ReadImageFromManifest( public async Task DownloadBlobAsync(string repository, Descriptor descriptor, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - string localPath = ContentStore.PathForDescriptor(descriptor); + string localPath = _store.PathForDescriptor(descriptor); if (File.Exists(localPath)) { @@ -411,10 +398,11 @@ public async Task DownloadBlobAsync(string repository, Descriptor descri return localPath; } - string tempTarballPath = ContentStore.GetTempFile(); + string tempTarballPath = _store.GetTempFile(); try { + _logger.LogInformation($"Downloading layer {descriptor.Digest} from {repository} to content store."); // No local copy, so download one using Stream responseStream = await _registryAPI.Blob.GetStreamAsync(repository, descriptor.Digest, cancellationToken).ConfigureAwait(false); @@ -423,9 +411,9 @@ public async Task DownloadBlobAsync(string repository, Descriptor descri await responseStream.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); } } - catch (Exception) + catch (Exception e) { - throw new UnableToDownloadFromRepositoryException(repository); + throw new UnableToDownloadFromRepositoryException(repository, e); } cancellationToken.ThrowIfCancellationRequested(); @@ -508,7 +496,15 @@ private Task UploadBlobContentsAsync(Stream contents, } } - private async Task UploadBlobAsync(string repository, string digest, Stream contents, CancellationToken cancellationToken) + /// + /// Uploads an opaque blob to the registry, checking for existence first. + /// + /// + /// + /// + /// + /// + public async Task UploadBlobAsync(string repository, string digest, Stream contents, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -545,9 +541,9 @@ public async Task PushManifestListAsync( foreach (var tag in destinationImageReference.Tags) { _logger.LogInformation(Strings.Registry_TagUploadStarted, tag, RegistryName); - await _registryAPI.Manifest.PutAsync(destinationImageReference.Repository, tag, multiArchImage.ImageIndex, multiArchImage.ImageIndexMediaType, cancellationToken).ConfigureAwait(false); + await _registryAPI.Manifest.PutAsync(destinationImageReference.Repository, tag, multiArchImage.ImageIndex, cancellationToken).ConfigureAwait(false); _logger.LogInformation(Strings.Registry_TagUploaded, tag, RegistryName); - } + } } public Task PushAsync(BuiltImage builtImage, SourceImageReference source, DestinationImageReference destination, CancellationToken cancellationToken) @@ -580,7 +576,7 @@ private async Task PushAsync(BuiltImage builtImage, SourceImageReference source, // Ensure the blob is available locally await sourceRegistry.DownloadBlobAsync(source.Repository, descriptor, cancellationToken).ConfigureAwait(false); // Then push it to the destination registry - await destinationRegistry.PushLayerAsync(Layer.FromDescriptor(descriptor), destination.Repository, cancellationToken).ConfigureAwait(false); + await destinationRegistry.PushLayerAsync(Layer.FromDescriptor(descriptor, _store), destination.Repository, cancellationToken).ConfigureAwait(false); _logger.LogInformation(Strings.Registry_LayerUploaded, digest, destinationRegistry.RegistryName); } else @@ -603,9 +599,9 @@ private async Task PushAsync(BuiltImage builtImage, SourceImageReference source, } cancellationToken.ThrowIfCancellationRequested(); - using (MemoryStream stringStream = new(Encoding.UTF8.GetBytes(builtImage.Config))) + using (MemoryStream stringStream = new(DigestUtils.UTF8.GetBytes(builtImage.Config.ToJsonString()))) { - var configDigest = builtImage.ImageDigest!; + var configDigest = builtImage.Manifest.Config.digest; _logger.LogInformation(Strings.Registry_ConfigUploadStarted, configDigest); await UploadBlobAsync(destination.Repository, configDigest, stringStream, cancellationToken).ConfigureAwait(false); _logger.LogInformation(Strings.Registry_ConfigUploaded); @@ -620,18 +616,26 @@ private async Task PushAsync(BuiltImage builtImage, SourceImageReference source, foreach (string tag in destination.Tags) { _logger.LogInformation(Strings.Registry_TagUploadStarted, tag, RegistryName); - await _registryAPI.Manifest.PutAsync(destination.Repository, tag, builtImage.Manifest, builtImage.ManifestMediaType, cancellationToken).ConfigureAwait(false); + await _registryAPI.Manifest.PutAsync(destination.Repository, tag, builtImage.Manifest, cancellationToken).ConfigureAwait(false); _logger.LogInformation(Strings.Registry_TagUploaded, tag, RegistryName); } } else { _logger.LogInformation(Strings.Registry_ManifestUploadStarted, RegistryName, builtImage.ManifestDigest); - await _registryAPI.Manifest.PutAsync(destination.Repository, builtImage.ManifestDigest, builtImage.Manifest, builtImage.ManifestMediaType, cancellationToken).ConfigureAwait(false); + await _registryAPI.Manifest.PutAsync(destination.Repository, builtImage.ManifestDigest, builtImage.Manifest, cancellationToken).ConfigureAwait(false); _logger.LogInformation(Strings.Registry_ManifestUploaded, RegistryName); } } + public async Task UploadManifestAsync(string repository, string tagOrDigest, T manifest, CancellationToken cancellationToken) where T : IManifest + { + cancellationToken.ThrowIfCancellationRequested(); + _logger.LogInformation(Strings.Registry_ManifestUploadStarted, RegistryName, tagOrDigest); + await _registryAPI.Manifest.PutAsync(repository, tagOrDigest, manifest, cancellationToken).ConfigureAwait(false); + _logger.LogInformation(Strings.Registry_ManifestUploaded, RegistryName); + } + private readonly ref struct RegistryApiFactory { private readonly IRegistryAPI? _registryApi; diff --git a/src/Containers/Microsoft.NET.Build.Containers/Registry/RidMapping.cs b/src/Containers/Microsoft.NET.Build.Containers/Registry/RidMapping.cs new file mode 100644 index 000000000000..6a178e872ad4 --- /dev/null +++ b/src/Containers/Microsoft.NET.Build.Containers/Registry/RidMapping.cs @@ -0,0 +1,119 @@ +using NuGet.RuntimeModel; + +namespace Microsoft.NET.Build.Containers; + +internal interface IManifestPicker +{ + public PlatformSpecificManifest? PickBestManifestForRid(IReadOnlyDictionary manifestList, string runtimeIdentifier); + public PlatformSpecificOciManifest? PickBestManifestForRid(IReadOnlyDictionary manifestList, string runtimeIdentifier); +} + +internal sealed class RidGraphManifestPicker : IManifestPicker +{ + private readonly RuntimeGraph _runtimeGraph; + + public RidGraphManifestPicker(string runtimeIdentifierGraphPath) + { + _runtimeGraph = GetRuntimeGraphForDotNet(runtimeIdentifierGraphPath); + } + public PlatformSpecificManifest? PickBestManifestForRid(IReadOnlyDictionary ridManifestDict, string runtimeIdentifier) + { + var bestManifestRid = GetBestMatchingRid(_runtimeGraph, runtimeIdentifier, ridManifestDict.Keys); + if (bestManifestRid is null) + { + return null; + } + return ridManifestDict[bestManifestRid]; + } + + public PlatformSpecificOciManifest? PickBestManifestForRid(IReadOnlyDictionary ridManifestDict, string runtimeIdentifier) + { + var bestManifestRid = GetBestMatchingRid(_runtimeGraph, runtimeIdentifier, ridManifestDict.Keys); + if (bestManifestRid is null) + { + return null; + } + return ridManifestDict[bestManifestRid]; + } + + private static string? GetBestMatchingRid(RuntimeGraph runtimeGraph, string runtimeIdentifier, IEnumerable availableRuntimeIdentifiers) + { + HashSet availableRids = new HashSet(availableRuntimeIdentifiers, StringComparer.Ordinal); + foreach (var candidateRuntimeIdentifier in runtimeGraph.ExpandRuntime(runtimeIdentifier)) + { + if (availableRids.Contains(candidateRuntimeIdentifier)) + { + return candidateRuntimeIdentifier; + } + } + + return null; + } + + private static RuntimeGraph GetRuntimeGraphForDotNet(string ridGraphPath) => JsonRuntimeFormat.ReadRuntimeGraph(ridGraphPath); +} + +public static class RidMapping +{ + public static IReadOnlyDictionary GetManifestsByRid(PlatformSpecificManifest[] manifestList) + { + var ridDict = new Dictionary(); + foreach (var manifest in manifestList) + { + if (CreateRidForPlatform(manifest.platform) is { } rid) + { + ridDict.TryAdd(rid, manifest); + } + } + + return ridDict; + } + + public static IReadOnlyDictionary GetManifestsByRid(PlatformSpecificOciManifest[] manifestList) + { + var ridDict = new Dictionary(); + foreach (var manifest in manifestList) + { + if (CreateRidForPlatform(manifest.platform) is { } rid) + { + ridDict.TryAdd(rid, manifest); + } + } + + return ridDict; + } + + public static string? CreateRidForPlatform(PlatformInformation platform) + { + // we only support linux and windows containers explicitly, so anything else we should skip past. + var osPart = platform.os switch + { + "linux" => "linux", + "windows" => "win", + _ => null + }; + // TODO: this part needs a lot of work, the RID graph isn't super precise here and version numbers (especially on windows) are _whack_ + // TODO: we _may_ need OS-specific version parsing. Need to do more research on what the field looks like across more manifest lists. + var versionPart = platform.version?.Split('.') switch + { + [var major, ..] => major, + _ => null + }; + var platformPart = platform.architecture switch + { + "amd64" => "x64", + "x386" => "x86", + "arm" => $"arm{(platform.variant != "v7" ? platform.variant : "")}", + "arm64" => "arm64", + "ppc64le" => "ppc64le", + "s390x" => "s390x", + "riscv64" => "riscv64", + "loongarch64" => "loongarch64", + _ => null + }; + + if (osPart is null || platformPart is null) return null; + return $"{osPart}{versionPart ?? ""}-{platformPart}"; + } + +} \ No newline at end of file diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/Strings.resx b/src/Containers/Microsoft.NET.Build.Containers/Resources/Strings.resx index 05e8baba0154..6479e7d1b188 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/Strings.resx +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/Strings.resx @@ -382,7 +382,7 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.cs.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.cs.xlf index d5ed687ee973..28815712b707 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.cs.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.cs.xlf @@ -225,8 +225,8 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. - Index image nejde vytvořit, protože zadané image nejsou platné. Položky musí mít metadata Config, Manifest, ManifestMediaType a ManifestDigest. + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. + Index image nejde vytvořit, protože zadané image nejsou platné. Položky musí mít metadata Config, Manifest, ManifestMediaType a ManifestDigest. diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.de.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.de.xlf index ba76f860056f..40210d696e84 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.de.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.de.xlf @@ -225,8 +225,8 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. - Der Imageindex kann nicht erstellt werden, da die angegebenen Bilder ungültig sind. Elemente müssen die Metadaten "Config", "Manifest", "ManifestMediaType" und "ManifestDigest" aufweisen. + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. + Der Imageindex kann nicht erstellt werden, da die angegebenen Bilder ungültig sind. Elemente müssen die Metadaten "Config", "Manifest", "ManifestMediaType" und "ManifestDigest" aufweisen. diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.es.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.es.xlf index 164c9d447a5d..1517ab2fa3ab 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.es.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.es.xlf @@ -225,8 +225,8 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. - No se puede crear el índice de imágenes porque las imágenes proporcionadas no son válidas. Los elementos deben tener metadatos 'Config', 'Manifest', 'ManifestMediaType' y 'ManifestDigest'. + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. + No se puede crear el índice de imágenes porque las imágenes proporcionadas no son válidas. Los elementos deben tener metadatos 'Config', 'Manifest', 'ManifestMediaType' y 'ManifestDigest'. diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.fr.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.fr.xlf index f0d3a647de7b..5d771137d92e 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.fr.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.fr.xlf @@ -225,8 +225,8 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. - Impossible de créer l’index d’images, car les images fournies ne sont pas valides. Les éléments doivent avoir les métadonnées 'Config', 'Manifest', 'ManifestMediaType' et 'ManifestDigest'. + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. + Impossible de créer l’index d’images, car les images fournies ne sont pas valides. Les éléments doivent avoir les métadonnées 'Config', 'Manifest', 'ManifestMediaType' et 'ManifestDigest'. diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.it.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.it.xlf index eaf97ac4648f..688d426b95fb 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.it.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.it.xlf @@ -225,8 +225,8 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. - Non è possibile creare l'indice dell'immagine perché le immagini specificate non sono valide. Gli elementi devono contenere i metadati 'Config', 'Manifest', 'ManifestMediaType' e 'ManifestDigest'. + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. + Non è possibile creare l'indice dell'immagine perché le immagini specificate non sono valide. Gli elementi devono contenere i metadati 'Config', 'Manifest', 'ManifestMediaType' e 'ManifestDigest'. diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ja.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ja.xlf index 8c99589574ad..e9c3a46b3b71 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ja.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ja.xlf @@ -225,8 +225,8 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. - 指定されたイメージが無効なため、イメージ インデックスを作成できません。項目には、'Config'、'Manifest'、'ManifestMediaType'、'ManifestDigest' のメタデータが必要です。 + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. + 指定されたイメージが無効なため、イメージ インデックスを作成できません。項目には、'Config'、'Manifest'、'ManifestMediaType'、'ManifestDigest' のメタデータが必要です。 diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ko.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ko.xlf index bff24bae4b17..ac2cf37e21c7 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ko.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ko.xlf @@ -225,8 +225,8 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. - 제공된 이미지가 잘못되었으므로 이미지 인덱스를 만들 수 없습니다. 항목에는 'Config', 'Manifest', 'ManifestMediaType' 및 'ManifestDigest' 메타데이터가 있어야 합니다. + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. + 제공된 이미지가 잘못되었으므로 이미지 인덱스를 만들 수 없습니다. 항목에는 'Config', 'Manifest', 'ManifestMediaType' 및 'ManifestDigest' 메타데이터가 있어야 합니다. diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pl.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pl.xlf index 801b6190de72..5c4ef7a1e50a 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pl.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pl.xlf @@ -225,8 +225,8 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. - Nie można utworzyć indeksu obrazu, ponieważ podane obrazy są nieprawidłowe. Elementy muszą mieć metadane "Config", "Manifest", "ManifestMediaType" i "ManifestDigest". + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. + Nie można utworzyć indeksu obrazu, ponieważ podane obrazy są nieprawidłowe. Elementy muszą mieć metadane "Config", "Manifest", "ManifestMediaType" i "ManifestDigest". diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pt-BR.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pt-BR.xlf index 971dda801778..e387ee411f54 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pt-BR.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pt-BR.xlf @@ -225,8 +225,8 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. - Não é possível criar o índice de imagem porque as imagens fornecidas são inválidas. Os itens devem ter metadados 'Config', 'Manifest', 'ManifestMediaType' e 'ManifestDigest'. + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. + Não é possível criar o índice de imagem porque as imagens fornecidas são inválidas. Os itens devem ter metadados 'Config', 'Manifest', 'ManifestMediaType' e 'ManifestDigest'. diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ru.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ru.xlf index 1f4ef89cfdfa..9c0e7175d6fc 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ru.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ru.xlf @@ -225,8 +225,8 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. - Не удается создать индекс образа, так как предоставленные образы недопустимы. Элементы должны иметь метаданные "Config", "Manifest", "ManifestMediaType" и "ManifestDigest". + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. + Не удается создать индекс образа, так как предоставленные образы недопустимы. Элементы должны иметь метаданные "Config", "Manifest", "ManifestMediaType" и "ManifestDigest". diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.tr.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.tr.xlf index faad0ded04f1..4dbf739e866e 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.tr.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.tr.xlf @@ -225,8 +225,8 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. - Sağlanan görüntüler geçersiz olduğundan görüntü dizini oluşturulamıyor. Öğeler 'Config', 'Manifest', 'ManifestMediaType' ve 'ManifestDigest' meta verilerine sahip olmalıdır. + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. + Sağlanan görüntüler geçersiz olduğundan görüntü dizini oluşturulamıyor. Öğeler 'Config', 'Manifest', 'ManifestMediaType' ve 'ManifestDigest' meta verilerine sahip olmalıdır. diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hans.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hans.xlf index f75697ff279f..111671db67fb 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hans.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hans.xlf @@ -225,8 +225,8 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. - 无法创建映像索引,因为提供的映像无效。项必须具有 “Config”、“Manifest”、“ManifestMediaType” 和 “ManifestDigest” 元数据。 + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. + 无法创建映像索引,因为提供的映像无效。项必须具有 “Config”、“Manifest”、“ManifestMediaType” 和 “ManifestDigest” 元数据。 diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hant.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hant.xlf index f9bdce96c863..03e5e76a1b3a 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hant.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hant.xlf @@ -225,8 +225,8 @@ - Cannot create image index because provided images are invalid. Items must have 'Config', 'Manifest', 'ManifestMediaType' and 'ManifestDigest' metadata. - 無法建立映射索引,因為提供的映像無效。項目必須有 'Config'、'Manifest'、'ManifestMediaType' 和 'ManifestDigest' 元數據。 + Cannot create image index because provided images are invalid. Items must have 'ConfigurationPath' and 'ManifestPath' metadata. + 無法建立映射索引,因為提供的映像無效。項目必須有 'Config'、'Manifest'、'ManifestMediaType' 和 'ManifestDigest' 元數據。 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..e91e288e843a 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateImageIndex.Interface.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateImageIndex.Interface.cs @@ -34,7 +34,7 @@ partial class CreateImageIndex public string BaseImageDigest { get; set; } /// - /// Manifests to include in the image index. + /// Manifests to include in the image index. Should have an ManifestPath and a ConfigurationPath metadata pointing to those relevant files. /// [Required] public ITaskItem[] GeneratedContainers { get; set; } @@ -66,6 +66,12 @@ partial class CreateImageIndex [Required] public string[] ImageTags { get; set; } + /// + /// Where to write the generated image index to + /// + [Required] + public string GeneratedManifestPath { get; set; } + /// /// The generated archive output path. /// @@ -92,7 +98,8 @@ public CreateImageIndex() ImageTags = Array.Empty(); GeneratedArchiveOutputPath = string.Empty; GeneratedImageIndex = string.Empty; + GeneratedManifestPath = 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 16e5b8392372..746d00767325 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateImageIndex.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateImageIndex.cs @@ -68,105 +68,86 @@ internal async Task ExecuteAsync(CancellationToken cancellationToken) OutputRegistry, LocalRegistry); - var images = ParseImages(destinationImageReference.Kind); + var images = await ParseImages(GeneratedContainers, cancellationToken); if (Log.HasLoggedErrors) { return false; } var multiArchImage = CreateMultiArchImage(images, destinationImageReference.Kind); + using var fileStream = File.OpenWrite(GeneratedManifestPath); + await JsonSerializer.SerializeAsync(fileStream, multiArchImage.ImageIndex); - GeneratedImageIndex = multiArchImage.ImageIndex; + GeneratedImageIndex = JsonSerializer.Serialize(multiArchImage.ImageIndex); GeneratedArchiveOutputPath = ArchiveOutputPath; logger.LogInformation(Strings.BuildingImageIndex, destinationImageReference, string.Join(", ", images.Select(i => i.ManifestDigest))); var telemetry = new Telemetry(sourceImageReference, destinationImageReference, Log); - + // TODO: remove this push and extract to another Task await ImagePublisher.PublishImageAsync(multiArchImage, sourceImageReference, destinationImageReference, Log, telemetry, cancellationToken) - .ConfigureAwait(false); + .ConfigureAwait(false); return !Log.HasLoggedErrors; } - private BuiltImage[] ParseImages(DestinationImageReferenceKind destinationKind) + private async Task ParseImages(ITaskItem[] containers, CancellationToken ctok) { - var images = new BuiltImage[GeneratedContainers.Length]; + var images = await Task.WhenAll(containers.Select(itemDescription => ParseBuiltImage(itemDescription))); + var validImages = images.Where(image => image is not null).Cast().ToArray()!; + return validImages; - for (int i = 0; i < GeneratedContainers.Length; i++) + async Task ParseBuiltImage(ITaskItem itemDescription) { - var unparsedImage = GeneratedContainers[i]; - - string config = unparsedImage.GetMetadata("Configuration"); - string manifestDigest = unparsedImage.GetMetadata("ManifestDigest"); - string manifest = unparsedImage.GetMetadata("Manifest"); - string manifestMediaType = unparsedImage.GetMetadata("ManifestMediaType"); + var configFile = new FileInfo(itemDescription.GetMetadata("ConfigurationPath")); + var manifestFile = new FileInfo(itemDescription.GetMetadata("ManifestPath")); - if (string.IsNullOrEmpty(config) || string.IsNullOrEmpty(manifestDigest) || string.IsNullOrEmpty(manifest) || string.IsNullOrEmpty(manifestMediaType)) + if (!configFile.Exists || !manifestFile.Exists) { Log.LogError(Strings.InvalidImageMetadata); - break; + return null; } - (string architecture, string os) = GetArchitectureAndOsFromConfig(config); - - // We don't need ImageDigest, ImageSha, Layers for remote registry, as the individual images should be pushed already - string? imageDigest = null; - string? imageSha = null; - List? layers = null; - - if (destinationKind == DestinationImageReferenceKind.LocalRegistry) + if (await GetArchitectureAndOsFromConfig(configFile, ctok) is not (var config, var architecture, var os)) { - var manifestV2 = JsonSerializer.Deserialize(manifest); - if (manifestV2 == null) - { - Log.LogError(Strings.InvalidImageManifest); - break; - } - - imageDigest = manifestV2.Config.digest; - imageSha = DigestUtils.GetShaFromDigest(imageDigest); - layers = manifestV2.Layers; - } + Log.LogError(Strings.InvalidImageConfig); + return null; + } - images[i] = new BuiltImage() + ManifestV2 manifestV2 = (await JsonSerializer.DeserializeAsync(manifestFile.OpenRead()))!; + return new BuiltImage() { Config = config, - ImageDigest = imageDigest, - ImageSha = imageSha, - Manifest = manifest, - ManifestDigest = manifestDigest, - ManifestMediaType = manifestMediaType, - Layers = layers, + Manifest = manifestV2, + Layers = manifestV2.Layers, OS = os, Architecture = architecture }; } - - return images; } - private (string, string) GetArchitectureAndOsFromConfig(string config) + private async Task<(JsonObject, string, string)?> GetArchitectureAndOsFromConfig(FileInfo config, CancellationToken cTok) { - var configJson = JsonNode.Parse(config) as JsonObject; + using var fileStream = config.OpenRead(); + var configJson = await JsonNode.ParseAsync(fileStream, cancellationToken: cTok) as JsonObject; if (configJson is null) { Log.LogError(Strings.InvalidImageConfig); - return (string.Empty, string.Empty); + return null; } var architecture = configJson["architecture"]?.ToString(); if (architecture is null) { Log.LogError(Strings.ImageConfigMissingArchitecture); - return (string.Empty, string.Empty); - } + return null; + } var os = configJson["os"]?.ToString(); if (os is null) { Log.LogError(Strings.ImageConfigMissingOs); - return (string.Empty, string.Empty); + return null; } - return (architecture, os); + return (configJson, architecture, os); } private static MultiArchImage CreateMultiArchImage(BuiltImage[] images, DestinationImageReferenceKind destinationImageKind) @@ -177,16 +158,14 @@ 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), - ImageIndexMediaType = SchemaTypes.OciImageIndexV1, + ImageIndex = ImageIndexGenerator.GenerateDockerManifestList(images, SchemaTypes.OciManifestV1, SchemaTypes.OciImageIndexV1), Images = images }; case DestinationImageReferenceKind.RemoteRegistry: - (string imageIndex, string mediaType) = ImageIndexGenerator.GenerateImageIndex(images); + var imageIndex = ImageIndexGenerator.GenerateImageIndex(images); return new MultiArchImage() { ImageIndex = imageIndex, - ImageIndexMediaType = mediaType, // For remote registry we don't need individual images, as they should be pushed already }; default: 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 ebbd1de29901..d3b093c7303b 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs @@ -41,6 +41,12 @@ partial class CreateNewImage /// public string BaseImageDigest { get; set; } + [Required] + public ITaskItem BaseImageManifestPath { get; set; } + + [Required] + public ITaskItem BaseImageConfigurationPath { get; set; } + /// /// The registry to push to. /// @@ -69,11 +75,11 @@ partial class CreateNewImage public string[] ImageTags { get; set; } /// - /// The directory for the build outputs to be published. - /// Constructed from "$(MSBuildProjectDirectory)\$(PublishDir)" + /// The files to be published to the container. + /// MUST have RelativePath metadata.. /// [Required] - public string PublishDirectory { get; set; } + public ITaskItem[] PublishFiles { get; set; } /// /// The working directory of the container. @@ -129,18 +135,6 @@ partial class CreateNewImage /// public ITaskItem[] ContainerEnvironmentVariables { get; set; } - /// - /// The RID to use to determine the host manifest if the parent container is a manifest list - /// - [Required] - public string ContainerRuntimeIdentifier { get; set; } - - /// - /// The path to the runtime identifier graph file. This is used to compute RID compatibility for Image Manifest List entries. - /// - [Required] - public string RuntimeIdentifierGraphPath { get; set; } - /// /// The username or UID which is a platform-specific structure that allows specific control over which user the process run as. /// This acts as a default value to use when the value is not specified when creating a container. @@ -170,9 +164,25 @@ partial class CreateNewImage /// public string? ImageFormat { get; set; } - /// If true, the tooling will skip the publishing step. + public string ContentStoreRoot { get; set; } + + /// + /// Where to write the generated manifest file. /// - public bool SkipPublishing { get; set; } + [Required] + public string GeneratedManifestPath { get; set; } = ""; + + /// + /// Where to write the generated configuration file. + /// + [Required] + public string GeneratedConfigurationPath { get; set; } = ""; + + /// + /// Where to write the generated layer tarball. + /// + [Required] + public string GeneratedLayerPath { get; set; } = ""; [Output] public string GeneratedContainerManifest { get; set; } @@ -192,6 +202,15 @@ partial class CreateNewImage [Output] public ITaskItem[] GeneratedContainerNames { get; set; } + [Output] + public ITaskItem GeneratedAppContainerLayer { get; set; } + + [Output] + public ITaskItem GeneratedAppContainerConfig { get; set; } + + [Output] + public ITaskItem GeneratedAppContainerManifest { get; set; } + [Output] public ITaskItem? GeneratedDigestLabel { get; set; } @@ -204,11 +223,13 @@ public CreateNewImage() BaseImageName = ""; BaseImageTag = ""; BaseImageDigest = ""; + BaseImageManifestPath = null!; + BaseImageConfigurationPath = null!; OutputRegistry = ""; ArchiveOutputPath = ""; Repository = ""; ImageTags = Array.Empty(); - PublishDirectory = ""; + PublishFiles = []; WorkingDirectory = ""; Entrypoint = Array.Empty(); EntrypointArgs = Array.Empty(); @@ -219,10 +240,9 @@ public CreateNewImage() Labels = Array.Empty(); ExposedPorts = Array.Empty(); ContainerEnvironmentVariables = Array.Empty(); - ContainerRuntimeIdentifier = ""; - RuntimeIdentifierGraphPath = ""; LocalRegistry = ""; ContainerUser = ""; + ContentStoreRoot = ""; GeneratedContainerConfiguration = ""; GeneratedContainerManifest = ""; @@ -231,6 +251,9 @@ public CreateNewImage() GeneratedContainerMediaType = ""; GeneratedContainerNames = Array.Empty(); GeneratedDigestLabel = null; + GeneratedAppContainerLayer = null!; + GeneratedAppContainerConfig = null!; + GeneratedAppContainerManifest = null!; GenerateLabels = false; GenerateDigestLabel = false; diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs index 4b9993e06cf7..83483dd760d9 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs @@ -1,6 +1,8 @@ // 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 System.Text.Json.Nodes; using Microsoft.Build.Framework; using Microsoft.Extensions.Logging; using Microsoft.NET.Build.Containers.Logging; @@ -52,12 +54,6 @@ internal async Task ExecuteAsync(CancellationToken cancellationToken) ILoggerFactory msbuildLoggerFactory = new LoggerFactory(new[] { loggerProvider }); ILogger logger = msbuildLoggerFactory.CreateLogger(); - if (!Directory.Exists(PublishDirectory)) - { - Log.LogErrorWithCodeFromResources(nameof(Strings.PublishDirectoryDoesntExist), nameof(PublishDirectory), PublishDirectory); - return !Log.HasLoggedErrors; - } - RegistryMode sourceRegistryMode = BaseRegistry.Equals(OutputRegistry, StringComparison.InvariantCultureIgnoreCase) ? RegistryMode.PullFromOutput : RegistryMode.Pull; Registry? sourceRegistry = IsLocalPull ? null : new Registry(BaseRegistry, logger, sourceRegistryMode); SourceImageReference sourceImageReference = new(sourceRegistry, BaseImageName, BaseImageTag, BaseImageDigest); @@ -72,96 +68,47 @@ internal async Task ExecuteAsync(CancellationToken cancellationToken) var telemetry = new Telemetry(sourceImageReference, destinationImageReference, Log); - ImageBuilder? imageBuilder; - if (sourceRegistry is { } registry) + KnownImageFormats? format = null; + if (ImageFormat is not null) { - try + if (Enum.TryParse(ImageFormat, out KnownImageFormats knownFormat)) { - var picker = new RidGraphManifestPicker(RuntimeIdentifierGraphPath); - imageBuilder = await registry.GetImageManifestAsync( - BaseImageName, - sourceImageReference.Reference, - ContainerRuntimeIdentifier, - picker, - cancellationToken).ConfigureAwait(false); + format = knownFormat; } - catch (RepositoryNotFoundException) - { - telemetry.LogUnknownRepository(); - Log.LogErrorWithCodeFromResources(nameof(Strings.RepositoryNotFound), BaseImageName, BaseImageTag, BaseImageDigest, registry.RegistryName); - return !Log.HasLoggedErrors; - } - catch (UnableToAccessRepositoryException) - { - telemetry.LogCredentialFailure(sourceImageReference); - Log.LogErrorWithCodeFromResources(nameof(Strings.UnableToAccessRepository), BaseImageName, registry.RegistryName); - return !Log.HasLoggedErrors; - } - catch (ContainerHttpException e) - { - Log.LogErrorFromException(e, showStackTrace: false, showDetail: true, file: null); - return !Log.HasLoggedErrors; - } - catch (BaseImageNotFoundException e) + else { - telemetry.LogRidMismatch(e.RequestedRuntimeIdentifier, e.AvailableRuntimeIdentifiers.ToArray()); - Log.LogErrorFromException(e, showStackTrace: false, showDetail: true, file: null); - return !Log.HasLoggedErrors; + Log.LogErrorWithCodeFromResources(nameof(Strings.InvalidContainerImageFormat), ImageFormat, string.Join(",", Enum.GetNames(typeof(KnownImageFormats)))); + return false; } } - else + + ImageBuilder? imageBuilder; + if (sourceRegistry is { } registry) { - throw new NotSupportedException(Resource.GetString(nameof(Strings.ImagePullNotSupported))); + imageBuilder = await ContainerBuilder.LoadFromManifestAndConfig(BaseImageManifestPath.ItemSpec, format, BaseImageConfigurationPath.ItemSpec, logger); } - - if (imageBuilder is null) + else { - Log.LogErrorWithCodeFromResources(nameof(Strings.BaseImageNotFound), sourceImageReference, ContainerRuntimeIdentifier); - return !Log.HasLoggedErrors; + throw new NotSupportedException(Resource.GetString(nameof(Strings.ImagePullNotSupported))); } - (string message, object[] parameters) = SkipPublishing ? - (Strings.ContainerBuilder_StartBuildingImageForRid, new object[] { Repository, ContainerRuntimeIdentifier, sourceImageReference }) : + (string message, object[] parameters) = (Strings.ContainerBuilder_StartBuildingImage, new object[] { Repository, String.Join(",", ImageTags), sourceImageReference }); Log.LogMessage(MessageImportance.High, message, parameters); - // forcibly change the media type if required - if (ImageFormat is not null) - { - if (Enum.TryParse(ImageFormat, out var imageFormat)) - { - imageBuilder.ManifestMediaType = imageFormat switch - { - KnownImageFormats.Docker => SchemaTypes.DockerManifestV2, - KnownImageFormats.OCI => SchemaTypes.OciManifestV1, - _ => imageBuilder.ManifestMediaType // should be impossible unless we add to the enum - }; - } - else - { - Log.LogErrorWithCodeFromResources(nameof(Strings.InvalidContainerImageFormat), ImageFormat, string.Join(",", Enum.GetValues())); - } - } - - // forcibly change the media type if required - if (ImageFormat is not null) + var storePath = new DirectoryInfo(ContentStoreRoot); + if (!storePath.Exists) { - if (Enum.TryParse(ImageFormat, out var imageFormat)) - { - imageBuilder.ManifestMediaType = imageFormat switch - { - KnownImageFormats.Docker => SchemaTypes.DockerManifestV2, - KnownImageFormats.OCI => SchemaTypes.OciManifestV1, - _ => imageBuilder.ManifestMediaType // should be impossible unless we add to the enum - }; - } - else - { - Log.LogErrorWithCodeFromResources(nameof(Strings.InvalidContainerImageFormat), ImageFormat, string.Join(",", Enum.GetValues())); - } + throw new ArgumentException($"The content store path '{ContentStoreRoot}' does not exist."); } - - Layer newLayer = Layer.FromDirectory(PublishDirectory, WorkingDirectory, imageBuilder.IsWindows, imageBuilder.ManifestMediaType); + var store = new ContentStore(storePath); + + (string absolutefilePath, string relativeContainerPath)[] filesWithRelativePaths = + PublishFiles + .Select(f => (f.ItemSpec, f.GetMetadata("RelativePath"))) + .Where(x => !string.IsNullOrWhiteSpace(x.ItemSpec) && !string.IsNullOrWhiteSpace(x.Item2)) + .ToArray(); + Layer newLayer = await Layer.FromFiles(filesWithRelativePaths, WorkingDirectory, imageBuilder.IsWindows, imageBuilder.ManifestMediaType, store, new(GeneratedLayerPath), cancellationToken); imageBuilder.AddLayer(newLayer); imageBuilder.SetWorkingDirectory(WorkingDirectory); @@ -209,12 +156,42 @@ internal async Task ExecuteAsync(CancellationToken cancellationToken) cancellationToken.ThrowIfCancellationRequested(); // at this point we're done with modifications and are just pushing the data other places - GeneratedContainerManifest = builtImage.Manifest; - GeneratedContainerConfiguration = builtImage.Config; + + var serializedManifest = JsonSerializer.Serialize(builtImage.Manifest); + var manifestWriteTask = File.WriteAllTextAsync(GeneratedManifestPath, serializedManifest, DigestUtils.UTF8); + + var serializedConfig = JsonSerializer.Serialize(builtImage.Config); + var configWriteTask = File.WriteAllTextAsync(GeneratedConfigurationPath, serializedConfig, DigestUtils.UTF8); + + await Task.WhenAll(manifestWriteTask, configWriteTask).ConfigureAwait(false); + + GeneratedContainerManifest = serializedManifest; + GeneratedContainerConfiguration = serializedConfig; GeneratedContainerDigest = builtImage.ManifestDigest; GeneratedArchiveOutputPath = ArchiveOutputPath; GeneratedContainerMediaType = builtImage.ManifestMediaType; GeneratedContainerNames = destinationImageReference.FullyQualifiedImageNames().Select(name => new Microsoft.Build.Utilities.TaskItem(name)).ToArray(); + GeneratedAppContainerLayer = new Microsoft.Build.Utilities.TaskItem(GeneratedLayerPath, new Dictionary(4) + { + ["Size"] = newLayer.Descriptor.Size.ToString(), + ["MediaType"] = newLayer.Descriptor.MediaType, + ["Digest"] = newLayer.Descriptor.Digest, + }); + + GeneratedAppContainerConfig = new Microsoft.Build.Utilities.TaskItem(GeneratedConfigurationPath, new Dictionary(2) + { + ["Size"] = builtImage.Manifest.Config.size.ToString(), + ["MediaType"] = builtImage.Manifest.Config.mediaType, + ["Digest"] = builtImage.Manifest.Config.digest, + }); + + GeneratedAppContainerManifest = new Microsoft.Build.Utilities.TaskItem(GeneratedManifestPath, new Dictionary(2) + { + ["Size"] = new FileInfo(GeneratedManifestPath).Length.ToString(), + ["MediaType"] = builtImage.Manifest.MediaType!, + ["Digest"] = builtImage.Manifest.GetDigest(), + }); + if (baseImageLabel is not null && baseImageDigest is not null) { var labelItem = new Microsoft.Build.Utilities.TaskItem(baseImageLabel); @@ -222,12 +199,6 @@ internal async Task ExecuteAsync(CancellationToken cancellationToken) GeneratedDigestLabel = labelItem; } - if (!SkipPublishing) - { - await ImagePublisher.PublishImageAsync(builtImage, sourceImageReference, destinationImageReference, Log, telemetry, cancellationToken) - .ConfigureAwait(false); - } - return !Log.HasLoggedErrors; } diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImageToolTask.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImageToolTask.cs index 90bf398c900b..0cb7992a761c 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImageToolTask.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImageToolTask.cs @@ -71,10 +71,6 @@ protected override ProcessStartInfo GetProcessStartInfo(string pathToTool, strin /// internal string GenerateCommandLineCommandsInt() { - if (string.IsNullOrWhiteSpace(PublishDirectory)) - { - throw new InvalidOperationException(Resource.FormatString(nameof(Strings.RequiredPropertyNotSetOrEmpty), nameof(PublishDirectory))); - } if (string.IsNullOrWhiteSpace(BaseRegistry)) { throw new InvalidOperationException(Resource.FormatString(nameof(Strings.RequiredPropertyNotSetOrEmpty), nameof(BaseRegistry))); @@ -96,7 +92,11 @@ internal string GenerateCommandLineCommandsInt() //mandatory options builder.AppendFileNameIfNotNull(Path.Combine(ContainerizeDirectory, "containerize.dll")); - builder.AppendFileNameIfNotNull(PublishDirectory.TrimEnd(new char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar })); + foreach (ITaskItem inputFile in PublishFiles) + { + var relPath = inputFile.GetMetadata("TargetPath") is string tp && !string.IsNullOrEmpty(tp) ? tp : inputFile.GetMetadata("RelativePath"); + builder.AppendSwitchIfNotNull("--input-file", $"{inputFile.ItemSpec}={relPath}"); + } builder.AppendSwitchIfNotNull("--baseregistry ", BaseRegistry); builder.AppendSwitchIfNotNull("--baseimagename ", BaseImageName); builder.AppendSwitchIfNotNull("--repository ", Repository); @@ -180,16 +180,6 @@ internal string GenerateCommandLineCommandsInt() string[] readyEnvVariables = sanitizedEnvVariables.Select(i => i.ItemSpec + "=" + i.GetMetadata("Value")).ToArray(); builder.AppendSwitchIfNotNull("--environmentvariables ", readyEnvVariables, delimiter: " "); - if (!string.IsNullOrWhiteSpace(ContainerRuntimeIdentifier)) - { - builder.AppendSwitchIfNotNull("--rid ", ContainerRuntimeIdentifier); - } - - if (!string.IsNullOrWhiteSpace(RuntimeIdentifierGraphPath)) - { - builder.AppendSwitchIfNotNull("--ridgraphpath ", RuntimeIdentifierGraphPath); - } - if (!string.IsNullOrWhiteSpace(ContainerUser)) { builder.AppendSwitchIfNotNull("--container-user ", ContainerUser); @@ -210,6 +200,13 @@ internal string GenerateCommandLineCommandsInt() builder.AppendSwitch("--generate-digest-label"); } + builder.AppendSwitchIfNotNull("--base-image-manifest", BaseImageManifestPath); + builder.AppendSwitchIfNotNull("--base-image-config", BaseImageConfigurationPath); + + builder.AppendSwitchIfNotNull("--generated-manifest", GeneratedManifestPath); + builder.AppendSwitchIfNotNull("--generated-configuration", GeneratedConfigurationPath); + builder.AppendSwitchIfNotNull("--generated-layer", GeneratedLayerPath); + return builder.ToString(); void AppendSwitchIfNotNullSanitized(CommandLineBuilder builder, string commandArgName, string propertyName, ITaskItem[] value) diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/DownloadContainerManifest.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/DownloadContainerManifest.cs new file mode 100644 index 000000000000..832b0854efe4 --- /dev/null +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/DownloadContainerManifest.cs @@ -0,0 +1,183 @@ +// 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 System.Text.Json.Nodes; +using System.Threading.Tasks; +using Microsoft.Build.Framework; +using Microsoft.Extensions.Logging; +using Microsoft.NET.Build.Containers.Logging; +using NuGet.RuntimeModel; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Microsoft.NET.Build.Containers.Tasks; + +public class DownloadContainerManifest : Microsoft.Build.Utilities.Task, ICancelableTask +{ + private CancellationTokenSource _cts = new(); + + [Required] + public string Registry { get; set; } = string.Empty; + + [Required] + public string Repository { get; set; } = string.Empty; + + public string? Tag { get; set; } + + public string? Digest { get; set; } + + [Required] + public string ContentStore { get; set; } = string.Empty; + + + [Output] + public ITaskItem[] Manifests { get; set; } = Array.Empty(); + + [Output] + public ITaskItem[] Configs { get; set; } = Array.Empty(); + + [Output] + public ITaskItem[] Layers { get; set; } = Array.Empty(); + + + public void Cancel() => _cts.Cancel(); + + public override bool Execute() => ExecuteAsync().GetAwaiter().GetResult(); + + public async Task ExecuteAsync() + { + if (Tag is null && Digest is null) + { + throw new ArgumentException("Must provide one of Tag and Digest"); + } + + using MSBuildLoggerProvider loggerProvider = new(Log); + ILoggerFactory msbuildLoggerFactory = new LoggerFactory(new[] { loggerProvider }); + ILogger logger = msbuildLoggerFactory.CreateLogger(); + var store = new ContentStore(new(ContentStore)); + var registry = new Registry(Registry, logger, RegistryMode.Pull, store: store); + + // download the manifest from the registry (maybe), and download child manifests if it's a multi-image manifest + // we accept a nullable coercion here for tag/digest because we just checked that at least one is present + // TODO: set skipCache to false when we have some kind of user-settable semantic for Docker's ímage pull policy concept + var outerManifest = await registry.GetManifestCore(Repository, (Digest ?? Tag)!, _cts.Token, skipCache: true); + if (outerManifest is ManifestV2 singleArchManifest) + { + Log.LogMessage($"Found single-arch manifest for {Repository} with digest {singleArchManifest.KnownDigest}"); + var platformData = await DownloadConfigForManifest(registry, singleArchManifest); + SetOutputs([(singleArchManifest, platformData)], store); + return true; + } + else if (outerManifest is IMultiImageManifest multiArchManifest) + { + Log.LogMessage($"Found multi-arch manifest for {Repository}, fetching child manifests"); ; + if (multiArchManifest is ManifestListV2 manifestList) + { + var manifests = await Task.WhenAll(manifestList.manifests.Select(GetPlatformDataForManifest)); + SetOutputs(manifests, store); + return true; + } + else if (multiArchManifest is ImageIndexV1 imageIndex) + { + var manifests = await Task.WhenAll(imageIndex.manifests.Select(GetOciPlatformDataForManifest)); + SetOutputs(manifests, store); + return true; + } + else + { + throw new InvalidOperationException("Unknown multi-arch manifest type"); + } + } + else + { + throw new InvalidOperationException("Unknown manifest type"); + } + async Task<(ManifestV2, PlatformInformation)> GetPlatformDataForManifest(PlatformSpecificManifest p) + { + var manifest = await registry.GetManifestCore(Repository, p.digest, _cts.Token); + if (manifest is not ManifestV2 manifestV2) + { + throw new InvalidOperationException("Expected single-arch manifest"); + } + Log.LogMessage($"Found child manifest for platform {p.platform} with digest {manifestV2.KnownDigest}"); + var platformData = await DownloadConfigForManifest(registry, manifestV2); + return (manifestV2, platformData); + } + + async Task<(ManifestV2, PlatformInformation)> GetOciPlatformDataForManifest(PlatformSpecificOciManifest p) + { + var manifest = await registry.GetManifestCore(Repository, p.digest, _cts.Token); + if (manifest is not ManifestV2 manifestV2) + { + throw new InvalidOperationException("Expected single-arch manifest"); + } + Log.LogMessage($"Found child manifest for platform {p.platform} with digest {manifestV2.KnownDigest}"); + var platformData = await DownloadConfigForManifest(registry, manifestV2); + return (manifestV2, platformData); + } + } + + /// + /// Downloads the configuration for a manifest, which contains platform information. + /// This ensures that the per-RID configs are present for future build steps + /// + /// + /// + /// + async Task DownloadConfigForManifest(Registry registry, ManifestV2 manifest) + { + var configJson = await registry.GetJsonBlobCore(Repository, manifest.Config.digest, manifest.Config.mediaType, _cts.Token); + return configJson.Deserialize(); + } + + void SetOutputs(IReadOnlyList<(ManifestV2 manifest, PlatformInformation platformInfo)> manifests, ContentStore store) + { + var manifestItems = new List(manifests.Count); + var configItems = new List(manifests.Count); + var layerItems = new List(manifests.Count * manifests[0].manifest.Layers.Count); //estimate + foreach (var (manifest, platform) in manifests) + { + var manifestDescriptor = new Descriptor(manifest.MediaType!, manifest.KnownDigest!, 0); + var manifestLocalPath = store.PathForDescriptor(manifestDescriptor); + + var itemRid = RidMapping.CreateRidForPlatform(platform); + var manifestItem = new Microsoft.Build.Utilities.TaskItem(manifestLocalPath); + manifestItem.SetMetadata("MediaType", manifest.MediaType ?? string.Empty); + manifestItem.SetMetadata("ConfigDigest", manifest.Config.digest); + manifestItem.SetMetadata("RuntimeIdentifier", itemRid); + manifestItem.SetMetadata("Registry", Registry); + manifestItem.SetMetadata("Repository", Repository); + manifestItems.Add(manifestItem); + + var configDescriptor = new Descriptor(manifest.Config.mediaType, manifest.Config.digest, manifest.Config.size); + var configLocalPath = store.PathForDescriptor(configDescriptor); + var configItem = new Microsoft.Build.Utilities.TaskItem(configLocalPath); + configItem.SetMetadata("MediaType", manifest.Config.mediaType ?? string.Empty); + configItem.SetMetadata("Size", manifest.Config.size.ToString()); + configItem.SetMetadata("Digest", manifest.Config.digest); + configItem.SetMetadata("RuntimeIdentifier", itemRid); + configItem.SetMetadata("Registry", Registry); + configItem.SetMetadata("Repository", Repository); + configItems.Add(configItem); + + foreach (var layer in manifest.Layers) + { + var layerDescriptor = new Descriptor(layer.mediaType!, layer.digest, layer.size); + var layerLocalPath = store.PathForDescriptor(layerDescriptor); + var layerItem = new Microsoft.Build.Utilities.TaskItem(layerLocalPath); + layerItem.SetMetadata("MediaType", layer.mediaType ?? string.Empty); + layerItem.SetMetadata("Size", layer.size.ToString()); + layerItem.SetMetadata("Digest", layer.digest); + layerItem.SetMetadata("ConfigDigest", manifest.Config.digest); + layerItem.SetMetadata("RuntimeIdentifier", itemRid); + layerItem.SetMetadata("Registry", Registry); + layerItem.SetMetadata("Repository", Repository); + layerItems.Add(layerItem); + } + } + + Manifests = manifestItems.ToArray(); + Configs = configItems.ToArray(); + Layers = layerItems.ToArray(); + } +} diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/DownloadLayers.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/DownloadLayers.cs new file mode 100644 index 000000000000..8e5b30c044c4 --- /dev/null +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/DownloadLayers.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Framework; +using Microsoft.Extensions.Logging; +using Microsoft.NET.Build.Containers.Logging; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Microsoft.NET.Build.Containers.Tasks; + +public class DownloadLayers : Microsoft.Build.Utilities.Task, ICancelableTask +{ + private CancellationTokenSource _cts = new(); + + [Required] + public string Registry { get; set; } = string.Empty; + + [Required] + public string Repository { get; set; } = string.Empty; + + [Required] + public string ContentStore { get; set; } = string.Empty; + + /// + /// should have the same data model as the output layers from + /// + [Required] + public ITaskItem[] Layers { get; set; } = Array.Empty(); + + public void Cancel() => _cts.Cancel(); + + public override bool Execute() => ExecuteAsync().GetAwaiter().GetResult(); + + public async Task ExecuteAsync() + { + using MSBuildLoggerProvider loggerProvider = new(Log); + ILoggerFactory msbuildLoggerFactory = new LoggerFactory(new[] { loggerProvider }); + ILogger logger = msbuildLoggerFactory.CreateLogger(); + var store = new ContentStore(new(ContentStore)); + var registry = new Registry(Registry, logger, RegistryMode.Pull, store: store); + + var layerDownloadTasks = new List(Layers.Length); + foreach (var layer in Layers) + { + var storagePath = layer.ItemSpec; + var digest = layer.GetMetadata("Digest"); + var size = layer.GetMetadata("Size"); + var mediaType = layer.GetMetadata("MediaType"); + + if (string.IsNullOrEmpty(digest) || string.IsNullOrEmpty(size) || string.IsNullOrEmpty(mediaType)) + { + logger.LogError(new EventId(123456, "layer_item_validation_failure"), $"Layer {layer.ItemSpec} must have Digest, Size, and MediaType metadata"); + } + + var descriptor = new Descriptor(mediaType, digest, long.Parse(size)); + layerDownloadTasks.Add( + registry.DownloadBlobAsync(Repository, descriptor, _cts.Token).ContinueWith(t => + { + if (t.IsFaulted) + { + logger.LogError(new EventId(123457, "layer_download_failure"), exception: t.Exception, $"Failed to download layer {digest} from {Registry}/{Repository}"); + } + else if (t.IsCanceled) + { + logger.LogError(new EventId(123457, "layer_download_failure"), exception: t.Exception, $"Failed to download layer {digest} from {Registry}/{Repository}"); + } + }) + ); + } + await Task.WhenAll(layerDownloadTasks); + return !Log.HasLoggedErrors; + } +} diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/MakeContainerTarball.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/MakeContainerTarball.cs new file mode 100644 index 000000000000..62ca318a1e49 --- /dev/null +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/MakeContainerTarball.cs @@ -0,0 +1,111 @@ +// 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 System.Text.Json.Nodes; +using Microsoft.Build.Framework; +using Microsoft.Extensions.Logging; +using Microsoft.NET.Build.Containers.Logging; +using Microsoft.NET.Build.Containers.Resources; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Microsoft.NET.Build.Containers.Tasks; + +public class MakeContainerTarball : Microsoft.Build.Utilities.Task, ICancelableTask +{ + private CancellationTokenSource _cts = new(); + + [Required] + public string ArchivePath { get; set; } = null!; + + [Required] + public string Repository { get; set; } = string.Empty; + + [Required] + public string[] Tags { get; set; } = []; + + [Required] + public ITaskItem Manifest { get; set; } = null!; + + [Required] + public ITaskItem Configuration { get; set; } = null!; + + [Required] + public ITaskItem[] Layers { get; set; } = []; + + [Output] + public string GeneratedArchiveFilePath { get; set; } = null!; + + + public void Cancel() => _cts.Cancel(); + + public override bool Execute() => ExecuteAsync().GetAwaiter().GetResult(); + + public async Task ExecuteAsync() + { + using MSBuildLoggerProvider loggerProvider = new(Log); + ILoggerFactory msbuildLoggerFactory = new LoggerFactory(new[] { loggerProvider }); + ILogger logger = msbuildLoggerFactory.CreateLogger(); + (long manifestSize, string manifestDigest, ManifestV2 manifestStructure) = await ReadManifest(); + var configDigest = manifestStructure.Config.digest; + var config = await JsonSerializer.DeserializeAsync(File.OpenRead(Configuration.ItemSpec), cancellationToken: _cts.Token); + var layers = Layers.Select(l => Layer.FromBackingFile(new(l.ItemSpec), GetDescriptor(l))).ToArray(); + var filePath = DetermineFilePath(); + await using var fileStream = File.Create(filePath); + GeneratedArchiveFilePath = filePath; + var telemetry = new Telemetry(new(null, null, null, Telemetry.LocalStorageType.Tarball), Log); + await DockerCli.WriteImageToStreamAsync(Repository, Tags, config!, layers, manifestStructure, fileStream, _cts.Token); + telemetry.LogPublishSuccess(); + return true; + } + + private Descriptor GetDescriptor(ITaskItem item) + { + var mediaType = item.GetMetadata("MediaType"); + var digest = item.GetMetadata("Digest"); + var size = long.Parse(item.GetMetadata("Size")!); + return new Descriptor + { + MediaType = mediaType, + Digest = digest, + Size = size + }; + } + + private async Task<(long size, string digest, ManifestV2 manifest)> ReadManifest() + { + var size = long.Parse(Manifest.GetMetadata("Size")!); + var digest = Manifest.GetMetadata("Digest")!; + var manifestStructure = await JsonSerializer.DeserializeAsync(File.OpenRead(Manifest.ItemSpec), cancellationToken: _cts.Token); + return (size, digest, manifestStructure!); + } + + private string DetermineFilePath() + { + + var fullPath = Path.GetFullPath(ArchivePath); + + var directorySeparatorChar = Path.DirectorySeparatorChar; + + // if doesn't end with a file extension, assume it's a directory + if (!Path.HasExtension(fullPath)) + { + fullPath += Path.DirectorySeparatorChar; + } + + // pointing to a directory? -> append default name + if (fullPath.EndsWith(directorySeparatorChar)) + { + fullPath = Path.Combine(fullPath, Repository + ".tar.gz"); + } + + // create parent directory if required. + var parentDirectory = Path.GetDirectoryName(fullPath); + if (parentDirectory != null && !Directory.Exists(parentDirectory)) + { + Directory.CreateDirectory(parentDirectory); + } + + return fullPath; + } +} diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/PushContainerToLocal.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/PushContainerToLocal.cs new file mode 100644 index 000000000000..ac1938573191 --- /dev/null +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/PushContainerToLocal.cs @@ -0,0 +1,100 @@ +// 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 System.Text.Json.Nodes; +using Microsoft.Build.Framework; +using Microsoft.Extensions.Logging; +using Microsoft.NET.Build.Containers.Logging; +using Microsoft.NET.Build.Containers.Resources; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Microsoft.NET.Build.Containers.Tasks; + +public class PushContainerToLocal : Microsoft.Build.Utilities.Task, ICancelableTask +{ + private CancellationTokenSource _cts = new(); + + public string? LocalRegistry { get; set; } + + [Required] + public string Repository { get; set; } = string.Empty; + + [Required] + public string[] Tags { get; set; } = []; + + [Required] + public ITaskItem Manifest { get; set; } = null!; + + [Required] + public ITaskItem Configuration { get; set; } = null!; + + [Required] + public ITaskItem[] Layers { get; set; } = []; + + + public void Cancel() => _cts.Cancel(); + + public override bool Execute() => ExecuteAsync().GetAwaiter().GetResult(); + + public async Task ExecuteAsync() + { + using MSBuildLoggerProvider loggerProvider = new(Log); + ILoggerFactory msbuildLoggerFactory = new LoggerFactory(new[] { loggerProvider }); + ILogger logger = msbuildLoggerFactory.CreateLogger(); + (long manifestSize, string manifestDigest, ManifestV2 manifestStructure) = await ReadManifest(); + var configDigest = manifestStructure.Config.digest; + var config = await JsonSerializer.DeserializeAsync(File.OpenRead(Configuration.ItemSpec), cancellationToken: _cts.Token); + var containerCli = new DockerCli(LocalRegistry, msbuildLoggerFactory); + + var telemetry = new Telemetry(new(null, null, null, containerCli.IsDocker ? Telemetry.LocalStorageType.Docker : Telemetry.LocalStorageType.Podman), Log); + if (!await containerCli.IsAvailableAsync(_cts.Token).ConfigureAwait(false)) + { + telemetry.LogMissingLocalBinary(); + Log.LogErrorWithCodeFromResources(nameof(Strings.LocalRegistryNotAvailable)); + return false; + } + + var layers = Layers.Select(l => Layer.FromBackingFile(new(l.ItemSpec), GetDescriptor(l))).ToArray(); + try + { + await containerCli.LoadAsync((Repository, Tags, configDigest, config!, layers), DockerCli.WriteDockerImageToStreamAsync, _cts.Token); + } + catch (AggregateException ex) when (ex.InnerException is DockerLoadException dle) + { + telemetry.LogLocalLoadError(); + Log.LogErrorFromException(dle, showStackTrace: false); + } + catch (ArgumentException argEx) + { + Log.LogErrorFromException(argEx, showStackTrace: false); + } + catch (DockerLoadException dle) + { + telemetry.LogLocalLoadError(); + Log.LogErrorFromException(dle, showStackTrace: false); + } + return true; + } + + private Descriptor GetDescriptor(ITaskItem item) + { + var mediaType = item.GetMetadata("MediaType"); + var digest = item.GetMetadata("Digest"); + var size = long.Parse(item.GetMetadata("Size")!); + return new Descriptor + { + MediaType = mediaType, + Digest = digest, + Size = size + }; + } + + private async Task<(long size, string digest, ManifestV2 manifest)> ReadManifest() + { + var size = long.Parse(Manifest.GetMetadata("Size")!); + var digest = Manifest.GetMetadata("Digest")!; + var manifestStructure = await JsonSerializer.DeserializeAsync(File.OpenRead(Manifest.ItemSpec), cancellationToken: _cts.Token); + return (size, digest, manifestStructure!); + } +} diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/PushContainerToRemoteRegistry.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/PushContainerToRemoteRegistry.cs new file mode 100644 index 000000000000..a37e99640604 --- /dev/null +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/PushContainerToRemoteRegistry.cs @@ -0,0 +1,152 @@ +// 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.Build.Framework; +using Microsoft.Extensions.Logging; +using Microsoft.NET.Build.Containers.Logging; +using Microsoft.NET.Build.Containers.Resources; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Microsoft.NET.Build.Containers.Tasks; + +public class PushContainerToRemoteRegistry : Microsoft.Build.Utilities.Task, ICancelableTask +{ + private CancellationTokenSource _cts = new(); + + [Required] + public string Registry { get; set; } = string.Empty; + + [Required] + public string Repository { get; set; } = string.Empty; + + [Required] + public string[] Tags { get; set; } = []; + + [Required] + public ITaskItem Manifest { get; set; } = null!; + + [Required] + public ITaskItem Configuration { get; set; } = null!; + + [Required] + public ITaskItem[] Layers { get; set; } = []; + + public void Cancel() => _cts.Cancel(); + + public override bool Execute() => ExecuteAsync().GetAwaiter().GetResult(); + + public async Task ExecuteAsync() + { + using MSBuildLoggerProvider loggerProvider = new(Log); + ILoggerFactory msbuildLoggerFactory = new LoggerFactory(new[] { loggerProvider }); + ILogger logger = msbuildLoggerFactory.CreateLogger(nameof(PushContainerToRemoteRegistry)); + var destinationRegistry = new Registry(Registry, msbuildLoggerFactory.CreateLogger(Registry), RegistryMode.Push); + + var telemetry = new Telemetry(new(null, null, Telemetry.GetRegistryType(destinationRegistry), null), Log); + // functionally, we need to + // * upload the layers + var layerUploadTasks = Layers.Select(l => new Layer(new(l.ItemSpec), GetDescriptor(l))).Select(async l => + { + using var _layerScope = logger.BeginScope(new Dictionary + { + ["Layer"] = l.Descriptor.Digest, + ["Repository"] = Repository, + ["MediaType"] = l.Descriptor.MediaType, + ["Digest"] = l.Descriptor.Digest, + ["Size"] = l.Descriptor.Size + }); + logger.LogTrace($"Pushing layer to {Registry}."); + await destinationRegistry.PushLayerAsync(l, Repository, _cts.Token); + }).ToArray(); + await Task.WhenAll(layerUploadTasks); + + // * upload the config + var (size, digest, manifestStructure) = await ReadManifest().ConfigureAwait(false); + _cts.Token.ThrowIfCancellationRequested(); + using (logger.BeginScope(new Dictionary + { + ["Repository"] = Repository, + ["MediaType"] = Configuration.GetMetadata("MediaType")!, + ["Digest"] = Configuration.GetMetadata("Digest")!, + ["Size"] = Configuration.GetMetadata("Size")! + })) + { + logger.LogTrace($"Pushing config to {Registry}."); + var configText = await File.ReadAllTextAsync(Configuration.ItemSpec, _cts.Token); + var configBytes = DigestUtils.UTF8.GetBytes(configText); + var configDigest = DigestUtils.GetDigest(configText); + var msbuildConfigDigest = Configuration.GetMetadata("Digest")!; + + using (MemoryStream configStream = new(configBytes)) + { + logger.LogInformation(Strings.Registry_ConfigUploadStarted, manifestStructure.Config.digest); + await destinationRegistry.UploadBlobAsync(Repository, manifestStructure.Config.digest, configStream, _cts.Token); + logger.LogInformation(Strings.Registry_ConfigUploaded); + } + } + + using (logger.BeginScope(new Dictionary + { + ["Repository"] = Repository, + ["MediaType"] = Manifest.GetMetadata("MediaType")!, + ["Digest"] = Manifest.GetMetadata("Digest")!, + ["Size"] = Manifest.GetMetadata("Size")! + })) + { + if (manifestStructure.GetDigest() != Manifest.GetMetadata("Digest")) + { + logger.LogError($"Manifest digest {Manifest.GetMetadata("Digest")} does not match the computed digest {manifestStructure.GetDigest()} from the manifest file itself."); + } + if (manifestStructure.GetDigest() != DigestUtils.GetDigest(manifestStructure)) + { + logger.LogError($"Manifest digest {manifestStructure.GetDigest()} does not match the computed digest {DigestUtils.GetDigest(manifestStructure)} from the manifest structure itself."); + } + // * upload the manifest as a digest + _cts.Token.ThrowIfCancellationRequested(); + logger.LogInformation(Strings.Registry_ManifestUploadStarted, Registry, manifestStructure.GetDigest()); + await destinationRegistry.UploadManifestAsync(Repository, Manifest.GetMetadata("Digest"), manifestStructure, _cts.Token); + logger.LogInformation(Strings.Registry_ManifestUploaded, Registry); + } + + // * upload the manifest as tags + foreach (var tag in Tags) + { + using var _manifestTagScope = logger.BeginScope(new Dictionary + { + ["Repository"] = Repository, + ["MediaType"] = Manifest.GetMetadata("MediaType")!, + ["Digest"] = Manifest.GetMetadata("Digest")!, + ["Size"] = Manifest.GetMetadata("Size")!, + ["Tag"] = tag + }); + _cts.Token.ThrowIfCancellationRequested(); + logger.LogInformation(Strings.Registry_TagUploadStarted, tag, Registry); + await destinationRegistry.UploadManifestAsync(Repository, tag, manifestStructure, _cts.Token); + logger.LogInformation(Strings.Registry_TagUploaded, tag, Registry); + } + telemetry.LogPublishSuccess(); + return true; + } + + private Descriptor GetDescriptor(ITaskItem item) + { + var mediaType = item.GetMetadata("MediaType"); + var digest = item.GetMetadata("Digest"); + var size = long.Parse(item.GetMetadata("Size")!); + return new Descriptor + { + MediaType = mediaType, + Digest = digest, + Size = size + }; + } + + private async Task<(long size, string digest, ManifestV2 manifest)> ReadManifest() + { + var size = long.Parse(Manifest.GetMetadata("Size")!); + var digest = Manifest.GetMetadata("Digest")!; + var manifestStructure = await JsonSerializer.DeserializeAsync(File.OpenRead(Manifest.ItemSpec), cancellationToken: _cts.Token); + return (size, digest, manifestStructure!); + } +} diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/SelectRuntimeIdentifierSpecificItems.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/SelectRuntimeIdentifierSpecificItems.cs new file mode 100644 index 000000000000..72288f6155c2 --- /dev/null +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/SelectRuntimeIdentifierSpecificItems.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Framework; +using Microsoft.Extensions.Logging; +using Microsoft.NET.Build.Containers.Logging; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Microsoft.NET.Build.Containers.Tasks; + +public class SelectRuntimeIdentifierSpecificItems : Microsoft.Build.Utilities.Task, ICancelableTask +{ + private CancellationTokenSource _cts = new(); + + [Required] + public string TargetRuntimeIdentifier { get; set; } = string.Empty; + + [Required] + public ITaskItem[] Items { get; set; } = []; + + [Required] + public string RuntimeIdentifierGraphPath { get; set; } = string.Empty; + + [Output] + public ITaskItem[] SelectedItems { get; set; } = []; + + public void Cancel() => _cts.Cancel(); + + public override bool Execute() + { + using MSBuildLoggerProvider loggerProvider = new(Log); + ILoggerFactory msbuildLoggerFactory = new LoggerFactory(new[] { loggerProvider }); + ILogger logger = msbuildLoggerFactory.CreateLogger(); + var graph = NuGet.RuntimeModel.JsonRuntimeFormat.ReadRuntimeGraph(RuntimeIdentifierGraphPath); + + var selectedItems = new List(Items.Length); + foreach (var item in Items) + { + if (item.GetMetadata("RuntimeIdentifier") is string ridValue && + graph.AreCompatible(TargetRuntimeIdentifier, ridValue)) + { + selectedItems.Add(item); + } + } + + // TODO: log if no items were selected + // TODO: log telemetry.LogRidMismatch + + SelectedItems = selectedItems.ToArray(); + return true; + } + + +} diff --git a/src/Containers/Microsoft.NET.Build.Containers/Telemetry.cs b/src/Containers/Microsoft.NET.Build.Containers/Telemetry.cs index 009b93d7717c..ef86289fe46d 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Telemetry.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Telemetry.cs @@ -15,9 +15,9 @@ internal class Telemetry /// If the base image came from a local store of some kind, what kind of store was it? /// If the new image is being pushed to a remote registry, what kind of registry is it? /// If the new image is being stored in a local store of some kind, what kind of store is it? - private record class PublishTelemetryContext(RegistryType? RemotePullType, LocalStorageType? LocalPullType, RegistryType? RemotePushType, LocalStorageType? LocalPushType); - private enum RegistryType { Azure, AWS, Google, GitHub, DockerHub, MCR, Other } - private enum LocalStorageType { Docker, Podman, Tarball } + internal record class PublishTelemetryContext(RegistryType? RemotePullType, LocalStorageType? LocalPullType, RegistryType? RemotePushType, LocalStorageType? LocalPushType); + internal enum RegistryType { Azure, AWS, Google, GitHub, DockerHub, MCR, Other } + internal enum LocalStorageType { Docker, Podman, Tarball } private readonly Microsoft.Build.Utilities.TaskLoggingHelper Log; private readonly PublishTelemetryContext Context; @@ -25,9 +25,9 @@ private enum LocalStorageType { Docker, Podman, Tarball } internal Telemetry( SourceImageReference source, DestinationImageReference destination, - Microsoft.Build.Utilities.TaskLoggingHelper Log) + Microsoft.Build.Utilities.TaskLoggingHelper log) { - this.Log = Log; + Log = log; Context = new PublishTelemetryContext( source.Registry is not null ? GetRegistryType(source.Registry) : null, null, // we don't support local pull yet, but we may in the future @@ -35,7 +35,13 @@ internal Telemetry( destination.LocalRegistry is not null ? GetLocalStorageType(destination.LocalRegistry) : null); } - private RegistryType GetRegistryType(Registry r) + internal Telemetry(PublishTelemetryContext context, Microsoft.Build.Utilities.TaskLoggingHelper log) + { + Log = log; + Context = context; + } + + internal static RegistryType GetRegistryType(Registry r) { if (r.IsMcr) return RegistryType.MCR; if (r.IsGithubPackageRegistry) return RegistryType.GitHub; @@ -113,4 +119,4 @@ public void LogLocalLoadError() props.Add("error", "local_load"); Log.LogTelemetry("sdk/container/publish/error", props); } -} \ No newline at end of file +} diff --git a/src/Containers/containerize/ContainerizeCommand.cs b/src/Containers/containerize/ContainerizeCommand.cs index 8da04309a921..32a8d6e90771 100644 --- a/src/Containers/containerize/ContainerizeCommand.cs +++ b/src/Containers/containerize/ContainerizeCommand.cs @@ -12,10 +12,42 @@ namespace containerize; internal class ContainerizeCommand : RootCommand { - internal Argument PublishDirectoryArgument { get; } = new Argument("PublishDirectory") + // internal Argument PublishDirectoryArgument { get; } = new Argument("PublishDirectory") + // { + // Description = "The directory for the build outputs to be published." + // }.AcceptExistingOnly(); + + internal Option<(string absolutefilePath, string relativePath)[]> InputFilesOption { get; } = new("--input-file") { - Description = "The directory for the build outputs to be published." - }.AcceptExistingOnly(); + Description = "Specify once per file in the container, in the format '='", + Required = true, + Arity = ArgumentArity.OneOrMore, + CustomParser = result => + { + var maps = new List<(string absolutefilePath, string relativePath)>(result.Tokens.Count); + foreach (var token in result.Tokens) + { + var parts = token.Value.Split('=', StringSplitOptions.TrimEntries); + if (parts.Length != 2) + { + result.AddError($"Invalid input file format: '{token.Value}'. Expected format is '='."); + continue; + } + if (!Path.IsPathRooted(parts[0])) + { + result.AddError($"The absolute path '{parts[0]}' is not rooted. Please provide a valid absolute path."); + continue; + } + if (string.IsNullOrWhiteSpace(parts[1])) + { + result.AddError("The relative path inside the container working directory cannot be empty."); + continue; + } + maps.Add((absolutefilePath: parts[0], relativePath: parts[1])); + } + return maps.ToArray(); + } + }; internal Option BaseRegistryOption { get; } = new("--baseregistry") { @@ -185,10 +217,6 @@ internal class ContainerizeCommand : RootCommand AllowMultipleArgumentsPerToken = true }; - internal Option RidOption { get; } = new("--rid") { Description = "Runtime Identifier of the generated container." }; - - internal Option RidGraphPathOption { get; } = new("--ridgraphpath") { Description = "Path to the RID graph file." }; - internal Option ContainerUserOption { get; } = new("--container-user") { Description = "User to run the container as." }; internal Option GenerateLabelsOption { get; } = new("--generate-labels") @@ -208,10 +236,50 @@ internal class ContainerizeCommand : RootCommand Description = "If set to OCI or Docker will force the generated image to be that format. If unset, the base images format will be used." }; + internal Option ContentStoreRootOption { get; } = new("--content-store-root") + { + Description = "The path to the content store root. This is used to compute RID compatibility for Image Manifest List entries.", + Required = true + }; + + internal Option BaseImageManifestFileOption { get; } = new("--base-image-manifest") + { + Description = "The path to the local manifest of the base image, selected earlier on in the build process", + Required = true, + Arity = ArgumentArity.ExactlyOne + }; + + internal Option BaseImageConfigFileOption { get; } = new("--base-image-config") + { + Description = "The path to the local container configuration of the base image, selected earlier on in the build process", + Required = true, + Arity = ArgumentArity.ExactlyOne + }; + + internal Option GeneratedManifestPathOption { get; } = new("--generated-manifest") + { + Description = "The path to the generated manifest file.", + Required = true, + Arity = ArgumentArity.ExactlyOne + }; + + internal Option GeneratedConfigurationPathOption { get; } = new("--generated-configuration") + { + Description = "The path to the generated configuration file.", + Required = true, + Arity = ArgumentArity.ExactlyOne + }; + + internal Option GeneratedLayerPathOption { get; } = new("--generated-layer") + { + Description = "The path to the generated layer file.", + Required = true, + Arity = ArgumentArity.ExactlyOne + }; + internal ContainerizeCommand() : base("Containerize an application without Docker.") { - PublishDirectoryArgument.AcceptLegalFilePathsOnly(); - Arguments.Add(PublishDirectoryArgument); + Options.Add(InputFilesOption); Options.Add(BaseRegistryOption); Options.Add(BaseImageNameOption); Options.Add(BaseImageTagOption); @@ -230,18 +298,19 @@ internal ContainerizeCommand() : base("Containerize an application without Docke Options.Add(LabelsOption); Options.Add(PortsOption); Options.Add(EnvVarsOption); - Options.Add(RidOption); - Options.Add(RidGraphPathOption); LocalRegistryOption.AcceptOnlyFromAmong(KnownLocalRegistryTypes.SupportedLocalRegistryTypes); Options.Add(LocalRegistryOption); Options.Add(ContainerUserOption); Options.Add(GenerateLabelsOption); Options.Add(GenerateDigestLabelOption); Options.Add(ImageFormatOption); + Options.Add(ContentStoreRootOption); + Options.Add(BaseImageManifestFileOption); + Options.Add(BaseImageConfigFileOption); SetAction(async (parseResult, cancellationToken) => { - DirectoryInfo _publishDir = parseResult.GetValue(PublishDirectoryArgument)!; + var _inputFiles = parseResult.GetValue(InputFilesOption)!; string _baseReg = parseResult.GetValue(BaseRegistryOption)!; string _baseName = parseResult.GetValue(BaseImageNameOption)!; string _baseTag = parseResult.GetValue(BaseImageTagOption)!; @@ -260,13 +329,17 @@ internal ContainerizeCommand() : base("Containerize an application without Docke Dictionary _labels = parseResult.GetValue(LabelsOption) ?? new Dictionary(); Port[]? _ports = parseResult.GetValue(PortsOption); Dictionary _envVars = parseResult.GetValue(EnvVarsOption) ?? new Dictionary(); - string _rid = parseResult.GetValue(RidOption)!; - string _ridGraphPath = parseResult.GetValue(RidGraphPathOption)!; string _localContainerDaemon = parseResult.GetValue(LocalRegistryOption)!; string? _containerUser = parseResult.GetValue(ContainerUserOption); bool _generateLabels = parseResult.GetValue(GenerateLabelsOption); bool _generateDigestLabel = parseResult.GetValue(GenerateDigestLabelOption); KnownImageFormats? _imageFormat = parseResult.GetValue(ImageFormatOption); + string _contentStoreRoot = parseResult.GetValue(ContentStoreRootOption)!; + FileInfo _baseImageManifestFile = parseResult.GetValue(BaseImageManifestFileOption)!; + FileInfo _baseImageConfigFile = parseResult.GetValue(BaseImageConfigFileOption)!; + FileInfo _generatedManifestPath = parseResult.GetValue(GeneratedManifestPathOption)!; + FileInfo _generatedConfigPath = parseResult.GetValue(GeneratedConfigurationPathOption)!; + FileInfo _generatedLayerPath = parseResult.GetValue(GeneratedLayerPathOption)!; //setup basic logging bool traceEnabled = Env.GetEnvironmentVariableAsBool("CONTAINERIZE_TRACE_LOGGING_ENABLED"); @@ -274,7 +347,7 @@ internal ContainerizeCommand() : base("Containerize an application without Docke using ILoggerFactory loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(c => c.ColorBehavior = LoggerColorBehavior.Disabled).SetMinimumLevel(verbosity)); await ContainerBuilder.ContainerizeAsync( - _publishDir, + _inputFiles, _workingDir, _baseReg, _baseName, @@ -292,14 +365,18 @@ await ContainerBuilder.ContainerizeAsync( _labels, _ports, _envVars, - _rid, - _ridGraphPath, _localContainerDaemon, _containerUser, _archiveOutputPath, _generateLabels, _generateDigestLabel, _imageFormat, + _contentStoreRoot, + _baseImageManifestFile, + _baseImageConfigFile, + _generatedManifestPath, + _generatedConfigPath, + _generatedLayerPath, loggerFactory, cancellationToken).ConfigureAwait(false); }); diff --git a/src/Containers/packaging/build/Microsoft.NET.Build.Containers.props b/src/Containers/packaging/build/Microsoft.NET.Build.Containers.props index 82178233188c..1503622fdd10 100644 --- a/src/Containers/packaging/build/Microsoft.NET.Build.Containers.props +++ b/src/Containers/packaging/build/Microsoft.NET.Build.Containers.props @@ -14,8 +14,14 @@ - + + + + + - + + + diff --git a/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets b/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets index ae67c5c24fce..1d92d4e018c4 100644 --- a/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets +++ b/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets @@ -17,6 +17,7 @@ <_ContainerIsSelfContained Condition="'$(SelfContained)' == 'true' or '$(PublishSelfContained)' == 'true'">true true + $([System.IO.Path]::GetTempPath()) @@ -121,7 +122,8 @@ true - true + + false true true true @@ -183,10 +185,11 @@ _ContainerVerifySDKVersion; ComputeContainerConfig; _CheckContainersPackage; + _EnsureManifestsAvailable; - @@ -249,9 +252,116 @@ Text="The $(_ContainersPackageIdentity) NuGet package is explicitly referenced but the current SDK can natively publish the project as a container. Consider removing the package reference to $(_ContainersPackageIdentity) because it is no longer needed." /> + + + + <_ContainerBaseImageManifestDataCacheFile>$(IntermediateOutputPath)$(MSBuildProjectName).ContainerManifestData + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_ContainerGeneratedConfigTrackingFile>$(IntermediateOutputPath)$(MSBuildProjectName).GeneratedContainerConfig + + + + + + + + + + <_ContainerGeneratedManifestPath>$(IntermediateOutputPath)container.manifest.json + <_ContainerGeneratedConfigurationPath>$(IntermediateOutputPath)container.config.json + <_ContainerGeneratedLayerPath>$(IntermediateOutputPath)container.layer.tar.gz + + + + + + + <_ContainerPublishItemBase Include="$(PublishDir)/**/*.*" /> + + <_ContainerPublishItem Include="@(_ContainerPublishItemBase)"> + $([MSBuild]::MakeRelative($(PublishDir), %(_ContainerPublishItemBase.FullPath))) + + + + - + + $(NetCoreRoot) dotnet @@ -265,13 +375,15 @@ BaseImageName="$(ContainerBaseName)" BaseImageTag="$(ContainerBaseTag)" BaseImageDigest="$(ContainerBaseDigest)" + BaseImageManifestPath="@(_BaseContainerThisArchManifest)" + BaseImageConfigurationPath="@(_BaseContainerThisArchConfig)" ImageFormat="$(ContainerImageFormat)" LocalRegistry="$(LocalRegistry)" OutputRegistry="$(ContainerRegistry)" ArchiveOutputPath="$(ContainerArchiveOutputPath)" Repository="$(ContainerRepository)" ImageTags="@(ContainerImageTags)" - PublishDirectory="$(PublishDir)" + PublishFiles="@(_ContainerPublishItem)" WorkingDirectory="$(ContainerWorkingDirectory)" Entrypoint="@(ContainerEntrypoint)" EntrypointArgs="@(ContainerEntrypointArgs)" @@ -282,33 +394,100 @@ Labels="@(ContainerLabel)" ExposedPorts="@(ContainerPort)" ContainerEnvironmentVariables="@(ContainerEnvironmentVariables)" - ContainerRuntimeIdentifier="$(ContainerRuntimeIdentifier)" ContainerUser="$(ContainerUser)" - RuntimeIdentifierGraphPath="$(RuntimeIdentifierGraphPath)" - SkipPublishing="$(_SkipContainerPublishing)" GenerateLabels="$(ContainerGenerateLabels)" - GenerateDigestLabel="$(ContainerGenerateLabelsImageBaseDigest)"> + GenerateDigestLabel="$(ContainerGenerateLabelsImageBaseDigest)" + ContentStoreRoot="$(ContainerContentStoreRoot)" + GeneratedManifestPath="$(_ContainerGeneratedManifestPath)" + GeneratedConfigurationPath="$(_ContainerGeneratedConfigurationPath)" + GeneratedLayerPath="$(_ContainerGeneratedLayerPath)"> - - - - + + + + + $(RuntimeIdentifier) + + + $(RuntimeIdentifier) + + + $(RuntimeIdentifier) + + + + + - $(GeneratedContainerManifest) - $(GeneratedContainerConfiguration) - $(GeneratedContainerDigest) - $(GeneratedContainerMediaType) + $(_ContainerGeneratedManifestPath) + $(_ContainerGeneratedConfigurationPath) + $(_ContainerGeneratedLayerPath) - + + + <_ContainerSinglePublishTrackingFile>$(IntermediateOutputPath)container.SingleContainerPublish + + + + + + + + + + + + + + + + + + + + + + @@ -326,7 +505,9 @@ <_SingleImageContainerFormat Condition="$(_SkipContainerPublishing) == 'true' ">OCI + + <_rids Include="$(ContainerRuntimeIdentifiers)" Condition="'$(ContainerRuntimeIdentifiers)' != ''" /> <_rids Include="$(RuntimeIdentifiers)" Condition="'$(ContainerRuntimeIdentifiers)' == '' and '$(RuntimeIdentifiers)' != ''" /> @@ -347,7 +528,6 @@ _ContainerEnvironmentVariables=@(ContainerEnvironmentVariable->'%(Identity):%(Value)'); ContainerGenerateLabels=$(ContainerGenerateLabels); ContainerGenerateLabelsImageBaseDigest=$(ContainerGenerateLabelsImageBaseDigest); - _SkipContainerPublishing=$(_SkipContainerPublishing); ContainerImageFormat=$(_SingleImageContainerFormat); _IsMultiRIDBuild=false; _IsSingleRIDBuild=true; @@ -356,13 +536,35 @@ <_rids Remove ="$(_rids)" /> + + <_InnerContainerBuildTargets>Publish;_ParseItemsForPublishingSingleContainer;_ComputeContainerExecutionArgs; + <_InnerContainerBuildTargets Condition="'$(_SkipContainerPublishing)' == 'true'">$(_InnerContainerBuildTargets);_PublishSingleContainer + <_InnerContainerBuildTargets Condition="'$(_SkipContainerPublishing)' != 'true'">$(_InnerContainerBuildTargets);_CreateSingleContainer + + + + + + + <_MultiArchManifestInputs Include="@(GeneratedContainer->'%(ManifestPath)');@(GeneratedContainer->'%(ConfigurationPath)')" /> + + + <_MultiArchManifestFilePath>$(IntermediateOutputPath)multiarchcontainer.manifest + + + + + BaseImageDigest="$(ContainerBaseDigest)" + GeneratedManifestPath="$(_MultiArchManifestFilePath)"> + + + @@ -431,8 +637,8 @@ Condition="'$(IsPublishable)' == 'true' AND '$(EnableSdkContainerSupport)' == 'true'" Returns="@(GeneratedContainer)" > - - + + diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/ArchiveFileRegistryTests.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/ArchiveFileRegistryTests.cs index e78fb6854005..afbbf10f72cf 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/ArchiveFileRegistryTests.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/ArchiveFileRegistryTests.cs @@ -14,9 +14,9 @@ public async Task ArchiveOutputPathIsExistingDirectory_CreatesFileWithRepository string expectedCreatedFilePath = Path.Combine(TestSettings.TestArtifactsDirectory, "repository.tar.gz"); await CreateRegistryAndCallLoadAsync(archiveOutputPath); - - Assert.True(File.Exists(expectedCreatedFilePath)); - } + + Assert.True(File.Exists(expectedCreatedFilePath)); + } [Theory] [InlineData(true)] @@ -29,8 +29,8 @@ public async Task ArchiveOutputPathIsNonExistingDirectory_CreatesDirectoryAndFil string expectedCreatedFilePath = Path.Combine(archiveOutputPath, "repository.tar.gz"); await CreateRegistryAndCallLoadAsync(archiveOutputPath); - - Assert.True(File.Exists(expectedCreatedFilePath)); + + Assert.True(File.Exists(expectedCreatedFilePath)); } [Fact] @@ -40,8 +40,8 @@ public async Task ArchiveOutputPathIsCustomFileNameInExistingDirectory_CreatesFi string expectedCreatedFilePath = archiveOutputPath; await CreateRegistryAndCallLoadAsync(archiveOutputPath); - - Assert.True(File.Exists(expectedCreatedFilePath)); + + Assert.True(File.Exists(expectedCreatedFilePath)); } [Fact] @@ -51,8 +51,8 @@ public async Task ArchiveOutputPathIsCustomFileNameInNonExistingDirectory_Create string expectedCreatedFilePath = archiveOutputPath; await CreateRegistryAndCallLoadAsync(archiveOutputPath); - - Assert.True(File.Exists(expectedCreatedFilePath)); + + Assert.True(File.Exists(expectedCreatedFilePath)); } private async Task CreateRegistryAndCallLoadAsync(string archiveOutputPath) @@ -65,9 +65,10 @@ await registry.LoadAsync( new SourceImageReference(), destinationImageReference, CancellationToken.None, - async (img, srcRef, destRef, stream, token) => + async ((string img, SourceImageReference srcRef, DestinationImageReference destRef) pushData, Stream stream, CancellationToken token) => + { await Task.CompletedTask; }); } -} \ No newline at end of file +} diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateImageIndexTests.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateImageIndexTests.cs index 2615b2e178ee..5b390e97dfd2 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateImageIndexTests.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateImageIndexTests.cs @@ -84,6 +84,20 @@ private DirectoryInfo CreateNewProject() return newProjectDir; } + private static ITaskItem[] MakeItemsForPublishDir(string publishDir) + { + var files = Directory.GetFiles(publishDir, "*", new EnumerationOptions() + { + RecurseSubdirectories=true + }); + + return files.Select(f => new TaskItem(f, new Dictionary + { + ["RelativePath"] = Path.GetRelativePath(publishDir, f) + })).ToArray(); + } + + private TaskItem PublishAndCreateNewImage( string rid, string outputRegistry, @@ -108,13 +122,11 @@ private TaskItem PublishAndCreateNewImage( cni.OutputRegistry = outputRegistry; cni.LocalRegistry = DockerAvailableFactAttribute.LocalRegistry; - cni.PublishDirectory = Path.Combine(newProjectDir.FullName, "bin", "Release", ToolsetInfo.CurrentTargetFramework, rid, "publish"); + cni.PublishFiles = MakeItemsForPublishDir(Path.Combine(newProjectDir.FullName, "bin", "Release", ToolsetInfo.CurrentTargetFramework, rid, "publish")); cni.Repository = repository; cni.ImageTags = tags.Select(t => $"{t}-{rid}").ToArray(); cni.WorkingDirectory = "app/"; - cni.ContainerRuntimeIdentifier = rid; cni.Entrypoint = new TaskItem[] { new("dotnet"), new("build") }; - cni.RuntimeIdentifierGraphPath = ToolsetUtils.GetRuntimeGraphFilePath(); Assert.True(cni.Execute(), FormatBuildMessages(errors)); diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateNewImageTests.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateNewImageTests.cs index b00de74ceaa0..8f8bc8a27bbf 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateNewImageTests.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateNewImageTests.cs @@ -53,18 +53,30 @@ public void CreateNewImage_Baseline() task.OutputRegistry = "localhost:5010"; task.LocalRegistry = DockerAvailableFactAttribute.LocalRegistry; - task.PublishDirectory = Path.Combine(newProjectDir.FullName, "bin", "Release", ToolsetInfo.CurrentTargetFramework, "linux-arm64", "publish"); + var baseDir = Path.Combine(newProjectDir.FullName, "bin", "Release"); + task.PublishFiles = MakeItemsForPublishDir(baseDir); task.Repository = "dotnet/create-new-image-baseline"; task.ImageTags = new[] { "latest" }; task.WorkingDirectory = "app/"; - task.ContainerRuntimeIdentifier = "linux-arm64"; task.Entrypoint = new TaskItem[] { new("dotnet"), new("build") }; - task.RuntimeIdentifierGraphPath = ToolsetUtils.GetRuntimeGraphFilePath(); - + Assert.True(task.Execute(), FormatBuildMessages(errors)); newProjectDir.Delete(true); } + private static ITaskItem[] MakeItemsForPublishDir(string publishDir) + { + var files = Directory.GetFiles(publishDir, "*", new EnumerationOptions() + { + RecurseSubdirectories=true + }); + + return files.Select(f => new TaskItem(f, new Dictionary + { + ["RelativePath"] = Path.GetRelativePath(publishDir, f) + })).ToArray(); + } + private static ImageConfig GetImageConfigFromTask(CreateNewImage task) { return new(task.GeneratedContainerConfiguration); @@ -119,12 +131,10 @@ public void ParseContainerProperties_EndToEnd() cni.BaseImageTag = pcp.ParsedContainerTag; cni.Repository = pcp.NewContainerRepository; cni.OutputRegistry = "localhost:5010"; - cni.PublishDirectory = Path.Combine(newProjectDir.FullName, "bin", "release", ToolsetInfo.CurrentTargetFramework); + cni.PublishFiles = MakeItemsForPublishDir(Path.Combine(newProjectDir.FullName, "bin", "release", ToolsetInfo.CurrentTargetFramework)); cni.WorkingDirectory = "app/"; cni.Entrypoint = new TaskItem[] { new(newProjectDir.Name) }; cni.ImageTags = pcp.NewContainerTags; - cni.ContainerRuntimeIdentifier = "linux-x64"; - cni.RuntimeIdentifierGraphPath = ToolsetUtils.GetRuntimeGraphFilePath(); Assert.True(cni.Execute(), FormatBuildMessages(errors)); newProjectDir.Delete(true); @@ -193,13 +203,11 @@ public void Tasks_EndToEnd_With_EnvironmentVariable_Validation() cni.BaseImageTag = pcp.ParsedContainerTag; cni.Repository = pcp.NewContainerRepository; cni.OutputRegistry = pcp.NewContainerRegistry; - cni.PublishDirectory = Path.Combine(newProjectDir.FullName, "bin", "release", EndToEndTests._oldFramework, "linux-x64"); + cni.PublishFiles = MakeItemsForPublishDir(Path.Combine(newProjectDir.FullName, "bin", "release", EndToEndTests._oldFramework, "linux-x64")); cni.WorkingDirectory = "/app"; cni.Entrypoint = new TaskItem[] { new($"/app/{newProjectDir.Name}") }; cni.ImageTags = pcp.NewContainerTags; cni.ContainerEnvironmentVariables = pcp.NewContainerEnvironmentVariables; - cni.ContainerRuntimeIdentifier = "linux-x64"; - cni.RuntimeIdentifierGraphPath = ToolsetUtils.GetRuntimeGraphFilePath(); cni.LocalRegistry = DockerAvailableFactAttribute.LocalRegistry; Assert.True(cni.Execute(), FormatBuildMessages(errors)); @@ -276,13 +284,11 @@ public async System.Threading.Tasks.Task CreateNewImage_RootlessBaseImage() task.BaseImageTag = "latest"; task.OutputRegistry = "localhost:5010"; - task.PublishDirectory = Path.Combine(newProjectDir.FullName, "bin", "Release", ToolsetInfo.CurrentTargetFramework, "linux-x64", "publish"); + task.PublishFiles = MakeItemsForPublishDir(Path.Combine(newProjectDir.FullName, "bin", "Release", ToolsetInfo.CurrentTargetFramework, "linux-x64", "publish")); task.Repository = AppImage; task.ImageTags = new[] { "latest" }; task.WorkingDirectory = "app/"; - task.ContainerRuntimeIdentifier = "linux-x64"; task.Entrypoint = new TaskItem[] { new("dotnet"), new("build") }; - task.RuntimeIdentifierGraphPath = ToolsetUtils.GetRuntimeGraphFilePath(); Assert.True(task.Execute()); newProjectDir.Delete(true); @@ -348,12 +354,10 @@ public void CanOverrideContainerImageFormat() cni.BaseImageTag = pcp.ParsedContainerTag; cni.Repository = pcp.NewContainerRepository; cni.OutputRegistry = "localhost:5010"; - cni.PublishDirectory = Path.Combine(newProjectDir.FullName, "bin", "release", ToolsetInfo.CurrentTargetFramework); + cni.PublishFiles = MakeItemsForPublishDir(Path.Combine(newProjectDir.FullName, "bin", "release", ToolsetInfo.CurrentTargetFramework)); cni.WorkingDirectory = "app/"; cni.Entrypoint = new TaskItem[] { new(newProjectDir.Name) }; cni.ImageTags = pcp.NewContainerTags; - cni.ContainerRuntimeIdentifier = "linux-x64"; - cni.RuntimeIdentifierGraphPath = ToolsetUtils.GetRuntimeGraphFilePath(); cni.ImageFormat = KnownImageFormats.OCI.ToString(); diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/EndToEndTests.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/EndToEndTests.cs index 7aefe1ca7791..bc6d658a9787 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/EndToEndTests.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/EndToEndTests.cs @@ -17,11 +17,13 @@ public class EndToEndTests : IDisposable { private ITestOutputHelper _testOutput; private readonly TestLoggerFactory _loggerFactory; + private readonly ContentStore _store; public EndToEndTests(ITestOutputHelper testOutput) { _testOutput = testOutput; _loggerFactory = new TestLoggerFactory(testOutput); + _store = new ContentStore(new(Path.GetTempPath())); } public static string NewImageName([CallerMemberName] string callerMemberName = "") @@ -53,6 +55,17 @@ internal static void ChangeTargetFrameworkAfterAppCreation(string path) File.WriteAllText(Path.Combine(path, csprojFilename), text); } + private static (string absolutefilePath, string relativePath)[] MakeItemsForPublishDir(string publishDir) + { + var files = Directory.GetFiles(publishDir, "*", new EnumerationOptions() + { + RecurseSubdirectories = true + }); + + return files.Select(f => new(f, Path.GetRelativePath(publishDir, f))).ToArray(); + } + + [DockerAvailableFact] public async Task ApiEndToEndWithRegistryPushAndPull() { @@ -72,7 +85,9 @@ public async Task ApiEndToEndWithRegistryPushAndPull() Assert.NotNull(imageBuilder); - Layer l = Layer.FromDirectory(publishDirectory, "/app", false, imageBuilder.ManifestMediaType); + var layerFilePath = new FileInfo(_store.GetTempFile()); + + Layer l = await Layer.FromFiles(MakeItemsForPublishDir(publishDirectory), "/app", false, imageBuilder.ManifestMediaType, _store, layerFilePath, CancellationToken.None); imageBuilder.AddLayer(l); @@ -118,7 +133,9 @@ public async Task ApiEndToEndWithLocalLoad() cancellationToken: default).ConfigureAwait(false); Assert.NotNull(imageBuilder); - Layer l = Layer.FromDirectory(publishDirectory, "/app", false, imageBuilder.ManifestMediaType); + var layerFilePath = new FileInfo(_store.GetTempFile()); + + Layer l = await Layer.FromFiles(MakeItemsForPublishDir(publishDirectory), "/app", false, imageBuilder.ManifestMediaType, _store, layerFilePath, CancellationToken.None); imageBuilder.AddLayer(l); @@ -158,8 +175,9 @@ public async Task ApiEndToEndWithArchiveWritingAndLoad() ToolsetUtils.RidGraphManifestPicker, cancellationToken: default).ConfigureAwait(false); Assert.NotNull(imageBuilder); + var layerFilePath = new FileInfo(_store.GetTempFile()); - Layer l = Layer.FromDirectory(publishDirectory, "/app", false, imageBuilder.ManifestMediaType); + Layer l = await Layer.FromFiles(MakeItemsForPublishDir(publishDirectory), "/app", false, imageBuilder.ManifestMediaType, _store, layerFilePath, CancellationToken.None); imageBuilder.AddLayer(l); @@ -245,11 +263,14 @@ private BuiltImage ConvertToOciImage(BuiltImage builtImage) var ociImage = new BuiltImage { Config = builtImage.Config, - ImageDigest = builtImage.ImageDigest, - ImageSha = builtImage.ImageSha, - Manifest = builtImage.Manifest, - ManifestDigest = builtImage.ManifestDigest, - ManifestMediaType = SchemaTypes.OciManifestV1, + Manifest = new() + { + Config = builtImage.Manifest.Config, + Layers = builtImage.Manifest.Layers, + SchemaVersion = builtImage.Manifest.SchemaVersion, + MediaType = SchemaTypes.OciManifestV1, + KnownDigest = builtImage.Manifest.KnownDigest, + }, Layers = builtImage.Layers }; @@ -1395,8 +1416,9 @@ public async Task CanPackageForAllSupportedContainerRIDs(string dockerPlatform, ToolsetUtils.RidGraphManifestPicker, cancellationToken: default).ConfigureAwait(false); Assert.NotNull(imageBuilder); + var layerFilePath = new FileInfo(_store.GetTempFile()); - Layer l = Layer.FromDirectory(publishDirectory, isWin ? "C:\\app" : "/app", isWin, imageBuilder.ManifestMediaType); + Layer l = await Layer.FromFiles(MakeItemsForPublishDir(publishDirectory), isWin ? "C:\\app" : "/app", isWin, imageBuilder.ManifestMediaType, _store, layerFilePath, CancellationToken.None); imageBuilder.AddLayer(l); imageBuilder.SetWorkingDirectory(workingDir); @@ -1446,8 +1468,9 @@ public async Task CheckErrorMessageWhenSourceRepositoryThrows() ToolsetUtils.RidGraphManifestPicker, cancellationToken: default).ConfigureAwait(false); Assert.NotNull(imageBuilder); + var layerFilePath = new FileInfo(_store.GetTempFile()); - Layer l = Layer.FromDirectory(publishDirectory, "C:\\app", true, imageBuilder.ManifestMediaType); + Layer l = await Layer.FromFiles(MakeItemsForPublishDir(publishDirectory), "C:\\app", true, imageBuilder.ManifestMediaType, _store, layerFilePath, CancellationToken.None); imageBuilder.AddLayer(l); imageBuilder.SetWorkingDirectory("C:\\app"); diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/FullFramework/CreateNewImageToolTaskTests.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/FullFramework/CreateNewImageToolTaskTests.cs index cfffd3a8ca47..9ea5733a5827 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/FullFramework/CreateNewImageToolTaskTests.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/FullFramework/CreateNewImageToolTaskTests.cs @@ -1,601 +1,601 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. +// // Licensed to the .NET Foundation under one or more agreements. +// // The .NET Foundation licenses this file to you under the MIT license. -using FakeItEasy; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; -using Microsoft.NET.Build.Containers.Tasks; - -namespace Microsoft.NET.Build.Containers.IntegrationTests.FullFramework; - -public class CreateNewImageToolTaskTests -{ - private ITestOutputHelper _testOutput; - - public CreateNewImageToolTaskTests(ITestOutputHelper testOutput) - { - _testOutput = testOutput; - } - - [Fact] - public void GenerateCommandLineCommands_ThrowsWhenRequiredPropertiesNotSet() - { - CreateNewImage task = new(); - - Exception e = Assert.Throws(() => task.GenerateCommandLineCommandsInt()); - Assert.Equal("CONTAINER4001: Required property 'PublishDirectory' was not set or empty.", e.Message); - - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - - task.PublishDirectory = publishDir.FullName; - - e = Assert.Throws(() => task.GenerateCommandLineCommandsInt()); - Assert.Equal("CONTAINER4001: Required property 'BaseRegistry' was not set or empty.", e.Message); - - task.BaseRegistry = "MyBaseRegistry"; - - e = Assert.Throws(() => task.GenerateCommandLineCommandsInt()); - Assert.Equal("CONTAINER4001: Required property 'BaseImageName' was not set or empty.", e.Message); - - task.BaseImageName = "MyBaseImageName"; - - e = Assert.Throws(() => task.GenerateCommandLineCommandsInt()); - Assert.Equal("CONTAINER4001: Required property 'Repository' was not set or empty.", e.Message); - - task.Repository = "MyImageName"; - - e = Assert.Throws(() => task.GenerateCommandLineCommandsInt()); - Assert.Equal("CONTAINER4001: Required property 'WorkingDirectory' was not set or empty.", e.Message); - - task.WorkingDirectory = "MyWorkingDirectory"; - - string args = task.GenerateCommandLineCommandsInt(); - string workDir = GetPathToContainerize(); - - new DotnetCommand(_testOutput, args) - .WithRawArguments() - .WithWorkingDirectory(workDir) - .Execute().Should().Fail() - .And.NotHaveStdOutContaining("Description:"); //standard help output for parse error - - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("ValidTag", true)] - public void GenerateCommandLineCommands_BaseImageTag(string? value, bool optionExpected = false) - { - CreateNewImage task = new(); - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; - - if (value != null) - { - task.BaseImageTag = value; - } - - string args = task.GenerateCommandLineCommandsInt(); - - if (optionExpected) - { - Assert.Contains($"--baseimagetag {value}", args); - } - else - { - Assert.DoesNotContain("--baseimagetag", args); - } - } - - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("Valid", true)] - public void GenerateCommandLineCommands_OutputRegistry(string? value, bool optionExpected = false) - { - CreateNewImage task = new(); - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; - - if (value != null) - { - task.OutputRegistry = value; - } - - string args = task.GenerateCommandLineCommandsInt(); - - if (optionExpected) - { - Assert.Contains($"--outputregistry {value}", args); - } - else - { - Assert.DoesNotContain("--outputregistry", args); - } - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("Valid", true)] - public void GenerateCommandLineCommands_ContainerRuntimeIdentifier(string? value, bool optionExpected = false) - { - CreateNewImage task = new(); - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; - - if (value != null) - { - task.ContainerRuntimeIdentifier = value; - } - - string args = task.GenerateCommandLineCommandsInt(); - if (optionExpected) - { - Assert.Contains($"--rid {value}", args); - } - else - { - Assert.DoesNotContain("--rid", args); - } - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("Valid", true)] - public void GenerateCommandLineCommands_RuntimeIdentifierGraphPath(string? value, bool optionExpected = false) - { - CreateNewImage task = new(); - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; - - if (value != null) - { - task.RuntimeIdentifierGraphPath = value; - } - - string args = task.GenerateCommandLineCommandsInt(); - - if (optionExpected) - { - Assert.Contains($"--ridgraphpath {value}", args); - } - else - { - Assert.DoesNotContain("--ridgraphpath", args); - } - } - - [Fact] - public void GenerateCommandLineCommands_Labels() - { - CreateNewImage task = new(); - - List warnings = new(); - IBuildEngine buildEngine = A.Fake(); - A.CallTo(() => buildEngine.LogWarningEvent(A.Ignored)).Invokes((BuildWarningEventArgs e) => warnings.Add(e.Message)); - - task.BuildEngine = buildEngine; - - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; - - task.Labels = new[] - { - new TaskItem("NoValue"), - new TaskItem(" "), - new TaskItem("Valid1", new Dictionary() {{ "Value", "Val1" }}), - new TaskItem("Valid12", new Dictionary() {{ "Value", "Val2" }}), - new TaskItem("Valid12", new Dictionary() {{ "Value", "" }}), - new TaskItem("Valid3", new Dictionary() {{ "Value", "has space" }}), - new TaskItem("Valid4", new Dictionary() {{ "Value", "has\"quotes\"" }}) - }; - - string args = task.GenerateCommandLineCommandsInt(); - - Assert.Contains(""" - --labels NoValue= Valid1=Val1 Valid12=Val2 Valid12= "Valid3=has space" "Valid4=has\"quotes\"" - """, args); - Assert.Equal("Items 'Labels' contain empty item(s) which will be ignored.", Assert.Single(warnings)); - - string workDir = GetPathToContainerize(); - - new DotnetCommand(_testOutput, args) - .WithRawArguments() - .WithWorkingDirectory(workDir) - .Execute().Should().Fail() - .And.NotHaveStdOutContaining("Description:"); //standard help output for parse error - } - - [Fact] - public void GenerateCommandLineCommands_ContainerEnvironmentVariables() - { - CreateNewImage task = new(); - - List warnings = new(); - IBuildEngine buildEngine = A.Fake(); - A.CallTo(() => buildEngine.LogWarningEvent(A.Ignored)).Invokes((BuildWarningEventArgs e) => warnings.Add(e.Message)); - - task.BuildEngine = buildEngine; - - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; - - task.ContainerEnvironmentVariables = new[] - { - new TaskItem("NoValue"), - new TaskItem(" "), - new TaskItem("Valid1", new Dictionary() {{ "Value", "Val1" }}), - new TaskItem("Valid12", new Dictionary() {{ "Value", "Val2" }}), - new TaskItem("Valid12", new Dictionary() {{ "Value", "" }}), - new TaskItem("Valid3", new Dictionary() {{ "Value", "has space" }}), - new TaskItem("Valid4", new Dictionary() {{ "Value", "has\"quotes\"" }}) - }; - - string args = task.GenerateCommandLineCommandsInt(); - - Assert.Contains(""" - --environmentvariables NoValue= Valid1=Val1 Valid12=Val2 Valid12= "Valid3=has space" "Valid4=has\"quotes\"" - """, args); - Assert.Equal("Items 'ContainerEnvironmentVariables' contain empty item(s) which will be ignored.", Assert.Single(warnings)); - - string workDir = GetPathToContainerize(); - - new DotnetCommand(_testOutput, args) - .WithRawArguments() - .WithWorkingDirectory(workDir) - .Execute().Should().Fail() - .And.NotHaveStdOutContaining("Description:"); //standard help output for parse error - } - - [InlineData(nameof(CreateNewImage.Entrypoint), "entrypoint")] - [InlineData(nameof(CreateNewImage.EntrypointArgs), "entrypointargs", true)] - [InlineData(nameof(CreateNewImage.DefaultArgs), "defaultargs", true)] - [InlineData(nameof(CreateNewImage.AppCommand), "appcommand", true)] - [InlineData(nameof(CreateNewImage.AppCommandArgs), "appcommandargs", true)] - [Theory] - public void GenerateCommandLineCommands_EntryPointAndCommand(string propertyName, string commandArgName, bool warningExpected = false) - { - CreateNewImage task = new(); - - List warnings = new(); - IBuildEngine buildEngine = A.Fake(); - A.CallTo(() => buildEngine.LogWarningEvent(A.Ignored)).Invokes((BuildWarningEventArgs e) => warnings.Add(e.Message)); - - task.BuildEngine = buildEngine; - - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; - - switch (propertyName) - { - case nameof(CreateNewImage.Entrypoint): - task.Entrypoint = new[] - { - new TaskItem("Valid1"), - new TaskItem("Valid2"), - new TaskItem("Quoted item") - }; - break; - case nameof(CreateNewImage.EntrypointArgs): - task.EntrypointArgs = new[] - { - new TaskItem(""), - new TaskItem(" "), - new TaskItem("Valid1"), - new TaskItem("Valid2"), - new TaskItem("Quoted item") - }; - break; - case nameof(CreateNewImage.DefaultArgs): - task.DefaultArgs = new[] - { - new TaskItem(""), - new TaskItem(" "), - new TaskItem("Valid1"), - new TaskItem("Valid2"), - new TaskItem("Quoted item") - }; - break; - case nameof(CreateNewImage.AppCommand): - task.AppCommand = new[] - { - new TaskItem(""), - new TaskItem(" "), - new TaskItem("Valid1"), - new TaskItem("Valid2"), - new TaskItem("Quoted item") - }; - break; - case nameof(CreateNewImage.AppCommandArgs): - task.AppCommandArgs = new[] - { - new TaskItem(""), - new TaskItem(" "), - new TaskItem("Valid1"), - new TaskItem("Valid2"), - new TaskItem("Quoted item") - }; - break; - } - - string args = task.GenerateCommandLineCommandsInt(); - - Assert.Contains($""" - --{commandArgName} Valid1 Valid2 "Quoted item" - """, args); - - if (warningExpected) - { - Assert.Equal($"Items '{propertyName}' contain empty item(s) which will be ignored.", Assert.Single(warnings)); - } - - string workDir = GetPathToContainerize(); - - new DotnetCommand(_testOutput, args) - .WithRawArguments() - .WithWorkingDirectory(workDir) - .Execute().Should().Fail() - .And.NotHaveStdOutContaining("Description:"); //standard help output for parse error - } - - [InlineData("")] - [InlineData(" ")] - [Theory] - public void GenerateCommandLineCommands_EntryPointCanHaveEmptyItems(string itemValue) - { - CreateNewImage task = new(); - IBuildEngine buildEngine = A.Fake(); - - task.BuildEngine = buildEngine; - - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - task.Entrypoint = new[] { new TaskItem(itemValue) }; - - task.GenerateCommandLineCommandsInt(); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("Valid", true)] - public void GenerateCommandLineCommands_AppCommandInstruction(string? value, bool optionExpected = false) - { - CreateNewImage task = new(); - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - - if (value != null) - { - task.AppCommandInstruction = value; - } - - string args = task.GenerateCommandLineCommandsInt(); - - if (optionExpected) - { - Assert.Contains($"--appcommandinstruction {value}", args); - } - else - { - Assert.DoesNotContain("--appcommandinstruction", args); - } - } - - [Fact] - public void GenerateCommandLineCommands_ImageTags() - { - CreateNewImage task = new(); - - List warnings = new(); - IBuildEngine buildEngine = A.Fake(); - A.CallTo(() => buildEngine.LogWarningEvent(A.Ignored)).Invokes((BuildWarningEventArgs e) => warnings.Add(e.Message)); - - task.BuildEngine = buildEngine; - - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; - - task.ImageTags = new[] { "", " ", "Valid1", "To be quoted" }; - - string args = task.GenerateCommandLineCommandsInt(); - - Assert.Contains(""" - --imagetags Valid1 "To be quoted" - """, actualString: args); - Assert.Equal("Property 'ImageTags' is empty or contains whitespace and will be ignored.", Assert.Single(warnings)); - - string workDir = GetPathToContainerize(); - - new DotnetCommand(_testOutput, args) - .WithRawArguments() - .WithWorkingDirectory(workDir) - .Execute().Should().Fail() - .And.NotHaveStdOutContaining("Description:"); //standard help output for parse error - } - - [Fact] - public void GenerateCommandLineCommands_ExposedPorts() - { - CreateNewImage task = new(); - - List warnings = new(); - IBuildEngine buildEngine = A.Fake(); - A.CallTo(() => buildEngine.LogWarningEvent(A.Ignored)).Invokes((BuildWarningEventArgs e) => warnings.Add(e.Message)); - - task.BuildEngine = buildEngine; - - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; - - task.ExposedPorts = new[] - { - new TaskItem("1500"), - new TaskItem(" "), - new TaskItem("1501", new Dictionary() {{ "Type", "udp" }}), - new TaskItem("1501", new Dictionary() {{ "Type", "tcp" }}), - new TaskItem("1502", new Dictionary() {{ "Type", "tcp" }}), - new TaskItem("1503", new Dictionary() {{ "Type", "" }}) - }; - - string args = task.GenerateCommandLineCommandsInt(); - - Assert.Contains(""" - --ports 1500 1501/udp 1501/tcp 1502/tcp 1503 - """, args); - Assert.Equal("Items 'ExposedPorts' contain empty item(s) which will be ignored.", Assert.Single(warnings)); - - string workDir = GetPathToContainerize(); - - new DotnetCommand(_testOutput, args) - .WithRawArguments() - .WithWorkingDirectory(workDir) - .Execute().Should().Fail() - .And.NotHaveStdOutContaining("Description:"); //standard help output for parse error - } - - [Fact] - public void Logging_CanEnableTraceLogging() - { - CreateNewImage task = new(); - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - task.Entrypoint = new[] { new TaskItem("") }; - task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; - - string args = task.GenerateCommandLineCommandsInt(); - string workDir = GetPathToContainerize(); - - new DotnetCommand(_testOutput, args) - .WithRawArguments() - .WithWorkingDirectory(workDir) - .WithEnvironmentVariable("CONTAINERIZE_TRACE_LOGGING_ENABLED", "1") - .Execute().Should().Fail() - .And.NotHaveStdOutContaining("Description:") //standard help output for parse error - .And.HaveStdOutContaining("Trace logging: enabled."); - } - - [Fact] - public void Logging_TraceLoggingIsDisabledByDefault() - { - CreateNewImage task = new(); - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - task.Entrypoint = new[] { new TaskItem("") }; - task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; - - string args = task.GenerateCommandLineCommandsInt(); - string workDir = GetPathToContainerize(); - - new DotnetCommand(_testOutput, args) - .WithRawArguments() - .WithWorkingDirectory(workDir) - .Execute().Should().Fail() - .And.NotHaveStdOutContaining("Description:") //standard help output for parse error - .And.NotHaveStdOutContaining("Trace logging: enabled."); - } - - [Fact] - public void GenerateCommandLineCommands_LabelGeneration() - { - CreateNewImage task = new(); - - List warnings = new(); - IBuildEngine buildEngine = A.Fake(); - - task.BuildEngine = buildEngine; - - DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); - task.PublishDirectory = publishDir.FullName; - task.BaseRegistry = "MyBaseRegistry"; - task.BaseImageName = "MyBaseImageName"; - task.Repository = "MyImageName"; - task.WorkingDirectory = "MyWorkingDirectory"; - task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; - task.GenerateLabels = true; - task.GenerateDigestLabel = true; - - string args = task.GenerateCommandLineCommandsInt(); - - Assert.Contains("--generate-labels", args); - Assert.Contains("--generate-digest-label", args); - } - - - - private static string GetPathToContainerize() - { - return Path.Combine(TestContext.Current.TestExecutionDirectory, "Container", "containerize"); - } -} +// using FakeItEasy; +// using Microsoft.Build.Framework; +// using Microsoft.Build.Utilities; +// using Microsoft.NET.Build.Containers.Tasks; + +// namespace Microsoft.NET.Build.Containers.IntegrationTests.FullFramework; + +// public class CreateNewImageToolTaskTests +// { +// private ITestOutputHelper _testOutput; + +// public CreateNewImageToolTaskTests(ITestOutputHelper testOutput) +// { +// _testOutput = testOutput; +// } + +// [Fact] +// public void GenerateCommandLineCommands_ThrowsWhenRequiredPropertiesNotSet() +// { +// CreateNewImage task = new(); + +// Exception e = Assert.Throws(() => task.GenerateCommandLineCommandsInt()); +// Assert.Equal("CONTAINER4001: Required property 'PublishDirectory' was not set or empty.", e.Message); + +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); + +// task.PublishDirectory = publishDir.FullName; + +// e = Assert.Throws(() => task.GenerateCommandLineCommandsInt()); +// Assert.Equal("CONTAINER4001: Required property 'BaseRegistry' was not set or empty.", e.Message); + +// task.BaseRegistry = "MyBaseRegistry"; + +// e = Assert.Throws(() => task.GenerateCommandLineCommandsInt()); +// Assert.Equal("CONTAINER4001: Required property 'BaseImageName' was not set or empty.", e.Message); + +// task.BaseImageName = "MyBaseImageName"; + +// e = Assert.Throws(() => task.GenerateCommandLineCommandsInt()); +// Assert.Equal("CONTAINER4001: Required property 'Repository' was not set or empty.", e.Message); + +// task.Repository = "MyImageName"; + +// e = Assert.Throws(() => task.GenerateCommandLineCommandsInt()); +// Assert.Equal("CONTAINER4001: Required property 'WorkingDirectory' was not set or empty.", e.Message); + +// task.WorkingDirectory = "MyWorkingDirectory"; + +// string args = task.GenerateCommandLineCommandsInt(); +// string workDir = GetPathToContainerize(); + +// new DotnetCommand(_testOutput, args) +// .WithRawArguments() +// .WithWorkingDirectory(workDir) +// .Execute().Should().Fail() +// .And.NotHaveStdOutContaining("Description:"); //standard help output for parse error + +// } + +// [Theory] +// [InlineData(null)] +// [InlineData("")] +// [InlineData(" ")] +// [InlineData("ValidTag", true)] +// public void GenerateCommandLineCommands_BaseImageTag(string? value, bool optionExpected = false) +// { +// CreateNewImage task = new(); +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; +// task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; + +// if (value != null) +// { +// task.BaseImageTag = value; +// } + +// string args = task.GenerateCommandLineCommandsInt(); + +// if (optionExpected) +// { +// Assert.Contains($"--baseimagetag {value}", args); +// } +// else +// { +// Assert.DoesNotContain("--baseimagetag", args); +// } +// } + + +// [Theory] +// [InlineData(null)] +// [InlineData("")] +// [InlineData(" ")] +// [InlineData("Valid", true)] +// public void GenerateCommandLineCommands_OutputRegistry(string? value, bool optionExpected = false) +// { +// CreateNewImage task = new(); +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; +// task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; + +// if (value != null) +// { +// task.OutputRegistry = value; +// } + +// string args = task.GenerateCommandLineCommandsInt(); + +// if (optionExpected) +// { +// Assert.Contains($"--outputregistry {value}", args); +// } +// else +// { +// Assert.DoesNotContain("--outputregistry", args); +// } +// } + +// [Theory] +// [InlineData(null)] +// [InlineData("")] +// [InlineData(" ")] +// [InlineData("Valid", true)] +// public void GenerateCommandLineCommands_ContainerRuntimeIdentifier(string? value, bool optionExpected = false) +// { +// CreateNewImage task = new(); +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; +// task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; + +// if (value != null) +// { +// task.ContainerRuntimeIdentifier = value; +// } + +// string args = task.GenerateCommandLineCommandsInt(); +// if (optionExpected) +// { +// Assert.Contains($"--rid {value}", args); +// } +// else +// { +// Assert.DoesNotContain("--rid", args); +// } +// } + +// [Theory] +// [InlineData(null)] +// [InlineData("")] +// [InlineData(" ")] +// [InlineData("Valid", true)] +// public void GenerateCommandLineCommands_RuntimeIdentifierGraphPath(string? value, bool optionExpected = false) +// { +// CreateNewImage task = new(); +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; +// task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; + +// if (value != null) +// { +// task.RuntimeIdentifierGraphPath = value; +// } + +// string args = task.GenerateCommandLineCommandsInt(); + +// if (optionExpected) +// { +// Assert.Contains($"--ridgraphpath {value}", args); +// } +// else +// { +// Assert.DoesNotContain("--ridgraphpath", args); +// } +// } + +// [Fact] +// public void GenerateCommandLineCommands_Labels() +// { +// CreateNewImage task = new(); + +// List warnings = new(); +// IBuildEngine buildEngine = A.Fake(); +// A.CallTo(() => buildEngine.LogWarningEvent(A.Ignored)).Invokes((BuildWarningEventArgs e) => warnings.Add(e.Message)); + +// task.BuildEngine = buildEngine; + +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; +// task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; + +// task.Labels = new[] +// { +// new TaskItem("NoValue"), +// new TaskItem(" "), +// new TaskItem("Valid1", new Dictionary() {{ "Value", "Val1" }}), +// new TaskItem("Valid12", new Dictionary() {{ "Value", "Val2" }}), +// new TaskItem("Valid12", new Dictionary() {{ "Value", "" }}), +// new TaskItem("Valid3", new Dictionary() {{ "Value", "has space" }}), +// new TaskItem("Valid4", new Dictionary() {{ "Value", "has\"quotes\"" }}) +// }; + +// string args = task.GenerateCommandLineCommandsInt(); + +// Assert.Contains(""" +// --labels NoValue= Valid1=Val1 Valid12=Val2 Valid12= "Valid3=has space" "Valid4=has\"quotes\"" +// """, args); +// Assert.Equal("Items 'Labels' contain empty item(s) which will be ignored.", Assert.Single(warnings)); + +// string workDir = GetPathToContainerize(); + +// new DotnetCommand(_testOutput, args) +// .WithRawArguments() +// .WithWorkingDirectory(workDir) +// .Execute().Should().Fail() +// .And.NotHaveStdOutContaining("Description:"); //standard help output for parse error +// } + +// [Fact] +// public void GenerateCommandLineCommands_ContainerEnvironmentVariables() +// { +// CreateNewImage task = new(); + +// List warnings = new(); +// IBuildEngine buildEngine = A.Fake(); +// A.CallTo(() => buildEngine.LogWarningEvent(A.Ignored)).Invokes((BuildWarningEventArgs e) => warnings.Add(e.Message)); + +// task.BuildEngine = buildEngine; + +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; +// task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; + +// task.ContainerEnvironmentVariables = new[] +// { +// new TaskItem("NoValue"), +// new TaskItem(" "), +// new TaskItem("Valid1", new Dictionary() {{ "Value", "Val1" }}), +// new TaskItem("Valid12", new Dictionary() {{ "Value", "Val2" }}), +// new TaskItem("Valid12", new Dictionary() {{ "Value", "" }}), +// new TaskItem("Valid3", new Dictionary() {{ "Value", "has space" }}), +// new TaskItem("Valid4", new Dictionary() {{ "Value", "has\"quotes\"" }}) +// }; + +// string args = task.GenerateCommandLineCommandsInt(); + +// Assert.Contains(""" +// --environmentvariables NoValue= Valid1=Val1 Valid12=Val2 Valid12= "Valid3=has space" "Valid4=has\"quotes\"" +// """, args); +// Assert.Equal("Items 'ContainerEnvironmentVariables' contain empty item(s) which will be ignored.", Assert.Single(warnings)); + +// string workDir = GetPathToContainerize(); + +// new DotnetCommand(_testOutput, args) +// .WithRawArguments() +// .WithWorkingDirectory(workDir) +// .Execute().Should().Fail() +// .And.NotHaveStdOutContaining("Description:"); //standard help output for parse error +// } + +// [InlineData(nameof(CreateNewImage.Entrypoint), "entrypoint")] +// [InlineData(nameof(CreateNewImage.EntrypointArgs), "entrypointargs", true)] +// [InlineData(nameof(CreateNewImage.DefaultArgs), "defaultargs", true)] +// [InlineData(nameof(CreateNewImage.AppCommand), "appcommand", true)] +// [InlineData(nameof(CreateNewImage.AppCommandArgs), "appcommandargs", true)] +// [Theory] +// public void GenerateCommandLineCommands_EntryPointAndCommand(string propertyName, string commandArgName, bool warningExpected = false) +// { +// CreateNewImage task = new(); + +// List warnings = new(); +// IBuildEngine buildEngine = A.Fake(); +// A.CallTo(() => buildEngine.LogWarningEvent(A.Ignored)).Invokes((BuildWarningEventArgs e) => warnings.Add(e.Message)); + +// task.BuildEngine = buildEngine; + +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; +// task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; + +// switch (propertyName) +// { +// case nameof(CreateNewImage.Entrypoint): +// task.Entrypoint = new[] +// { +// new TaskItem("Valid1"), +// new TaskItem("Valid2"), +// new TaskItem("Quoted item") +// }; +// break; +// case nameof(CreateNewImage.EntrypointArgs): +// task.EntrypointArgs = new[] +// { +// new TaskItem(""), +// new TaskItem(" "), +// new TaskItem("Valid1"), +// new TaskItem("Valid2"), +// new TaskItem("Quoted item") +// }; +// break; +// case nameof(CreateNewImage.DefaultArgs): +// task.DefaultArgs = new[] +// { +// new TaskItem(""), +// new TaskItem(" "), +// new TaskItem("Valid1"), +// new TaskItem("Valid2"), +// new TaskItem("Quoted item") +// }; +// break; +// case nameof(CreateNewImage.AppCommand): +// task.AppCommand = new[] +// { +// new TaskItem(""), +// new TaskItem(" "), +// new TaskItem("Valid1"), +// new TaskItem("Valid2"), +// new TaskItem("Quoted item") +// }; +// break; +// case nameof(CreateNewImage.AppCommandArgs): +// task.AppCommandArgs = new[] +// { +// new TaskItem(""), +// new TaskItem(" "), +// new TaskItem("Valid1"), +// new TaskItem("Valid2"), +// new TaskItem("Quoted item") +// }; +// break; +// } + +// string args = task.GenerateCommandLineCommandsInt(); + +// Assert.Contains($""" +// --{commandArgName} Valid1 Valid2 "Quoted item" +// """, args); + +// if (warningExpected) +// { +// Assert.Equal($"Items '{propertyName}' contain empty item(s) which will be ignored.", Assert.Single(warnings)); +// } + +// string workDir = GetPathToContainerize(); + +// new DotnetCommand(_testOutput, args) +// .WithRawArguments() +// .WithWorkingDirectory(workDir) +// .Execute().Should().Fail() +// .And.NotHaveStdOutContaining("Description:"); //standard help output for parse error +// } + +// [InlineData("")] +// [InlineData(" ")] +// [Theory] +// public void GenerateCommandLineCommands_EntryPointCanHaveEmptyItems(string itemValue) +// { +// CreateNewImage task = new(); +// IBuildEngine buildEngine = A.Fake(); + +// task.BuildEngine = buildEngine; + +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; +// task.Entrypoint = new[] { new TaskItem(itemValue) }; + +// task.GenerateCommandLineCommandsInt(); +// } + +// [Theory] +// [InlineData(null)] +// [InlineData("")] +// [InlineData(" ")] +// [InlineData("Valid", true)] +// public void GenerateCommandLineCommands_AppCommandInstruction(string? value, bool optionExpected = false) +// { +// CreateNewImage task = new(); +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; + +// if (value != null) +// { +// task.AppCommandInstruction = value; +// } + +// string args = task.GenerateCommandLineCommandsInt(); + +// if (optionExpected) +// { +// Assert.Contains($"--appcommandinstruction {value}", args); +// } +// else +// { +// Assert.DoesNotContain("--appcommandinstruction", args); +// } +// } + +// [Fact] +// public void GenerateCommandLineCommands_ImageTags() +// { +// CreateNewImage task = new(); + +// List warnings = new(); +// IBuildEngine buildEngine = A.Fake(); +// A.CallTo(() => buildEngine.LogWarningEvent(A.Ignored)).Invokes((BuildWarningEventArgs e) => warnings.Add(e.Message)); + +// task.BuildEngine = buildEngine; + +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; +// task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; + +// task.ImageTags = new[] { "", " ", "Valid1", "To be quoted" }; + +// string args = task.GenerateCommandLineCommandsInt(); + +// Assert.Contains(""" +// --imagetags Valid1 "To be quoted" +// """, actualString: args); +// Assert.Equal("Property 'ImageTags' is empty or contains whitespace and will be ignored.", Assert.Single(warnings)); + +// string workDir = GetPathToContainerize(); + +// new DotnetCommand(_testOutput, args) +// .WithRawArguments() +// .WithWorkingDirectory(workDir) +// .Execute().Should().Fail() +// .And.NotHaveStdOutContaining("Description:"); //standard help output for parse error +// } + +// [Fact] +// public void GenerateCommandLineCommands_ExposedPorts() +// { +// CreateNewImage task = new(); + +// List warnings = new(); +// IBuildEngine buildEngine = A.Fake(); +// A.CallTo(() => buildEngine.LogWarningEvent(A.Ignored)).Invokes((BuildWarningEventArgs e) => warnings.Add(e.Message)); + +// task.BuildEngine = buildEngine; + +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; +// task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; + +// task.ExposedPorts = new[] +// { +// new TaskItem("1500"), +// new TaskItem(" "), +// new TaskItem("1501", new Dictionary() {{ "Type", "udp" }}), +// new TaskItem("1501", new Dictionary() {{ "Type", "tcp" }}), +// new TaskItem("1502", new Dictionary() {{ "Type", "tcp" }}), +// new TaskItem("1503", new Dictionary() {{ "Type", "" }}) +// }; + +// string args = task.GenerateCommandLineCommandsInt(); + +// Assert.Contains(""" +// --ports 1500 1501/udp 1501/tcp 1502/tcp 1503 +// """, args); +// Assert.Equal("Items 'ExposedPorts' contain empty item(s) which will be ignored.", Assert.Single(warnings)); + +// string workDir = GetPathToContainerize(); + +// new DotnetCommand(_testOutput, args) +// .WithRawArguments() +// .WithWorkingDirectory(workDir) +// .Execute().Should().Fail() +// .And.NotHaveStdOutContaining("Description:"); //standard help output for parse error +// } + +// [Fact] +// public void Logging_CanEnableTraceLogging() +// { +// CreateNewImage task = new(); +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); + +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; +// task.Entrypoint = new[] { new TaskItem("") }; +// task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; + +// string args = task.GenerateCommandLineCommandsInt(); +// string workDir = GetPathToContainerize(); + +// new DotnetCommand(_testOutput, args) +// .WithRawArguments() +// .WithWorkingDirectory(workDir) +// .WithEnvironmentVariable("CONTAINERIZE_TRACE_LOGGING_ENABLED", "1") +// .Execute().Should().Fail() +// .And.NotHaveStdOutContaining("Description:") //standard help output for parse error +// .And.HaveStdOutContaining("Trace logging: enabled."); +// } + +// [Fact] +// public void Logging_TraceLoggingIsDisabledByDefault() +// { +// CreateNewImage task = new(); +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); + +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; +// task.Entrypoint = new[] { new TaskItem("") }; +// task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; + +// string args = task.GenerateCommandLineCommandsInt(); +// string workDir = GetPathToContainerize(); + +// new DotnetCommand(_testOutput, args) +// .WithRawArguments() +// .WithWorkingDirectory(workDir) +// .Execute().Should().Fail() +// .And.NotHaveStdOutContaining("Description:") //standard help output for parse error +// .And.NotHaveStdOutContaining("Trace logging: enabled."); +// } + +// [Fact] +// public void GenerateCommandLineCommands_LabelGeneration() +// { +// CreateNewImage task = new(); + +// List warnings = new(); +// IBuildEngine buildEngine = A.Fake(); + +// task.BuildEngine = buildEngine; + +// DirectoryInfo publishDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), DateTime.Now.ToString("yyyyMMddHHmmssfff"))); +// task.PublishDirectory = publishDir.FullName; +// task.BaseRegistry = "MyBaseRegistry"; +// task.BaseImageName = "MyBaseImageName"; +// task.Repository = "MyImageName"; +// task.WorkingDirectory = "MyWorkingDirectory"; +// task.Entrypoint = new[] { new TaskItem("MyEntryPoint") }; +// task.GenerateLabels = true; +// task.GenerateDigestLabel = true; + +// string args = task.GenerateCommandLineCommandsInt(); + +// Assert.Contains("--generate-labels", args); +// Assert.Contains("--generate-digest-label", args); +// } + + + +// private static string GetPathToContainerize() +// { +// return Path.Combine(TestContext.Current.TestExecutionDirectory, "Container", "containerize"); +// } +// } diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/LayerEndToEndTests.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/LayerEndToEndTests.cs index b0a23aabf122..6dff3d9c0118 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/LayerEndToEndTests.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/LayerEndToEndTests.cs @@ -4,6 +4,7 @@ using System.Formats.Tar; using System.IO.Compression; using System.Security.Cryptography; +using System.Threading.Tasks; namespace Microsoft.NET.Build.Containers.IntegrationTests; @@ -15,12 +16,11 @@ public LayerEndToEndTests(ITestOutputHelper testOutput) { _testOutput = testOutput; testSpecificArtifactRoot = new(); - priorArtifactRoot = ContentStore.ArtifactRoot; - ContentStore.ArtifactRoot = testSpecificArtifactRoot.Path; + _store = new ContentStore(new(testSpecificArtifactRoot.Path)); } [Fact] - public void SingleFileInFolder() + public async Task SingleFileInFolder() { using TransientTestFolder folder = new(); @@ -29,7 +29,9 @@ public void SingleFileInFolder() File.WriteAllText(testFilePath, testString); - Layer l = Layer.FromDirectory(directory: folder.Path, containerPath: "/app", false, SchemaTypes.DockerManifestV2); + var layerFilePath = new FileInfo(_store.GetTempFile()); + + Layer l = await Layer.FromFiles([(Path.GetFullPath(testFilePath), "TestFile.txt")], containerPath: "/app", false, SchemaTypes.DockerManifestV2, _store, layerFilePath, CancellationToken.None); Console.WriteLine(l.Descriptor); @@ -45,7 +47,7 @@ public void SingleFileInFolder() } [Fact] - public void SingleFileInFolderWindows() + public async Task SingleFileInFolderWindows() { using TransientTestFolder folder = new(); @@ -53,8 +55,9 @@ public void SingleFileInFolderWindows() string testString = $"Test content for {nameof(SingleFileInFolder)}"; File.WriteAllText(testFilePath, testString); + var layerFilePath = new FileInfo(_store.GetTempFile()); - Layer l = Layer.FromDirectory(directory: folder.Path, containerPath: "C:\\app", true, SchemaTypes.DockerManifestV2); + Layer l = await Layer.FromFiles([(Path.GetFullPath(testFilePath), "TestFile.txt")], containerPath: "C:\\app", true, SchemaTypes.DockerManifestV2, _store, layerFilePath, CancellationToken.None); var allEntries = LoadAllTarEntries(l.BackingFile); Assert.True(allEntries.TryGetValue("Files", out var filesEntry) && filesEntry.EntryType == TarEntryType.Directory, "Missing Files directory entry"); @@ -73,7 +76,7 @@ public void SingleFileInFolderWindows() } [Fact] // https://github.com/dotnet/sdk/issues/40511 - public void SingleFileInHiddenFolder() + public async Task SingleFileInHiddenFolder() { using TransientTestFolder folder = new(); @@ -87,8 +90,9 @@ public void SingleFileInHiddenFolder() string testString = $"Test content for {nameof(SingleFileInHiddenFolder)}"; File.WriteAllText(testFilePath, testString); + var layerFilePath = new FileInfo(_store.GetTempFile()); - Layer l = Layer.FromDirectory(directory: folder.Path, containerPath: "/app", false, SchemaTypes.DockerManifestV2); + Layer l = await Layer.FromFiles([(Path.GetFullPath(testFilePath), ".well-known/wwwroot")], containerPath: "/app", false, SchemaTypes.DockerManifestV2, _store, layerFilePath, CancellationToken.None); VerifyDescriptorInfo(l); @@ -101,12 +105,12 @@ public void SingleFileInHiddenFolder() private static void VerifyDescriptorInfo(Layer l) { - Assert.Equal(l.Descriptor.Size, new FileInfo(l.BackingFile).Length); + Assert.Equal(l.Descriptor.Size, l.BackingFile.Length); byte[] hashBytes; byte[] uncompressedHashBytes; - using (FileStream fs = File.OpenRead(l.BackingFile)) + using (FileStream fs = l.BackingFile.OpenRead()) { hashBytes = SHA256.HashData(fs); @@ -123,21 +127,16 @@ private static void VerifyDescriptorInfo(Layer l) } TransientTestFolder? testSpecificArtifactRoot; - string? priorArtifactRoot; + private readonly ContentStore _store; public void Dispose() { testSpecificArtifactRoot?.Dispose(); - if (priorArtifactRoot is not null) - { - ContentStore.ArtifactRoot = priorArtifactRoot; - } } - - private static Dictionary LoadAllTarEntries(string file) + private static Dictionary LoadAllTarEntries(FileInfo file) { - using var gzip = new GZipStream(File.OpenRead(file), CompressionMode.Decompress); + using var gzip = new GZipStream(file.OpenRead(), CompressionMode.Decompress); using var tar = new TarReader(gzip); var entries = new Dictionary(); diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs index 6a359c970480..d6b134b47f64 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs @@ -75,10 +75,7 @@ public void CanAddLabelsToImage() baseConfig.AddLabel("testLabel1", "v1"); baseConfig.AddLabel("testLabel2", "v2"); - string readyImage = baseConfig.BuildConfig(); - - JsonNode? result = JsonNode.Parse(readyImage); - + var result = baseConfig.BuildConfig(); var resultLabels = result?["config"]?["Labels"] as JsonObject; Assert.NotNull(resultLabels); @@ -146,9 +143,7 @@ public void CanPreserveExistingLabels() baseConfig.AddLabel("testLabel1", "v1"); baseConfig.AddLabel("existing2", "v2"); - string readyImage = baseConfig.BuildConfig(); - - JsonNode? result = JsonNode.Parse(readyImage); + var result = baseConfig.BuildConfig(); var resultLabels = result?["config"]?["Labels"] as JsonObject; Assert.NotNull(resultLabels); @@ -214,9 +209,7 @@ public void CanAddPortsToImage() baseConfig.ExposePort(6000, PortType.tcp); baseConfig.ExposePort(6010, PortType.udp); - string readyImage = baseConfig.BuildConfig(); - - JsonNode? result = JsonNode.Parse(readyImage); + var result = baseConfig.BuildConfig(); var resultPorts = result?["config"]?["ExposedPorts"] as JsonObject; Assert.NotNull(resultPorts); @@ -288,9 +281,7 @@ public void CanPreserveExistingPorts() baseConfig.ExposePort(6100, PortType.udp); baseConfig.ExposePort(6200, PortType.tcp); - string readyImage = baseConfig.BuildConfig(); - - JsonNode? result = JsonNode.Parse(readyImage); + var result = baseConfig.BuildConfig(); var resultPorts = result?["config"]?["ExposedPorts"] as JsonObject; Assert.NotNull(resultPorts); @@ -372,9 +363,9 @@ public void HistoryEntriesMatchNonEmptyLayers() ImageConfig baseConfig = new(node); - string readyImage = baseConfig.BuildConfig(); + var result = baseConfig.BuildConfig(); - JsonNode? result = JsonNode.Parse(readyImage); + var historyNode = result?["history"]; Assert.NotNull(historyNode); @@ -425,7 +416,7 @@ public void CanSetUserFromAppUIDEnvVarFromBaseImage() var builtImage = builder.Build(); - JsonNode? result = JsonNode.Parse(builtImage.Config); + JsonNode? result = builtImage.Config; Assert.NotNull(result); var assignedUid = result["config"]?["User"]?.GetValue(); Assert.Equal(assignedUid, expectedUid); @@ -467,7 +458,7 @@ public void CanSetUserFromAppUIDEnvVarFromUser() builder.AddEnvironmentVariable(ImageBuilder.EnvironmentVariables.APP_UID, "12345"); var builtImage = builder.Build(); - JsonNode? result = JsonNode.Parse(builtImage.Config); + JsonNode? result = builtImage.Config; Assert.NotNull(result); var assignedUser = result["config"]?["User"]?.GetValue(); Assert.Equal(assignedUser, expectedUid); @@ -511,7 +502,7 @@ public void CanSetPortFromEnvVarFromBaseImage(string envVar, string envValue, pa var builtImage = builder.Build(); - JsonNode? result = JsonNode.Parse(builtImage.Config); + JsonNode? result = builtImage.Config; Assert.NotNull(result); var portsObject = result["config"]?["ExposedPorts"]?.AsObject(); var assignedPorts = portsObject?.AsEnumerable().Select(portString => int.Parse(portString.Key.Split('/')[0])).ToArray(); @@ -557,7 +548,7 @@ public void CanSetPortFromEnvVarFromUser(string envVar, string envValue, params var builtImage = builder.Build(); - JsonNode? result = JsonNode.Parse(builtImage.Config); + JsonNode? result = builtImage.Config; Assert.NotNull(result); var portsObject = result["config"]?["ExposedPorts"]?.AsObject(); var assignedPorts = portsObject?.AsEnumerable().Select(portString => int.Parse(portString.Key.Split('/')[0])).ToArray(); @@ -600,7 +591,7 @@ public void CanSetContainerUserAndOverrideAppUID() """); baseConfigBuilder.SetUser(userId); - var config = JsonNode.Parse(baseConfigBuilder.Build().Config); + var config = baseConfigBuilder.Build().Config; config!["config"]?["User"]?.GetValue().Should().Be(expected: userId, because: "The precedence of SetUser should override inferred user ids"); } @@ -640,7 +631,7 @@ public void WhenMultipleUrlSourcesAreSetOnlyAspnetcoreUrlsIsUsed() builder.AddEnvironmentVariable(ImageBuilder.EnvironmentVariables.ASPNETCORE_URLS, "https://*:12345"); builder.AddEnvironmentVariable(ImageBuilder.EnvironmentVariables.ASPNETCORE_HTTPS_PORTS, "456"); var builtImage = builder.Build(); - JsonNode? result = JsonNode.Parse(builtImage.Config); + JsonNode? result = builtImage.Config; Assert.NotNull(result); var portsObject = result["config"]?["ExposedPorts"]?.AsObject(); var assignedPorts = portsObject?.AsEnumerable().Select(portString => int.Parse(portString.Key.Split('/')[0])).ToArray(); @@ -681,7 +672,7 @@ public void CanSetBaseImageDigestLabel() builder.AddBaseImageDigestLabel(); var builtImage = builder.Build(); - JsonNode? result = JsonNode.Parse(builtImage.Config); + JsonNode? result = builtImage.Config; Assert.NotNull(result); var labels = result["config"]?["Labels"]?.AsObject(); var digest = labels?.AsEnumerable().First(label => label.Key == "org.opencontainers.image.base.digest").Value!; diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/ImageConfigTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/ImageConfigTests.cs index 25d3cafe2268..1e9dc1864272 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/ImageConfigTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/ImageConfigTests.cs @@ -50,7 +50,7 @@ public class ImageConfigTests public void PassesThroughPropertyEvenThoughPropertyIsntExplicitlyHandled(string property) { ImageConfig c = new(SampleImageConfig); - JsonNode after = JsonNode.Parse(c.BuildConfig())!; + JsonNode after = c.BuildConfig(); JsonNode? prop = after["config"]?[property]; Assert.NotNull(prop); } diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs index 1acc846dcdcd..57979747782b 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; @@ -19,22 +20,29 @@ public void ImagesCannotBeEmpty() public void ImagesCannotBeEmpty_SpecifiedMediaType() { BuiltImage[] images = Array.Empty(); - var ex = Assert.Throws(() => ImageIndexGenerator.GenerateImageIndex(images, "manifestMediaType", "imageIndexMediaType")); + var ex = Assert.Throws(() => ImageIndexGenerator.GenerateDockerManifestList(images, "manifestMediaType", "imageIndexMediaType")); Assert.Equal(Strings.ImagesEmpty, ex.Message); } + static BuiltImage EmptyWithMediaType(string mediaType) => + new() + { + Config = new(), + Manifest = new() + { + MediaType = mediaType, + Layers = [], + SchemaVersion = 2, + Config = new(), + }, + }; + [Fact] public void UnsupportedMediaTypeThrows() { BuiltImage[] images = [ - new BuiltImage - { - Config = "", - Manifest = "", - ManifestDigest = "", - ManifestMediaType = "unsupported" - } + EmptyWithMediaType("unsupported"), ]; var ex = Assert.Throws(() => ImageIndexGenerator.GenerateImageIndex(images)); @@ -48,20 +56,8 @@ public void ImagesWithMixedMediaTypes(string supportedMediaType) { BuiltImage[] images = [ - new BuiltImage - { - Config = "", - Manifest = "", - ManifestDigest = "", - ManifestMediaType = supportedMediaType, - }, - new BuiltImage - { - Config = "", - Manifest = "", - ManifestDigest = "", - ManifestMediaType = "anotherMediaType" - } + EmptyWithMediaType(supportedMediaType), + EmptyWithMediaType("unsupported"), ]; var ex = Assert.Throws(() => ImageIndexGenerator.GenerateImageIndex(images)); @@ -75,27 +71,38 @@ public void GenerateDockerManifestList() [ new BuiltImage { - Config = "", - Manifest = "123", - ManifestDigest = "sha256:digest1", - ManifestMediaType = SchemaTypes.DockerManifestV2, + Config = new(){ + + }, + Manifest = new(){ + KnownDigest = "sha256:digest1", + MediaType = SchemaTypes.DockerManifestV2, + SchemaVersion = 2, + Config = new(), + Layers = [], + }, Architecture = "arch1", OS = "os1" }, new BuiltImage { - Config = "", - Manifest = "123", - ManifestDigest = "sha256:digest2", - ManifestMediaType = SchemaTypes.DockerManifestV2, + Config = new(), + Manifest = new(){ + KnownDigest = "sha256:digest2", + MediaType = SchemaTypes.DockerManifestV2, + SchemaVersion = 2, + Config = new(), + Layers = [], + }, Architecture = "arch2", OS = "os2" } ]; - var (imageIndex, mediaType) = ImageIndexGenerator.GenerateImageIndex(images); - Assert.Equal("{\"schemaVersion\":2,\"mediaType\":\"application/vnd.docker.distribution.manifest.list.v2+json\",\"manifests\":[{\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"size\":3,\"digest\":\"sha256:digest1\",\"platform\":{\"architecture\":\"arch1\",\"os\":\"os1\"}},{\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"size\":3,\"digest\":\"sha256:digest2\",\"platform\":{\"architecture\":\"arch2\",\"os\":\"os2\"}}]}", imageIndex); - Assert.Equal(SchemaTypes.DockerManifestListV2, mediaType); + var imageIndex = ImageIndexGenerator.GenerateImageIndex(images); + var imageIndexJson = JsonSerializer.Serialize(imageIndex); + Assert.Equal("{\"schemaVersion\":2,\"mediaType\":\"application/vnd.docker.distribution.manifest.list.v2+json\",\"manifests\":[{\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"size\":3,\"digest\":\"sha256:digest1\",\"platform\":{\"architecture\":\"arch1\",\"os\":\"os1\"}},{\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"size\":3,\"digest\":\"sha256:digest2\",\"platform\":{\"architecture\":\"arch2\",\"os\":\"os2\"}}]}", imageIndexJson); + Assert.Equal(SchemaTypes.DockerManifestListV2, imageIndex.MediaType); } [Fact] @@ -105,33 +112,45 @@ public void GenerateOciImageIndex() [ new BuiltImage { - Config = "", - Manifest = "123", - ManifestDigest = "sha256:digest1", - ManifestMediaType = SchemaTypes.OciManifestV1, + Config = new(){ + + }, + Manifest = new(){ + KnownDigest = "sha256:digest1", + MediaType = SchemaTypes.OciManifestV1, + SchemaVersion = 2, + Config = new(), + Layers = [], + }, Architecture = "arch1", OS = "os1" }, new BuiltImage { - Config = "", - Manifest = "123", - ManifestDigest = "sha256:digest2", - ManifestMediaType = SchemaTypes.OciManifestV1, + Config = new(), + Manifest = new(){ + KnownDigest = "sha256:digest2", + MediaType = SchemaTypes.OciManifestV1, + SchemaVersion = 2, + Config = new(), + Layers = [], + }, Architecture = "arch2", OS = "os2" } ]; - var (imageIndex, mediaType) = ImageIndexGenerator.GenerateImageIndex(images); - Assert.Equal("{\"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\"}},{\"mediaType\":\"application/vnd.oci.image.manifest.v1+json\",\"size\":3,\"digest\":\"sha256:digest2\",\"platform\":{\"architecture\":\"arch2\",\"os\":\"os2\"}}]}", imageIndex); - Assert.Equal(SchemaTypes.OciImageIndexV1, mediaType); + var imageIndex = ImageIndexGenerator.GenerateImageIndex(images); + var imageIndexJson = JsonSerializer.Serialize(imageIndex); + Assert.Equal("{\"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\"}},{\"mediaType\":\"application/vnd.oci.image.manifest.v1+json\",\"size\":3,\"digest\":\"sha256:digest2\",\"platform\":{\"architecture\":\"arch2\",\"os\":\"os2\"}}]}", imageIndexJson); + Assert.Equal(SchemaTypes.OciImageIndexV1, imageIndex.MediaType); } [Fact] public void GenerateImageIndexWithAnnotations() { - string imageIndex = ImageIndexGenerator.GenerateImageIndexWithAnnotations("mediaType", "sha256:digest", 3, "repository", ["1.0", "2.0"]); - Assert.Equal("{\"schemaVersion\":2,\"mediaType\":\"application/vnd.oci.image.index.v1+json\",\"manifests\":[{\"mediaType\":\"mediaType\",\"size\":3,\"digest\":\"sha256:digest\",\"platform\":{},\"annotations\":{\"io.containerd.image.name\":\"docker.io/library/repository:1.0\",\"org.opencontainers.image.ref.name\":\"1.0\"}},{\"mediaType\":\"mediaType\",\"size\":3,\"digest\":\"sha256:digest\",\"platform\":{},\"annotations\":{\"io.containerd.image.name\":\"docker.io/library/repository:2.0\",\"org.opencontainers.image.ref.name\":\"2.0\"}}]}", imageIndex); + var imageIndex = ImageIndexGenerator.GenerateImageIndexWithAnnotations("mediaType", "sha256:digest", 3, "repository", ["1.0", "2.0"]); + var imageIndexJson = JsonSerializer.Serialize(imageIndex); + Assert.Equal("{\"schemaVersion\":2,\"mediaType\":\"application/vnd.oci.image.index.v1+json\",\"manifests\":[{\"mediaType\":\"mediaType\",\"size\":3,\"digest\":\"sha256:digest\",\"platform\":{},\"annotations\":{\"io.containerd.image.name\":\"docker.io/library/repository:1.0\",\"org.opencontainers.image.ref.name\":\"1.0\"}},{\"mediaType\":\"mediaType\",\"size\":3,\"digest\":\"sha256:digest\",\"platform\":{},\"annotations\":{\"io.containerd.image.name\":\"docker.io/library/repository:2.0\",\"org.opencontainers.image.ref.name\":\"2.0\"}}]}", imageIndexJson); } }