ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
1 changed files with 145336 additions and 0 deletions
209
packages/fsharp-runner/oci/OciLayout.cs
Normal file
209
packages/fsharp-runner/oci/OciLayout.cs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace Quorate.Oci;
|
||||
|
||||
internal sealed record OciBlob(OciDigest Digest, long Size, string Path);
|
||||
|
||||
internal sealed record OciManifest(OciDigest Digest, string MediaType, byte[] Content);
|
||||
|
||||
internal sealed record OciPublicationPlan(
|
||||
OciDigest RootDigest,
|
||||
string RootMediaType,
|
||||
byte[] RootContent,
|
||||
IReadOnlyList<OciBlob> Blobs,
|
||||
IReadOnlyList<OciManifest> Manifests);
|
||||
|
||||
internal static class OciLayout
|
||||
{
|
||||
private const string RefName = "org.opencontainers.image.ref.name";
|
||||
|
||||
public static async Task<OciPublicationPlan> LoadAsync(
|
||||
string layout,
|
||||
OciTag sourceTag,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(layout);
|
||||
string root = Path.GetFullPath(layout);
|
||||
string blobRoot = Path.Combine(root, "blobs", "sha256");
|
||||
byte[] indexContent = await File.ReadAllBytesAsync(
|
||||
Path.Combine(root, "index.json"),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
OciDocumentModel index = Deserialize(indexContent, "OCI index");
|
||||
OciDescriptorModel rootDescriptor = SelectRoot(index, sourceTag);
|
||||
var blobs = new Dictionary<OciDigest, OciBlob>();
|
||||
var manifests = new List<OciManifest>();
|
||||
var complete = new HashSet<OciDigest>();
|
||||
var active = new HashSet<OciDigest>();
|
||||
|
||||
await VisitAsync(
|
||||
blobRoot,
|
||||
rootDescriptor,
|
||||
blobs,
|
||||
manifests,
|
||||
complete,
|
||||
active,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
OciManifest rootManifest = manifests[^1];
|
||||
return new OciPublicationPlan(
|
||||
rootManifest.Digest,
|
||||
rootManifest.MediaType,
|
||||
rootManifest.Content,
|
||||
blobs.Values.OrderBy(item => item.Digest.ToString(), StringComparer.Ordinal).ToArray(),
|
||||
manifests);
|
||||
}
|
||||
|
||||
private static async Task VisitAsync(
|
||||
string blobRoot,
|
||||
OciDescriptorModel descriptor,
|
||||
Dictionary<OciDigest, OciBlob> blobs,
|
||||
List<OciManifest> manifests,
|
||||
HashSet<OciDigest> complete,
|
||||
HashSet<OciDigest> active,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
OciDigest digest = OciDigest.Parse(descriptor.Digest);
|
||||
|
||||
if (complete.Contains(digest))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active.Add(digest))
|
||||
{
|
||||
throw new InvalidDataException($"OCI manifest graph contains a cycle at {digest}.");
|
||||
}
|
||||
|
||||
string mediaType = RequireManifestType(descriptor.MediaType);
|
||||
string path = BlobPath(blobRoot, digest);
|
||||
byte[] content = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
ValidateSize(descriptor.Size, content.LongLength, digest);
|
||||
OciDigest actual = OciDigest.Compute(content);
|
||||
|
||||
if (actual != digest)
|
||||
{
|
||||
throw new InvalidDataException($"Manifest digest {actual} does not match {digest}.");
|
||||
}
|
||||
|
||||
OciDocumentModel document = Deserialize(content, $"OCI manifest {digest}");
|
||||
|
||||
if (IsIndex(mediaType))
|
||||
{
|
||||
OciDescriptorModel[] children = document.Manifests
|
||||
?? throw new InvalidDataException($"OCI index {digest} has no manifests.");
|
||||
|
||||
foreach (OciDescriptorModel child in children)
|
||||
{
|
||||
await VisitAsync(
|
||||
blobRoot,
|
||||
child,
|
||||
blobs,
|
||||
manifests,
|
||||
complete,
|
||||
active,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddBlob(blobRoot, document.Config, blobs, digest, "config");
|
||||
OciDescriptorModel[] layers = document.Layers
|
||||
?? throw new InvalidDataException($"OCI manifest {digest} has no layers.");
|
||||
|
||||
foreach (OciDescriptorModel layer in layers)
|
||||
{
|
||||
AddBlob(blobRoot, layer, blobs, digest, "layer");
|
||||
}
|
||||
}
|
||||
|
||||
active.Remove(digest);
|
||||
complete.Add(digest);
|
||||
manifests.Add(new OciManifest(digest, mediaType, content));
|
||||
}
|
||||
|
||||
private static void AddBlob(
|
||||
string blobRoot,
|
||||
OciDescriptorModel? descriptor,
|
||||
Dictionary<OciDigest, OciBlob> blobs,
|
||||
OciDigest manifest,
|
||||
string kind)
|
||||
{
|
||||
if (descriptor is null)
|
||||
{
|
||||
throw new InvalidDataException($"OCI manifest {manifest} has no {kind} descriptor.");
|
||||
}
|
||||
|
||||
OciDigest digest = OciDigest.Parse(descriptor.Digest);
|
||||
var blob = new OciBlob(digest, descriptor.Size, BlobPath(blobRoot, digest));
|
||||
|
||||
if (blobs.TryGetValue(digest, out OciBlob? existing) && existing.Size != blob.Size)
|
||||
{
|
||||
throw new InvalidDataException($"OCI blob {digest} has conflicting sizes.");
|
||||
}
|
||||
|
||||
blobs.TryAdd(digest, blob);
|
||||
}
|
||||
|
||||
private static OciDescriptorModel SelectRoot(OciDocumentModel index, OciTag tag)
|
||||
{
|
||||
OciDescriptorModel[] manifests = index.Manifests
|
||||
?? throw new InvalidDataException("OCI index has no manifests.");
|
||||
OciDescriptorModel[] named = manifests.Where(item =>
|
||||
item.Annotations?.TryGetValue(RefName, out string? name) == true &&
|
||||
StringComparer.Ordinal.Equals(name, tag.ToString())).ToArray();
|
||||
|
||||
return named.Length switch
|
||||
{
|
||||
1 => named[0],
|
||||
0 when manifests.Length == 1 => manifests[0],
|
||||
_ => throw new InvalidDataException($"OCI layout does not contain one root for {tag}.")
|
||||
};
|
||||
}
|
||||
|
||||
private static OciDocumentModel Deserialize(byte[] content, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
OciDocumentModel value = JsonSerializer.Deserialize(
|
||||
content,
|
||||
OciJsonContext.Default.OciDocumentModel)
|
||||
?? throw new InvalidDataException($"{name} is empty.");
|
||||
|
||||
if (value.SchemaVersion != 2)
|
||||
{
|
||||
throw new InvalidDataException($"{name} has unsupported schema version {value.SchemaVersion}.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
catch (JsonException error)
|
||||
{
|
||||
throw new InvalidDataException($"{name} is invalid.", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static string RequireManifestType(string? mediaType) =>
|
||||
mediaType switch
|
||||
{
|
||||
"application/vnd.docker.distribution.manifest.v2+json" => mediaType,
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json" => mediaType,
|
||||
"application/vnd.oci.image.manifest.v1+json" => mediaType,
|
||||
"application/vnd.oci.image.index.v1+json" => mediaType,
|
||||
null => throw new InvalidDataException("Manifest media type is unavailable."),
|
||||
_ => throw new InvalidDataException($"Unsupported manifest media type {mediaType}.")
|
||||
};
|
||||
|
||||
private static bool IsIndex(string mediaType) =>
|
||||
mediaType is "application/vnd.docker.distribution.manifest.list.v2+json" or
|
||||
"application/vnd.oci.image.index.v1+json";
|
||||
|
||||
private static void ValidateSize(long expected, long actual, OciDigest digest)
|
||||
{
|
||||
if (expected < 0 || expected != actual)
|
||||
{
|
||||
throw new InvalidDataException($"OCI content {digest} has size {actual}, expected {expected}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BlobPath(string root, OciDigest digest) => Path.Combine(root, digest.Hex);
|
||||
}
|
||||
Loading…
Reference in a new issue