Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
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 = await PushBlobAsync(reference, EmptyConfigBlob, OciMediaTypes.EmptyConfig, auth, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var layerDescriptors = new List<OciDescriptor>();
|
||||
var layerDigests = new List<string>();
|
||||
|
||||
foreach (var layer in request.Layers)
|
||||
{
|
||||
var digest = await PushBlobAsync(reference, layer.Content, layer.MediaType, auth, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
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 = reference.Tag ?? manifestDigest.Replace("sha256:", string.Empty, StringComparison.Ordinal);
|
||||
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,
|
||||
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 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");
|
||||
annotations["org.opencontainers.image.title"] = request.ArtifactType;
|
||||
|
||||
return new OciArtifactManifest
|
||||
{
|
||||
MediaType = OciMediaTypes.ArtifactManifest,
|
||||
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.ArtifactManifest,
|
||||
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.ArtifactManifest);
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user