365 lines
13 KiB
C#
365 lines
13 KiB
C#
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.Cryptography;
|
|
using System.Globalization;
|
|
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.Scanner.Storage.Oci;
|
|
|
|
public sealed class OciArtifactPusher
|
|
{
|
|
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = false
|
|
};
|
|
|
|
private static readonly byte[] EmptyConfigBlob = "{}"u8.ToArray();
|
|
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ICryptoHash _cryptoHash;
|
|
private readonly OciRegistryOptions _options;
|
|
private readonly ILogger<OciArtifactPusher> _logger;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
public OciArtifactPusher(
|
|
HttpClient httpClient,
|
|
ICryptoHash cryptoHash,
|
|
OciRegistryOptions options,
|
|
ILogger<OciArtifactPusher> logger,
|
|
TimeProvider? timeProvider = null)
|
|
{
|
|
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
|
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
public async Task<OciArtifactPushResult> PushAsync(
|
|
OciArtifactPushRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.Reference);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.ArtifactType);
|
|
|
|
if (request.Layers.Count == 0)
|
|
{
|
|
return OciArtifactPushResult.Failed("No layers supplied for OCI push.");
|
|
}
|
|
|
|
var reference = OciImageReference.Parse(request.Reference, _options.DefaultRegistry);
|
|
if (reference is null)
|
|
{
|
|
return OciArtifactPushResult.Failed($"Invalid OCI reference: {request.Reference}");
|
|
}
|
|
|
|
var auth = OciRegistryAuthorization.FromOptions(reference.Registry, _options.Auth);
|
|
|
|
try
|
|
{
|
|
var configDigest = ComputeDigest(EmptyConfigBlob);
|
|
|
|
var layerDescriptors = new List<OciDescriptor>();
|
|
var layerDigests = new List<string>();
|
|
|
|
foreach (var layer in request.Layers)
|
|
{
|
|
var digest = ComputeDigest(layer.Content);
|
|
|
|
layerDescriptors.Add(new OciDescriptor
|
|
{
|
|
MediaType = layer.MediaType,
|
|
Digest = digest,
|
|
Size = layer.Content.Length,
|
|
Annotations = NormalizeAnnotations(layer.Annotations)
|
|
});
|
|
|
|
layerDigests.Add(digest);
|
|
}
|
|
|
|
var manifest = BuildManifest(request, configDigest, layerDescriptors);
|
|
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, SerializerOptions);
|
|
var manifestDigest = ComputeDigest(manifestBytes);
|
|
|
|
var tag = request.Tag;
|
|
if (string.IsNullOrWhiteSpace(tag))
|
|
{
|
|
tag = reference.Tag ?? manifestDigest.Replace("sha256:", string.Empty, StringComparison.Ordinal);
|
|
}
|
|
|
|
if (request.SkipIfTagExists)
|
|
{
|
|
var existingDigest = await TryGetExistingManifestDigestAsync(reference, tag, auth, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (existingDigest is not null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(existingDigest))
|
|
{
|
|
existingDigest = manifestDigest;
|
|
}
|
|
|
|
var existingReference = $"{reference.Registry}/{reference.Repository}@{existingDigest}";
|
|
_logger.LogInformation("OCI artifact already exists for tag {Tag}: {Reference}", tag, existingReference);
|
|
return new OciArtifactPushResult
|
|
{
|
|
Success = true,
|
|
AlreadyExists = true,
|
|
ManifestDigest = existingDigest,
|
|
ManifestReference = existingReference,
|
|
LayerDigests = layerDigests
|
|
};
|
|
}
|
|
}
|
|
|
|
await PushBlobAsync(reference, EmptyConfigBlob, OciMediaTypes.EmptyConfig, auth, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
foreach (var layer in request.Layers)
|
|
{
|
|
await PushBlobAsync(reference, layer.Content, layer.MediaType, auth, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
await PushManifestAsync(reference, manifestBytes, tag, auth, cancellationToken).ConfigureAwait(false);
|
|
|
|
var manifestReference = $"{reference.Registry}/{reference.Repository}@{manifestDigest}";
|
|
|
|
_logger.LogInformation("Pushed OCI artifact {Reference}", manifestReference);
|
|
|
|
return new OciArtifactPushResult
|
|
{
|
|
Success = true,
|
|
AlreadyExists = false,
|
|
ManifestDigest = manifestDigest,
|
|
ManifestReference = manifestReference,
|
|
LayerDigests = layerDigests
|
|
};
|
|
}
|
|
catch (OciRegistryException ex)
|
|
{
|
|
_logger.LogError(ex, "OCI push failed: {Message}", ex.Message);
|
|
return OciArtifactPushResult.Failed(ex.Message);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
_logger.LogError(ex, "OCI push HTTP error: {Message}", ex.Message);
|
|
return OciArtifactPushResult.Failed($"HTTP error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<string?> TryGetExistingManifestDigestAsync(
|
|
OciImageReference reference,
|
|
string tag,
|
|
OciRegistryAuthorization auth,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var manifestUri = BuildRegistryUri(reference, $"manifests/{tag}");
|
|
using var request = new HttpRequestMessage(HttpMethod.Head, manifestUri);
|
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(OciMediaTypes.ImageManifest));
|
|
auth.ApplyTo(request);
|
|
|
|
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new OciRegistryException($"Manifest HEAD failed with {response.StatusCode}", "ERR_OCI_MANIFEST_HEAD");
|
|
}
|
|
|
|
if (response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues))
|
|
{
|
|
var digest = digestValues.FirstOrDefault();
|
|
if (!string.IsNullOrWhiteSpace(digest))
|
|
{
|
|
return digest;
|
|
}
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
private OciArtifactManifest BuildManifest(
|
|
OciArtifactPushRequest request,
|
|
string configDigest,
|
|
IReadOnlyList<OciDescriptor> layers)
|
|
{
|
|
var annotations = NormalizeAnnotations(request.Annotations);
|
|
if (annotations is null)
|
|
{
|
|
annotations = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
|
}
|
|
|
|
annotations["org.opencontainers.image.created"] = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
|
|
annotations["org.opencontainers.image.title"] = request.ArtifactType;
|
|
|
|
return new OciArtifactManifest
|
|
{
|
|
MediaType = OciMediaTypes.ImageManifest,
|
|
ArtifactType = request.ArtifactType,
|
|
Config = new OciDescriptor
|
|
{
|
|
MediaType = OciMediaTypes.EmptyConfig,
|
|
Digest = configDigest,
|
|
Size = EmptyConfigBlob.Length
|
|
},
|
|
Layers = layers,
|
|
Subject = string.IsNullOrWhiteSpace(request.SubjectDigest)
|
|
? null
|
|
: new OciDescriptor
|
|
{
|
|
MediaType = OciMediaTypes.ImageManifest,
|
|
Digest = request.SubjectDigest!,
|
|
Size = 0
|
|
},
|
|
Annotations = annotations
|
|
};
|
|
}
|
|
|
|
private async Task<string> PushBlobAsync(
|
|
OciImageReference reference,
|
|
byte[] content,
|
|
string mediaType,
|
|
OciRegistryAuthorization auth,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var digest = ComputeDigest(content);
|
|
var blobUri = BuildRegistryUri(reference, $"blobs/{digest}");
|
|
|
|
using (var head = new HttpRequestMessage(HttpMethod.Head, blobUri))
|
|
{
|
|
auth.ApplyTo(head);
|
|
using var headResponse = await _httpClient.SendAsync(head, cancellationToken).ConfigureAwait(false);
|
|
if (headResponse.IsSuccessStatusCode)
|
|
{
|
|
return digest;
|
|
}
|
|
|
|
if (headResponse.StatusCode != HttpStatusCode.NotFound)
|
|
{
|
|
throw new OciRegistryException($"Blob HEAD failed with {headResponse.StatusCode}", "ERR_OCI_BLOB_HEAD");
|
|
}
|
|
}
|
|
|
|
var startUploadUri = BuildRegistryUri(reference, "blobs/uploads/");
|
|
using var postRequest = new HttpRequestMessage(HttpMethod.Post, startUploadUri);
|
|
auth.ApplyTo(postRequest);
|
|
|
|
using var postResponse = await _httpClient.SendAsync(postRequest, cancellationToken).ConfigureAwait(false);
|
|
if (!postResponse.IsSuccessStatusCode)
|
|
{
|
|
throw new OciRegistryException($"Blob upload start failed with {postResponse.StatusCode}", "ERR_OCI_UPLOAD_START");
|
|
}
|
|
|
|
if (postResponse.Headers.Location is null)
|
|
{
|
|
throw new OciRegistryException("Blob upload start did not return a Location header.", "ERR_OCI_UPLOAD_LOCATION");
|
|
}
|
|
|
|
var uploadUri = ResolveUploadUri(reference, postResponse.Headers.Location);
|
|
uploadUri = AppendDigest(uploadUri, digest);
|
|
|
|
using var putRequest = new HttpRequestMessage(HttpMethod.Put, uploadUri)
|
|
{
|
|
Content = new ByteArrayContent(content)
|
|
};
|
|
putRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(mediaType);
|
|
auth.ApplyTo(putRequest);
|
|
|
|
using var putResponse = await _httpClient.SendAsync(putRequest, cancellationToken).ConfigureAwait(false);
|
|
if (!putResponse.IsSuccessStatusCode)
|
|
{
|
|
throw new OciRegistryException($"Blob upload failed with {putResponse.StatusCode}", "ERR_OCI_UPLOAD_PUT");
|
|
}
|
|
|
|
return digest;
|
|
}
|
|
|
|
private async Task PushManifestAsync(
|
|
OciImageReference reference,
|
|
byte[] manifestBytes,
|
|
string tag,
|
|
OciRegistryAuthorization auth,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var manifestUri = BuildRegistryUri(reference, $"manifests/{tag}");
|
|
using var request = new HttpRequestMessage(HttpMethod.Put, manifestUri)
|
|
{
|
|
Content = new ByteArrayContent(manifestBytes)
|
|
};
|
|
|
|
request.Content.Headers.ContentType = new MediaTypeHeaderValue(OciMediaTypes.ImageManifest);
|
|
auth.ApplyTo(request);
|
|
|
|
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new OciRegistryException($"Manifest upload failed with {response.StatusCode}", "ERR_OCI_MANIFEST");
|
|
}
|
|
}
|
|
|
|
private string ComputeDigest(ReadOnlySpan<byte> content)
|
|
{
|
|
return _cryptoHash.ComputePrefixedHashForPurpose(content, HashPurpose.Interop);
|
|
}
|
|
|
|
private Uri BuildRegistryUri(OciImageReference reference, string path)
|
|
{
|
|
var scheme = reference.Scheme;
|
|
if (_options.AllowInsecure)
|
|
{
|
|
scheme = "http";
|
|
}
|
|
|
|
return new Uri($"{scheme}://{reference.Registry}/v2/{reference.Repository}/{path}");
|
|
}
|
|
|
|
private static Uri ResolveUploadUri(OciImageReference reference, Uri location)
|
|
{
|
|
if (location.IsAbsoluteUri)
|
|
{
|
|
return location;
|
|
}
|
|
|
|
return new Uri($"{reference.Scheme}://{reference.Registry}{location}");
|
|
}
|
|
|
|
private static Uri AppendDigest(Uri uploadUri, string digest)
|
|
{
|
|
if (uploadUri.Query.Contains("digest=", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return uploadUri;
|
|
}
|
|
|
|
var delimiter = string.IsNullOrEmpty(uploadUri.Query) ? "?" : "&";
|
|
var uri = new Uri($"{uploadUri}{delimiter}digest={Uri.EscapeDataString(digest)}");
|
|
return uri;
|
|
}
|
|
|
|
private static SortedDictionary<string, string>? NormalizeAnnotations(IReadOnlyDictionary<string, string>? annotations)
|
|
{
|
|
if (annotations is null || annotations.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var normalized = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var (key, value) in annotations)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(key) || value is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
normalized[key.Trim()] = value.Trim();
|
|
}
|
|
|
|
return normalized.Count == 0 ? null : normalized;
|
|
}
|
|
}
|