Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support manifest lists and use the publish RID to pick the most relevant manifest from the list #247

Merged
merged 25 commits into from
Jan 19, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fdb756a
support manifest lists and use the RID to pick the most relevant mani…
baronfel Nov 11, 2022
5209f49
don't dispose the client
baronfel Nov 11, 2022
0468080
write tests for packaging an app in all supported configurations
baronfel Nov 21, 2022
c75bf13
more thought put into error messages
baronfel Nov 21, 2022
ca47a5c
WIP support real RID graphs
baronfel Dec 1, 2022
021c629
remove full dependency tracking and just use exact-matching
baronfel Dec 20, 2022
3510ea2
supply platform tags to allow non-native-architecture-images to run d…
baronfel Dec 20, 2022
999612f
use correct docker platform
baronfel Dec 20, 2022
fc072a6
debug actions build's docker context
baronfel Dec 20, 2022
4743318
try to add more platforms to the buildx container
baronfel Dec 20, 2022
a00a519
document the sadness that is cross-platform container execution
baronfel Dec 20, 2022
08c09df
remove artificial block for linux-x64
baronfel Dec 21, 2022
da69e11
re-add runtime inheritance for manifest list runtimes
baronfel Dec 21, 2022
aeb099c
fix the things
baronfel Dec 21, 2022
2ad8afd
documentation for the new ContainerRuntimeIdentifier property
baronfel Dec 21, 2022
ee9f48b
merge from main
baronfel Jan 16, 2023
bbf039a
Add NuGet assemblies to package
rainersigwald Jan 19, 2023
18ccb52
Pass RID through ToolTask implementation
rainersigwald Jan 19, 2023
af582c5
Merge branch 'main' into support-other-arch-and-os
baronfel Jan 19, 2023
37684cc
Pick a rid graph at semi-random for testing
rainersigwald Jan 19, 2023
53a8b6c
remove invalid mediaType check
baronfel Jan 19, 2023
8bbd201
Address some review questions
baronfel Jan 19, 2023
bfba2ac
only use the LEAF rids to compute compatibility with user-provided RIDs
baronfel Jan 19, 2023
abd8c18
bring clarity to the RIDs
baronfel Jan 19, 2023
81c9768
remove linux only messaging
baronfel Jan 19, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions Microsoft.NET.Build.Containers/ContainerBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ 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)
{
var isDockerPull = String.IsNullOrEmpty(registryName);
if (isDockerPull) {
throw new ArgumentException("Don't know how to pull images from local daemons at the moment");
}
Registry baseRegistry = new Registry(ContainerHelpers.TryExpandRegistryToUri(registryName));

Image img = await baseRegistry.GetImageManifest(baseName, baseTag);
var img = await baseRegistry.GetImageManifest(baseName, baseTag, containerRuntimeIdentifier);
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()
Expand Down Expand Up @@ -82,6 +86,5 @@ public static async Task Containerize(DirectoryInfo folder, string workingDir, s
}
}
}

}
}
9 changes: 9 additions & 0 deletions Microsoft.NET.Build.Containers/CreateNewImage.Interface.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ partial class CreateNewImage
/// </summary>
public ITaskItem[] ContainerEnvironmentVariables { get; set; }

/// <summary>
/// The RID to use to determine the host manifest if the parent container is a manifest list
/// </summary>
[Required]
public string ContainerRuntimeIdentifier { get; set; }


[Output]
public string GeneratedContainerManifest { get; set; }

Expand All @@ -117,6 +124,8 @@ public CreateNewImage()
Labels = Array.Empty<ITaskItem>();
ExposedPorts = Array.Empty<ITaskItem>();
ContainerEnvironmentVariables = Array.Empty<ITaskItem>();
ContainerRuntimeIdentifier = "";

GeneratedContainerConfiguration = "";
GeneratedContainerManifest = "";
}
Expand Down
14 changes: 10 additions & 4 deletions Microsoft.NET.Build.Containers/CreateNewImage.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Build.Framework;
using System.Text.Json;
using Microsoft.Build.Framework;

namespace Microsoft.NET.Build.Containers.Tasks;

Expand Down Expand Up @@ -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).Result;
}
}

Expand All @@ -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);
Expand All @@ -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));
Expand Down
34 changes: 20 additions & 14 deletions Microsoft.NET.Build.Containers/Image.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,7 +22,7 @@ public class Image

