Files
git.stella-ops.org/src/StellaOps.Vexer.Export/S3ArtifactStore.cs

182 lines
6.6 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Vexer.Core;
namespace StellaOps.Vexer.Export;
public sealed class S3ArtifactStoreOptions
{
public string BucketName { get; set; } = string.Empty;
public string? Prefix { get; set; }
= null;
public bool OverwriteExisting { get; set; }
= true;
}
public interface IS3ArtifactClient
{
Task<bool> ObjectExistsAsync(string bucketName, string key, CancellationToken cancellationToken);
Task PutObjectAsync(string bucketName, string key, Stream content, IDictionary<string, string> metadata, CancellationToken cancellationToken);
Task<Stream?> GetObjectAsync(string bucketName, string key, CancellationToken cancellationToken);
Task DeleteObjectAsync(string bucketName, string key, CancellationToken cancellationToken);
}
public sealed class S3ArtifactStore : IVexArtifactStore
{
private readonly IS3ArtifactClient _client;
private readonly S3ArtifactStoreOptions _options;
private readonly ILogger<S3ArtifactStore> _logger;
public S3ArtifactStore(
IS3ArtifactClient client,
IOptions<S3ArtifactStoreOptions> options,
ILogger<S3ArtifactStore> logger)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (string.IsNullOrWhiteSpace(_options.BucketName))
{
throw new ArgumentException("BucketName must be provided for S3ArtifactStore.", nameof(options));
}
}
public async ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(artifact);
var key = BuildObjectKey(artifact.ContentAddress, artifact.Format);
if (!_options.OverwriteExisting)
{
var exists = await _client.ObjectExistsAsync(_options.BucketName, key, cancellationToken).ConfigureAwait(false);
if (exists)
{
_logger.LogInformation("S3 object {Bucket}/{Key} already exists; skipping upload.", _options.BucketName, key);
return new VexStoredArtifact(artifact.ContentAddress, key, artifact.Content.Length, artifact.Metadata);
}
}
using var contentStream = new MemoryStream(artifact.Content.ToArray());
await _client.PutObjectAsync(
_options.BucketName,
key,
contentStream,
BuildObjectMetadata(artifact),
cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Uploaded export artifact {Digest} to {Bucket}/{Key}", artifact.ContentAddress.ToUri(), _options.BucketName, key);
return new VexStoredArtifact(
artifact.ContentAddress,
key,
artifact.Content.Length,
artifact.Metadata);
}
public async ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(contentAddress);
foreach (var key in BuildCandidateKeys(contentAddress))
{
await _client.DeleteObjectAsync(_options.BucketName, key, cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation("Deleted export artifact {Digest} from {Bucket}", contentAddress.ToUri(), _options.BucketName);
}
public async ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(contentAddress);
foreach (var key in BuildCandidateKeys(contentAddress))
{
var stream = await _client.GetObjectAsync(_options.BucketName, key, cancellationToken).ConfigureAwait(false);
if (stream is not null)
{
return stream;
}
}
return null;
}
private string BuildObjectKey(VexContentAddress address, VexExportFormat format)
{
var sanitizedDigest = address.Digest.Replace(':', '_');
var prefix = string.IsNullOrWhiteSpace(_options.Prefix) ? string.Empty : _options.Prefix.TrimEnd('/') + "/";
var formatSegment = format.ToString().ToLowerInvariant();
return $"{prefix}{formatSegment}/{sanitizedDigest}{GetExtension(format)}";
}
private IEnumerable<string> BuildCandidateKeys(VexContentAddress address)
{
foreach (VexExportFormat format in Enum.GetValues(typeof(VexExportFormat)))
{
yield return BuildObjectKey(address, format);
}
if (!string.IsNullOrWhiteSpace(_options.Prefix))
{
yield return $"{_options.Prefix.TrimEnd('/')}/{address.Digest.Replace(':', '_')}";
}
yield return address.Digest.Replace(':', '_');
}
private static IDictionary<string, string> BuildObjectMetadata(VexExportArtifact artifact)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["vex-format"] = artifact.Format.ToString().ToLowerInvariant(),
["vex-digest"] = artifact.ContentAddress.ToUri(),
["content-type"] = artifact.Format switch
{
VexExportFormat.Json => "application/json",
VexExportFormat.JsonLines => "application/json",
VexExportFormat.OpenVex => "application/vnd.openvex+json",
VexExportFormat.Csaf => "application/json",
_ => "application/octet-stream",
},
};
foreach (var kvp in artifact.Metadata)
{
metadata[$"meta-{kvp.Key}"] = kvp.Value;
}
return metadata;
}
private static string GetExtension(VexExportFormat format)
=> format switch
{
VexExportFormat.Json => ".json",
VexExportFormat.JsonLines => ".jsonl",
VexExportFormat.OpenVex => ".json",
VexExportFormat.Csaf => ".json",
_ => ".bin",
};
}
public static class S3ArtifactStoreServiceCollectionExtensions
{
public static IServiceCollection AddVexS3ArtifactStore(this IServiceCollection services, Action<S3ArtifactStoreOptions> configure)
{
ArgumentNullException.ThrowIfNull(configure);
services.Configure(configure);
services.AddSingleton<IVexArtifactStore, S3ArtifactStore>();
return services;
}
}