feat: Add Bun language analyzer and related functionality
- Implemented BunPackageNormalizer to deduplicate packages by name and version. - Created BunProjectDiscoverer to identify Bun project roots in the filesystem. - Added project files for the Bun analyzer including manifest and project configuration. - Developed comprehensive tests for Bun language analyzer covering various scenarios. - Included fixture files for testing standard installs, isolated linker installs, lockfile-only scenarios, and workspaces. - Established stubs for authentication sessions to facilitate testing in the web application.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Store for imported policy pack bundles.
|
||||
/// </summary>
|
||||
public interface IPolicyPackBundleStore
|
||||
{
|
||||
Task<ImportedPolicyPackBundle?> GetAsync(string bundleId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ImportedPolicyPackBundle>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
Task SaveAsync(ImportedPolicyPackBundle bundle, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(string bundleId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of policy pack bundle store.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryPolicyPackBundleStore : IPolicyPackBundleStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ImportedPolicyPackBundle> _bundles = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<ImportedPolicyPackBundle?> GetAsync(string bundleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_bundles.TryGetValue(bundleId, out var bundle);
|
||||
return Task.FromResult(bundle);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ImportedPolicyPackBundle>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<ImportedPolicyPackBundle> bundles = _bundles.Values;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
bundles = bundles.Where(b => string.Equals(b.TenantId, tenantId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
var ordered = bundles
|
||||
.OrderByDescending(b => b.ImportedAt)
|
||||
.ThenBy(b => b.BundleId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ImportedPolicyPackBundle>>(ordered);
|
||||
}
|
||||
|
||||
public Task SaveAsync(ImportedPolicyPackBundle bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
_bundles[bundle.BundleId] = bundle;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string bundleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_bundles.TryRemove(bundleId, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Service for importing policy pack bundles per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
internal sealed class PolicyPackBundleImportService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly IPolicyPackBundleStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PolicyPackBundleImportService> _logger;
|
||||
|
||||
public PolicyPackBundleImportService(
|
||||
IPolicyPackBundleStore store,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PolicyPackBundleImportService> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a bundle for import and begins validation.
|
||||
/// </summary>
|
||||
public async Task<RegisterBundleResponse> RegisterBundleAsync(
|
||||
string tenantId,
|
||||
RegisterBundleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var importId = GenerateImportId();
|
||||
|
||||
_logger.LogInformation("Registering bundle import {ImportId} from {BundlePath} for tenant {TenantId}",
|
||||
importId, request.BundlePath, tenantId);
|
||||
|
||||
// Create initial entry in validating state
|
||||
var entry = new ImportedPolicyPackBundle(
|
||||
BundleId: importId,
|
||||
DomainId: BundleDomainIds.PolicyPacks,
|
||||
TenantId: tenantId,
|
||||
Status: BundleImportStatus.Validating,
|
||||
ExportCount: 0,
|
||||
ImportedAt: now.ToString("O"),
|
||||
Error: null,
|
||||
Bundle: null);
|
||||
|
||||
await _store.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Start async import process
|
||||
_ = ImportBundleAsync(tenantId, importId, request, cancellationToken);
|
||||
|
||||
return new RegisterBundleResponse(importId, BundleImportStatus.Validating);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a bundle import.
|
||||
/// </summary>
|
||||
public async Task<BundleStatusResponse?> GetBundleStatusAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bundle = await _store.GetAsync(bundleId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BundleStatusResponse(
|
||||
BundleId: bundle.BundleId,
|
||||
DomainId: bundle.DomainId,
|
||||
Status: bundle.Status,
|
||||
ExportCount: bundle.ExportCount,
|
||||
ImportedAt: bundle.ImportedAt,
|
||||
Error: bundle.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists imported bundles for a tenant.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BundleStatusResponse>> ListBundlesAsync(
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bundles = await _store.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return bundles.Select(b => new BundleStatusResponse(
|
||||
BundleId: b.BundleId,
|
||||
DomainId: b.DomainId,
|
||||
Status: b.Status,
|
||||
ExportCount: b.ExportCount,
|
||||
ImportedAt: b.ImportedAt,
|
||||
Error: b.Error)).ToList();
|
||||
}
|
||||
|
||||
private async Task ImportBundleAsync(
|
||||
string tenantId,
|
||||
string importId,
|
||||
RegisterBundleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting bundle import {ImportId}", importId);
|
||||
|
||||
// Update status to importing
|
||||
var current = await _store.GetAsync(importId, cancellationToken).ConfigureAwait(false);
|
||||
if (current is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _store.SaveAsync(current with { Status = BundleImportStatus.Importing }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Load and parse bundle
|
||||
var bundle = await LoadBundleAsync(request.BundlePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Validate bundle
|
||||
ValidateBundle(bundle);
|
||||
|
||||
// Verify signatures if present
|
||||
if (bundle.Signature is not null)
|
||||
{
|
||||
await VerifySignatureAsync(bundle, request.TrustRootsPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Verify export digests
|
||||
VerifyExportDigests(bundle);
|
||||
|
||||
// Mark as imported
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var imported = new ImportedPolicyPackBundle(
|
||||
BundleId: importId,
|
||||
DomainId: bundle.DomainId,
|
||||
TenantId: tenantId,
|
||||
Status: BundleImportStatus.Imported,
|
||||
ExportCount: bundle.Exports.Count,
|
||||
ImportedAt: now.ToString("O"),
|
||||
Error: null,
|
||||
Bundle: bundle);
|
||||
|
||||
await _store.SaveAsync(imported, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Bundle import {ImportId} completed successfully with {ExportCount} exports",
|
||||
importId, bundle.Exports.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Bundle import {ImportId} failed: {Error}", importId, ex.Message);
|
||||
|
||||
var failed = await _store.GetAsync(importId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (failed is not null)
|
||||
{
|
||||
await _store.SaveAsync(failed with
|
||||
{
|
||||
Status = BundleImportStatus.Failed,
|
||||
Error = ex.Message
|
||||
}, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<PolicyPackBundle> LoadBundleAsync(string bundlePath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Bundle file not found: {bundlePath}");
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
var bundle = JsonSerializer.Deserialize<PolicyPackBundle>(json, JsonOptions)
|
||||
?? throw new InvalidDataException("Failed to parse bundle JSON");
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
private static void ValidateBundle(PolicyPackBundle bundle)
|
||||
{
|
||||
if (bundle.SchemaVersion < 1)
|
||||
{
|
||||
throw new InvalidDataException("Invalid schema version");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(bundle.DomainId))
|
||||
{
|
||||
throw new InvalidDataException("Domain ID is required");
|
||||
}
|
||||
|
||||
if (bundle.Exports.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException("Bundle must contain at least one export");
|
||||
}
|
||||
|
||||
foreach (var export in bundle.Exports)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(export.Key))
|
||||
{
|
||||
throw new InvalidDataException("Export key is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(export.ArtifactDigest))
|
||||
{
|
||||
throw new InvalidDataException($"Artifact digest is required for export '{export.Key}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task VerifySignatureAsync(PolicyPackBundle bundle, string? trustRootsPath, CancellationToken cancellationToken)
|
||||
{
|
||||
// Signature verification would integrate with the AirGap.Importer DsseVerifier
|
||||
// For now, log that signature is present
|
||||
_logger.LogInformation("Bundle signature present: algorithm={Algorithm}, keyId={KeyId}",
|
||||
bundle.Signature!.Algorithm, bundle.Signature.KeyId);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void VerifyExportDigests(PolicyPackBundle bundle)
|
||||
{
|
||||
foreach (var export in bundle.Exports)
|
||||
{
|
||||
// Verify digest format
|
||||
if (!export.ArtifactDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException($"Invalid digest format for export '{export.Key}': expected sha256: prefix");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Verified export '{Key}' with digest {Digest}",
|
||||
export.Key, export.ArtifactDigest);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateImportId()
|
||||
{
|
||||
return $"import-{Guid.NewGuid():N}"[..20];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Mirror bundle for policy packs per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackBundle(
|
||||
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
|
||||
[property: JsonPropertyName("generatedAt")] string GeneratedAt,
|
||||
[property: JsonPropertyName("targetRepository")] string? TargetRepository,
|
||||
[property: JsonPropertyName("domainId")] string DomainId,
|
||||
[property: JsonPropertyName("displayName")] string? DisplayName,
|
||||
[property: JsonPropertyName("exports")] IReadOnlyList<PolicyPackExport> Exports,
|
||||
[property: JsonPropertyName("signature")] BundleSignature? Signature);
|
||||
|
||||
/// <summary>
|
||||
/// Export entry within a policy pack bundle.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackExport(
|
||||
[property: JsonPropertyName("key")] string Key,
|
||||
[property: JsonPropertyName("format")] string Format,
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("querySignature")] string? QuerySignature,
|
||||
[property: JsonPropertyName("createdAt")] string CreatedAt,
|
||||
[property: JsonPropertyName("artifactSizeBytes")] long ArtifactSizeBytes,
|
||||
[property: JsonPropertyName("artifactDigest")] string ArtifactDigest,
|
||||
[property: JsonPropertyName("sourceProviders")] IReadOnlyList<string>? SourceProviders,
|
||||
[property: JsonPropertyName("consensusRevision")] string? ConsensusRevision,
|
||||
[property: JsonPropertyName("policyRevisionId")] string? PolicyRevisionId,
|
||||
[property: JsonPropertyName("policyDigest")] string? PolicyDigest,
|
||||
[property: JsonPropertyName("consensusDigest")] string? ConsensusDigest,
|
||||
[property: JsonPropertyName("scoreDigest")] string? ScoreDigest,
|
||||
[property: JsonPropertyName("attestation")] AttestationDescriptor? Attestation);
|
||||
|
||||
/// <summary>
|
||||
/// Attestation metadata for signed exports.
|
||||
/// </summary>
|
||||
public sealed record AttestationDescriptor(
|
||||
[property: JsonPropertyName("predicateType")] string PredicateType,
|
||||
[property: JsonPropertyName("rekorLocation")] string? RekorLocation,
|
||||
[property: JsonPropertyName("envelopeDigest")] string? EnvelopeDigest,
|
||||
[property: JsonPropertyName("signedAt")] string SignedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle signature metadata.
|
||||
/// </summary>
|
||||
public sealed record BundleSignature(
|
||||
[property: JsonPropertyName("path")] string Path,
|
||||
[property: JsonPropertyName("algorithm")] string Algorithm,
|
||||
[property: JsonPropertyName("keyId")] string KeyId,
|
||||
[property: JsonPropertyName("provider")] string? Provider,
|
||||
[property: JsonPropertyName("signedAt")] string SignedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Request to register a bundle for import.
|
||||
/// </summary>
|
||||
public sealed record RegisterBundleRequest(
|
||||
[property: JsonPropertyName("bundlePath")] string BundlePath,
|
||||
[property: JsonPropertyName("trustRootsPath")] string? TrustRootsPath);
|
||||
|
||||
/// <summary>
|
||||
/// Response for bundle registration.
|
||||
/// </summary>
|
||||
public sealed record RegisterBundleResponse(
|
||||
[property: JsonPropertyName("importId")] string ImportId,
|
||||
[property: JsonPropertyName("status")] string Status);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle import status response.
|
||||
/// </summary>
|
||||
public sealed record BundleStatusResponse(
|
||||
[property: JsonPropertyName("bundleId")] string BundleId,
|
||||
[property: JsonPropertyName("domainId")] string DomainId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("exportCount")] int ExportCount,
|
||||
[property: JsonPropertyName("importedAt")] string? ImportedAt,
|
||||
[property: JsonPropertyName("error")] string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Imported bundle catalog entry.
|
||||
/// </summary>
|
||||
public sealed record ImportedPolicyPackBundle(
|
||||
string BundleId,
|
||||
string DomainId,
|
||||
string TenantId,
|
||||
string Status,
|
||||
int ExportCount,
|
||||
string ImportedAt,
|
||||
string? Error,
|
||||
PolicyPackBundle? Bundle);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle import status values.
|
||||
/// </summary>
|
||||
public static class BundleImportStatus
|
||||
{
|
||||
public const string Validating = "validating";
|
||||
public const string Importing = "importing";
|
||||
public const string Imported = "imported";
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Domain IDs per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
public static class BundleDomainIds
|
||||
{
|
||||
public const string VexAdvisories = "vex-advisories";
|
||||
public const string VulnerabilityFeeds = "vulnerability-feeds";
|
||||
public const string PolicyPacks = "policy-packs";
|
||||
public const string SbomCatalog = "sbom-catalog";
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ConsoleExport;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing Console export jobs per CONTRACT-EXPORT-BUNDLE-009.
|
||||
/// </summary>
|
||||
internal sealed partial class ConsoleExportJobService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly Regex CronRegex = CreateCronRegex();
|
||||
|
||||
private readonly IConsoleExportJobStore _jobStore;
|
||||
private readonly IConsoleExportExecutionStore _executionStore;
|
||||
private readonly IConsoleExportBundleStore _bundleStore;
|
||||
private readonly LedgerExportService _ledgerExport;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ConsoleExportJobService(
|
||||
IConsoleExportJobStore jobStore,
|
||||
IConsoleExportExecutionStore executionStore,
|
||||
IConsoleExportBundleStore bundleStore,
|
||||
LedgerExportService ledgerExport,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_jobStore = jobStore ?? throw new ArgumentNullException(nameof(jobStore));
|
||||
_executionStore = executionStore ?? throw new ArgumentNullException(nameof(executionStore));
|
||||
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
|
||||
_ledgerExport = ledgerExport ?? throw new ArgumentNullException(nameof(ledgerExport));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<ExportBundleJob> CreateJobAsync(
|
||||
string tenantId,
|
||||
CreateExportJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
ValidateRequest(request);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var jobId = GenerateId("job");
|
||||
|
||||
var job = new ExportBundleJob(
|
||||
JobId: jobId,
|
||||
TenantId: tenantId,
|
||||
Name: request.Name,
|
||||
Description: request.Description,
|
||||
Query: request.Query,
|
||||
Format: request.Format,
|
||||
Schedule: request.Schedule,
|
||||
Destination: request.Destination,
|
||||
Signing: request.Signing,
|
||||
Enabled: true,
|
||||
CreatedAt: now.ToString("O"),
|
||||
LastRunAt: null,
|
||||
NextRunAt: CalculateNextRun(request.Schedule, now));
|
||||
|
||||
await _jobStore.SaveAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
return job;
|
||||
}
|
||||
|
||||
public async Task<ExportBundleJob?> GetJobAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ListJobsResponse> ListJobsAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jobs = await _jobStore.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return new ListJobsResponse(jobs, jobs.Count);
|
||||
}
|
||||
|
||||
public async Task<ExportBundleJob> UpdateJobAsync(
|
||||
string jobId,
|
||||
UpdateExportJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var existing = await _jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new KeyNotFoundException($"Job '{jobId}' not found");
|
||||
|
||||
if (request.Schedule is not null && !IsValidCron(request.Schedule))
|
||||
{
|
||||
throw new ArgumentException("Invalid schedule expression", nameof(request));
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var newSchedule = request.Schedule ?? existing.Schedule;
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Name = request.Name ?? existing.Name,
|
||||
Description = request.Description ?? existing.Description,
|
||||
Schedule = newSchedule,
|
||||
Signing = request.Signing ?? existing.Signing,
|
||||
Enabled = request.Enabled ?? existing.Enabled,
|
||||
NextRunAt = CalculateNextRun(newSchedule, now)
|
||||
};
|
||||
|
||||
await _jobStore.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async Task DeleteJobAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _jobStore.DeleteAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<TriggerExecutionResponse> TriggerJobAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var job = await _jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new KeyNotFoundException($"Job '{jobId}' not found");
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var executionId = GenerateId("exec");
|
||||
|
||||
var execution = new ExportExecution(
|
||||
ExecutionId: executionId,
|
||||
JobId: jobId,
|
||||
Status: "running",
|
||||
BundleId: null,
|
||||
StartedAt: now.ToString("O"),
|
||||
CompletedAt: null,
|
||||
Error: null);
|
||||
|
||||
await _executionStore.SaveAsync(execution, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Execute the export asynchronously
|
||||
_ = ExecuteJobAsync(job, execution, cancellationToken);
|
||||
|
||||
return new TriggerExecutionResponse(executionId, "running");
|
||||
}
|
||||
|
||||
public async Task<ExportExecution?> GetExecutionAsync(string executionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _executionStore.GetAsync(executionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ExportBundleManifest?> GetBundleAsync(string bundleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _bundleStore.GetAsync(bundleId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetBundleContentAsync(string bundleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _bundleStore.GetContentAsync(bundleId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ExecuteJobAsync(ExportBundleJob job, ExportExecution execution, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Build ledger export for this tenant
|
||||
var request = new LedgerExportRequest(job.TenantId);
|
||||
var ledgerExport = await _ledgerExport.BuildAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Build bundle content based on format
|
||||
var content = BuildContent(job, ledgerExport);
|
||||
var contentBytes = Encoding.UTF8.GetBytes(content);
|
||||
|
||||
// Create manifest
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var bundleId = GenerateId("bundle");
|
||||
var artifactDigest = ComputeSha256(contentBytes);
|
||||
var querySignature = ComputeSha256(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(job.Query, JsonOptions)));
|
||||
|
||||
var manifest = new ExportBundleManifest(
|
||||
BundleId: bundleId,
|
||||
JobId: job.JobId,
|
||||
TenantId: job.TenantId,
|
||||
CreatedAt: now.ToString("O"),
|
||||
Format: job.Format,
|
||||
ArtifactDigest: artifactDigest,
|
||||
ArtifactSizeBytes: contentBytes.Length,
|
||||
QuerySignature: querySignature,
|
||||
ItemCount: ledgerExport.Records.Count,
|
||||
PolicyDigest: ledgerExport.Manifest.Sha256,
|
||||
ConsensusDigest: null,
|
||||
ScoreDigest: null,
|
||||
Attestation: null);
|
||||
|
||||
await _bundleStore.SaveAsync(manifest, contentBytes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update execution as completed
|
||||
var completedExecution = execution with
|
||||
{
|
||||
Status = "completed",
|
||||
BundleId = bundleId,
|
||||
CompletedAt = now.ToString("O")
|
||||
};
|
||||
await _executionStore.SaveAsync(completedExecution, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update job with last run
|
||||
var updatedJob = job with
|
||||
{
|
||||
LastRunAt = now.ToString("O"),
|
||||
NextRunAt = CalculateNextRun(job.Schedule, now)
|
||||
};
|
||||
await _jobStore.SaveAsync(updatedJob, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var failedExecution = execution with
|
||||
{
|
||||
Status = "failed",
|
||||
CompletedAt = _timeProvider.GetUtcNow().ToString("O"),
|
||||
Error = ex.Message
|
||||
};
|
||||
await _executionStore.SaveAsync(failedExecution, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildContent(ExportBundleJob job, LedgerExport ledgerExport)
|
||||
{
|
||||
return job.Format.ToLowerInvariant() switch
|
||||
{
|
||||
ExportFormats.Ndjson => string.Join('\n', ledgerExport.Lines),
|
||||
ExportFormats.Json => JsonSerializer.Serialize(ledgerExport.Records, JsonOptions),
|
||||
_ => JsonSerializer.Serialize(ledgerExport.Records, JsonOptions)
|
||||
};
|
||||
}
|
||||
|
||||
private void ValidateRequest(CreateExportJobRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
throw new ArgumentException("Name is required", nameof(request));
|
||||
}
|
||||
|
||||
if (!ExportFormats.IsValid(request.Format))
|
||||
{
|
||||
throw new ArgumentException($"Invalid format: {request.Format}", nameof(request));
|
||||
}
|
||||
|
||||
if (!IsValidCron(request.Schedule))
|
||||
{
|
||||
throw new ArgumentException("Invalid schedule expression", nameof(request));
|
||||
}
|
||||
|
||||
if (!DestinationTypes.IsValid(request.Destination.Type))
|
||||
{
|
||||
throw new ArgumentException($"Invalid destination type: {request.Destination.Type}", nameof(request));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidCron(string schedule)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(schedule))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic 5-field cron validation
|
||||
return CronRegex.IsMatch(schedule);
|
||||
}
|
||||
|
||||
private static string? CalculateNextRun(string schedule, DateTimeOffset from)
|
||||
{
|
||||
// Simplified next run calculation - just add 24 hours for daily schedules
|
||||
// In production, this would use a proper cron parser like Cronos
|
||||
if (schedule.StartsWith("0 0 ", StringComparison.Ordinal))
|
||||
{
|
||||
return from.AddDays(1).ToString("O");
|
||||
}
|
||||
|
||||
if (schedule.StartsWith("0 */", StringComparison.Ordinal))
|
||||
{
|
||||
var hourMatch = Regex.Match(schedule, @"\*/(\d+)");
|
||||
if (hourMatch.Success && int.TryParse(hourMatch.Groups[1].Value, out var hours))
|
||||
{
|
||||
return from.AddHours(hours).ToString("O");
|
||||
}
|
||||
}
|
||||
|
||||
return from.AddDays(1).ToString("O");
|
||||
}
|
||||
|
||||
private static string GenerateId(string prefix)
|
||||
{
|
||||
return $"{prefix}-{Guid.NewGuid():N}"[..16];
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(\*|[0-9]|[1-5][0-9])\s+(\*|[0-9]|1[0-9]|2[0-3])\s+(\*|[1-9]|[12][0-9]|3[01])\s+(\*|[1-9]|1[0-2])\s+(\*|[0-6])$")]
|
||||
private static partial Regex CreateCronRegex();
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ConsoleExport;
|
||||
|
||||
/// <summary>
|
||||
/// Export bundle job definition per CONTRACT-EXPORT-BUNDLE-009.
|
||||
/// </summary>
|
||||
public sealed record ExportBundleJob(
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("description")] string? Description,
|
||||
[property: JsonPropertyName("query")] ExportQuery Query,
|
||||
[property: JsonPropertyName("format")] string Format,
|
||||
[property: JsonPropertyName("schedule")] string Schedule,
|
||||
[property: JsonPropertyName("destination")] ExportDestination Destination,
|
||||
[property: JsonPropertyName("signing")] ExportSigning? Signing,
|
||||
[property: JsonPropertyName("enabled")] bool Enabled,
|
||||
[property: JsonPropertyName("created_at")] string CreatedAt,
|
||||
[property: JsonPropertyName("last_run_at")] string? LastRunAt,
|
||||
[property: JsonPropertyName("next_run_at")] string? NextRunAt);
|
||||
|
||||
/// <summary>
|
||||
/// Query definition for export jobs.
|
||||
/// </summary>
|
||||
public sealed record ExportQuery(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("filters")] ExportFilters? Filters);
|
||||
|
||||
/// <summary>
|
||||
/// Filters for export queries.
|
||||
/// </summary>
|
||||
public sealed record ExportFilters(
|
||||
[property: JsonPropertyName("severity")] IReadOnlyList<string>? Severity,
|
||||
[property: JsonPropertyName("providers")] IReadOnlyList<string>? Providers,
|
||||
[property: JsonPropertyName("status")] IReadOnlyList<string>? Status,
|
||||
[property: JsonPropertyName("advisory_ids")] IReadOnlyList<string>? AdvisoryIds,
|
||||
[property: JsonPropertyName("component_purls")] IReadOnlyList<string>? ComponentPurls);
|
||||
|
||||
/// <summary>
|
||||
/// Export destination configuration.
|
||||
/// </summary>
|
||||
public sealed record ExportDestination(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("config")] IReadOnlyDictionary<string, string>? Config);
|
||||
|
||||
/// <summary>
|
||||
/// Signing configuration for exports.
|
||||
/// </summary>
|
||||
public sealed record ExportSigning(
|
||||
[property: JsonPropertyName("enabled")] bool Enabled,
|
||||
[property: JsonPropertyName("predicate_type")] string? PredicateType,
|
||||
[property: JsonPropertyName("key_id")] string? KeyId,
|
||||
[property: JsonPropertyName("include_rekor")] bool IncludeRekor);
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new export job.
|
||||
/// </summary>
|
||||
public sealed record CreateExportJobRequest(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("description")] string? Description,
|
||||
[property: JsonPropertyName("query")] ExportQuery Query,
|
||||
[property: JsonPropertyName("format")] string Format,
|
||||
[property: JsonPropertyName("schedule")] string Schedule,
|
||||
[property: JsonPropertyName("destination")] ExportDestination Destination,
|
||||
[property: JsonPropertyName("signing")] ExportSigning? Signing);
|
||||
|
||||
/// <summary>
|
||||
/// Request to update an existing export job.
|
||||
/// </summary>
|
||||
public sealed record UpdateExportJobRequest(
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("description")] string? Description,
|
||||
[property: JsonPropertyName("schedule")] string? Schedule,
|
||||
[property: JsonPropertyName("enabled")] bool? Enabled,
|
||||
[property: JsonPropertyName("signing")] ExportSigning? Signing);
|
||||
|
||||
/// <summary>
|
||||
/// Response for job execution trigger.
|
||||
/// </summary>
|
||||
public sealed record TriggerExecutionResponse(
|
||||
[property: JsonPropertyName("execution_id")] string ExecutionId,
|
||||
[property: JsonPropertyName("status")] string Status);
|
||||
|
||||
/// <summary>
|
||||
/// Export job execution status.
|
||||
/// </summary>
|
||||
public sealed record ExportExecution(
|
||||
[property: JsonPropertyName("execution_id")] string ExecutionId,
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("bundle_id")] string? BundleId,
|
||||
[property: JsonPropertyName("started_at")] string StartedAt,
|
||||
[property: JsonPropertyName("completed_at")] string? CompletedAt,
|
||||
[property: JsonPropertyName("error")] string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Export bundle manifest per CONTRACT-EXPORT-BUNDLE-009.
|
||||
/// </summary>
|
||||
public sealed record ExportBundleManifest(
|
||||
[property: JsonPropertyName("bundle_id")] string BundleId,
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("created_at")] string CreatedAt,
|
||||
[property: JsonPropertyName("format")] string Format,
|
||||
[property: JsonPropertyName("artifact_digest")] string ArtifactDigest,
|
||||
[property: JsonPropertyName("artifact_size_bytes")] long ArtifactSizeBytes,
|
||||
[property: JsonPropertyName("query_signature")] string QuerySignature,
|
||||
[property: JsonPropertyName("item_count")] int ItemCount,
|
||||
[property: JsonPropertyName("policy_digest")] string? PolicyDigest,
|
||||
[property: JsonPropertyName("consensus_digest")] string? ConsensusDigest,
|
||||
[property: JsonPropertyName("score_digest")] string? ScoreDigest,
|
||||
[property: JsonPropertyName("attestation")] ExportAttestation? Attestation);
|
||||
|
||||
/// <summary>
|
||||
/// Attestation metadata for export bundles.
|
||||
/// </summary>
|
||||
public sealed record ExportAttestation(
|
||||
[property: JsonPropertyName("predicate_type")] string PredicateType,
|
||||
[property: JsonPropertyName("rekor_uuid")] string? RekorUuid,
|
||||
[property: JsonPropertyName("rekor_index")] long? RekorIndex,
|
||||
[property: JsonPropertyName("signed_at")] string SignedAt);
|
||||
|
||||
/// <summary>
|
||||
/// List response for jobs.
|
||||
/// </summary>
|
||||
public sealed record ListJobsResponse(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<ExportBundleJob> Items,
|
||||
[property: JsonPropertyName("total")] int Total);
|
||||
|
||||
/// <summary>
|
||||
/// Export formats per CONTRACT-EXPORT-BUNDLE-009.
|
||||
/// </summary>
|
||||
public static class ExportFormats
|
||||
{
|
||||
public const string OpenVex = "openvex";
|
||||
public const string Csaf = "csaf";
|
||||
public const string CycloneDx = "cyclonedx";
|
||||
public const string Spdx = "spdx";
|
||||
public const string Ndjson = "ndjson";
|
||||
public const string Json = "json";
|
||||
|
||||
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
OpenVex, Csaf, CycloneDx, Spdx, Ndjson, Json
|
||||
};
|
||||
|
||||
public static bool IsValid(string format) => All.Contains(format);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destination types per CONTRACT-EXPORT-BUNDLE-009.
|
||||
/// </summary>
|
||||
public static class DestinationTypes
|
||||
{
|
||||
public const string S3 = "s3";
|
||||
public const string File = "file";
|
||||
public const string Webhook = "webhook";
|
||||
|
||||
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
S3, File, Webhook
|
||||
};
|
||||
|
||||
public static bool IsValid(string type) => All.Contains(type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Job status values per CONTRACT-EXPORT-BUNDLE-009.
|
||||
/// </summary>
|
||||
public static class JobStatus
|
||||
{
|
||||
public const string Idle = "idle";
|
||||
public const string Running = "running";
|
||||
public const string Completed = "completed";
|
||||
public const string Failed = "failed";
|
||||
public const string Disabled = "disabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export error codes per CONTRACT-EXPORT-BUNDLE-009.
|
||||
/// </summary>
|
||||
public static class ExportErrorCodes
|
||||
{
|
||||
public const string InvalidSchedule = "ERR_EXP_001";
|
||||
public const string InvalidDestination = "ERR_EXP_002";
|
||||
public const string ExportFailed = "ERR_EXP_003";
|
||||
public const string SigningFailed = "ERR_EXP_004";
|
||||
public const string JobNotFound = "ERR_EXP_005";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace StellaOps.Policy.Engine.ConsoleExport;
|
||||
|
||||
/// <summary>
|
||||
/// Store for Console export jobs.
|
||||
/// </summary>
|
||||
public interface IConsoleExportJobStore
|
||||
{
|
||||
Task<ExportBundleJob?> GetAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ExportBundleJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
Task SaveAsync(ExportBundleJob job, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store for export job executions.
|
||||
/// </summary>
|
||||
public interface IConsoleExportExecutionStore
|
||||
{
|
||||
Task<ExportExecution?> GetAsync(string executionId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ExportExecution>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
Task SaveAsync(ExportExecution execution, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store for export bundle manifests.
|
||||
/// </summary>
|
||||
public interface IConsoleExportBundleStore
|
||||
{
|
||||
Task<ExportBundleManifest?> GetAsync(string bundleId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ExportBundleManifest>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
Task SaveAsync(ExportBundleManifest manifest, byte[] content, CancellationToken cancellationToken = default);
|
||||
Task<byte[]?> GetContentAsync(string bundleId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ConsoleExport;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IConsoleExportJobStore.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryConsoleExportJobStore : IConsoleExportJobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ExportBundleJob> _jobs = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<ExportBundleJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs.TryGetValue(jobId, out var job);
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ExportBundleJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<ExportBundleJob> jobs = _jobs.Values;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
jobs = jobs.Where(j => string.Equals(j.TenantId, tenantId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
var ordered = jobs
|
||||
.OrderBy(j => j.CreatedAt, StringComparer.Ordinal)
|
||||
.ThenBy(j => j.JobId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ExportBundleJob>>(ordered);
|
||||
}
|
||||
|
||||
public Task SaveAsync(ExportBundleJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
_jobs[job.JobId] = job;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs.TryRemove(jobId, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IConsoleExportExecutionStore.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryConsoleExportExecutionStore : IConsoleExportExecutionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ExportExecution> _executions = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<ExportExecution?> GetAsync(string executionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_executions.TryGetValue(executionId, out var execution);
|
||||
return Task.FromResult(execution);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ExportExecution>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var executions = _executions.Values
|
||||
.Where(e => string.Equals(e.JobId, jobId, StringComparison.Ordinal))
|
||||
.OrderByDescending(e => e.StartedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ExportExecution>>(executions);
|
||||
}
|
||||
|
||||
public Task SaveAsync(ExportExecution execution, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(execution);
|
||||
_executions[execution.ExecutionId] = execution;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IConsoleExportBundleStore.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryConsoleExportBundleStore : IConsoleExportBundleStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ExportBundleManifest> _manifests = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, byte[]> _contents = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<ExportBundleManifest?> GetAsync(string bundleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_manifests.TryGetValue(bundleId, out var manifest);
|
||||
return Task.FromResult(manifest);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ExportBundleManifest>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifests = _manifests.Values
|
||||
.Where(m => string.Equals(m.JobId, jobId, StringComparison.Ordinal))
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ExportBundleManifest>>(manifests);
|
||||
}
|
||||
|
||||
public Task SaveAsync(ExportBundleManifest manifest, byte[] content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
_manifests[manifest.BundleId] = manifest;
|
||||
_contents[manifest.BundleId] = content;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetContentAsync(string bundleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_contents.TryGetValue(bundleId, out var content);
|
||||
return Task.FromResult(content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.ConsoleExport;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for Console export jobs per CONTRACT-EXPORT-BUNDLE-009.
|
||||
/// </summary>
|
||||
public static class ConsoleExportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapConsoleExportJobs(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/export");
|
||||
|
||||
// Job management
|
||||
group.MapPost("/jobs", CreateJobAsync)
|
||||
.WithName("Export.CreateJob")
|
||||
.WithDescription("Create a new export job");
|
||||
|
||||
group.MapGet("/jobs", ListJobsAsync)
|
||||
.WithName("Export.ListJobs")
|
||||
.WithDescription("List export jobs");
|
||||
|
||||
group.MapGet("/jobs/{jobId}", GetJobAsync)
|
||||
.WithName("Export.GetJob")
|
||||
.WithDescription("Get an export job by ID");
|
||||
|
||||
group.MapPut("/jobs/{jobId}", UpdateJobAsync)
|
||||
.WithName("Export.UpdateJob")
|
||||
.WithDescription("Update an export job");
|
||||
|
||||
group.MapDelete("/jobs/{jobId}", DeleteJobAsync)
|
||||
.WithName("Export.DeleteJob")
|
||||
.WithDescription("Delete an export job");
|
||||
|
||||
// Job execution
|
||||
group.MapPost("/jobs/{jobId}/run", TriggerJobAsync)
|
||||
.WithName("Export.TriggerJob")
|
||||
.WithDescription("Trigger a job execution");
|
||||
|
||||
group.MapGet("/jobs/{jobId}/executions/{executionId}", GetExecutionAsync)
|
||||
.WithName("Export.GetExecution")
|
||||
.WithDescription("Get execution status");
|
||||
|
||||
// Bundle retrieval
|
||||
group.MapGet("/bundles/{bundleId}", GetBundleAsync)
|
||||
.WithName("Export.GetBundle")
|
||||
.WithDescription("Get bundle manifest");
|
||||
|
||||
group.MapGet("/bundles/{bundleId}/download", DownloadBundleAsync)
|
||||
.WithName("Export.DownloadBundle")
|
||||
.WithDescription("Download bundle content");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateJobAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromBody] CreateExportJobRequest request,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Tenant ID required",
|
||||
detail: "X-Tenant-Id header is required",
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = "TENANT_REQUIRED" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var job = await service.CreateJobAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Created($"/api/v1/export/jobs/{job.JobId}", job);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
var code = ex.Message.Contains("schedule", StringComparison.OrdinalIgnoreCase)
|
||||
? ExportErrorCodes.InvalidSchedule
|
||||
: ExportErrorCodes.InvalidDestination;
|
||||
|
||||
return Results.Problem(
|
||||
title: "Validation failed",
|
||||
detail: ex.Message,
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = code });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListJobsAsync(
|
||||
[FromQuery] string? tenant_id,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await service.ListJobsAsync(tenant_id, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetJobAsync(
|
||||
[FromRoute] string jobId,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await service.GetJobAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Job not found",
|
||||
detail: $"Job '{jobId}' not found",
|
||||
statusCode: 404,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
|
||||
}
|
||||
|
||||
return Results.Ok(job);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateJobAsync(
|
||||
[FromRoute] string jobId,
|
||||
[FromBody] UpdateExportJobRequest request,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await service.UpdateJobAsync(jobId, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(job);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Job not found",
|
||||
detail: $"Job '{jobId}' not found",
|
||||
statusCode: 404,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Validation failed",
|
||||
detail: ex.Message,
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.InvalidSchedule });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteJobAsync(
|
||||
[FromRoute] string jobId,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await service.DeleteJobAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> TriggerJobAsync(
|
||||
[FromRoute] string jobId,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await service.TriggerJobAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted($"/api/v1/export/jobs/{jobId}/executions/{response.ExecutionId}", response);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Job not found",
|
||||
detail: $"Job '{jobId}' not found",
|
||||
statusCode: 404,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetExecutionAsync(
|
||||
[FromRoute] string jobId,
|
||||
[FromRoute] string executionId,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var execution = await service.GetExecutionAsync(executionId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (execution is null || !string.Equals(execution.JobId, jobId, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(execution);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBundleAsync(
|
||||
[FromRoute] string bundleId,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundle = await service.GetBundleAsync(bundleId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(bundle);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DownloadBundleAsync(
|
||||
[FromRoute] string bundleId,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundle = await service.GetBundleAsync(bundleId, cancellationToken).ConfigureAwait(false);
|
||||
if (bundle is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var content = await service.GetBundleContentAsync(bundleId, cancellationToken).ConfigureAwait(false);
|
||||
if (content is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var contentType = bundle.Format switch
|
||||
{
|
||||
ExportFormats.Ndjson => "application/x-ndjson",
|
||||
_ => "application/json"
|
||||
};
|
||||
|
||||
var fileName = $"export-{bundle.BundleId}-{DateTime.UtcNow:yyyy-MM-dd}.json";
|
||||
|
||||
return Results.File(
|
||||
content,
|
||||
contentType,
|
||||
fileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for policy pack bundle import per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
public static class PolicyPackBundleEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPolicyPackBundles(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/airgap/bundles");
|
||||
|
||||
group.MapPost("", RegisterBundleAsync)
|
||||
.WithName("AirGap.RegisterBundle")
|
||||
.WithDescription("Register a bundle for import");
|
||||
|
||||
group.MapGet("{bundleId}", GetBundleStatusAsync)
|
||||
.WithName("AirGap.GetBundleStatus")
|
||||
.WithDescription("Get bundle import status");
|
||||
|
||||
group.MapGet("", ListBundlesAsync)
|
||||
.WithName("AirGap.ListBundles")
|
||||
.WithDescription("List imported bundles");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> RegisterBundleAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromBody] RegisterBundleRequest request,
|
||||
PolicyPackBundleImportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Tenant ID required",
|
||||
detail: "X-Tenant-Id header is required",
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = "TENANT_REQUIRED" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await service.RegisterBundleAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted($"/api/v1/airgap/bundles/{response.ImportId}", response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: ex.Message,
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = "INVALID_REQUEST" });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBundleStatusAsync(
|
||||
[FromRoute] string bundleId,
|
||||
PolicyPackBundleImportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await service.GetBundleStatusAsync(bundleId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Bundle not found",
|
||||
detail: $"Bundle '{bundleId}' not found",
|
||||
statusCode: 404,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = "BUNDLE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
return Results.Ok(status);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListBundlesAsync(
|
||||
[FromQuery] string? tenant_id,
|
||||
PolicyPackBundleImportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundles = await service.ListBundlesAsync(tenant_id, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new { items = bundles, total = bundles.Count });
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,17 @@ builder.Services.AddSingleton<IWorkerResultStore, InMemoryWorkerResultStore>();
|
||||
builder.Services.AddSingleton<PolicyWorkerService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.ILedgerExportStore, StellaOps.Policy.Engine.Ledger.InMemoryLedgerExportStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.LedgerExportService>();
|
||||
|
||||
// Console export jobs per CONTRACT-EXPORT-BUNDLE-009
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportJobStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportJobStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportExecutionStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportExecutionStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportBundleStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportBundleStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.ConsoleExportJobService>();
|
||||
|
||||
// Air-gap bundle import per CONTRACT-MIRROR-BUNDLE-003
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IPolicyPackBundleStore, StellaOps.Policy.Engine.AirGap.InMemoryPolicyPackBundleStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.PolicyPackBundleImportService>();
|
||||
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.ISnapshotStore, StellaOps.Policy.Engine.Snapshots.InMemorySnapshotStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.SnapshotService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.IViolationEventStore, StellaOps.Policy.Engine.Violations.InMemoryViolationEventStore>();
|
||||
@@ -279,6 +290,8 @@ app.MapBatchContext();
|
||||
app.MapOrchestratorJobs();
|
||||
app.MapPolicyWorker();
|
||||
app.MapLedgerExport();
|
||||
app.MapConsoleExportJobs(); // CONTRACT-EXPORT-BUNDLE-009
|
||||
app.MapPolicyPackBundles(); // CONTRACT-MIRROR-BUNDLE-003
|
||||
app.MapSnapshots();
|
||||
app.MapViolations();
|
||||
app.MapPolicyDecisions();
|
||||
|
||||
Reference in New Issue
Block a user