internal Dictionary<string, string> 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;
Expand All @@ -38,39 +38,38 @@ public IEnumerable<Descriptor> 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<Descriptor>();
yield return new(layer.mediaType, layer.digest, layer.size);
}
}
}

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();
}

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;
baronfel marked this conversation as resolved.
Show resolved Hide resolved
}

private JsonObject CreatePortMap()
Expand Down Expand Up @@ -249,6 +248,13 @@ public string GetDigest(JsonNode json)
return $"sha256:{hashString}";
}

public string GetDigest<T>(T item)
{
var node = JsonSerializer.SerializeToNode(item);
if (node is not null) return GetDigest(node);
else return String.Empty;
baronfel marked this conversation as resolved.
Show resolved Hide resolved
}

public static string GetSha(JsonNode json)
{
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
Expand Down
4 changes: 2 additions & 2 deletions Microsoft.NET.Build.Containers/LocalDocker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ 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 null)
{
throw new NotImplementedException("Need a good error for 'couldn't download a thing because no link to registry'");
}

string localPath = await x.originatingRegistry.Value.DownloadBlob(x.OriginatingName, d);
string localPath = await x.originatingRegistry.DownloadBlob(x.OriginatingName, d);

// Stuff that (uncompressed) tarball into the image tar stream
// TODO uncompress!!
Expand Down
128 changes: 88 additions & 40 deletions Microsoft.NET.Build.Containers/Registry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,52 +8,102 @@
using System.Net.Http.Json;
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 record struct Registry(Uri BaseUri)
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<ManifestLayer> layers);

// not a complete list, only the subset that we support
// public enum GoOS { linux, windows };
// not a complete list, only the subset that we support
// public enum GoArch { amd64, arm , arm64, [JsonStringEnumMember("386")] x386 };
baronfel marked this conversation as resolved.
Show resolved Hide resolved
public record struct PlatformInformation(string architecture, string os, string? variant, string[] features);
public record struct PlatformSpecificManifest(string mediaType, long size, string digest, PlatformInformation platform);
public record struct ManifestListV2(int schemaVersion, string mediaType, PlatformSpecificManifest[] manifests);


