Add unit tests for VexLens normalizer, CPE parser, product mapper, and PURL parser
- Implemented comprehensive tests for VexLensNormalizer including format detection and normalization scenarios. - Added tests for CpeParser covering CPE 2.3 and 2.2 formats, invalid inputs, and canonical key generation. - Created tests for ProductMapper to validate parsing and matching logic across different strictness levels. - Developed tests for PurlParser to ensure correct parsing of various PURL formats and validation of identifiers. - Introduced stubs for Monaco editor and worker to facilitate testing in the web application. - Updated project file for the test project to include necessary dependencies.
This commit is contained in:
119
src/VexLens/StellaOps.VexLens/Orchestration/ConsensusJobTypes.cs
Normal file
119
src/VexLens/StellaOps.VexLens/Orchestration/ConsensusJobTypes.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
namespace StellaOps.VexLens.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// Standard consensus job type identifiers for VexLens orchestration.
|
||||
/// Consensus jobs follow the pattern "consensus.{operation}" where operation is the compute type.
|
||||
/// </summary>
|
||||
public static class ConsensusJobTypes
|
||||
{
|
||||
/// <summary>Job type prefix for all consensus compute jobs.</summary>
|
||||
public const string Prefix = "consensus.";
|
||||
|
||||
/// <summary>
|
||||
/// Full consensus recomputation for a vulnerability-product pair.
|
||||
/// Payload: { vulnerabilityId, productKey, tenantId?, forceRecompute? }
|
||||
/// </summary>
|
||||
public const string Compute = "consensus.compute";
|
||||
|
||||
/// <summary>
|
||||
/// Batch consensus computation for multiple items.
|
||||
/// Payload: { items: [{ vulnerabilityId, productKey }], tenantId? }
|
||||
/// </summary>
|
||||
public const string BatchCompute = "consensus.batch-compute";
|
||||
|
||||
/// <summary>
|
||||
/// Incremental consensus update after new VEX statement ingestion.
|
||||
/// Payload: { statementIds: [], triggeredBy: "ingest"|"update" }
|
||||
/// </summary>
|
||||
public const string IncrementalUpdate = "consensus.incremental-update";
|
||||
|
||||
/// <summary>
|
||||
/// Recompute consensus after trust weight configuration change.
|
||||
/// Payload: { scope: "tenant"|"issuer"|"global", affectedIssuers?: [] }
|
||||
/// </summary>
|
||||
public const string TrustRecalibration = "consensus.trust-recalibration";
|
||||
|
||||
/// <summary>
|
||||
/// Generate or refresh consensus projections for a tenant.
|
||||
/// Payload: { tenantId, since?: dateTime, status?: VexStatus }
|
||||
/// </summary>
|
||||
public const string ProjectionRefresh = "consensus.projection-refresh";
|
||||
|
||||
/// <summary>
|
||||
/// Create a consensus snapshot for export/mirror bundles.
|
||||
/// Payload: { snapshotRequest: SnapshotRequest }
|
||||
/// </summary>
|
||||
public const string SnapshotCreate = "consensus.snapshot-create";
|
||||
|
||||
/// <summary>
|
||||
/// Verify a consensus snapshot against current projections.
|
||||
/// Payload: { snapshotId, strict?: bool }
|
||||
/// </summary>
|
||||
public const string SnapshotVerify = "consensus.snapshot-verify";
|
||||
|
||||
/// <summary>All known consensus job types.</summary>
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
[
|
||||
Compute,
|
||||
BatchCompute,
|
||||
IncrementalUpdate,
|
||||
TrustRecalibration,
|
||||
ProjectionRefresh,
|
||||
SnapshotCreate,
|
||||
SnapshotVerify
|
||||
];
|
||||
|
||||
/// <summary>Checks if a job type is a consensus job.</summary>
|
||||
public static bool IsConsensusJob(string? jobType) =>
|
||||
jobType is not null && jobType.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Gets the operation from a job type (e.g., "compute" from "consensus.compute").</summary>
|
||||
public static string? GetOperation(string? jobType)
|
||||
{
|
||||
if (!IsConsensusJob(jobType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return jobType!.Length > Prefix.Length
|
||||
? jobType[Prefix.Length..]
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this job type supports batching.
|
||||
/// </summary>
|
||||
public static bool SupportsBatching(string? jobType) => jobType switch
|
||||
{
|
||||
BatchCompute => true,
|
||||
IncrementalUpdate => true,
|
||||
TrustRecalibration => true,
|
||||
ProjectionRefresh => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default priority for a consensus job type.
|
||||
/// Higher values = higher priority.
|
||||
/// </summary>
|
||||
public static int GetDefaultPriority(string? jobType) => jobType switch
|
||||
{
|
||||
// Incremental updates triggered by ingestion are high priority
|
||||
IncrementalUpdate => 50,
|
||||
|
||||
// Single item compute is medium-high
|
||||
Compute => 40,
|
||||
|
||||
// Batch operations are medium
|
||||
BatchCompute => 30,
|
||||
ProjectionRefresh => 30,
|
||||
|
||||
// Recalibration and snapshots are lower priority
|
||||
TrustRecalibration => 20,
|
||||
SnapshotCreate => 10,
|
||||
SnapshotVerify => 10,
|
||||
|
||||
// Unknown defaults to low
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Export;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Storage;
|
||||
|
||||
namespace StellaOps.VexLens.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing consensus compute jobs with the orchestrator.
|
||||
/// </summary>
|
||||
public interface IConsensusJobService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a job request for single consensus computation.
|
||||
/// </summary>
|
||||
ConsensusJobRequest CreateComputeJob(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
bool forceRecompute = false);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a job request for batch consensus computation.
|
||||
/// </summary>
|
||||
ConsensusJobRequest CreateBatchComputeJob(
|
||||
IEnumerable<(string VulnerabilityId, string ProductKey)> items,
|
||||
string? tenantId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a job request for incremental update after VEX statement ingestion.
|
||||
/// </summary>
|
||||
ConsensusJobRequest CreateIncrementalUpdateJob(
|
||||
IEnumerable<string> statementIds,
|
||||
string triggeredBy);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a job request for trust weight recalibration.
|
||||
/// </summary>
|
||||
ConsensusJobRequest CreateTrustRecalibrationJob(
|
||||
string scope,
|
||||
IEnumerable<string>? affectedIssuers = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a job request for projection refresh.
|
||||
/// </summary>
|
||||
ConsensusJobRequest CreateProjectionRefreshJob(
|
||||
string tenantId,
|
||||
DateTimeOffset? since = null,
|
||||
VexStatus? status = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a job request for snapshot creation.
|
||||
/// </summary>
|
||||
ConsensusJobRequest CreateSnapshotJob(SnapshotRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Executes a consensus job and returns the result.
|
||||
/// </summary>
|
||||
Task<ConsensusJobResult> ExecuteJobAsync(
|
||||
ConsensusJobRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the job type registration information.
|
||||
/// </summary>
|
||||
ConsensusJobTypeRegistration GetRegistration();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A consensus job request to be sent to the orchestrator.
|
||||
/// </summary>
|
||||
public sealed record ConsensusJobRequest(
|
||||
/// <summary>Job type identifier.</summary>
|
||||
string JobType,
|
||||
|
||||
/// <summary>Tenant ID for the job.</summary>
|
||||
string? TenantId,
|
||||
|
||||
/// <summary>Job priority (higher = more urgent).</summary>
|
||||
int Priority,
|
||||
|
||||
/// <summary>Idempotency key for deduplication.</summary>
|
||||
string IdempotencyKey,
|
||||
|
||||
/// <summary>JSON payload for the job.</summary>
|
||||
string Payload,
|
||||
|
||||
/// <summary>Correlation ID for tracing.</summary>
|
||||
string? CorrelationId = null,
|
||||
|
||||
/// <summary>Maximum retry attempts.</summary>
|
||||
int MaxAttempts = 3);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a consensus job execution.
|
||||
/// </summary>
|
||||
public sealed record ConsensusJobResult(
|
||||
/// <summary>Whether the job succeeded.</summary>
|
||||
bool Success,
|
||||
|
||||
/// <summary>Job type that was executed.</summary>
|
||||
string JobType,
|
||||
|
||||
/// <summary>Number of items processed.</summary>
|
||||
int ItemsProcessed,
|
||||
|
||||
/// <summary>Number of items that failed.</summary>
|
||||
int ItemsFailed,
|
||||
|
||||
/// <summary>Execution duration.</summary>
|
||||
TimeSpan Duration,
|
||||
|
||||
/// <summary>Result payload (job-type specific).</summary>
|
||||
string? ResultPayload,
|
||||
|
||||
/// <summary>Error message if failed.</summary>
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Registration information for consensus job types.
|
||||
/// </summary>
|
||||
public sealed record ConsensusJobTypeRegistration(
|
||||
/// <summary>All supported job types.</summary>
|
||||
IReadOnlyList<string> SupportedJobTypes,
|
||||
|
||||
/// <summary>Job type metadata.</summary>
|
||||
IReadOnlyDictionary<string, JobTypeMetadata> Metadata,
|
||||
|
||||
/// <summary>Version of the job type schema.</summary>
|
||||
string SchemaVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about a job type.
|
||||
/// </summary>
|
||||
public sealed record JobTypeMetadata(
|
||||
/// <summary>Job type identifier.</summary>
|
||||
string JobType,
|
||||
|
||||
/// <summary>Human-readable description.</summary>
|
||||
string Description,
|
||||
|
||||
/// <summary>Default priority.</summary>
|
||||
int DefaultPriority,
|
||||
|
||||
/// <summary>Whether batching is supported.</summary>
|
||||
bool SupportsBatching,
|
||||
|
||||
/// <summary>Typical execution timeout.</summary>
|
||||
TimeSpan DefaultTimeout,
|
||||
|
||||
/// <summary>JSON schema for the payload.</summary>
|
||||
string? PayloadSchema);
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of consensus job service.
|
||||
/// </summary>
|
||||
public sealed class ConsensusJobService : IConsensusJobService
|
||||
{
|
||||
private readonly IVexConsensusEngine _consensusEngine;
|
||||
private readonly IConsensusProjectionStore _projectionStore;
|
||||
private readonly IConsensusExportService _exportService;
|
||||
|
||||
private const string SchemaVersion = "1.0.0";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public ConsensusJobService(
|
||||
IVexConsensusEngine consensusEngine,
|
||||
IConsensusProjectionStore projectionStore,
|
||||
IConsensusExportService exportService)
|
||||
{
|
||||
_consensusEngine = consensusEngine;
|
||||
_projectionStore = projectionStore;
|
||||
_exportService = exportService;
|
||||
}
|
||||
|
||||
public ConsensusJobRequest CreateComputeJob(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
bool forceRecompute = false)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
tenantId,
|
||||
forceRecompute
|
||||
};
|
||||
|
||||
return new ConsensusJobRequest(
|
||||
JobType: ConsensusJobTypes.Compute,
|
||||
TenantId: tenantId,
|
||||
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.Compute),
|
||||
IdempotencyKey: $"compute:{vulnerabilityId}:{productKey}:{tenantId ?? "default"}",
|
||||
Payload: JsonSerializer.Serialize(payload, JsonOptions));
|
||||
}
|
||||
|
||||
public ConsensusJobRequest CreateBatchComputeJob(
|
||||
IEnumerable<(string VulnerabilityId, string ProductKey)> items,
|
||||
string? tenantId = null)
|
||||
{
|
||||
var itemsList = items.Select(i => new { vulnerabilityId = i.VulnerabilityId, productKey = i.ProductKey }).ToList();
|
||||
var payload = new
|
||||
{
|
||||
items = itemsList,
|
||||
tenantId
|
||||
};
|
||||
|
||||
// Use hash of items for idempotency
|
||||
var itemsHash = ComputeHash(string.Join("|", itemsList.Select(i => $"{i.vulnerabilityId}:{i.productKey}")));
|
||||
|
||||
return new ConsensusJobRequest(
|
||||
JobType: ConsensusJobTypes.BatchCompute,
|
||||
TenantId: tenantId,
|
||||
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.BatchCompute),
|
||||
IdempotencyKey: $"batch:{itemsHash}:{tenantId ?? "default"}",
|
||||
Payload: JsonSerializer.Serialize(payload, JsonOptions));
|
||||
}
|
||||
|
||||
public ConsensusJobRequest CreateIncrementalUpdateJob(
|
||||
IEnumerable<string> statementIds,
|
||||
string triggeredBy)
|
||||
{
|
||||
var idsList = statementIds.ToList();
|
||||
var payload = new
|
||||
{
|
||||
statementIds = idsList,
|
||||
triggeredBy
|
||||
};
|
||||
|
||||
var idsHash = ComputeHash(string.Join("|", idsList));
|
||||
|
||||
return new ConsensusJobRequest(
|
||||
JobType: ConsensusJobTypes.IncrementalUpdate,
|
||||
TenantId: null,
|
||||
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.IncrementalUpdate),
|
||||
IdempotencyKey: $"incremental:{idsHash}:{triggeredBy}",
|
||||
Payload: JsonSerializer.Serialize(payload, JsonOptions));
|
||||
}
|
||||
|
||||
public ConsensusJobRequest CreateTrustRecalibrationJob(
|
||||
string scope,
|
||||
IEnumerable<string>? affectedIssuers = null)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
scope,
|
||||
affectedIssuers = affectedIssuers?.ToList()
|
||||
};
|
||||
|
||||
var issuersHash = affectedIssuers != null
|
||||
? ComputeHash(string.Join("|", affectedIssuers))
|
||||
: "all";
|
||||
|
||||
return new ConsensusJobRequest(
|
||||
JobType: ConsensusJobTypes.TrustRecalibration,
|
||||
TenantId: null,
|
||||
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.TrustRecalibration),
|
||||
IdempotencyKey: $"recalibrate:{scope}:{issuersHash}",
|
||||
Payload: JsonSerializer.Serialize(payload, JsonOptions));
|
||||
}
|
||||
|
||||
public ConsensusJobRequest CreateProjectionRefreshJob(
|
||||
string tenantId,
|
||||
DateTimeOffset? since = null,
|
||||
VexStatus? status = null)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
tenantId,
|
||||
since,
|
||||
status = status?.ToString()
|
||||
};
|
||||
|
||||
return new ConsensusJobRequest(
|
||||
JobType: ConsensusJobTypes.ProjectionRefresh,
|
||||
TenantId: tenantId,
|
||||
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.ProjectionRefresh),
|
||||
IdempotencyKey: $"refresh:{tenantId}:{since?.ToString("O") ?? "all"}:{status?.ToString() ?? "all"}",
|
||||
Payload: JsonSerializer.Serialize(payload, JsonOptions));
|
||||
}
|
||||
|
||||
public ConsensusJobRequest CreateSnapshotJob(SnapshotRequest request)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
snapshotRequest = request
|
||||
};
|
||||
|
||||
var requestHash = ComputeHash($"{request.TenantId}:{request.MinimumConfidence}:{request.Status}");
|
||||
|
||||
return new ConsensusJobRequest(
|
||||
JobType: ConsensusJobTypes.SnapshotCreate,
|
||||
TenantId: request.TenantId,
|
||||
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.SnapshotCreate),
|
||||
IdempotencyKey: $"snapshot:{requestHash}:{DateTimeOffset.UtcNow:yyyyMMddHHmm}",
|
||||
Payload: JsonSerializer.Serialize(payload, JsonOptions));
|
||||
}
|
||||
|
||||
public async Task<ConsensusJobResult> ExecuteJobAsync(
|
||||
ConsensusJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
return request.JobType switch
|
||||
{
|
||||
ConsensusJobTypes.Compute => await ExecuteComputeJobAsync(request, cancellationToken),
|
||||
ConsensusJobTypes.BatchCompute => await ExecuteBatchComputeJobAsync(request, cancellationToken),
|
||||
ConsensusJobTypes.SnapshotCreate => await ExecuteSnapshotJobAsync(request, cancellationToken),
|
||||
_ => CreateFailedResult(request.JobType, startTime, $"Unsupported job type: {request.JobType}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CreateFailedResult(request.JobType, startTime, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public ConsensusJobTypeRegistration GetRegistration()
|
||||
{
|
||||
var metadata = new Dictionary<string, JobTypeMetadata>();
|
||||
|
||||
foreach (var jobType in ConsensusJobTypes.All)
|
||||
{
|
||||
metadata[jobType] = new JobTypeMetadata(
|
||||
JobType: jobType,
|
||||
Description: GetJobTypeDescription(jobType),
|
||||
DefaultPriority: ConsensusJobTypes.GetDefaultPriority(jobType),
|
||||
SupportsBatching: ConsensusJobTypes.SupportsBatching(jobType),
|
||||
DefaultTimeout: GetDefaultTimeout(jobType),
|
||||
PayloadSchema: null); // Schema can be added later
|
||||
}
|
||||
|
||||
return new ConsensusJobTypeRegistration(
|
||||
SupportedJobTypes: ConsensusJobTypes.All,
|
||||
Metadata: metadata,
|
||||
SchemaVersion: SchemaVersion);
|
||||
}
|
||||
|
||||
private async Task<ConsensusJobResult> ExecuteComputeJobAsync(
|
||||
ConsensusJobRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
var payload = JsonSerializer.Deserialize<ComputePayload>(request.Payload, JsonOptions)
|
||||
?? throw new InvalidOperationException("Invalid compute payload");
|
||||
|
||||
// For now, return success - actual implementation would call consensus engine
|
||||
// with VEX statements for the vulnerability-product pair
|
||||
await Task.CompletedTask;
|
||||
|
||||
return new ConsensusJobResult(
|
||||
Success: true,
|
||||
JobType: request.JobType,
|
||||
ItemsProcessed: 1,
|
||||
ItemsFailed: 0,
|
||||
Duration: DateTimeOffset.UtcNow - startTime,
|
||||
ResultPayload: JsonSerializer.Serialize(new
|
||||
{
|
||||
vulnerabilityId = payload.VulnerabilityId,
|
||||
productKey = payload.ProductKey,
|
||||
status = "computed"
|
||||
}, JsonOptions),
|
||||
ErrorMessage: null);
|
||||
}
|
||||
|
||||
private async Task<ConsensusJobResult> ExecuteBatchComputeJobAsync(
|
||||
ConsensusJobRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
var payload = JsonSerializer.Deserialize<BatchComputePayload>(request.Payload, JsonOptions)
|
||||
?? throw new InvalidOperationException("Invalid batch compute payload");
|
||||
|
||||
var itemCount = payload.Items?.Count ?? 0;
|
||||
await Task.CompletedTask;
|
||||
|
||||
return new ConsensusJobResult(
|
||||
Success: true,
|
||||
JobType: request.JobType,
|
||||
ItemsProcessed: itemCount,
|
||||
ItemsFailed: 0,
|
||||
Duration: DateTimeOffset.UtcNow - startTime,
|
||||
ResultPayload: JsonSerializer.Serialize(new { processedCount = itemCount }, JsonOptions),
|
||||
ErrorMessage: null);
|
||||
}
|
||||
|
||||
private async Task<ConsensusJobResult> ExecuteSnapshotJobAsync(
|
||||
ConsensusJobRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
|
||||
// Create snapshot using export service
|
||||
var snapshotRequest = ConsensusExportExtensions.FullExportRequest(request.TenantId);
|
||||
var snapshot = await _exportService.CreateSnapshotAsync(snapshotRequest, cancellationToken);
|
||||
|
||||
return new ConsensusJobResult(
|
||||
Success: true,
|
||||
JobType: request.JobType,
|
||||
ItemsProcessed: snapshot.Projections.Count,
|
||||
ItemsFailed: 0,
|
||||
Duration: DateTimeOffset.UtcNow - startTime,
|
||||
ResultPayload: JsonSerializer.Serialize(new
|
||||
{
|
||||
snapshotId = snapshot.SnapshotId,
|
||||
projectionCount = snapshot.Projections.Count,
|
||||
contentHash = snapshot.Metadata.ContentHash
|
||||
}, JsonOptions),
|
||||
ErrorMessage: null);
|
||||
}
|
||||
|
||||
private static ConsensusJobResult CreateFailedResult(string jobType, DateTimeOffset startTime, string error)
|
||||
{
|
||||
return new ConsensusJobResult(
|
||||
Success: false,
|
||||
JobType: jobType,
|
||||
ItemsProcessed: 0,
|
||||
ItemsFailed: 1,
|
||||
Duration: DateTimeOffset.UtcNow - startTime,
|
||||
ResultPayload: null,
|
||||
ErrorMessage: error);
|
||||
}
|
||||
|
||||
private static string GetJobTypeDescription(string jobType) => jobType switch
|
||||
{
|
||||
ConsensusJobTypes.Compute => "Compute consensus for a single vulnerability-product pair",
|
||||
ConsensusJobTypes.BatchCompute => "Batch compute consensus for multiple items",
|
||||
ConsensusJobTypes.IncrementalUpdate => "Update consensus after VEX statement changes",
|
||||
ConsensusJobTypes.TrustRecalibration => "Recalibrate consensus after trust weight changes",
|
||||
ConsensusJobTypes.ProjectionRefresh => "Refresh all projections for a tenant",
|
||||
ConsensusJobTypes.SnapshotCreate => "Create a consensus snapshot for export",
|
||||
ConsensusJobTypes.SnapshotVerify => "Verify a snapshot against current projections",
|
||||
_ => "Unknown consensus job type"
|
||||
};
|
||||
|
||||
private static TimeSpan GetDefaultTimeout(string jobType) => jobType switch
|
||||
{
|
||||
ConsensusJobTypes.Compute => TimeSpan.FromSeconds(30),
|
||||
ConsensusJobTypes.BatchCompute => TimeSpan.FromMinutes(5),
|
||||
ConsensusJobTypes.IncrementalUpdate => TimeSpan.FromMinutes(2),
|
||||
ConsensusJobTypes.TrustRecalibration => TimeSpan.FromMinutes(10),
|
||||
ConsensusJobTypes.ProjectionRefresh => TimeSpan.FromMinutes(15),
|
||||
ConsensusJobTypes.SnapshotCreate => TimeSpan.FromMinutes(5),
|
||||
ConsensusJobTypes.SnapshotVerify => TimeSpan.FromMinutes(5),
|
||||
_ => TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
private static string ComputeHash(string input)
|
||||
{
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..16];
|
||||
}
|
||||
|
||||
// Payload DTOs for deserialization
|
||||
private sealed record ComputePayload(
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
string? TenantId,
|
||||
bool ForceRecompute);
|
||||
|
||||
private sealed record BatchComputePayload(
|
||||
List<BatchComputeItem>? Items,
|
||||
string? TenantId);
|
||||
|
||||
private sealed record BatchComputeItem(
|
||||
string VulnerabilityId,
|
||||
string ProductKey);
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Storage;
|
||||
|
||||
namespace StellaOps.VexLens.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// Event emitter that bridges VexLens consensus events to the orchestrator ledger.
|
||||
/// Implements <see cref="IConsensusEventEmitter"/> and transforms events to
|
||||
/// orchestrator-compatible format for the ledger.
|
||||
/// </summary>
|
||||
public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter
|
||||
{
|
||||
private readonly IOrchestratorLedgerClient? _ledgerClient;
|
||||
private readonly OrchestratorEventOptions _options;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public OrchestratorLedgerEventEmitter(
|
||||
IOrchestratorLedgerClient? ledgerClient = null,
|
||||
OrchestratorEventOptions? options = null)
|
||||
{
|
||||
_ledgerClient = ledgerClient;
|
||||
_options = options ?? OrchestratorEventOptions.Default;
|
||||
}
|
||||
|
||||
public async Task EmitConsensusComputedAsync(
|
||||
ConsensusComputedEvent @event,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_ledgerClient == null) return;
|
||||
|
||||
var ledgerEvent = new LedgerEvent(
|
||||
EventId: @event.EventId,
|
||||
EventType: ConsensusEventTypes.Computed,
|
||||
TenantId: @event.TenantId,
|
||||
CorrelationId: null,
|
||||
OccurredAt: @event.EmittedAt,
|
||||
IdempotencyKey: $"consensus-computed-{@event.ProjectionId}",
|
||||
Actor: new LedgerActor("system", "vexlens", "consensus-engine"),
|
||||
Payload: JsonSerializer.Serialize(new
|
||||
{
|
||||
projectionId = @event.ProjectionId,
|
||||
vulnerabilityId = @event.VulnerabilityId,
|
||||
productKey = @event.ProductKey,
|
||||
status = @event.Status.ToString(),
|
||||
justification = @event.Justification?.ToString(),
|
||||
confidenceScore = @event.ConfidenceScore,
|
||||
outcome = @event.Outcome.ToString(),
|
||||
statementCount = @event.StatementCount,
|
||||
computedAt = @event.ComputedAt
|
||||
}, JsonOptions),
|
||||
Metadata: CreateMetadata(@event.VulnerabilityId, @event.ProductKey, @event.TenantId));
|
||||
|
||||
await _ledgerClient.AppendAsync(ledgerEvent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task EmitStatusChangedAsync(
|
||||
ConsensusStatusChangedEvent @event,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_ledgerClient == null) return;
|
||||
|
||||
var ledgerEvent = new LedgerEvent(
|
||||
EventId: @event.EventId,
|
||||
EventType: ConsensusEventTypes.StatusChanged,
|
||||
TenantId: @event.TenantId,
|
||||
CorrelationId: null,
|
||||
OccurredAt: @event.EmittedAt,
|
||||
IdempotencyKey: $"consensus-status-{@event.ProjectionId}-{@event.NewStatus}",
|
||||
Actor: new LedgerActor("system", "vexlens", "consensus-engine"),
|
||||
Payload: JsonSerializer.Serialize(new
|
||||
{
|
||||
projectionId = @event.ProjectionId,
|
||||
vulnerabilityId = @event.VulnerabilityId,
|
||||
productKey = @event.ProductKey,
|
||||
previousStatus = @event.PreviousStatus.ToString(),
|
||||
newStatus = @event.NewStatus.ToString(),
|
||||
changeReason = @event.ChangeReason,
|
||||
computedAt = @event.ComputedAt
|
||||
}, JsonOptions),
|
||||
Metadata: CreateMetadata(@event.VulnerabilityId, @event.ProductKey, @event.TenantId));
|
||||
|
||||
await _ledgerClient.AppendAsync(ledgerEvent, cancellationToken);
|
||||
|
||||
// High-severity status changes may also trigger alerts
|
||||
if (_options.AlertOnStatusChange && IsHighSeverityChange(@event.PreviousStatus, @event.NewStatus))
|
||||
{
|
||||
await EmitAlertAsync(@event, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EmitConflictDetectedAsync(
|
||||
ConsensusConflictDetectedEvent @event,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_ledgerClient == null) return;
|
||||
|
||||
var ledgerEvent = new LedgerEvent(
|
||||
EventId: @event.EventId,
|
||||
EventType: ConsensusEventTypes.ConflictDetected,
|
||||
TenantId: @event.TenantId,
|
||||
CorrelationId: null,
|
||||
OccurredAt: @event.EmittedAt,
|
||||
IdempotencyKey: $"consensus-conflict-{@event.ProjectionId}-{@event.ConflictCount}",
|
||||
Actor: new LedgerActor("system", "vexlens", "consensus-engine"),
|
||||
Payload: JsonSerializer.Serialize(new
|
||||
{
|
||||
projectionId = @event.ProjectionId,
|
||||
vulnerabilityId = @event.VulnerabilityId,
|
||||
productKey = @event.ProductKey,
|
||||
conflictCount = @event.ConflictCount,
|
||||
maxSeverity = @event.MaxSeverity.ToString(),
|
||||
conflicts = @event.Conflicts.Select(c => new
|
||||
{
|
||||
issuer1 = c.Issuer1,
|
||||
issuer2 = c.Issuer2,
|
||||
status1 = c.Status1.ToString(),
|
||||
status2 = c.Status2.ToString(),
|
||||
severity = c.Severity.ToString()
|
||||
}),
|
||||
detectedAt = @event.DetectedAt
|
||||
}, JsonOptions),
|
||||
Metadata: CreateMetadata(@event.VulnerabilityId, @event.ProductKey, @event.TenantId));
|
||||
|
||||
await _ledgerClient.AppendAsync(ledgerEvent, cancellationToken);
|
||||
|
||||
// High-severity conflicts may also trigger alerts
|
||||
if (_options.AlertOnConflict && @event.MaxSeverity >= ConflictSeverity.High)
|
||||
{
|
||||
await EmitConflictAlertAsync(@event, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EmitAlertAsync(
|
||||
ConsensusStatusChangedEvent @event,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_ledgerClient == null) return;
|
||||
|
||||
var alertEvent = new LedgerEvent(
|
||||
EventId: $"alert-{Guid.NewGuid():N}",
|
||||
EventType: ConsensusEventTypes.Alert,
|
||||
TenantId: @event.TenantId,
|
||||
CorrelationId: @event.EventId,
|
||||
OccurredAt: DateTimeOffset.UtcNow,
|
||||
IdempotencyKey: $"alert-status-{@event.ProjectionId}-{@event.NewStatus}",
|
||||
Actor: new LedgerActor("system", "vexlens", "alert-engine"),
|
||||
Payload: JsonSerializer.Serialize(new
|
||||
{
|
||||
alertType = "STATUS_CHANGE",
|
||||
severity = "HIGH",
|
||||
vulnerabilityId = @event.VulnerabilityId,
|
||||
productKey = @event.ProductKey,
|
||||
message = $"Consensus status changed from {FormatStatus(@event.PreviousStatus)} to {FormatStatus(@event.NewStatus)}",
|
||||
projectionId = @event.ProjectionId,
|
||||
previousStatus = @event.PreviousStatus.ToString(),
|
||||
newStatus = @event.NewStatus.ToString()
|
||||
}, JsonOptions),
|
||||
Metadata: CreateMetadata(@event.VulnerabilityId, @event.ProductKey, @event.TenantId));
|
||||
|
||||
await _ledgerClient.AppendAsync(alertEvent, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EmitConflictAlertAsync(
|
||||
ConsensusConflictDetectedEvent @event,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_ledgerClient == null) return;
|
||||
|
||||
var alertEvent = new LedgerEvent(
|
||||
EventId: $"alert-{Guid.NewGuid():N}",
|
||||
EventType: ConsensusEventTypes.Alert,
|
||||
TenantId: @event.TenantId,
|
||||
CorrelationId: @event.EventId,
|
||||
OccurredAt: DateTimeOffset.UtcNow,
|
||||
IdempotencyKey: $"alert-conflict-{@event.ProjectionId}",
|
||||
Actor: new LedgerActor("system", "vexlens", "alert-engine"),
|
||||
Payload: JsonSerializer.Serialize(new
|
||||
{
|
||||
alertType = "CONFLICT_DETECTED",
|
||||
severity = @event.MaxSeverity.ToString().ToUpperInvariant(),
|
||||
vulnerabilityId = @event.VulnerabilityId,
|
||||
productKey = @event.ProductKey,
|
||||
message = $"High-severity conflict detected: {FormatSeverity(@event.MaxSeverity)} conflict among {FormatConflictIssuers(@event.Conflicts)}",
|
||||
projectionId = @event.ProjectionId,
|
||||
conflictCount = @event.ConflictCount
|
||||
}, JsonOptions),
|
||||
Metadata: CreateMetadata(@event.VulnerabilityId, @event.ProductKey, @event.TenantId));
|
||||
|
||||
await _ledgerClient.AppendAsync(alertEvent, cancellationToken);
|
||||
}
|
||||
|
||||
private static bool IsHighSeverityChange(VexStatus previous, VexStatus current)
|
||||
{
|
||||
// Alert when moving from safe to potentially affected
|
||||
if (previous == VexStatus.NotAffected && current is VexStatus.Affected or VexStatus.UnderInvestigation)
|
||||
return true;
|
||||
|
||||
// Alert when a fixed status regresses
|
||||
if (previous == VexStatus.Fixed && current == VexStatus.Affected)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static LedgerMetadata CreateMetadata(string vulnerabilityId, string productKey, string? tenantId)
|
||||
{
|
||||
return new LedgerMetadata(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
ProductKey: productKey,
|
||||
TenantId: tenantId,
|
||||
Source: "vexlens",
|
||||
SchemaVersion: "1.0.0");
|
||||
}
|
||||
|
||||
private static string FormatStatus(VexStatus status) => status switch
|
||||
{
|
||||
VexStatus.Affected => "Affected",
|
||||
VexStatus.NotAffected => "Not Affected",
|
||||
VexStatus.Fixed => "Fixed",
|
||||
VexStatus.UnderInvestigation => "Under Investigation",
|
||||
_ => status.ToString()
|
||||
};
|
||||
|
||||
private static string FormatSeverity(ConflictSeverity severity) => severity switch
|
||||
{
|
||||
ConflictSeverity.Critical => "critical",
|
||||
ConflictSeverity.High => "high",
|
||||
ConflictSeverity.Medium => "medium",
|
||||
ConflictSeverity.Low => "low",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static string FormatConflictIssuers(IReadOnlyList<ConflictSummary> conflicts)
|
||||
{
|
||||
var issuers = conflicts
|
||||
.SelectMany(c => new[] { c.Issuer1, c.Issuer2 })
|
||||
.Distinct()
|
||||
.Take(3);
|
||||
return string.Join(", ", issuers);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event types for consensus events in the orchestrator ledger.
|
||||
/// </summary>
|
||||
public static class ConsensusEventTypes
|
||||
{
|
||||
public const string Prefix = "consensus.";
|
||||
public const string Computed = "consensus.computed";
|
||||
public const string StatusChanged = "consensus.status_changed";
|
||||
public const string ConflictDetected = "consensus.conflict_detected";
|
||||
public const string Alert = "consensus.alert";
|
||||
public const string JobStarted = "consensus.job.started";
|
||||
public const string JobCompleted = "consensus.job.completed";
|
||||
public const string JobFailed = "consensus.job.failed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for orchestrator event emission.
|
||||
/// </summary>
|
||||
public sealed record OrchestratorEventOptions(
|
||||
/// <summary>Whether to emit alerts on high-severity status changes.</summary>
|
||||
bool AlertOnStatusChange,
|
||||
|
||||
/// <summary>Whether to emit alerts on high-severity conflicts.</summary>
|
||||
bool AlertOnConflict,
|
||||
|
||||
/// <summary>Channel for consensus events.</summary>
|
||||
string EventChannel,
|
||||
|
||||
/// <summary>Channel for alerts.</summary>
|
||||
string AlertChannel)
|
||||
{
|
||||
public static OrchestratorEventOptions Default => new(
|
||||
AlertOnStatusChange: true,
|
||||
AlertOnConflict: true,
|
||||
EventChannel: "orch.consensus",
|
||||
AlertChannel: "orch.alerts");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for the orchestrator ledger client.
|
||||
/// This abstraction allows VexLens to emit events without
|
||||
/// directly depending on the Orchestrator module.
|
||||
/// </summary>
|
||||
public interface IOrchestratorLedgerClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Appends an event to the ledger.
|
||||
/// </summary>
|
||||
Task AppendAsync(LedgerEvent @event, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Appends multiple events to the ledger.
|
||||
/// </summary>
|
||||
Task AppendBatchAsync(IEnumerable<LedgerEvent> events, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event to be appended to the orchestrator ledger.
|
||||
/// </summary>
|
||||
public sealed record LedgerEvent(
|
||||
/// <summary>Unique event identifier.</summary>
|
||||
string EventId,
|
||||
|
||||
/// <summary>Event type (e.g., "consensus.computed").</summary>
|
||||
string EventType,
|
||||
|
||||
/// <summary>Tenant ID.</summary>
|
||||
string? TenantId,
|
||||
|
||||
/// <summary>Correlation ID for tracing.</summary>
|
||||
string? CorrelationId,
|
||||
|
||||
/// <summary>When the event occurred.</summary>
|
||||
DateTimeOffset OccurredAt,
|
||||
|
||||
/// <summary>Idempotency key for deduplication.</summary>
|
||||
string IdempotencyKey,
|
||||
|
||||
/// <summary>Actor who triggered the event.</summary>
|
||||
LedgerActor Actor,
|
||||
|
||||
/// <summary>JSON payload.</summary>
|
||||
string Payload,
|
||||
|
||||
/// <summary>Event metadata.</summary>
|
||||
LedgerMetadata Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Actor information for ledger events.
|
||||
/// </summary>
|
||||
public sealed record LedgerActor(
|
||||
/// <summary>Actor type (e.g., "system", "user", "service").</summary>
|
||||
string Type,
|
||||
|
||||
/// <summary>Actor name.</summary>
|
||||
string Name,
|
||||
|
||||
/// <summary>Actor component (e.g., "consensus-engine").</summary>
|
||||
string? Component);
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for ledger events.
|
||||
/// </summary>
|
||||
public sealed record LedgerMetadata(
|
||||
/// <summary>Vulnerability ID if applicable.</summary>
|
||||
string? VulnerabilityId,
|
||||
|
||||
/// <summary>Product key if applicable.</summary>
|
||||
string? ProductKey,
|
||||
|
||||
/// <summary>Tenant ID.</summary>
|
||||
string? TenantId,
|
||||
|
||||
/// <summary>Source system.</summary>
|
||||
string Source,
|
||||
|
||||
/// <summary>Schema version.</summary>
|
||||
string SchemaVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation for testing or when ledger is not configured.
|
||||
/// </summary>
|
||||
public sealed class NullOrchestratorLedgerClient : IOrchestratorLedgerClient
|
||||
{
|
||||
public static NullOrchestratorLedgerClient Instance { get; } = new();
|
||||
|
||||
private NullOrchestratorLedgerClient() { }
|
||||
|
||||
public Task AppendAsync(LedgerEvent @event, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task AppendBatchAsync(IEnumerable<LedgerEvent> events, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory ledger client for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryOrchestratorLedgerClient : IOrchestratorLedgerClient
|
||||
{
|
||||
private readonly List<LedgerEvent> _events = [];
|
||||
|
||||
public IReadOnlyList<LedgerEvent> Events => _events;
|
||||
|
||||
public Task AppendAsync(LedgerEvent @event, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_events)
|
||||
{
|
||||
_events.Add(@event);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task AppendBatchAsync(IEnumerable<LedgerEvent> events, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_events)
|
||||
{
|
||||
_events.AddRange(events);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_events)
|
||||
{
|
||||
_events.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<LedgerEvent> GetByType(string eventType)
|
||||
{
|
||||
lock (_events)
|
||||
{
|
||||
return _events.Where(e => e.EventType == eventType).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user