using Microsoft.Extensions.Logging; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace StellaOps.ExportCenter.RiskBundles; public sealed record RiskBundleJobRequest( RiskBundleBuildRequest BuildRequest, string StoragePrefix = "risk-bundles", string BundleFileName = "risk-bundle.tar.gz", bool IncludeOsv = false, bool AllowStaleOptional = true); public sealed record RiskBundleJobOutcome( RiskBundleManifest Manifest, RiskBundleStorageMetadata ManifestStorage, RiskBundleStorageMetadata ManifestSignatureStorage, RiskBundleStorageMetadata BundleStorage, RiskBundleStorageMetadata BundleSignatureStorage, string ManifestJson, string ManifestSignatureJson, string BundleSignature, string RootHash); public sealed class RiskBundleJob { private readonly RiskBundleBuilder _builder; private readonly IRiskBundleManifestSigner _signer; private readonly IRiskBundleArchiveSigner _archiveSigner; private readonly IRiskBundleObjectStore _objectStore; private readonly ILogger _logger; public RiskBundleJob( RiskBundleBuilder builder, IRiskBundleManifestSigner signer, IRiskBundleArchiveSigner archiveSigner, IRiskBundleObjectStore objectStore, ILogger logger) { _builder = builder ?? throw new ArgumentNullException(nameof(builder)); _signer = signer ?? throw new ArgumentNullException(nameof(signer)); _archiveSigner = archiveSigner ?? throw new ArgumentNullException(nameof(archiveSigner)); _objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task ExecuteAsync(RiskBundleJobRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); cancellationToken.ThrowIfCancellationRequested(); var bundleFileName = string.IsNullOrWhiteSpace(request.BundleFileName) ? "risk-bundle.tar.gz" : request.BundleFileName; var prepared = _builder.Prepare(request.BuildRequest, cancellationToken); _logger.LogInformation("Risk bundle built with {ProviderCount} providers (prepared).", prepared.Manifest.Providers.Count); var manifestSignature = await _signer.SignAsync(prepared.ManifestJson, cancellationToken).ConfigureAwait(false); var signatureJson = JsonSerializer.Serialize(manifestSignature, SerializerOptions); var additionalFiles = new[] { RiskBundleAdditionalFile.FromText($"signatures/{request.BuildRequest.ManifestDsseFileName}", signatureJson) }; var build = _builder.Build(request.BuildRequest, additionalFiles, prepared, cancellationToken); var bundleSignature = await _archiveSigner.SignArchiveAsync(build.BundleStream, cancellationToken).ConfigureAwait(false); var bundleSignatureFileName = $"{bundleFileName}.sig"; var manifestKey = Combine(request.StoragePrefix, request.BuildRequest.ManifestFileName); var manifestSigKey = Combine(request.StoragePrefix, $"signatures/{request.BuildRequest.ManifestDsseFileName}"); var bundleKey = Combine(request.StoragePrefix, bundleFileName); var bundleSigKey = Combine(request.StoragePrefix, $"signatures/{bundleSignatureFileName}"); var manifestStorage = await _objectStore.StoreAsync( new RiskBundleObjectStoreOptions(manifestKey, "application/json"), new MemoryStream(Encoding.UTF8.GetBytes(build.ManifestJson)), cancellationToken).ConfigureAwait(false); var signatureStorage = await _objectStore.StoreAsync( new RiskBundleObjectStoreOptions(manifestSigKey, "application/json"), new MemoryStream(Encoding.UTF8.GetBytes(signatureJson)), cancellationToken).ConfigureAwait(false); build.BundleStream.Position = 0; var bundleStorage = await _objectStore.StoreAsync( new RiskBundleObjectStoreOptions(bundleKey, "application/gzip"), build.BundleStream, cancellationToken).ConfigureAwait(false); var bundleSignatureStorage = await _objectStore.StoreAsync( new RiskBundleObjectStoreOptions(bundleSigKey, "application/vnd.stellaops.signature"), new MemoryStream(Encoding.UTF8.GetBytes(bundleSignature)), cancellationToken).ConfigureAwait(false); return new RiskBundleJobOutcome( build.Manifest, manifestStorage, signatureStorage, bundleStorage, bundleSignatureStorage, build.ManifestJson, signatureJson, bundleSignature, build.RootHash); } private static string Combine(string prefix, string fileName) { if (string.IsNullOrWhiteSpace(prefix)) { return fileName; } var sanitizedPrefix = prefix.Trim('/'); return string.IsNullOrWhiteSpace(sanitizedPrefix) ? fileName : $"{sanitizedPrefix}/{fileName}"; } private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = false }; }