public record Registry(Uri BaseUri)
{
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 const int MaxChunkSizeBytes = 1024 * 64;

public static string[] SupportedRuntimeIdentifiers = new [] {
"linux-x86",
"linux-x64",
"linux-arm",
"linux-arm64",
"win-x64"
};

private string RegistryName { get; } = BaseUri.Host;

public async Task<Image> GetImageManifest(string name, string reference)
public async Task<Image?> GetImageManifest(string name, string reference, string runtimeIdentifier)
{
HttpClient client = GetClient();

var response = await client.GetAsync(new Uri(BaseUri, $"/v2/{name}/manifests/{reference}"));

response.EnsureSuccessStatusCode();

var s = await response.Content.ReadAsStringAsync();

var manifest = JsonNode.Parse(s);

if (manifest is null) throw new NotImplementedException("Got a manifest but it was null");

if ((string?)manifest["mediaType"] != DockerManifestV2)
{
throw new NotImplementedException($"Do not understand the mediaType {manifest["mediaType"]}");
var client = GetClient();
var initialManifestResponse = await GetManifest(reference);

return initialManifestResponse.Content.Headers.ContentType?.MediaType switch {
DockerManifestV2 => await TryReadSingleImage(await initialManifestResponse.Content.ReadFromJsonAsync<ManifestV2>()),
DockerManifestListV2 => await TryPickBestImageFromManifestList(await initialManifestResponse.Content.ReadFromJsonAsync<ManifestListV2>(), runtimeIdentifier),
var unknownMediaType => throw new NotImplementedException($"The manifest for {name}:{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.")
};

async Task<HttpResponseMessage> GetManifest(string reference) {
var client = GetClient();
var response = await client.GetAsync(new Uri(BaseUri, $"/v2/{name}/manifests/{reference}"));
response.EnsureSuccessStatusCode();
return response;
}

JsonNode? config = manifest["config"];
Debug.Assert(config is not null);
Debug.Assert(((string?)config["mediaType"]) == DockerContainerV1);
async Task<HttpResponseMessage> GetBlob(string digest) {
var client = GetClient();
var response = await client.GetAsync(new Uri(BaseUri, $"/v2/{name}/blobs/{digest}"));
response.EnsureSuccessStatusCode();
return response;
}

string? configSha = (string?)config["digest"];
Debug.Assert(configSha is not null);
async Task<Image?> TryReadSingleImage(ManifestV2 manifest) {
var config = manifest.config;
string configSha = config.digest;

var blobResponse = await GetBlob(configSha);

response = await client.GetAsync(new Uri(BaseUri, $"/v2/{name}/blobs/{configSha}"));
JsonNode? configDoc = JsonNode.Parse(await blobResponse.Content.ReadAsStringAsync());
Debug.Assert(configDoc is not null);

JsonNode? configDoc = JsonNode.Parse(await response.Content.ReadAsStringAsync());
Debug.Assert(configDoc is not null);
//Debug.Assert(((string?)configDoc["mediaType"]) == DockerContainerV1);
return new Image(manifest, configDoc, name, this);
}

return new Image(manifest, configDoc, name, this);
async Task<Image?> TryPickBestImageFromManifestList(ManifestListV2 manifestList, string runtimeIdentifier) {
// TODO: we probably need to pull in actual RID parsing code and look for 'platform' here.
baronfel marked this conversation as resolved.
Show resolved Hide resolved
// 'win' can take a version number and we'd break.
// Also, there are more specific linux RIDs (like rhel) that we should instead be looking for the correct 'family' for?
// we probably also need to look at the 'variant' field if the RID contains a version.
(string os, string arch, string? variant) = runtimeIdentifier.Split('-') switch {
["linux", "x64"] => ("linux", "amd64", null),
["linux", "x86"] => ("linux", "386", null),
["linux", "arm"] => ("linux", "arm", "v7"),
["linux", "arm64"] => ("linux", "arm64", "v8"),
["win", "x64"] => ("windows", "amd64", null),
var parts => throw new ArgumentException($"The runtimeIdentifier '{runtimeIdentifier}' is not supported. The supported RuntimeIdentifiers are {Registry.SupportedRuntimeIdentifiers}.")
};

var potentialManifest = manifestList.manifests.SingleOrDefault(manifest => manifest.platform.os == os && manifest.platform.architecture == arch && manifest.platform.variant == variant);
if (potentialManifest != default) {
var manifestResponse = await GetManifest(potentialManifest.digest);
return await TryReadSingleImage(await manifestResponse.Content.ReadFromJsonAsync<ManifestV2>());
} else {
return null;
}
}
}

/// <summary>
Expand Down Expand Up @@ -103,7 +153,7 @@ public async Task Push(Layer layer, string name, Action<string> logProgressMessa
}
}

private readonly async Task UploadBlob(string name, string digest, Stream contents)
private async Task UploadBlob(string name, string digest, Stream contents)
{
HttpClient client = GetClient();

Expand All @@ -118,7 +168,7 @@ private readonly async Task UploadBlob(string name, string digest, Stream conten

if (pushResponse.StatusCode != HttpStatusCode.Accepted)
{
string errorMessage = $"Failed to upload blob to {pushUri}; recieved {pushResponse.StatusCode} with detail {await pushResponse.Content.ReadAsStringAsync()}";
string errorMessage = $"Failed to upload blob to {pushUri}; received {pushResponse.StatusCode} with detail {await pushResponse.Content.ReadAsStringAsync()}";
throw new ApplicationException(errorMessage);
}

Expand Down Expand Up @@ -199,7 +249,7 @@ private readonly async Task UploadBlob(string name, string digest, Stream conten
}
}

private readonly async Task<bool> BlobAlreadyUploaded(string name, string digest, HttpClient client)
private async Task<bool> BlobAlreadyUploaded(string name, string digest, HttpClient client)
{
HttpResponseMessage response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, new Uri(BaseUri, $"/v2/{name}/blobs/{digest}")));

Expand All @@ -224,12 +274,10 @@ private static 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");

Expand Down Expand Up @@ -258,13 +306,13 @@ await Task.WhenAll(x.LayerDescriptors.Select(async descriptor => {
{
// The blob wasn't already available in another namespace, so fall back to explicitly uploading it

if (!x.originatingRegistry.HasValue)
if (x.originatingRegistry is null)
{
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);
await x.originatingRegistry.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}");
Expand All @@ -280,8 +328,8 @@ await Task.WhenAll(x.LayerDescriptors.Select(async descriptor => {
}

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);
Expand Down
Loading