Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/OciArtifactPusher.cs

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