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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user