182 lines
6.6 KiB
C#
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;
|
|
}
|
|
}
|