diff --git a/Directory.Packages.props b/Directory.Packages.props index e6d66c5d..a3475b0b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/Microsoft.NET.Build.Containers/ContainerBuilder.cs b/Microsoft.NET.Build.Containers/ContainerBuilder.cs index e1507175..89383c72 100644 --- a/Microsoft.NET.Build.Containers/ContainerBuilder.cs +++ b/Microsoft.NET.Build.Containers/ContainerBuilder.cs @@ -7,7 +7,7 @@ namespace Microsoft.NET.Build.Containers; public static class ContainerBuilder { - public static async Task Containerize(DirectoryInfo folder, string workingDir, string registryName, string baseName, string baseTag, string[] entrypoint, string[] entrypointArgs, string imageName, string[] imageTags, string? outputRegistry, string[] labels, Port[] exposedPorts, string[] envVars) + public static async Task Containerize(DirectoryInfo folder, string workingDir, string registryName, string baseName, string baseTag, string[] entrypoint, string[] entrypointArgs, string imageName, string[] imageTags, string? outputRegistry, string[] labels, Port[] exposedPorts, string[] envVars, string containerRuntimeIdentifier, string ridGraphPath) { var isDockerPull = String.IsNullOrEmpty(registryName); if (isDockerPull) { @@ -15,7 +15,11 @@ public static async Task Containerize(DirectoryInfo folder, string workingDir, s } Registry baseRegistry = new Registry(ContainerHelpers.TryExpandRegistryToUri(registryName)); - Image img = await baseRegistry.GetImageManifest(baseName, baseTag); + var img = await baseRegistry.GetImageManifest(baseName, baseTag, containerRuntimeIdentifier, ridGraphPath); + if (img is null) { + throw new ArgumentException($"Could not find image {baseName}:{baseTag} in registry {registryName} matching RuntimeIdentifier {containerRuntimeIdentifier}"); + } + img.WorkingDirectory = workingDir; JsonSerializerOptions options = new() @@ -82,6 +86,5 @@ public static async Task Containerize(DirectoryInfo folder, string workingDir, s } } } - } } \ No newline at end of file diff --git a/Microsoft.NET.Build.Containers/ContentStore.cs b/Microsoft.NET.Build.Containers/ContentStore.cs index 76771900..3bdc1d42 100644 --- a/Microsoft.NET.Build.Containers/ContentStore.cs +++ b/Microsoft.NET.Build.Containers/ContentStore.cs @@ -34,6 +34,7 @@ public static string PathForDescriptor(Descriptor descriptor) { "application/vnd.docker.image.rootfs.diff.tar.gzip" or "application/vnd.oci.image.layer.v1.tar+gzip" + or "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip" => ".tar.gz", "application/vnd.docker.image.rootfs.diff.tar" or "application/vnd.oci.image.layer.v1.tar" diff --git a/Microsoft.NET.Build.Containers/CreateNewImage.Interface.cs b/Microsoft.NET.Build.Containers/CreateNewImage.Interface.cs index 072c24af..2a394cd7 100644 --- a/Microsoft.NET.Build.Containers/CreateNewImage.Interface.cs +++ b/Microsoft.NET.Build.Containers/CreateNewImage.Interface.cs @@ -93,12 +93,25 @@ 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; } + [Output] public string GeneratedContainerManifest { get; set; } [Output] public string GeneratedContainerConfiguration { get; set; } + public CreateNewImage() { ContainerizeDirectory = ""; @@ -117,6 +130,9 @@ public CreateNewImage() Labels = Array.Empty(); ExposedPorts = Array.Empty(); ContainerEnvironmentVariables = Array.Empty(); + ContainerRuntimeIdentifier = ""; + RuntimeIdentifierGraphPath = ""; + GeneratedContainerConfiguration = ""; GeneratedContainerManifest = ""; } diff --git a/Microsoft.NET.Build.Containers/CreateNewImage.cs b/Microsoft.NET.Build.Containers/CreateNewImage.cs index 40afe9fc..d21fca76 100644 --- a/Microsoft.NET.Build.Containers/CreateNewImage.cs +++ b/Microsoft.NET.Build.Containers/CreateNewImage.cs @@ -1,4 +1,5 @@ -using Microsoft.Build.Framework; +using System.Text.Json; +using Microsoft.Build.Framework; namespace Microsoft.NET.Build.Containers.Tasks; @@ -72,12 +73,12 @@ private void SetEnvironmentVariables(Image img, ITaskItem[] envVars) } } - private Image GetBaseImage() { + private Image? GetBaseImage() { if (IsDockerPull) { throw new ArgumentException("Don't know how to pull images from local daemons at the moment"); } else { var reg = new Registry(ContainerHelpers.TryExpandRegistryToUri(BaseRegistry)); - return reg.GetImageManifest(BaseImageName, BaseImageTag).Result; + return reg.GetImageManifest(BaseImageName, BaseImageTag, ContainerRuntimeIdentifier, RuntimeIdentifierGraphPath).Result; } } @@ -95,6 +96,11 @@ public override bool Execute() var image = GetBaseImage(); + if (image is null) { + Log.LogError($"Couldn't find matching base image for {0}:{1} that matches RuntimeIdentifier {2}", BaseImageName, BaseImageTag, ContainerRuntimeIdentifier); + return !Log.HasLoggedErrors; + } + SafeLog("Building image '{0}' with tags {1} on top of base image {2}/{3}:{4}", ImageName, String.Join(",", ImageTags), BaseRegistry, BaseImageName, BaseImageTag); Layer newLayer = Layer.FromDirectory(PublishDirectory, WorkingDirectory); @@ -118,7 +124,7 @@ public override bool Execute() } // at this point we're done with modifications and are just pushing the data other places - GeneratedContainerManifest = image.manifest.ToJsonString(); + GeneratedContainerManifest = JsonSerializer.Serialize(image.manifest); GeneratedContainerConfiguration = image.config.ToJsonString(); Registry? outputReg = IsDockerPush ? null : new Registry(ContainerHelpers.TryExpandRegistryToUri(OutputRegistry)); diff --git a/Microsoft.NET.Build.Containers/CreateNewImageToolTask.cs b/Microsoft.NET.Build.Containers/CreateNewImageToolTask.cs index cb92e06b..c9028625 100644 --- a/Microsoft.NET.Build.Containers/CreateNewImageToolTask.cs +++ b/Microsoft.NET.Build.Containers/CreateNewImageToolTask.cs @@ -83,7 +83,9 @@ protected override string GenerateCommandLineCommands() (ImageTags.Length > 0 ? " --imagetags " + String.Join(" ", ImageTags.Select((i) => Quote(i))) : "") + (EntrypointArgs.Length > 0 ? " --entrypointargs " + String.Join(" ", EntrypointArgs.Select((i) => i.ItemSpec)) : "") + (ExposedPorts.Length > 0 ? " --ports " + String.Join(" ", ExposedPorts.Select((i) => i.ItemSpec + "/" + i.GetMetadata("Type"))) : "") + - (ContainerEnvironmentVariables.Length > 0 ? " --environmentvariables " + String.Join(" ", ContainerEnvironmentVariables.Select((i) => i.ItemSpec + "=" + Quote(i.GetMetadata("Value")))) : ""); + (ContainerEnvironmentVariables.Length > 0 ? " --environmentvariables " + String.Join(" ", ContainerEnvironmentVariables.Select((i) => i.ItemSpec + "=" + Quote(i.GetMetadata("Value")))) : "") + + $" --rid {Quote(ContainerRuntimeIdentifier)}" + + $" --ridgraphpath {Quote(RuntimeIdentifierGraphPath)}"; } private string Quote(string path) diff --git a/Microsoft.NET.Build.Containers/Image.cs b/Microsoft.NET.Build.Containers/Image.cs index af2af13a..10993432 100644 --- a/Microsoft.NET.Build.Containers/Image.cs +++ b/Microsoft.NET.Build.Containers/Image.cs @@ -8,7 +8,7 @@ namespace Microsoft.NET.Build.Containers; public class Image { - public JsonNode manifest; + public ManifestV2 manifest; public JsonNode config; public readonly string OriginatingName; @@ -22,7 +22,7 @@ public class Image internal Dictionary environmentVariables; - public Image(JsonNode manifest, JsonNode config, string name, Registry? registry) + public Image(ManifestV2 manifest, JsonNode config, string name, Registry? registry) { this.manifest = manifest; this.config = config; @@ -38,21 +38,16 @@ public IEnumerable LayerDescriptors { get { - JsonNode? layersNode = manifest["layers"]; + var layersNode = manifest.layers; if (layersNode is null) { throw new NotImplementedException("Tried to get layer information but there is no layer node?"); } - foreach (JsonNode? descriptorJson in layersNode.AsArray()) + foreach (var layer in layersNode) { - if (descriptorJson is null) - { - throw new NotImplementedException("Null layer descriptor in the list?"); - } - - yield return descriptorJson.Deserialize(); + yield return new(layer.mediaType, layer.digest, layer.size); } } } @@ -60,7 +55,8 @@ public IEnumerable LayerDescriptors public void AddLayer(Layer l) { newLayers.Add(l); - manifest["layers"]!.AsArray().Add(l.Descriptor); + + manifest.layers.Add(new(l.Descriptor.MediaType, l.Descriptor.Size, l.Descriptor.Digest, l.Descriptor.Urls)); config["rootfs"]!["diff_ids"]!.AsArray().Add(l.Descriptor.UncompressedDigest); RecalculateDigest(); } @@ -68,9 +64,12 @@ public void AddLayer(Layer l) private void RecalculateDigest() { config["created"] = DateTime.UtcNow; - - manifest["config"]!["digest"] = GetDigest(config); - manifest["config"]!["size"] = Encoding.UTF8.GetBytes(config.ToJsonString()).Length; + var newManifestConfig = manifest.config with + { + digest = GetDigest(config), + size = Encoding.UTF8.GetBytes(config.ToJsonString()).Length + }; + manifest.config = newManifestConfig; } private JsonObject CreatePortMap() @@ -249,6 +248,13 @@ public string GetDigest(JsonNode json) return $"sha256:{hashString}"; } + public string GetDigest(T item) + { + var node = JsonSerializer.SerializeToNode(item); + if (node is not null) return GetDigest(node); + else return String.Empty; + } + public static string GetSha(JsonNode json) { Span hash = stackalloc byte[SHA256.HashSizeInBytes]; diff --git a/Microsoft.NET.Build.Containers/LocalDocker.cs b/Microsoft.NET.Build.Containers/LocalDocker.cs index 7346f710..8e039a05 100644 --- a/Microsoft.NET.Build.Containers/LocalDocker.cs +++ b/Microsoft.NET.Build.Containers/LocalDocker.cs @@ -47,19 +47,20 @@ public static async Task WriteImageToStream(Image x, string name, string tag, St foreach (var d in x.LayerDescriptors) { - if (!x.originatingRegistry.HasValue) + if (x.originatingRegistry is {} registry) { - throw new NotImplementedException("Need a good error for 'couldn't download a thing because no link to registry'"); - } + string localPath = await registry.DownloadBlob(x.OriginatingName, d); - string localPath = await x.originatingRegistry.Value.DownloadBlob(x.OriginatingName, d); - - // Stuff that (uncompressed) tarball into the image tar stream - // TODO uncompress!! - string layerTarballPath = $"{d.Digest.Substring("sha256:".Length)}/layer.tar"; - await writer.WriteEntryAsync(localPath, layerTarballPath); - layerTarballPaths.Add(layerTarballPath); - } + // Stuff that (uncompressed) tarball into the image tar stream + // TODO uncompress!! + string layerTarballPath = $"{d.Digest.Substring("sha256:".Length)}/layer.tar"; + await writer.WriteEntryAsync(localPath, layerTarballPath); + layerTarballPaths.Add(layerTarballPath); + } + else + { + throw new NotImplementedException("Need a good error for 'couldn't download a thing because no link to registry'"); + } } // add config string configTarballPath = $"{Image.GetSha(x.config)}.json"; diff --git a/Microsoft.NET.Build.Containers/Microsoft.NET.Build.Containers.csproj b/Microsoft.NET.Build.Containers/Microsoft.NET.Build.Containers.csproj index c9c77de3..af359f29 100644 --- a/Microsoft.NET.Build.Containers/Microsoft.NET.Build.Containers.csproj +++ b/Microsoft.NET.Build.Containers/Microsoft.NET.Build.Containers.csproj @@ -22,6 +22,7 @@ + diff --git a/Microsoft.NET.Build.Containers/Registry.cs b/Microsoft.NET.Build.Containers/Registry.cs index a3af8410..092a03a6 100644 --- a/Microsoft.NET.Build.Containers/Registry.cs +++ b/Microsoft.NET.Build.Containers/Registry.cs @@ -1,5 +1,5 @@ -using Microsoft.VisualBasic; - +using NuGet.Packaging; +using NuGet.RuntimeModel; using System.Diagnostics; using System.IO.Compression; using System.Net; @@ -9,17 +9,29 @@ using System.Reflection.Metadata.Ecma335; using System.Runtime.ExceptionServices; using System.Text; +using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Xml.Linq; namespace Microsoft.NET.Build.Containers; -public struct Registry +public record struct ManifestConfig(string mediaType, long size, string digest); +public record struct ManifestLayer(string mediaType, long size, string digest, string[]? urls); +public record struct ManifestV2(int schemaVersion, string tag, string mediaType, ManifestConfig config, List layers); + +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 ManifestListV2(int schemaVersion, string mediaType, PlatformSpecificManifest[] manifests); + + +public record struct Registry { private const string DockerManifestV2 = "application/vnd.docker.distribution.manifest.v2+json"; + private const string DockerManifestListV2 = "application/vnd.docker.distribution.manifest.list.v2+json"; private const string DockerContainerV1 = "application/vnd.docker.container.image.v1+json"; - private readonly Uri BaseUri { get; init; } + private readonly Uri BaseUri; private readonly string RegistryName => BaseUri.Host; public Registry(Uri baseUri) @@ -82,39 +94,115 @@ public readonly bool IsGoogleArtifactRegistry { /// private readonly bool SupportsParallelUploads => !IsAmazonECRRegistry; - public async Task GetImageManifest(string name, string reference) + public async Task GetImageManifest(string repositoryName, string reference, string runtimeIdentifier, string runtimeIdentifierGraphPath) { - HttpClient client = GetClient(); + var client = GetClient(); + var initialManifestResponse = await GetManifest(repositoryName, reference); + + return initialManifestResponse.Content.Headers.ContentType?.MediaType switch { + DockerManifestV2 => await TryReadSingleImage(repositoryName, await initialManifestResponse.Content.ReadFromJsonAsync()), + DockerManifestListV2 => await TryPickBestImageFromManifestList(repositoryName, reference, await initialManifestResponse.Content.ReadFromJsonAsync(), runtimeIdentifier, runtimeIdentifierGraphPath), + var unknownMediaType => throw new NotImplementedException($"The manifest for {repositoryName}:{reference} from registry {BaseUri} was an unknown type: {unknownMediaType}. Please raise an issue at https://github.com/dotnet/sdk-container-builds/issues with this message.") + }; + } - var response = await client.GetAsync(new Uri(BaseUri, $"/v2/{name}/manifests/{reference}")); + private async Task TryReadSingleImage(string repositoryName, ManifestV2 manifest) { + var config = manifest.config; + string configSha = config.digest; + + var blobResponse = await GetBlob(repositoryName, configSha); - response.EnsureSuccessStatusCode(); + JsonNode? configDoc = JsonNode.Parse(await blobResponse.Content.ReadAsStringAsync()); + Debug.Assert(configDoc is not null); - var s = await response.Content.ReadAsStringAsync(); + return new Image(manifest, configDoc, repositoryName, this); + } - var manifest = JsonNode.Parse(s); + async Task TryPickBestImageFromManifestList(string repositoryName, string reference, ManifestListV2 manifestList, string runtimeIdentifier, string runtimeIdentifierGraphPath) { + var runtimeGraph = GetRuntimeGraphForDotNet(runtimeIdentifierGraphPath); + var (ridDict, graphForManifestList) = ConstructRuntimeGraphForManifestList(manifestList, runtimeGraph); + var bestManifestRid = CheckIfRidExistsInGraph(graphForManifestList, ridDict.Keys, runtimeIdentifier); + if (bestManifestRid is null) { + throw new ArgumentException($"The runtimeIdentifier '{runtimeIdentifier}' is not supported. The supported RuntimeIdentifiers for the base image {repositoryName}:{reference} are {String.Join(",", graphForManifestList.Runtimes.Keys)}"); + } + var matchingManifest = ridDict[bestManifestRid]; + var manifestResponse = await GetManifest(repositoryName, matchingManifest.digest); + return await TryReadSingleImage(repositoryName, await manifestResponse.Content.ReadFromJsonAsync()); + } - if (manifest is null) throw new NotImplementedException("Got a manifest but it was null"); + private async Task GetManifest(string repositoryName, string reference) + { + var client = GetClient(); + var response = await client.GetAsync(new Uri(BaseUri, $"/v2/{repositoryName}/manifests/{reference}")); + response.EnsureSuccessStatusCode(); + return response; + } - if ((string?)manifest["mediaType"] != DockerManifestV2) - { - throw new NotImplementedException($"Do not understand the mediaType {manifest["mediaType"]}"); - } + async Task GetBlob(string repositoryName, string digest) + { + var client = GetClient(); + var response = await client.GetAsync(new Uri(BaseUri, $"/v2/{repositoryName}/blobs/{digest}")); + response.EnsureSuccessStatusCode(); + return response; + } - JsonNode? config = manifest["config"]; - Debug.Assert(config is not null); - Debug.Assert(((string?)config["mediaType"]) == DockerContainerV1); + private string? CheckIfRidExistsInGraph(RuntimeGraph graphForManifestList, IEnumerable leafRids, string userRid) => leafRids.FirstOrDefault(leaf => graphForManifestList.AreCompatible(leaf, userRid)); - string? configSha = (string?)config["digest"]; - Debug.Assert(configSha is not null); + private (IReadOnlyDictionary, RuntimeGraph) ConstructRuntimeGraphForManifestList(ManifestListV2 manifestList, RuntimeGraph dotnetRuntimeGraph) + { + var ridDict = new Dictionary(); + var runtimeDescriptionSet = new HashSet(); + foreach (var manifest in manifestList.manifests) { + if (CreateRidForPlatform(manifest.platform) is { } rid) + { + if (ridDict.TryAdd(rid, manifest)) { + AddRidAndDescendantsToSet(runtimeDescriptionSet, rid, dotnetRuntimeGraph); + } + } + } + + var graph = new RuntimeGraph(runtimeDescriptionSet); + return (ridDict, graph); + } - response = await client.GetAsync(new Uri(BaseUri, $"/v2/{name}/blobs/{configSha}")); + private string? CreateRidForPlatform(PlatformInformation platform) + { + // we only support linux and windows containers explicitly, so anything else we should skip past. + // there are theoretically other platforms/architectures that Docker supports (s390x?), but we are + // deliberately ignoring them without clear user signal. + 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", + _ => null + }; + + if (osPart is null || platformPart is null) return null; + return $"{osPart}{versionPart ?? ""}-{platformPart}"; + } - JsonNode? configDoc = JsonNode.Parse(await response.Content.ReadAsStringAsync()); - Debug.Assert(configDoc is not null); - //Debug.Assert(((string?)configDoc["mediaType"]) == DockerContainerV1); + private RuntimeGraph GetRuntimeGraphForDotNet(string ridGraphPath) => JsonRuntimeFormat.ReadRuntimeGraph(ridGraphPath); - return new Image(manifest, configDoc, name, this); + private void AddRidAndDescendantsToSet(HashSet runtimeDescriptionSet, string rid, RuntimeGraph dotnetRuntimeGraph) + { + var R = dotnetRuntimeGraph.Runtimes[rid]; + runtimeDescriptionSet.Add(R); + foreach (var r in R.InheritedRuntimes) AddRidAndDescendantsToSet(runtimeDescriptionSet, r, dotnetRuntimeGraph); } /// @@ -319,12 +407,10 @@ private HttpClient CreateClient() HttpClient client = new(clientHandler); client.DefaultRequestHeaders.Accept.Clear(); - client.DefaultRequestHeaders.Accept.Add( - new MediaTypeWithQualityHeaderValue("application/json")); - client.DefaultRequestHeaders.Accept.Add( - new MediaTypeWithQualityHeaderValue(DockerManifestV2)); - client.DefaultRequestHeaders.Accept.Add( - new MediaTypeWithQualityHeaderValue(DockerContainerV1)); + client.DefaultRequestHeaders.Accept.Add(new("application/json")); + client.DefaultRequestHeaders.Accept.Add(new(DockerManifestListV2)); + client.DefaultRequestHeaders.Accept.Add(new(DockerManifestV2)); + client.DefaultRequestHeaders.Accept.Add(new(DockerContainerV1)); client.DefaultRequestHeaders.Add("User-Agent", ".NET Container Library"); @@ -355,16 +441,17 @@ public async Task Push(Image x, string name, string? tag, string baseName, Actio { // The blob wasn't already available in another namespace, so fall back to explicitly uploading it - if (!x.originatingRegistry.HasValue) + if (x.originatingRegistry is { } registry) { + // Ensure the blob is available locally + await registry.DownloadBlob(x.OriginatingName, descriptor); + // Then push it to the destination registry + await reg.Push(Layer.FromDescriptor(descriptor), name, logProgressMessage); + logProgressMessage($"Finished uploading layer {digest} to {reg.RegistryName}"); + } + else { throw new NotImplementedException("Need a good error for 'couldn't download a thing because no link to registry'"); } - - // Ensure the blob is available locally - await x.originatingRegistry.Value.DownloadBlob(x.OriginatingName, descriptor); - // Then push it to the destination registry - await reg.Push(Layer.FromDescriptor(descriptor), name, logProgressMessage); - logProgressMessage($"Finished uploading layer {digest} to {reg.RegistryName}"); } }; @@ -389,8 +476,8 @@ public async Task Push(Image x, string name, string? tag, string baseName, Actio } var manifestDigest = x.GetDigest(x.manifest); - logProgressMessage($"Uploading manifest to {RegistryName} as blob {manifestDigest}"); - string jsonString = x.manifest.ToJsonString(); + logProgressMessage($"Uploading manifest to registry {RegistryName} as blob {manifestDigest}"); + string jsonString = JsonSerializer.SerializeToNode(x.manifest)?.ToJsonString() ?? ""; HttpContent manifestUploadContent = new StringContent(jsonString); manifestUploadContent.Headers.ContentType = new MediaTypeHeaderValue(DockerManifestV2); var putResponse = await client.PutAsync(new Uri(BaseUri, $"/v2/{name}/manifests/{manifestDigest}"), manifestUploadContent); diff --git a/Test.Microsoft.NET.Build.Containers.Filesystem/CreateNewImageTests.cs b/Test.Microsoft.NET.Build.Containers.Filesystem/CreateNewImageTests.cs index 21b32736..6ddbcff5 100644 --- a/Test.Microsoft.NET.Build.Containers.Filesystem/CreateNewImageTests.cs +++ b/Test.Microsoft.NET.Build.Containers.Filesystem/CreateNewImageTests.cs @@ -9,6 +9,14 @@ namespace Test.Microsoft.NET.Build.Containers.Tasks; [TestClass] public class CreateNewImageTests { + public static string RuntimeGraphFilePath() { + DirectoryInfo sdksDir = new(Path.Combine(Environment.GetEnvironmentVariable("DOTNET_ROOT"), "sdk")); + + var lastWrittenSdk = sdksDir.EnumerateDirectories().OrderByDescending(di => di.LastWriteTime).First(); + + return lastWrittenSdk.GetFiles("RuntimeIdentifierGraph.json").Single().FullName; + } + [TestMethod] public void CreateNewImage_Baseline() { @@ -34,7 +42,7 @@ public void CreateNewImage_Baseline() dotnetNew.WaitForExit(); Assert.AreEqual(0, dotnetNew.ExitCode); - info.Arguments = "build --configuration release"; + info.Arguments = "publish -c Release -r linux-arm64 --no-self-contained"; Process dotnetPublish = Process.Start(info); Assert.IsNotNull(dotnetPublish); @@ -47,11 +55,13 @@ public void CreateNewImage_Baseline() task.BaseImageTag = "7.0"; task.OutputRegistry = "localhost:5010"; - task.PublishDirectory = Path.Combine(newProjectDir.FullName, "bin", "release", "net7.0"); + task.PublishDirectory = Path.Combine(newProjectDir.FullName, "bin", "Release", "net7.0", "linux-arm64", "publish"); task.ImageName = "dotnet/testimage"; task.ImageTags = new[] { "latest" }; task.WorkingDirectory = "app/"; + task.ContainerRuntimeIdentifier = "linux-arm64"; task.Entrypoint = new TaskItem[] { new("dotnet"), new("build") }; + task.RuntimeIdentifierGraphPath = RuntimeGraphFilePath(); Assert.IsTrue(task.Execute()); newProjectDir.Delete(true); @@ -114,6 +124,8 @@ public void ParseContainerProperties_EndToEnd() cni.WorkingDirectory = "app/"; cni.Entrypoint = new TaskItem[] { new("ParseContainerProperties_EndToEnd") }; cni.ImageTags = pcp.NewContainerTags; + cni.ContainerRuntimeIdentifier = "linux-x64"; + cni.RuntimeIdentifierGraphPath = RuntimeGraphFilePath(); Assert.IsTrue(cni.Execute()); newProjectDir.Delete(true); @@ -190,6 +202,8 @@ public void Tasks_EndToEnd_With_EnvironmentVariable_Validation() cni.Entrypoint = new TaskItem[] { new("/app/Tasks_EndToEnd_With_EnvironmentVariable_Validation") }; cni.ImageTags = pcp.NewContainerTags; cni.ContainerEnvironmentVariables = pcp.NewContainerEnvironmentVariables; + cni.ContainerRuntimeIdentifier = "linux-x64"; + cni.RuntimeIdentifierGraphPath = RuntimeGraphFilePath(); Assert.IsTrue(cni.Execute()); diff --git a/Test.Microsoft.NET.Build.Containers.Filesystem/DockerRegistryManager.cs b/Test.Microsoft.NET.Build.Containers.Filesystem/DockerRegistryManager.cs index 716e25e3..e9500d44 100644 --- a/Test.Microsoft.NET.Build.Containers.Filesystem/DockerRegistryManager.cs +++ b/Test.Microsoft.NET.Build.Containers.Filesystem/DockerRegistryManager.cs @@ -7,9 +7,10 @@ public class DockerRegistryManager { public const string BaseImage = "dotnet/runtime"; public const string BaseImageSource = "mcr.microsoft.com/"; - public const string BaseImageTag = "7.0"; + public const string Net6ImageTag = "6.0"; + public const string Net7ImageTag = "7.0"; public const string LocalRegistry = "localhost:5010"; - public const string FullyQualifiedBaseImageDefault = $"{BaseImageSource}{BaseImage}:{BaseImageTag}"; + public const string FullyQualifiedBaseImageDefault = $"{BaseImageSource}{BaseImage}:{Net6ImageTag}"; private static string s_registryContainerId; private static void Exec(string command, string args) { @@ -48,9 +49,12 @@ public static void StartAndPopulateDockerRegistry(TestContext context) Assert.AreEqual(0, registryProcess.ExitCode, $"Could not start Docker registry. Are you running one for manual testing?{Environment.NewLine}{errStream}"); s_registryContainerId = registryContainerId; - Exec("docker", $"pull {BaseImageSource}{BaseImage}:{BaseImageTag}"); - Exec("docker", $"tag {BaseImageSource}{BaseImage}:{BaseImageTag} {LocalRegistry}/{BaseImage}:{BaseImageTag}"); - Exec("docker", $"push {LocalRegistry}/{BaseImage}:{BaseImageTag}"); + foreach (var tag in new[] { Net6ImageTag, Net7ImageTag }) + { + Exec("docker", $"pull {BaseImageSource}{BaseImage}:{tag}"); + Exec("docker", $"tag {BaseImageSource}{BaseImage}:{tag} {LocalRegistry}/{BaseImage}:{tag}"); + Exec("docker", $"push {LocalRegistry}/{BaseImage}:{tag}"); + } } public static void ShutdownDockerRegistry() diff --git a/Test.Microsoft.NET.Build.Containers.Filesystem/EndToEnd.cs b/Test.Microsoft.NET.Build.Containers.Filesystem/EndToEnd.cs index af7df094..0d1e8426 100644 --- a/Test.Microsoft.NET.Build.Containers.Filesystem/EndToEnd.cs +++ b/Test.Microsoft.NET.Build.Containers.Filesystem/EndToEnd.cs @@ -10,6 +10,14 @@ namespace Test.Microsoft.NET.Build.Containers.Filesystem; [TestClass] public class EndToEnd { + public static string RuntimeGraphFilePath() { + DirectoryInfo sdksDir = new(Path.Combine(Environment.GetEnvironmentVariable("DOTNET_ROOT"), "sdk")); + + var lastWrittenSdk = sdksDir.EnumerateDirectories().OrderByDescending(di => di.LastWriteTime).First(); + + return lastWrittenSdk.GetFiles("RuntimeIdentifierGraph.json").Single().FullName; + } + public static string NewImageName([CallerMemberName] string callerMemberName = "") { bool normalized = ContainerHelpers.NormalizeImageName(callerMemberName, out string normalizedName); @@ -48,7 +56,7 @@ public async Task ApiEndToEndWithRegistryPushAndPull() Registry registry = new Registry(ContainerHelpers.TryExpandRegistryToUri(DockerRegistryManager.LocalRegistry)); - Image x = await registry.GetImageManifest(DockerRegistryManager.BaseImage, DockerRegistryManager.BaseImageTag); + Image x = await registry.GetImageManifest(DockerRegistryManager.BaseImage, DockerRegistryManager.Net6ImageTag, "linux-x64", RuntimeGraphFilePath()); Layer l = Layer.FromDirectory(publishDirectory, "/app"); @@ -86,7 +94,7 @@ public async Task ApiEndToEndWithLocalLoad() Registry registry = new Registry(ContainerHelpers.TryExpandRegistryToUri(DockerRegistryManager.LocalRegistry)); - Image x = await registry.GetImageManifest(DockerRegistryManager.BaseImage, DockerRegistryManager.BaseImageTag); + Image x = await registry.GetImageManifest(DockerRegistryManager.BaseImage, DockerRegistryManager.Net6ImageTag, "linux-x64", RuntimeGraphFilePath()); Layer l = Layer.FromDirectory(publishDirectory, "/app"); @@ -108,7 +116,7 @@ public async Task ApiEndToEndWithLocalLoad() Assert.AreEqual(0, run.ExitCode); } - private static async Task BuildLocalApp() + private static async Task BuildLocalApp(string tfm = "net6.0", string rid = "linux-x64") { DirectoryInfo d = new DirectoryInfo("MinimalTestApp"); if (d.Exists) @@ -116,12 +124,12 @@ private static async Task BuildLocalApp() d.Delete(recursive: true); } - await Execute("dotnet", "new console -f net7.0 -o MinimalTestApp"); + await Execute("dotnet", $"new console -f {tfm} -o MinimalTestApp"); // Build project - await Execute("dotnet", "publish -bl MinimalTestApp -r linux-x64"); + await Execute("dotnet", $"publish -bl MinimalTestApp -r {rid} -f {tfm}"); - string publishDirectory = Path.Join("MinimalTestApp", "bin", "Debug", "net7.0", "linux-x64", "publish"); + string publishDirectory = Path.Join("MinimalTestApp", "bin", "Debug", tfm, rid, "publish"); return publishDirectory; } @@ -276,4 +284,62 @@ public async Task EndToEnd_NoAPI() newProjectDir.Delete(true); privateNuGetAssets.Delete(true); } + + // These two are commented because the Github Actions runers don't let us easily configure the Docker Buildx config - + // we need to configure it to allow emulation of other platforms on amd64 hosts before these two will run. + // They do run locally, however. + + //[DataRowAttribute("linux-arm", false, "/app", "linux/arm/v7")] // packaging framework-dependent because emulating arm on x64 Docker host doesn't work + //[DataRowAttribute("linux-arm64", false, "/app", "linux/arm64/v8")] // packaging framework-dependent because emulating arm64 on x64 Docker host doesn't work + + // this one should be skipped in all cases because we don't ship linux-x86 runtime packs, so we can't execute the 'apphost' version of the app + //[DataRowAttribute("linux-x86", false, "/app", "linux/386")] // packaging framework-dependent because missing runtime packs for x86 linux. + + // This one should be skipped because containers can't be configured to run on Linux hosts :( + //[DataRow("win-x64", true, "C:\\app", "windows/amd64")] + + // As a result, we only have one actual data-driven test + [DataRow("linux-x64", true, "/app", "linux/amd64")] + [DataTestMethod] + public async Task CanPackageForAllSupportedContainerRIDs(string rid, bool isRIDSpecific, string workingDir, string dockerPlatform) { + string publishDirectory = await BuildLocalApp(tfm : "net7.0", rid : (isRIDSpecific ? rid : null)); + + // Build the image + Registry registry = new Registry(ContainerHelpers.TryExpandRegistryToUri(DockerRegistryManager.BaseImageSource)); + + Image x = await registry.GetImageManifest(DockerRegistryManager.BaseImage, DockerRegistryManager.Net7ImageTag, rid, RuntimeGraphFilePath()); + + Layer l = Layer.FromDirectory(publishDirectory, "/app"); + + x.AddLayer(l); + x.WorkingDirectory = workingDir; + + var entryPoint = DecideEntrypoint(rid, isRIDSpecific, "MinimalTestApp", workingDir); + x.SetEntrypoint(entryPoint); + + // Load the image into the local Docker daemon + + await LocalDocker.Load(x, NewImageName(), rid, DockerRegistryManager.BaseImage); + + var args = $"run --rm --tty --platform {dockerPlatform} {NewImageName()}:{rid}"; + // Run the image + ProcessStartInfo runInfo = new("docker", args) { + RedirectStandardError = true, + RedirectStandardOutput = true, + }; + Process run = Process.Start(runInfo); + Assert.IsNotNull(run); + await run.WaitForExitAsync(); + + Assert.AreEqual(0, run.ExitCode, $"Arguments: {args}\n{run.StandardOutput.ReadToEnd()}\n{run.StandardError.ReadToEnd()}"); + + string[] DecideEntrypoint(string rid, bool isRIDSpecific, string appName, string workingDir) { + var binary = rid.StartsWith("win") ? $"{appName}.exe" : appName; + if (isRIDSpecific) { + return new[] { $"{workingDir}/{binary}" }; + } else { + return new[] { "dotnet", $"{workingDir}/{binary}.dll" }; + } + } + } } \ No newline at end of file diff --git a/Test.Microsoft.NET.Build.Containers.Filesystem/RegistryTests.cs b/Test.Microsoft.NET.Build.Containers.Filesystem/RegistryTests.cs index b14a1853..4105bba4 100644 --- a/Test.Microsoft.NET.Build.Containers.Filesystem/RegistryTests.cs +++ b/Test.Microsoft.NET.Build.Containers.Filesystem/RegistryTests.cs @@ -15,7 +15,12 @@ public async Task GetFromRegistry() { Registry registry = new Registry(ContainerHelpers.TryExpandRegistryToUri(DockerRegistryManager.LocalRegistry)); - Image downloadedImage = await registry.GetImageManifest(DockerRegistryManager.BaseImage, DockerRegistryManager.BaseImageTag); + // TODO: The DOTNET_ROOT comes from the test host, but we have no idea what the SDK version is. + var ridgraphfile = Path.Combine(Environment.GetEnvironmentVariable("DOTNET_ROOT"), "sdk", "7.0.100", "RuntimeIdentifierGraph.json"); + + // Don't need rid graph for local registry image pulls - since we're only pushing single image manifests (not manifest lists) + // as part of our setup, we could put literally anything in here. The file at the passed-in path would only get read when parsing manifests lists. + Image downloadedImage = await registry.GetImageManifest(DockerRegistryManager.BaseImage, DockerRegistryManager.Net6ImageTag, "linux-x64", ridgraphfile); Assert.IsNotNull(downloadedImage); } diff --git a/containerize/Program.cs b/containerize/Program.cs index 18a5490d..6f1a5396 100644 --- a/containerize/Program.cs +++ b/containerize/Program.cs @@ -151,6 +151,9 @@ AllowMultipleArgumentsPerToken = true }; +var ridOpt = new Option(name: "--rid", description: "Runtime Identifier of the generated container."); + +var ridGraphPathOpt = new Option(name: "--ridgraphpath", description: "Path to the RID graph file."); RootCommand root = new RootCommand("Containerize an application without Docker.") { @@ -166,7 +169,9 @@ entrypointArgsOpt, labelsOpt, portsOpt, - envVarsOpt + envVarsOpt, + ridOpt, + ridGraphPathOpt }; root.SetHandler(async (context) => @@ -184,7 +189,9 @@ string[] _labels = context.ParseResult.GetValueForOption(labelsOpt) ?? Array.Empty(); Port[] _ports = context.ParseResult.GetValueForOption(portsOpt) ?? Array.Empty(); string[] _envVars = context.ParseResult.GetValueForOption(envVarsOpt) ?? Array.Empty(); - await ContainerBuilder.Containerize(_publishDir, _workingDir, _baseReg, _baseName, _baseTag, _entrypoint, _entrypointArgs, _name, _tags, _outputReg, _labels, _ports, _envVars); + string _rid = context.ParseResult.GetValueForOption(ridOpt) ?? ""; + string _ridGraphPath = context.ParseResult.GetValueForOption(ridGraphPathOpt) ?? ""; + await ContainerBuilder.Containerize(_publishDir, _workingDir, _baseReg, _baseName, _baseTag, _entrypoint, _entrypointArgs, _name, _tags, _outputReg, _labels, _ports, _envVars, _rid, _ridGraphPath); }); return await root.InvokeAsync(args); diff --git a/docs/ContainerCustomization.md b/docs/ContainerCustomization.md index 4d1ee055..7fd9248e 100644 --- a/docs/ContainerCustomization.md +++ b/docs/ContainerCustomization.md @@ -5,9 +5,6 @@ You can control many aspects of the generated container through MSBuild properti > **Note** > The only exception to this is `RUN` commands - due to the way we build containers, those cannot be emulated. If you need this functionality, you will need to use a Dockerfile to build your container images. -> **Note** -> This package only supports Linux containers in this version. - ## ContainerBaseImage This property controls the image used as the basis for your image. By default, we will infer the following values for you based on the properties of your project: @@ -24,6 +21,18 @@ If you set a value here, you should set the fully-qualified name of the image to mcr.microsoft.com/dotnet/runtime:6.0 ``` +## ContainerRuntimeIdentifier + +This property controls the OS and platform used by your container if your [`ContainerBaseImage`](#containerbaseimage) is a 'Manifest List'. Manifest Lists are images that support more than one architecture behind a single, common, name. For example, the `mcr.microsoft.com/dotnet/runtime` image is a manifest list that supports `linux-x64`, `linux-arm`, `linux-arm64` and `win10-x64` images. + +When a Manifest List is your base image, we need to choose the most relevant image to use as the base. We do this by choosing the image that best matches the `RuntimeIdentifier` of your project. If you set a value here, we will use that value to choose the best image to use as the base. Valid values for this property will vary based on the image you choose, but will always be in the form of a .NET SDK Runtime Identifier. + +By default, if your project has a RuntimeIdentifier set, that value will be used. A RuntimeIdentifer is usually set via the `-r` parameter to the `dotnet publish` command, or by setting the `RuntimeIdentifier` property in a PublishProfile used from Visual Studio. + +```xml +linux-x64 +``` + ## ContainerRegistry This property controls the destination registry - the place that the newly-created image will be pushed to. diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md index 9b51219b..3046f9af 100644 --- a/docs/GettingStarted.md +++ b/docs/GettingStarted.md @@ -21,8 +21,6 @@ The `Microsoft.NET.Build.Containers` package infers a number of properties about For more information, see [Customizing a container](./ContainerCustomization.md) -> **Note** -> This package only supports Linux containers in this version. > **Note** > If you are publishing a console application (or any non-Web project) you will need to add the `/t:PublishContainer` option to the command line above. See [dotnet/sdk-container-builds#141](https://github.com/dotnet/sdk-container-builds/issues/141) for more details. \ No newline at end of file diff --git a/docs/ZeroToContainer.md b/docs/ZeroToContainer.md index 2b386d71..087dd7c0 100644 --- a/docs/ZeroToContainer.md +++ b/docs/ZeroToContainer.md @@ -7,7 +7,6 @@ You should expect it to shrink noticeably over time! * [.NET SDK 7.0.100-preview.7](https://dotnet.microsoft.com/download/dotnet/7.0) or higher * Docker should be installed and running -* On Windows, Docker must be [configured for Linux containers](https://docs.microsoft.com/virtualization/windowscontainers/quick-start/quick-start-windows-10-linux) ## Usage diff --git a/packaging/README.md b/packaging/README.md index 7fb9a94b..82ad16da 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -17,6 +17,5 @@ Pushed container ':' to registry 'docker://' Out of the box, this package will infer a number of properties about the generated container image, including which base image to use, which version of that image to use, and where to push the generated image. You have control over all of these properties, however. You can read more about these customizations [here](https://aka.ms/dotnet/containers/customization). -**Note**: This package only supports Linux containers in this version. **Note**: This package only supports Web projects (those that use the `Microsoft.NET.Sdk.Web` SDK) in this version. \ No newline at end of file diff --git a/packaging/build/Microsoft.NET.Build.Containers.targets b/packaging/build/Microsoft.NET.Build.Containers.targets index 8cc1e164..6476fda1 100644 --- a/packaging/build/Microsoft.NET.Build.Containers.targets +++ b/packaging/build/Microsoft.NET.Build.Containers.targets @@ -23,13 +23,6 @@ - - - - - @@ -51,6 +44,8 @@ $(RegistryUrl) $(PublishImageTag) + + $([System.DateTime]::UtcNow.ToString('yyyyMMddhhmmss')) @@ -66,7 +61,10 @@ $(Version) $([System.DateTime]::UtcNow.ToString('yyyyMMddhhmmss')) /app - + + $(RuntimeIdentifier) + $(NETCoreSdkPortableRuntimeIdentifier) @@ -77,6 +75,7 @@ + @@ -100,7 +99,6 @@ _ContainerVerifySDKVersion; - _ContainerVerifyRuntime; ComputeContainerConfig @@ -128,7 +126,9 @@ EntrypointArgs="@(ContainerEntrypointArgs)" Labels="@(ContainerLabel)" ExposedPorts="@(ContainerPort)" - ContainerEnvironmentVariables="@(ContainerEnvironmentVariables)"> + ContainerEnvironmentVariables="@(ContainerEnvironmentVariables)" + ContainerRuntimeIdentifier="$(ContainerRuntimeIdentifier)" + RuntimeIdentifierGraphPath="$(RuntimeIdentifierGraphPath)"> diff --git a/packaging/package.csproj b/packaging/package.csproj index f03721dd..f8a3d042 100644 --- a/packaging/package.csproj +++ b/packaging/package.csproj @@ -49,6 +49,8 @@ + +