|
|
|
|
@@ -1,6 +1,7 @@
|
|
|
|
|
using System.CommandLine;
|
|
|
|
|
using System.Diagnostics;
|
|
|
|
|
using System.Diagnostics.Metrics;
|
|
|
|
|
using System.Runtime.CompilerServices;
|
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using System.Text;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
@@ -13,6 +14,7 @@ using StellaOps.Findings.Ledger.Domain;
|
|
|
|
|
using StellaOps.Findings.Ledger.Hashing;
|
|
|
|
|
using StellaOps.Findings.Ledger.Infrastructure;
|
|
|
|
|
using StellaOps.Findings.Ledger.Infrastructure.Merkle;
|
|
|
|
|
using StellaOps.Findings.Ledger.Infrastructure.Policy;
|
|
|
|
|
using StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
|
|
|
|
using StellaOps.Findings.Ledger.Infrastructure.Projection;
|
|
|
|
|
using StellaOps.Findings.Ledger.Options;
|
|
|
|
|
@@ -20,55 +22,66 @@ using StellaOps.Findings.Ledger.Observability;
|
|
|
|
|
using StellaOps.Findings.Ledger.Services;
|
|
|
|
|
|
|
|
|
|
// Command-line options
|
|
|
|
|
var fixturesOption = new Option<FileInfo[]>(
|
|
|
|
|
name: "--fixture",
|
|
|
|
|
description: "NDJSON fixtures containing canonical ledger envelopes (sequence-ordered)")
|
|
|
|
|
var fixturesOption = new Option<FileInfo[]>("--fixture")
|
|
|
|
|
{
|
|
|
|
|
IsRequired = true
|
|
|
|
|
};
|
|
|
|
|
fixturesOption.AllowMultipleArgumentsPerToken = true;
|
|
|
|
|
|
|
|
|
|
var connectionOption = new Option<string>(
|
|
|
|
|
name: "--connection",
|
|
|
|
|
description: "PostgreSQL connection string for ledger DB")
|
|
|
|
|
{
|
|
|
|
|
IsRequired = true
|
|
|
|
|
Description = "NDJSON fixtures containing canonical ledger envelopes (sequence-ordered)",
|
|
|
|
|
Required = true,
|
|
|
|
|
AllowMultipleArgumentsPerToken = true
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var tenantOption = new Option<string>(
|
|
|
|
|
name: "--tenant",
|
|
|
|
|
getDefaultValue: () => "tenant-a",
|
|
|
|
|
description: "Tenant identifier for appended events");
|
|
|
|
|
var connectionOption = new Option<string>("--connection")
|
|
|
|
|
{
|
|
|
|
|
Description = "PostgreSQL connection string for ledger DB",
|
|
|
|
|
Required = true
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var maxParallelOption = new Option<int>(
|
|
|
|
|
name: "--maxParallel",
|
|
|
|
|
getDefaultValue: () => 4,
|
|
|
|
|
description: "Maximum concurrent append operations");
|
|
|
|
|
var tenantOption = new Option<string>("--tenant")
|
|
|
|
|
{
|
|
|
|
|
Description = "Tenant identifier for appended events",
|
|
|
|
|
DefaultValueFactory = _ => "tenant-a"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var reportOption = new Option<FileInfo?>(
|
|
|
|
|
name: "--report",
|
|
|
|
|
description: "Path to write harness report JSON (with DSSE placeholder)");
|
|
|
|
|
var maxParallelOption = new Option<int>("--maxParallel")
|
|
|
|
|
{
|
|
|
|
|
Description = "Maximum concurrent append operations",
|
|
|
|
|
DefaultValueFactory = _ => 4
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var metricsOption = new Option<FileInfo?>(
|
|
|
|
|
name: "--metrics",
|
|
|
|
|
description: "Optional path to write metrics snapshot JSON");
|
|
|
|
|
var reportOption = new Option<FileInfo?>("--report")
|
|
|
|
|
{
|
|
|
|
|
Description = "Path to write harness report JSON (with DSSE placeholder)"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var expectedChecksumOption = new Option<FileInfo?>(
|
|
|
|
|
name: "--expected-checksum",
|
|
|
|
|
description: "Optional JSON file containing expected eventStream/projection checksums");
|
|
|
|
|
var metricsOption = new Option<FileInfo?>("--metrics")
|
|
|
|
|
{
|
|
|
|
|
Description = "Optional path to write metrics snapshot JSON"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var expectedChecksumOption = new Option<FileInfo?>("--expected-checksum")
|
|
|
|
|
{
|
|
|
|
|
Description = "Optional JSON file containing expected eventStream/projection checksums"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var root = new RootCommand("Findings Ledger Replay Harness (LEDGER-29-008)");
|
|
|
|
|
root.AddOption(fixturesOption);
|
|
|
|
|
root.AddOption(connectionOption);
|
|
|
|
|
root.AddOption(tenantOption);
|
|
|
|
|
root.AddOption(maxParallelOption);
|
|
|
|
|
root.AddOption(reportOption);
|
|
|
|
|
root.AddOption(metricsOption);
|
|
|
|
|
root.AddOption(expectedChecksumOption);
|
|
|
|
|
root.Add(fixturesOption);
|
|
|
|
|
root.Add(connectionOption);
|
|
|
|
|
root.Add(tenantOption);
|
|
|
|
|
root.Add(maxParallelOption);
|
|
|
|
|
root.Add(reportOption);
|
|
|
|
|
root.Add(metricsOption);
|
|
|
|
|
root.Add(expectedChecksumOption);
|
|
|
|
|
|
|
|
|
|
root.SetHandler(async (FileInfo[] fixtures, string connection, string tenant, int maxParallel, FileInfo? reportFile, FileInfo? metricsFile, FileInfo? expectedChecksumsFile) =>
|
|
|
|
|
root.SetAction(async (parseResult, ct) =>
|
|
|
|
|
{
|
|
|
|
|
await using var host = BuildHost(connection);
|
|
|
|
|
var fixtures = parseResult.GetValue(fixturesOption)!;
|
|
|
|
|
var connection = parseResult.GetValue(connectionOption)!;
|
|
|
|
|
var tenant = parseResult.GetValue(tenantOption)!;
|
|
|
|
|
var maxParallel = parseResult.GetValue(maxParallelOption);
|
|
|
|
|
var reportFile = parseResult.GetValue(reportOption);
|
|
|
|
|
var metricsFile = parseResult.GetValue(metricsOption);
|
|
|
|
|
var expectedChecksumsFile = parseResult.GetValue(expectedChecksumOption);
|
|
|
|
|
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
|
|
|
using var host = BuildHost(connection);
|
|
|
|
|
using var scope = host.Services.CreateScope();
|
|
|
|
|
|
|
|
|
|
var writeService = scope.ServiceProvider.GetRequiredService<ILedgerEventWriteService>();
|
|
|
|
|
@@ -77,7 +90,6 @@ root.SetHandler(async (FileInfo[] fixtures, string connection, string tenant, in
|
|
|
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("Harness");
|
|
|
|
|
var timeProvider = scope.ServiceProvider.GetRequiredService<TimeProvider>();
|
|
|
|
|
|
|
|
|
|
var cts = new CancellationTokenSource();
|
|
|
|
|
var projectionTask = projectionWorker.StartAsync(cts.Token);
|
|
|
|
|
var anchorTask = anchorWorker.StartAsync(cts.Token);
|
|
|
|
|
|
|
|
|
|
@@ -124,7 +136,7 @@ root.SetHandler(async (FileInfo[] fixtures, string connection, string tenant, in
|
|
|
|
|
fixtures.Select(f => f.FullName).ToArray(),
|
|
|
|
|
eventsWritten,
|
|
|
|
|
sw.Elapsed.TotalSeconds,
|
|
|
|
|
status: verification.Success ? "pass" : "fail",
|
|
|
|
|
Status: verification.Success ? "pass" : "fail",
|
|
|
|
|
WriteLatencyP95Ms: writeLatencyP95Ms,
|
|
|
|
|
ProjectionRebuildP95Ms: rebuildP95Ms,
|
|
|
|
|
ProjectionLagSecondsMax: projectionLagSeconds,
|
|
|
|
|
@@ -154,9 +166,9 @@ root.SetHandler(async (FileInfo[] fixtures, string connection, string tenant, in
|
|
|
|
|
|
|
|
|
|
cts.Cancel();
|
|
|
|
|
await Task.WhenAll(projectionTask, anchorTask).WaitAsync(TimeSpan.FromSeconds(5));
|
|
|
|
|
}, fixturesOption, connectionOption, tenantOption, maxParallelOption, reportOption, metricsOption);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await root.InvokeAsync(args);
|
|
|
|
|
await root.Parse(args).InvokeAsync();
|
|
|
|
|
|
|
|
|
|
static async Task WriteDssePlaceholderAsync(string reportPath, string json, string? policyHash, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
@@ -246,9 +258,9 @@ static async IAsyncEnumerable<LedgerEventDraft> ReadDraftsAsync(FileInfo file, s
|
|
|
|
|
using var reader = new StreamReader(stream);
|
|
|
|
|
var recordedAtBase = timeProvider.GetUtcNow();
|
|
|
|
|
|
|
|
|
|
while (!reader.EndOfStream)
|
|
|
|
|
string? line;
|
|
|
|
|
while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) is not null)
|
|
|
|
|
{
|
|
|
|
|
var line = await reader.ReadLineAsync().ConfigureAwait(false);
|
|
|
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
@@ -281,7 +293,7 @@ static LedgerEventDraft ToDraft(JsonObject node, string defaultTenant, DateTimeO
|
|
|
|
|
var findingId = required("finding_id");
|
|
|
|
|
var artifactId = required("artifact_id");
|
|
|
|
|
var sourceRunId = node.TryGetPropertyValue("source_run_id", out var sourceRunNode) && sourceRunNode is not null && !string.IsNullOrWhiteSpace(sourceRunNode.GetValue<string>())
|
|
|
|
|
? Guid.Parse(sourceRunNode!.GetValue<string>())
|
|
|
|
|
? (Guid?)Guid.Parse(sourceRunNode!.GetValue<string>())
|
|
|
|
|
: null;
|
|
|
|
|
var actorId = required("actor_id");
|
|
|
|
|
var actorType = required("actor_type");
|
|
|
|
|
@@ -331,7 +343,8 @@ static async Task<VerificationResult> VerifyLedgerAsync(IServiceProvider service
|
|
|
|
|
await using (var countCommand = new Npgsql.NpgsqlCommand("select count(*) from ledger_events where tenant_id = @tenant", connection))
|
|
|
|
|
{
|
|
|
|
|
countCommand.Parameters.AddWithValue("tenant", tenant);
|
|
|
|
|
var count = (long)await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
var countResult = await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
var count = countResult is long l ? l : 0L;
|
|
|
|
|
if (count < expectedEvents)
|
|
|
|
|
{
|
|
|
|
|
errors.Add($"event_count_mismatch:{count}/{expectedEvents}");
|
|
|
|
|
@@ -464,6 +477,21 @@ static double Percentile(IEnumerable<double> values, double percentile)
|
|
|
|
|
return data[lowerIndex] + (data[upperIndex] - data[lowerIndex]) * fraction;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Local function - must be before type declarations
|
|
|
|
|
static ExpectedChecksums LoadExpectedChecksums(FileInfo? file)
|
|
|
|
|
{
|
|
|
|
|
if (file is null)
|
|
|
|
|
{
|
|
|
|
|
return ExpectedChecksums.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
using var doc = JsonDocument.Parse(File.ReadAllText(file.FullName));
|
|
|
|
|
var root = doc.RootElement;
|
|
|
|
|
var eventStream = root.TryGetProperty("eventStream", out var ev) ? ev.GetString() : null;
|
|
|
|
|
var projection = root.TryGetProperty("projection", out var pr) ? pr.GetString() : null;
|
|
|
|
|
return new ExpectedChecksums(eventStream, projection);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal sealed record HarnessReport(
|
|
|
|
|
string Tenant,
|
|
|
|
|
IReadOnlyList<string> Fixtures,
|
|
|
|
|
@@ -508,20 +536,6 @@ internal sealed class MetricsBag
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static ExpectedChecksums LoadExpectedChecksums(FileInfo? file)
|
|
|
|
|
{
|
|
|
|
|
if (file is null)
|
|
|
|
|
{
|
|
|
|
|
return ExpectedChecksums.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
using var doc = JsonDocument.Parse(File.ReadAllText(file.FullName));
|
|
|
|
|
var root = doc.RootElement;
|
|
|
|
|
var eventStream = root.TryGetProperty("eventStream", out var ev) ? ev.GetString() : null;
|
|
|
|
|
var projection = root.TryGetProperty("projection", out var pr) ? pr.GetString() : null;
|
|
|
|
|
return new ExpectedChecksums(eventStream, projection);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Harness lightweight no-op implementations for projection/merkle to keep replay fast
|
|
|
|
|
internal sealed class NoOpPolicyEvaluationService : IPolicyEvaluationService
|
|
|
|
|
{
|
|
|
|
|
@@ -535,6 +549,7 @@ internal sealed class NoOpPolicyEvaluationService : IPolicyEvaluationService
|
|
|
|
|
RiskSeverity: current?.RiskSeverity,
|
|
|
|
|
RiskProfileVersion: current?.RiskProfileVersion,
|
|
|
|
|
RiskExplanationId: current?.RiskExplanationId,
|
|
|
|
|
RiskEventSequence: null,
|
|
|
|
|
Labels: labels,
|
|
|
|
|
ExplainRef: null,
|
|
|
|
|
Rationale: new JsonArray()));
|
|
|
|
|
@@ -556,6 +571,21 @@ internal sealed class NoOpProjectionRepository : IFindingProjectionRepository
|
|
|
|
|
Task.FromResult(new ProjectionCheckpoint(DateTimeOffset.MinValue, Guid.Empty, DateTimeOffset.MinValue));
|
|
|
|
|
|
|
|
|
|
public Task UpsertAsync(FindingProjection projection, CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
|
|
|
|
|
|
public Task<FindingStatsResult> GetFindingStatsSinceAsync(string tenantId, DateTimeOffset since, CancellationToken cancellationToken) =>
|
|
|
|
|
Task.FromResult(new FindingStatsResult(0, 0, 0, 0, 0, 0));
|
|
|
|
|
|
|
|
|
|
public Task<(IReadOnlyList<FindingProjection> Projections, int TotalCount)> QueryScoredAsync(ScoredFindingsQuery query, CancellationToken cancellationToken) =>
|
|
|
|
|
Task.FromResult<(IReadOnlyList<FindingProjection>, int)>((Array.Empty<FindingProjection>(), 0));
|
|
|
|
|
|
|
|
|
|
public Task<SeverityDistribution> GetSeverityDistributionAsync(string tenantId, string? policyVersion, CancellationToken cancellationToken) =>
|
|
|
|
|
Task.FromResult(new SeverityDistribution());
|
|
|
|
|
|
|
|
|
|
public Task<ScoreDistribution> GetScoreDistributionAsync(string tenantId, string? policyVersion, CancellationToken cancellationToken) =>
|
|
|
|
|
Task.FromResult(new ScoreDistribution());
|
|
|
|
|
|
|
|
|
|
public Task<(int Total, int Scored, decimal AvgScore, decimal MaxScore)> GetRiskAggregatesAsync(string tenantId, string? policyVersion, CancellationToken cancellationToken) =>
|
|
|
|
|
Task.FromResult((0, 0, 0m, 0m));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal sealed class NoOpMerkleAnchorRepository : IMerkleAnchorRepository
|
|
|
|
|
|