feat: Add UI benchmark driver and scenarios for graph interactions
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
- Introduced `ui_bench_driver.mjs` to read scenarios and fixture manifest, generating a deterministic run plan. - Created `ui_bench_plan.md` outlining the purpose, scope, and next steps for the benchmark. - Added `ui_bench_scenarios.json` containing various scenarios for graph UI interactions. - Implemented tests for CLI commands, ensuring bundle verification and telemetry defaults. - Developed schemas for orchestrator components, including replay manifests and event envelopes. - Added mock API for risk management, including listing and statistics functionalities. - Implemented models for risk profiles and query options to support the new API.
This commit is contained in:
24
src/Bench/StellaOps.Bench/Graph/run_graph_bench.sh
Normal file
24
src/Bench/StellaOps.Bench/Graph/run_graph_bench.sh
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Repo root is four levels up from Graph/
|
||||
REPO_ROOT="$(cd "${ROOT}/../../../.." && pwd)"
|
||||
FIXTURES_ROOT="${FIXTURES_ROOT:-${REPO_ROOT}/samples/graph/interim}"
|
||||
OUT_DIR="${OUT_DIR:-$ROOT/results}"
|
||||
SAMPLES="${SAMPLES:-100}"
|
||||
|
||||
mkdir -p "${OUT_DIR}"
|
||||
|
||||
run_one() {
|
||||
local fixture="$1"
|
||||
local name
|
||||
name="$(basename "${fixture}")"
|
||||
local out_file="${OUT_DIR}/${name}.json"
|
||||
python "${ROOT}/graph_bench.py" --fixture "${fixture}" --output "${out_file}" --samples "${SAMPLES}"
|
||||
}
|
||||
|
||||
run_one "${FIXTURES_ROOT}/graph-50k"
|
||||
run_one "${FIXTURES_ROOT}/graph-100k"
|
||||
|
||||
echo "Graph bench complete. Results in ${OUT_DIR}"
|
||||
50
src/Bench/StellaOps.Bench/Graph/ui_bench_driver.mjs
Normal file
50
src/Bench/StellaOps.Bench/Graph/ui_bench_driver.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ui_bench_driver.mjs
|
||||
*
|
||||
* Reads scenarios and fixture manifest, and emits a deterministic run plan.
|
||||
* This is browser-free; intended to be wrapped by Playwright later.
|
||||
*/
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
function readJson(p) {
|
||||
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
}
|
||||
|
||||
function buildPlan(scenarios, manifest, fixtureName) {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
version: "1.0.0",
|
||||
fixture: fixtureName,
|
||||
manifestHash: manifest?.hashes || {},
|
||||
timestamp: now,
|
||||
steps: scenarios.map((s, idx) => ({
|
||||
order: idx + 1,
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
actions: s.steps,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const fixtureDir = process.argv[2];
|
||||
const scenariosPath = process.argv[3];
|
||||
const outputPath = process.argv[4];
|
||||
if (!fixtureDir || !scenariosPath || !outputPath) {
|
||||
console.error("usage: ui_bench_driver.mjs <fixture_dir> <scenarios.json> <output.json>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const manifestPath = path.join(fixtureDir, "manifest.json");
|
||||
const manifest = fs.existsSync(manifestPath) ? readJson(manifestPath) : {};
|
||||
const scenarios = readJson(scenariosPath).scenarios || [];
|
||||
|
||||
const plan = buildPlan(scenarios, manifest, path.basename(fixtureDir));
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, JSON.stringify(plan, null, 2));
|
||||
console.log(`Wrote plan to ${outputPath}`);
|
||||
}
|
||||
|
||||
main();
|
||||
30
src/Bench/StellaOps.Bench/Graph/ui_bench_plan.md
Normal file
30
src/Bench/StellaOps.Bench/Graph/ui_bench_plan.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Graph UI Bench Plan (BENCH-GRAPH-21-002)
|
||||
|
||||
Purpose: provide a deterministic, headless flow for measuring graph UI interactions over large fixtures (50k/100k nodes).
|
||||
|
||||
## Scope
|
||||
- Use synthetic fixtures under `samples/graph/interim/` until canonical SAMPLES-GRAPH-24-003 lands.
|
||||
- Drive a deterministic sequence of interactions:
|
||||
1) Load graph canvas with specified fixture.
|
||||
2) Pan to node `pkg-000001`.
|
||||
3) Zoom in 2×, zoom out 1×.
|
||||
4) Apply filter `name contains "package-0001"`.
|
||||
5) Select node, expand neighbors (depth 1), collapse.
|
||||
6) Toggle overlay layer (once available).
|
||||
- Capture timings: initial render, filter apply, expand/collapse, overlay toggle.
|
||||
|
||||
## Determinism rules
|
||||
- Fixed seed for any randomized layouts (seed `424242`).
|
||||
- Disable animations/transitions where possible; otherwise measure after `requestAnimationFrame` settle.
|
||||
- No network calls; fixtures loaded from local file served by test harness.
|
||||
- Stable viewport (width=1280, height=720), device scale factor 1.
|
||||
|
||||
## Artifacts
|
||||
- `ui_bench_scenarios.json` — canonical scenario list with step ids and notes.
|
||||
- `ui_bench_driver.mjs` — helper that reads scenario + fixture manifest and emits a run plan (no browser dependency). Intended to be wrapped by a Playwright script later.
|
||||
- Results format (proposed): NDJSON with `{stepId, name, durationMs, fixture, timestamp}`.
|
||||
|
||||
## Next steps
|
||||
- Bind `ui_bench_driver.mjs` into a Playwright harness when Graph UI build/serve target is available.
|
||||
- Swap fixtures to SAMPLES-GRAPH-24-003 + overlay once schema finalized; keep scenario ids stable.
|
||||
- Add CI slice to run the driver and validate scenario/fixture bindings (no browser) to keep determinism checked in commits.
|
||||
35
src/Bench/StellaOps.Bench/Graph/ui_bench_scenarios.json
Normal file
35
src/Bench/StellaOps.Bench/Graph/ui_bench_scenarios.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "load",
|
||||
"name": "Load graph canvas",
|
||||
"steps": ["navigate", "waitForRender"]
|
||||
},
|
||||
{
|
||||
"id": "pan-start-node",
|
||||
"name": "Pan to pkg-000001",
|
||||
"steps": ["panTo:pkg-000001"]
|
||||
},
|
||||
{
|
||||
"id": "zoom-in-out",
|
||||
"name": "Zoom in twice, out once",
|
||||
"steps": ["zoomIn", "zoomIn", "zoomOut"]
|
||||
},
|
||||
{
|
||||
"id": "filter-name",
|
||||
"name": "Filter name contains package-0001",
|
||||
"steps": ["setFilter:name=package-0001", "waitForRender"]
|
||||
},
|
||||
{
|
||||
"id": "expand-collapse",
|
||||
"name": "Expand neighbors then collapse",
|
||||
"steps": ["select:pkg-000001", "expandDepth:1", "collapseSelection"]
|
||||
},
|
||||
{
|
||||
"id": "overlay-toggle",
|
||||
"name": "Toggle overlay layer",
|
||||
"steps": ["toggleOverlay:on", "toggleOverlay:off"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,3 +4,4 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| BENCH-DETERMINISM-401-057 | DONE (2025-11-26) | SPRINT_0512_0001_0001_bench | Determinism harness and mock scanner added under `src/Bench/StellaOps.Bench/Determinism`; manifests + sample inputs included. | `src/Bench/StellaOps.Bench/Determinism/results` (generated) |
|
||||
| BENCH-GRAPH-21-001 | DOING (2025-12-01) | SPRINT_0512_0001_0001_bench | Added interim graph bench harness (`Graph/graph_bench.py`) using synthetic 50k/100k fixtures; measures adjacency build + depth-3 reach; pending overlay schema for final fixture integration. | `src/Bench/StellaOps.Bench/Graph` |
|
||||
| BENCH-GRAPH-21-002 | DOING (2025-12-01) | SPRINT_0512_0001_0001_bench | Added Graph UI bench scaffold (scenarios JSON + driver + plan) using interim fixtures; awaits overlay schema/UI target for Playwright binding and timing collection. | `src/Bench/StellaOps.Bench/Graph` |
|
||||
|
||||
@@ -48,17 +48,43 @@ namespace StellaOps.Cli.Commands;
|
||||
internal static class CommandHandlers
|
||||
{
|
||||
private const string KmsPassphraseEnvironmentVariable = "STELLAOPS_KMS_PASSPHRASE";
|
||||
private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static async Task VerifyBundleAsync(string path, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
// Simple SHA256 check using sidecar .sha256 file if present; fail closed on mismatch.
|
||||
var shaPath = path + ".sha256";
|
||||
if (!File.Exists(shaPath))
|
||||
{
|
||||
logger.LogError("Checksum file missing for bundle {Bundle}. Expected sidecar {Sidecar}.", path, shaPath);
|
||||
Environment.ExitCode = 21;
|
||||
throw new InvalidOperationException("Checksum file missing");
|
||||
}
|
||||
|
||||
var expected = (await File.ReadAllTextAsync(shaPath, cancellationToken).ConfigureAwait(false)).Trim();
|
||||
using var stream = File.OpenRead(path);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
var actual = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogError("Checksum mismatch for {Bundle}. Expected {Expected} but found {Actual}", path, expected, actual);
|
||||
Environment.ExitCode = 22;
|
||||
throw new InvalidOperationException("Checksum verification failed");
|
||||
}
|
||||
|
||||
logger.LogInformation("Checksum verified for {Bundle}", path);
|
||||
}
|
||||
|
||||
public static async Task HandleScannerDownloadAsync(
|
||||
IServiceProvider services,
|
||||
string channel,
|
||||
string? output,
|
||||
bool overwrite,
|
||||
bool install,
|
||||
public static async Task HandleScannerDownloadAsync(
|
||||
IServiceProvider services,
|
||||
string channel,
|
||||
string? output,
|
||||
bool overwrite,
|
||||
bool install,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -88,24 +114,29 @@ internal static class CommandHandlers
|
||||
|
||||
CliMetrics.RecordScannerDownload(channel, result.FromCache);
|
||||
|
||||
if (install)
|
||||
{
|
||||
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
|
||||
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
|
||||
CliMetrics.RecordScannerInstall(channel);
|
||||
}
|
||||
if (install)
|
||||
{
|
||||
await VerifyBundleAsync(result.Path, logger, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
|
||||
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
|
||||
CliMetrics.RecordScannerInstall(channel);
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to download scanner bundle.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to download scanner bundle.");
|
||||
if (Environment.ExitCode == 0)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleTaskRunnerSimulateAsync(
|
||||
|
||||
@@ -107,9 +107,9 @@ public sealed class CliProfileStore
|
||||
public Dictionary<string, CliProfile> Profiles { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Default telemetry opt-in status.
|
||||
/// Default telemetry opt-in status. Defaults to false for privacy.
|
||||
/// </summary>
|
||||
public bool? TelemetryEnabled { get; set; }
|
||||
public bool TelemetryEnabled { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -225,7 +225,7 @@ public sealed class CliProfileManager
|
||||
public async Task SetTelemetryEnabledAsync(bool? enabled, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
store.TelemetryEnabled = enabled;
|
||||
store.TelemetryEnabled = enabled ?? false;
|
||||
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -247,7 +247,18 @@ public sealed class CliProfileManager
|
||||
{
|
||||
await using var stream = File.OpenRead(_profilesFilePath);
|
||||
var store = await JsonSerializer.DeserializeAsync<CliProfileStore>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return store ?? new CliProfileStore();
|
||||
if (store is null)
|
||||
{
|
||||
return new CliProfileStore();
|
||||
}
|
||||
|
||||
// Ensure default-off if older files had telemetryEnabled missing/null.
|
||||
if (!store.TelemetryEnabled)
|
||||
{
|
||||
store.TelemetryEnabled = false;
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
|
||||
@@ -4512,11 +4512,11 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
}
|
||||
|
||||
// CLI-SDK-64-001: SDK update operations
|
||||
public async Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
EnsureBackendConfigured();
|
||||
public async Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
EnsureBackendConfigured();
|
||||
OfflineModeGuard.ThrowIfOffline("sdk update");
|
||||
|
||||
var queryParams = new List<string>();
|
||||
@@ -4554,9 +4554,9 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SdkUpdateResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return result ?? new SdkUpdateResponse { Success = false, Error = "Empty response" };
|
||||
}
|
||||
var result = await response.Content.ReadFromJsonAsync<SdkUpdateResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return result ?? new SdkUpdateResponse { Success = false, Error = "Empty response" };
|
||||
}
|
||||
|
||||
public async Task<SdkListResponse> ListInstalledSdksAsync(string? language, string? tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cli.Commands;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class ScannerDownloadVerifyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_Succeeds_WhenHashMatches()
|
||||
{
|
||||
var tmp = Path.Combine(Path.GetTempPath(), $"stellaops-cli-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tmp);
|
||||
var bundle = Path.Combine(tmp, "scanner.tgz");
|
||||
await File.WriteAllTextAsync(bundle, "hello");
|
||||
|
||||
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(bundle))).ToLowerInvariant();
|
||||
await File.WriteAllTextAsync(bundle + ".sha256", hash);
|
||||
|
||||
await CommandHandlersTestShim.VerifyBundlePublicAsync(bundle, NullLogger.Instance, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_Throws_WhenHashMismatch()
|
||||
{
|
||||
var tmp = Path.Combine(Path.GetTempPath(), $"stellaops-cli-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tmp);
|
||||
var bundle = Path.Combine(tmp, "scanner.tgz");
|
||||
await File.WriteAllTextAsync(bundle, "hello");
|
||||
await File.WriteAllTextAsync(bundle + ".sha256", "deadbeef");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
CommandHandlersTestShim.VerifyBundlePublicAsync(bundle, NullLogger.Instance, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_Throws_WhenChecksumMissing()
|
||||
{
|
||||
var tmp = Path.Combine(Path.GetTempPath(), $"stellaops-cli-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tmp);
|
||||
var bundle = Path.Combine(tmp, "scanner.tgz");
|
||||
await File.WriteAllTextAsync(bundle, "hello");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
CommandHandlersTestShim.VerifyBundlePublicAsync(bundle, NullLogger.Instance, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
internal static class CommandHandlersTestShim
|
||||
{
|
||||
public static Task VerifyBundlePublicAsync(string path, ILogger logger, CancellationToken token)
|
||||
=> typeof(CommandHandlers)
|
||||
.GetMethod(\"VerifyBundleAsync\", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!
|
||||
.Invoke(null, new object[] { path, logger, token }) as Task
|
||||
?? Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Configuration;
|
||||
|
||||
public sealed class TelemetryDefaultsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NewStore_DefaultsTelemetryToOff()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-cli-telemetry-{Path.GetRandomFileName()}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var manager = new CliProfileManager(tempDir);
|
||||
var store = await manager.GetStoreAsync();
|
||||
|
||||
Assert.False(store.TelemetryEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTelemetryEnabled_PersistsValue()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-cli-telemetry-{Path.GetRandomFileName()}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var manager = new CliProfileManager(tempDir);
|
||||
|
||||
await manager.SetTelemetryEnabledAsync(true);
|
||||
var store = await manager.GetStoreAsync();
|
||||
|
||||
Assert.True(store.TelemetryEnabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Contracts;
|
||||
|
||||
public sealed class CliSpecTests
|
||||
{
|
||||
private static readonly string SpecPath = Path.Combine("docs", "modules", "cli", "contracts", "cli-spec-v1.yaml");
|
||||
|
||||
[Fact]
|
||||
public async Task Spec_Exists_And_Has_PrivacyDefaults()
|
||||
{
|
||||
Assert.True(File.Exists(SpecPath), $"Spec file missing: {SpecPath}");
|
||||
|
||||
var text = await File.ReadAllTextAsync(SpecPath);
|
||||
|
||||
Assert.Contains("defaultEnabled: false", text);
|
||||
Assert.Contains("checksumRequired: true", text);
|
||||
Assert.Contains("cosignVerifyDefault: true", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Spec_Has_Pinned_Buildx_Digest()
|
||||
{
|
||||
var text = await File.ReadAllLinesAsync(SpecPath);
|
||||
var digestLine = text.FirstOrDefault(l => l.TrimStart().StartsWith("imageDigest:"));
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(digestLine));
|
||||
Assert.DoesNotContain("TO-BE-PINNED", digestLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Spec_Declares_Install_ExitCodes()
|
||||
{
|
||||
var text = await File.ReadAllTextAsync(SpecPath);
|
||||
Assert.Contains("21: checksum-file-missing", text);
|
||||
Assert.Contains("22: checksum-mismatch", text);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
namespace StellaOps.Orchestrator.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
@@ -90,9 +92,22 @@ public sealed record AuditEntry(
|
||||
var entryId = Guid.NewGuid();
|
||||
var occurredAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Compute content hash from entry data
|
||||
var contentToHash = $"{entryId}|{tenantId}|{eventType}|{resourceType}|{resourceId}|{actorId}|{actorType}|{description}|{oldState}|{newState}|{occurredAt:O}|{sequenceNumber}";
|
||||
var contentHash = ComputeSha256(contentToHash);
|
||||
// Compute canonical hash from immutable content
|
||||
var contentHash = CanonicalJsonHasher.ComputeCanonicalSha256(new
|
||||
{
|
||||
entryId,
|
||||
tenantId,
|
||||
eventType,
|
||||
resourceType,
|
||||
resourceId,
|
||||
actorId,
|
||||
actorType,
|
||||
description,
|
||||
oldState,
|
||||
newState,
|
||||
occurredAt,
|
||||
sequenceNumber
|
||||
});
|
||||
|
||||
return new AuditEntry(
|
||||
EntryId: entryId,
|
||||
@@ -122,8 +137,21 @@ public sealed record AuditEntry(
|
||||
/// </summary>
|
||||
public bool VerifyIntegrity()
|
||||
{
|
||||
var contentToHash = $"{EntryId}|{TenantId}|{EventType}|{ResourceType}|{ResourceId}|{ActorId}|{ActorType}|{Description}|{OldState}|{NewState}|{OccurredAt:O}|{SequenceNumber}";
|
||||
var computed = ComputeSha256(contentToHash);
|
||||
var computed = CanonicalJsonHasher.ComputeCanonicalSha256(new
|
||||
{
|
||||
EntryId,
|
||||
TenantId,
|
||||
EventType,
|
||||
ResourceType,
|
||||
ResourceId,
|
||||
ActorId,
|
||||
ActorType,
|
||||
Description,
|
||||
OldState,
|
||||
NewState,
|
||||
OccurredAt,
|
||||
SequenceNumber
|
||||
});
|
||||
return string.Equals(ContentHash, computed, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
namespace StellaOps.Orchestrator.Core.Domain.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic replay manifest that captures all inputs required to faithfully re-run a job.
|
||||
/// Aligns with replay-manifest.schema.json and is hashed via canonical JSON.
|
||||
/// </summary>
|
||||
public sealed record ReplayManifest(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("jobId")] string JobId,
|
||||
[property: JsonPropertyName("replayOf")] string ReplayOf,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("reason")] string? Reason,
|
||||
[property: JsonPropertyName("inputs")] ReplayInputs Inputs,
|
||||
[property: JsonPropertyName("artifacts")] ImmutableArray<ReplayArtifact> Artifacts)
|
||||
{
|
||||
public static ReplayManifest Create(
|
||||
string jobId,
|
||||
string replayOf,
|
||||
ReplayInputs inputs,
|
||||
IEnumerable<ReplayArtifact>? artifacts = null,
|
||||
string schemaVersion = "orch.replay.v1",
|
||||
string? reason = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(replayOf);
|
||||
ArgumentNullException.ThrowIfNull(inputs);
|
||||
|
||||
return new ReplayManifest(
|
||||
SchemaVersion: schemaVersion,
|
||||
JobId: jobId,
|
||||
ReplayOf: replayOf,
|
||||
CreatedAt: createdAt ?? DateTimeOffset.UtcNow,
|
||||
Reason: string.IsNullOrWhiteSpace(reason) ? null : reason,
|
||||
Inputs: inputs,
|
||||
Artifacts: artifacts is null ? ImmutableArray<ReplayArtifact>.Empty : ImmutableArray.CreateRange(artifacts));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic SHA-256 over canonical JSON representation of the manifest.
|
||||
/// </summary>
|
||||
public string ComputeHash() => CanonicalJsonHasher.ComputeCanonicalSha256(this);
|
||||
}
|
||||
|
||||
public sealed record ReplayInputs(
|
||||
[property: JsonPropertyName("policyHash")] string PolicyHash,
|
||||
[property: JsonPropertyName("graphRevisionId")] string GraphRevisionId,
|
||||
[property: JsonPropertyName("latticeHash")] string? LatticeHash,
|
||||
[property: JsonPropertyName("toolImages")] ImmutableArray<string> ToolImages,
|
||||
[property: JsonPropertyName("seeds")] ReplaySeeds Seeds,
|
||||
[property: JsonPropertyName("timeSource")] ReplayTimeSource TimeSource,
|
||||
[property: JsonPropertyName("env")] ImmutableDictionary<string, string> Env);
|
||||
|
||||
public sealed record ReplaySeeds(
|
||||
[property: JsonPropertyName("rng")] int? Rng,
|
||||
[property: JsonPropertyName("sampling")] int? Sampling);
|
||||
|
||||
public sealed record ReplayArtifact(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("digest")] string Digest,
|
||||
[property: JsonPropertyName("mediaType")] string? MediaType);
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ReplayTimeSource
|
||||
{
|
||||
monotonic,
|
||||
wall
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
/// <summary>
|
||||
/// Produces deterministic, canonical JSON and hashes for orchestrator payloads (events, audit, manifests).
|
||||
/// Keys are sorted lexicographically; arrays preserve order; nulls are retained; timestamps remain ISO 8601 with offsets.
|
||||
/// </summary>
|
||||
public static class CanonicalJsonHasher
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = null,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serialize the value to canonical JSON (sorted object keys, stable formatting).
|
||||
/// </summary>
|
||||
public static string ToCanonicalJson<T>(T value)
|
||||
{
|
||||
var node = JsonSerializer.SerializeToNode(value, SerializerOptions) ?? new JsonObject();
|
||||
var ordered = OrderNode(node);
|
||||
return ordered.ToJsonString(SerializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute SHA-256 over canonical JSON (lowercase hex).
|
||||
/// </summary>
|
||||
public static string ComputeCanonicalSha256<T>(T value)
|
||||
{
|
||||
var canonicalJson = ToCanonicalJson(value);
|
||||
var bytes = Encoding.UTF8.GetBytes(canonicalJson);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static JsonNode OrderNode(JsonNode node)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject obj:
|
||||
var orderedObj = new JsonObject();
|
||||
foreach (var kvp in obj.OrderBy(x => x.Key, StringComparer.Ordinal))
|
||||
{
|
||||
orderedObj.Add(kvp.Key, kvp.Value is null ? null : OrderNode(kvp.Value));
|
||||
}
|
||||
return orderedObj;
|
||||
case JsonArray arr:
|
||||
var orderedArr = new JsonArray();
|
||||
foreach (var item in arr)
|
||||
{
|
||||
orderedArr.Add(item is null ? null : OrderNode(item));
|
||||
}
|
||||
return orderedArr;
|
||||
default:
|
||||
return node; // primitives stay as-is
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$id": "https://stellaops.dev/orchestrator/schemas/audit-bundle.schema.json",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Orchestrator Audit Bundle",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["manifestVersion", "bundleId", "createdAt", "entries"],
|
||||
"properties": {
|
||||
"manifestVersion": { "type": "string", "pattern": "^orch\.audit\.v[0-9]+$" },
|
||||
"bundleId": { "type": "string", "minLength": 1 },
|
||||
"createdAt": { "type": "string", "format": "date-time" },
|
||||
"tenantId": { "type": "string" },
|
||||
"hashAlgorithm": { "type": "string", "enum": ["sha256"] },
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["entryId", "contentHash", "sequenceNumber", "occurredAt"],
|
||||
"properties": {
|
||||
"entryId": { "type": "string" },
|
||||
"contentHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
||||
"previousEntryHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
||||
"sequenceNumber": { "type": "integer", "minimum": 1 },
|
||||
"occurredAt": { "type": "string", "format": "date-time" },
|
||||
"resourceType": { "type": "string" },
|
||||
"resourceId": { "type": "string" },
|
||||
"eventType": { "type": "string" },
|
||||
"actorId": { "type": "string" },
|
||||
"actorType": { "type": "string" },
|
||||
"oldState": { "type": "string" },
|
||||
"newState": { "type": "string" },
|
||||
"metadata": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"attachments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["uri", "digest"],
|
||||
"properties": {
|
||||
"uri": { "type": "string" },
|
||||
"digest": { "type": "string", "pattern": "^[a-z0-9]+:[A-Fa-f0-9]+$" },
|
||||
"mediaType": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"$id": "https://stellaops.dev/orchestrator/schemas/event-envelope.schema.json",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Orchestrator Event Envelope",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"eventId",
|
||||
"eventType",
|
||||
"occurredAt",
|
||||
"idempotencyKey",
|
||||
"tenantId",
|
||||
"actor",
|
||||
"job"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": { "type": "string", "pattern": "^orch\.event\.v[0-9]+$" },
|
||||
"eventId": { "type": "string", "minLength": 1 },
|
||||
"eventType": { "type": "string", "minLength": 1 },
|
||||
"occurredAt": { "type": "string", "format": "date-time" },
|
||||
"idempotencyKey": { "type": "string", "minLength": 1 },
|
||||
"correlationId": { "type": "string" },
|
||||
"tenantId": { "type": "string", "minLength": 1 },
|
||||
"projectId": { "type": "string" },
|
||||
"actor": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["subject", "scopes"],
|
||||
"properties": {
|
||||
"subject": { "type": "string", "minLength": 1 },
|
||||
"scopes": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"job": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "type", "attempt", "status"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"type": { "type": "string", "minLength": 1 },
|
||||
"runId": { "type": "string" },
|
||||
"attempt": { "type": "integer", "minimum": 0 },
|
||||
"leaseId": { "type": "string" },
|
||||
"taskRunnerId": { "type": "string" },
|
||||
"status": { "type": "string" },
|
||||
"reason": { "type": "string" },
|
||||
"payloadDigest": { "type": "string" },
|
||||
"artifacts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["uri", "digest"],
|
||||
"properties": {
|
||||
"uri": { "type": "string" },
|
||||
"digest": { "type": "string" },
|
||||
"mime": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"durationSeconds": { "type": "number", "minimum": 0 },
|
||||
"logStreamLagSeconds": { "type": "number", "minimum": 0 },
|
||||
"backoffSeconds": { "type": "number", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"notifier": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"channel": { "type": "string" },
|
||||
"delivery": { "type": "string" },
|
||||
"replay": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["ordinal", "total"],
|
||||
"properties": {
|
||||
"ordinal": { "type": "integer", "minimum": 0 },
|
||||
"total": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"$id": "https://stellaops.dev/orchestrator/schemas/replay-manifest.schema.json",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Replay Manifest",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schemaVersion", "jobId", "replayOf", "inputs"],
|
||||
"properties": {
|
||||
"schemaVersion": { "type": "string", "pattern": "^orch\.replay\.v[0-9]+$" },
|
||||
"jobId": { "type": "string" },
|
||||
"replayOf": { "type": "string" },
|
||||
"createdAt": { "type": "string", "format": "date-time" },
|
||||
"reason": { "type": "string" },
|
||||
"inputs": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["policyHash", "graphRevisionId", "toolImages", "seeds"],
|
||||
"properties": {
|
||||
"policyHash": { "type": "string" },
|
||||
"graphRevisionId": { "type": "string" },
|
||||
"latticeHash": { "type": "string" },
|
||||
"toolImages": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"seeds": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"rng": { "type": "integer", "minimum": 0 },
|
||||
"sampling": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"timeSource": { "type": "string", "enum": ["monotonic", "wall"] },
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"artifacts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "digest"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"digest": { "type": "string" },
|
||||
"mediaType": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$id": "https://stellaops.dev/orchestrator/schemas/taskrunner-integrity.schema.json",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "TaskRunner Integrity Capsule",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["jobId", "runId", "attempt", "artifacts", "logs"],
|
||||
"properties": {
|
||||
"jobId": { "type": "string" },
|
||||
"runId": { "type": "string" },
|
||||
"attempt": { "type": "integer", "minimum": 0 },
|
||||
"workerImage": { "type": "string" },
|
||||
"configHash": { "type": "string" },
|
||||
"artifacts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "digest", "sizeBytes"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"digest": { "type": "string", "pattern": "^[a-z0-9]+:[A-Fa-f0-9]+$" },
|
||||
"sizeBytes": { "type": "integer", "minimum": 0 },
|
||||
"mediaType": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["digest", "sizeBytes"],
|
||||
"properties": {
|
||||
"digest": { "type": "string", "pattern": "^[a-z0-9]+:[A-Fa-f0-9]+$" },
|
||||
"sizeBytes": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "occurredAt"],
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"occurredAt": { "type": "string", "format": "date-time" },
|
||||
"traceId": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Orchestrator.Core.Domain;
|
||||
using StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
namespace StellaOps.Orchestrator.Tests;
|
||||
|
||||
public class CanonicalJsonHasherTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProducesStableHash_WhenObjectPropertyOrderDiffers()
|
||||
{
|
||||
var first = new { b = 1, a = 2 };
|
||||
var second = new { a = 2, b = 1 };
|
||||
|
||||
var firstHash = CanonicalJsonHasher.ComputeCanonicalSha256(first);
|
||||
var secondHash = CanonicalJsonHasher.ComputeCanonicalSha256(second);
|
||||
|
||||
Assert.Equal(firstHash, secondHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalJson_SortsKeysAndPreservesArrays()
|
||||
{
|
||||
var value = new
|
||||
{
|
||||
meta = new { z = 1, a = 2 },
|
||||
items = new[] { "first", "second" }
|
||||
};
|
||||
|
||||
var json = CanonicalJsonHasher.ToCanonicalJson(value);
|
||||
|
||||
// keys sorted inside meta
|
||||
Assert.Equal("{\"items\":[\"first\",\"second\"],\"meta\":{\"a\":2,\"z\":1}}", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditEntry_UsesCanonicalHash()
|
||||
{
|
||||
var entry = AuditEntry.Create(
|
||||
tenantId: "tenant-1",
|
||||
eventType: AuditEventType.JobCreated,
|
||||
resourceType: "job",
|
||||
resourceId: Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
|
||||
actorId: "user-1",
|
||||
actorType: ActorType.User,
|
||||
description: "created job");
|
||||
|
||||
Assert.True(entry.VerifyIntegrity());
|
||||
|
||||
// Changing description should invalidate hash
|
||||
var tampered = entry with { Description = "tampered" };
|
||||
Assert.False(tampered.VerifyIntegrity());
|
||||
}
|
||||
}
|
||||
@@ -203,7 +203,6 @@ public sealed class PackRunStreamCoordinatorTests
|
||||
public override void Dispose()
|
||||
{
|
||||
_state = WebSocketState.Closed;
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
public override Task<WebSocketReceiveResult> ReceiveAsync(ArraySegment<byte> buffer, CancellationToken cancellationToken)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Orchestrator.Core.Domain.Replay;
|
||||
|
||||
namespace StellaOps.Orchestrator.Tests;
|
||||
|
||||
public class ReplayManifestTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeHash_IsStableWithCanonicalOrdering()
|
||||
{
|
||||
var inputs = new ReplayInputs(
|
||||
PolicyHash: "sha256:policy",
|
||||
GraphRevisionId: "graph-42",
|
||||
LatticeHash: "sha256:lattice",
|
||||
ToolImages: ImmutableArray.Create("img:1", "img:2"),
|
||||
Seeds: new ReplaySeeds(Rng: 1234, Sampling: 99),
|
||||
TimeSource: ReplayTimeSource.monotonic,
|
||||
Env: ImmutableDictionary.Create<string, string>().Add("TZ", "UTC"));
|
||||
|
||||
var manifestA = ReplayManifest.Create(
|
||||
jobId: "job-123",
|
||||
replayOf: "job-original",
|
||||
inputs: inputs,
|
||||
artifacts: new[] { new ReplayArtifact("ledger.ndjson", "sha256:abc", "application/x-ndjson") },
|
||||
createdAt: new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var manifestB = ReplayManifest.Create(
|
||||
jobId: "job-123",
|
||||
replayOf: "job-original",
|
||||
inputs: inputs,
|
||||
artifacts: new[] { new ReplayArtifact("ledger.ndjson", "sha256:abc", "application/x-ndjson") },
|
||||
createdAt: new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var hashA = manifestA.ComputeHash();
|
||||
var hashB = manifestB.ComputeHash();
|
||||
|
||||
Assert.Equal(hashA, hashB);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Orchestrator.Core;
|
||||
using StellaOps.Orchestrator.Core.Hashing;
|
||||
|
||||
namespace StellaOps.Orchestrator.Tests;
|
||||
|
||||
public class SchemaSmokeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("event-envelope.schema.json")]
|
||||
[InlineData("audit-bundle.schema.json")]
|
||||
[InlineData("replay-manifest.schema.json")]
|
||||
[InlineData("taskrunner-integrity.schema.json")]
|
||||
public void Schemas_AreWellFormedJson(string schemaFile)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "../../../StellaOps.Orchestrator.Core/Schemas", schemaFile);
|
||||
Assert.True(File.Exists(path), $"Schema missing: {schemaFile}");
|
||||
|
||||
var text = File.ReadAllText(path);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
|
||||
Assert.True(doc.RootElement.TryGetProperty("$id", out _));
|
||||
Assert.True(doc.RootElement.TryGetProperty("title", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalHash_For_EventEnvelope_IsStable()
|
||||
{
|
||||
var envelope = EventEnvelope.Create(
|
||||
eventType: "job.completed",
|
||||
tenantId: "tenant-a",
|
||||
job: new EventJob(
|
||||
Id: "job-123",
|
||||
Type: "pack-run",
|
||||
RunId: "run-1",
|
||||
Attempt: 1,
|
||||
LeaseId: "lease-9",
|
||||
TaskRunnerId: "tr-1",
|
||||
Status: "completed",
|
||||
Reason: null,
|
||||
PayloadDigest: "sha256:abc",
|
||||
Artifacts: ImmutableArray.Create<EventArtifact>(),
|
||||
Provenance: ImmutableDictionary<string, string>.Empty),
|
||||
actor: new EventActor("worker-go", ImmutableArray.Create("orch:job")),
|
||||
occurredAt: new DateTimeOffset(2025, 12, 1, 12, 0, 0, TimeSpan.Zero),
|
||||
eventId: "evt-1",
|
||||
idempotencyKey: "fixed");
|
||||
|
||||
var hash1 = CanonicalJsonHasher.ComputeCanonicalSha256(envelope);
|
||||
var hash2 = CanonicalJsonHasher.ComputeCanonicalSha256(envelope);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.Equal(64, hash1.Length);
|
||||
}
|
||||
}
|
||||
@@ -257,6 +257,7 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher
|
||||
ArtifactDocumentFormat.ComponentFragmentJson => "layer.fragments",
|
||||
ArtifactDocumentFormat.ObservationJson => "observation.json",
|
||||
ArtifactDocumentFormat.SurfaceManifestJson => "surface.manifest",
|
||||
ArtifactDocumentFormat.CompositionRecipeJson => "composition.recipe",
|
||||
ArtifactDocumentFormat.CycloneDxJson => "cdx-json",
|
||||
ArtifactDocumentFormat.CycloneDxProtobuf => "cdx-protobuf",
|
||||
ArtifactDocumentFormat.SpdxJson => "spdx-json",
|
||||
|
||||
@@ -265,8 +265,8 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
pins["policy"] = policy;
|
||||
}
|
||||
|
||||
var (artifactHashes, merkle) = ComputeDeterminismHashes(payloads);
|
||||
merkleRoot = merkle;
|
||||
var (artifactHashes, recipeBytes, recipeSha256) = BuildCompositionRecipe(payloads);
|
||||
merkleRoot = recipeSha256;
|
||||
|
||||
var report = new
|
||||
{
|
||||
@@ -277,12 +277,26 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
concurrencyLimit = _determinism.ConcurrencyLimit,
|
||||
pins = pins,
|
||||
artifacts = artifactHashes,
|
||||
merkleRoot = merkle
|
||||
merkleRoot = recipeSha256
|
||||
};
|
||||
|
||||
var evidence = new Determinism.DeterminismEvidence(artifactHashes, merkle);
|
||||
var evidence = new Determinism.DeterminismEvidence(artifactHashes, recipeSha256);
|
||||
context.Analysis.Set(ScanAnalysisKeys.DeterminismEvidence, evidence);
|
||||
|
||||
// Publish composition recipe as a manifest artifact for offline replay.
|
||||
payloads = payloads.ToList();
|
||||
((List<SurfaceManifestPayload>)payloads).Add(new SurfaceManifestPayload(
|
||||
ArtifactDocumentType.CompositionRecipe,
|
||||
ArtifactDocumentFormat.CompositionRecipeJson,
|
||||
Kind: "composition.recipe",
|
||||
MediaType: "application/vnd.stellaops.composition.recipe+json",
|
||||
Content: recipeBytes,
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["schema"] = "stellaops.composition.recipe@1",
|
||||
["merkleRoot"] = recipeSha256,
|
||||
}));
|
||||
|
||||
var json = JsonSerializer.Serialize(report, JsonOptions);
|
||||
return new SurfaceManifestPayload(
|
||||
ArtifactDocumentType.SurfaceObservation,
|
||||
@@ -293,9 +307,9 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
View: "replay");
|
||||
}
|
||||
|
||||
private static (Dictionary<string, string> Hashes, string MerkleRoot) ComputeDeterminismHashes(IEnumerable<SurfaceManifestPayload> payloads)
|
||||
private static (Dictionary<string, string> Hashes, byte[] RecipeBytes, string RecipeSha256) BuildCompositionRecipe(IEnumerable<SurfaceManifestPayload> payloads)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var map = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
using var sha = SHA256.Create();
|
||||
|
||||
foreach (var payload in payloads.OrderBy(p => p.Kind, StringComparer.Ordinal))
|
||||
@@ -304,18 +318,18 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
map[payload.Kind] = digest;
|
||||
}
|
||||
|
||||
// Build Merkle-like root by hashing the ordered list of kind:digest lines.
|
||||
var builder = new StringBuilder();
|
||||
foreach (var kvp in map.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
var recipe = new
|
||||
{
|
||||
builder.Append(kvp.Key).Append(':').Append(kvp.Value).Append('\n');
|
||||
}
|
||||
schema = "stellaops.composition.recipe@1",
|
||||
artifacts = map, // already sorted
|
||||
};
|
||||
|
||||
var rootBytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
var rootHash = sha.ComputeHash(rootBytes);
|
||||
var recipeJson = JsonSerializer.Serialize(recipe, JsonOptions);
|
||||
var recipeBytes = Encoding.UTF8.GetBytes(recipeJson);
|
||||
var rootHash = sha.ComputeHash(recipeBytes);
|
||||
var merkleRoot = Convert.ToHexString(rootHash).ToLowerInvariant();
|
||||
|
||||
return (map, merkleRoot);
|
||||
return (new Dictionary<string, string>(map, StringComparer.OrdinalIgnoreCase), recipeBytes, merkleRoot);
|
||||
}
|
||||
|
||||
private static string? GetReplayBundleUri(ScanJobContext context)
|
||||
|
||||
@@ -24,11 +24,11 @@ public sealed class CycloneDxComposer
|
||||
private const string InventoryMediaTypeProtobuf = "application/vnd.cyclonedx+protobuf; version=1.6";
|
||||
private const string UsageMediaTypeProtobuf = "application/vnd.cyclonedx+protobuf; version=1.6; view=usage";
|
||||
|
||||
public SbomCompositionResult Compose(SbomCompositionRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (request.LayerFragments.IsDefaultOrEmpty)
|
||||
{
|
||||
public SbomCompositionResult Compose(SbomCompositionRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (request.LayerFragments.IsDefaultOrEmpty)
|
||||
{
|
||||
throw new ArgumentException("At least one layer fragment is required.", nameof(request));
|
||||
}
|
||||
|
||||
@@ -48,9 +48,9 @@ public sealed class CycloneDxComposer
|
||||
.Where(static component => component.Usage.UsedByEntrypoint)
|
||||
.ToImmutableArray();
|
||||
|
||||
CycloneDxArtifact? usageArtifact = null;
|
||||
if (!usageComponents.IsEmpty)
|
||||
{
|
||||
CycloneDxArtifact? usageArtifact = null;
|
||||
if (!usageComponents.IsEmpty)
|
||||
{
|
||||
usageArtifact = BuildArtifact(
|
||||
request,
|
||||
graph,
|
||||
@@ -59,15 +59,36 @@ public sealed class CycloneDxComposer
|
||||
generatedAt,
|
||||
UsageMediaTypeJson,
|
||||
UsageMediaTypeProtobuf);
|
||||
}
|
||||
|
||||
return new SbomCompositionResult
|
||||
{
|
||||
Inventory = inventoryArtifact,
|
||||
Usage = usageArtifact,
|
||||
Graph = graph,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var compositionRecipeJson = BuildCompositionRecipeJson(graph, generatedAt);
|
||||
var compositionRecipeSha = ComputeSha256(compositionRecipeJson);
|
||||
var compositionRecipeUri = $"cas://sbom/composition/{compositionRecipeSha}.json";
|
||||
|
||||
inventoryArtifact = inventoryArtifact with
|
||||
{
|
||||
MerkleRoot = compositionRecipeSha,
|
||||
CompositionRecipeUri = compositionRecipeUri,
|
||||
};
|
||||
|
||||
if (usageArtifact is not null)
|
||||
{
|
||||
usageArtifact = usageArtifact with
|
||||
{
|
||||
MerkleRoot = compositionRecipeSha,
|
||||
CompositionRecipeUri = compositionRecipeUri,
|
||||
};
|
||||
}
|
||||
|
||||
return new SbomCompositionResult
|
||||
{
|
||||
Inventory = inventoryArtifact,
|
||||
Usage = usageArtifact,
|
||||
Graph = graph,
|
||||
CompositionRecipeJson = compositionRecipeJson,
|
||||
CompositionRecipeSha256 = compositionRecipeSha,
|
||||
};
|
||||
}
|
||||
|
||||
private CycloneDxArtifact BuildArtifact(
|
||||
SbomCompositionRequest request,
|
||||
@@ -92,6 +113,7 @@ public sealed class CycloneDxComposer
|
||||
: null;
|
||||
|
||||
request.AdditionalProperties?.TryGetValue("stellaops:composition.manifest", out var compositionUri);
|
||||
request.AdditionalProperties?.TryGetValue("stellaops:composition.recipe", out var compositionRecipeUri);
|
||||
|
||||
return new CycloneDxArtifact
|
||||
{
|
||||
@@ -104,12 +126,38 @@ public sealed class CycloneDxComposer
|
||||
ContentHash = jsonHash,
|
||||
MerkleRoot = merkleRoot,
|
||||
CompositionUri = compositionUri,
|
||||
CompositionRecipeUri = compositionRecipeUri,
|
||||
JsonMediaType = jsonMediaType,
|
||||
ProtobufBytes = protobufBytes,
|
||||
ProtobufSha256 = protobufHash,
|
||||
ProtobufMediaType = protobufMediaType,
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] BuildCompositionRecipeJson(ComponentGraph graph, DateTimeOffset generatedAt)
|
||||
{
|
||||
var recipe = new
|
||||
{
|
||||
schema = "stellaops.composition.recipe@1",
|
||||
generatedAt = ScannerTimestamps.ToIso8601(generatedAt),
|
||||
layers = graph.Layers.Select(layer => new
|
||||
{
|
||||
layer.LayerDigest,
|
||||
components = layer.Components
|
||||
.Select(component => component.Identity.Key)
|
||||
.OrderBy(key => key, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
}).OrderBy(entry => entry.LayerDigest, StringComparer.Ordinal).ToArray(),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(recipe, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
});
|
||||
|
||||
return Encoding.UTF8.GetBytes(json);
|
||||
}
|
||||
|
||||
private Bom BuildBom(
|
||||
SbomCompositionRequest request,
|
||||
|
||||
@@ -33,6 +33,11 @@ public sealed record CycloneDxArtifact
|
||||
/// </summary>
|
||||
public string? CompositionUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CAS URI of the layer composition recipe (_composition.json) if emitted.
|
||||
/// </summary>
|
||||
public string? CompositionRecipeUri { get; init; }
|
||||
|
||||
public required string JsonMediaType { get; init; }
|
||||
|
||||
public required byte[] ProtobufBytes { get; init; }
|
||||
@@ -42,11 +47,21 @@ public sealed record CycloneDxArtifact
|
||||
public required string ProtobufMediaType { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SbomCompositionResult
|
||||
{
|
||||
public required CycloneDxArtifact Inventory { get; init; }
|
||||
|
||||
public CycloneDxArtifact? Usage { get; init; }
|
||||
|
||||
public required ComponentGraph Graph { get; init; }
|
||||
}
|
||||
public sealed record SbomCompositionResult
|
||||
{
|
||||
public required CycloneDxArtifact Inventory { get; init; }
|
||||
|
||||
public CycloneDxArtifact? Usage { get; init; }
|
||||
|
||||
public required ComponentGraph Graph { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Composition recipe JSON bytes (canonical) capturing fragment ordering and hashes.
|
||||
/// </summary>
|
||||
public required byte[] CompositionRecipeJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 hex of the composition recipe JSON.
|
||||
/// </summary>
|
||||
public required string CompositionRecipeSha256 { get; init; }
|
||||
}
|
||||
|
||||
@@ -88,18 +88,26 @@ public sealed class ScannerArtifactPackageBuilder
|
||||
descriptors.Add(CreateDescriptor(ArtifactDocumentType.ImageBom, ArtifactDocumentFormat.CycloneDxProtobuf, composition.Usage.ProtobufMediaType, composition.Usage.ProtobufBytes, composition.Usage.ProtobufSha256, SbomView.Usage));
|
||||
}
|
||||
|
||||
descriptors.Add(CreateDescriptor(ArtifactDocumentType.Index, ArtifactDocumentFormat.BomIndex, "application/vnd.stellaops.bom-index.v1+binary", bomIndex.Bytes, bomIndex.Sha256, null));
|
||||
descriptors.Add(CreateDescriptor(ArtifactDocumentType.Index, ArtifactDocumentFormat.BomIndex, "application/vnd.stellaops.bom-index.v1+binary", bomIndex.Bytes, bomIndex.Sha256, null));
|
||||
|
||||
descriptors.Add(CreateDescriptor(
|
||||
ArtifactDocumentType.CompositionRecipe,
|
||||
ArtifactDocumentFormat.CompositionRecipeJson,
|
||||
"application/vnd.stellaops.composition.recipe+json",
|
||||
composition.CompositionRecipeJson,
|
||||
composition.CompositionRecipeSha256,
|
||||
null));
|
||||
|
||||
var manifest = new ScannerArtifactManifest
|
||||
{
|
||||
ImageDigest = imageDigest.Trim(),
|
||||
GeneratedAt = generatedAt,
|
||||
Artifacts = descriptors
|
||||
.Select(ToManifestEntry)
|
||||
.OrderBy(entry => entry.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(entry => entry.Format)
|
||||
.ToImmutableArray(),
|
||||
};
|
||||
var manifest = new ScannerArtifactManifest
|
||||
{
|
||||
ImageDigest = imageDigest.Trim(),
|
||||
GeneratedAt = generatedAt,
|
||||
Artifacts = descriptors
|
||||
.Select(ToManifestEntry)
|
||||
.OrderBy(entry => entry.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(entry => entry.Format)
|
||||
.ToImmutableArray(),
|
||||
};
|
||||
|
||||
return new ScannerArtifactPackage
|
||||
{
|
||||
@@ -136,9 +144,10 @@ public sealed class ScannerArtifactPackageBuilder
|
||||
ArtifactDocumentType.ImageBom => "sbom-inventory",
|
||||
ArtifactDocumentType.LayerBom => "layer-sbom",
|
||||
ArtifactDocumentType.Diff => "diff",
|
||||
ArtifactDocumentType.Attestation => "attestation",
|
||||
_ => descriptor.Type.ToString().ToLowerInvariant(),
|
||||
};
|
||||
ArtifactDocumentType.Attestation => "attestation",
|
||||
ArtifactDocumentType.CompositionRecipe => "composition-recipe",
|
||||
_ => descriptor.Type.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
return new ScannerArtifactManifestEntry
|
||||
{
|
||||
|
||||
@@ -12,7 +12,8 @@ public enum ArtifactDocumentType
|
||||
SurfaceManifest,
|
||||
SurfaceEntryTrace,
|
||||
SurfaceLayerFragment,
|
||||
SurfaceObservation
|
||||
SurfaceObservation,
|
||||
CompositionRecipe
|
||||
}
|
||||
|
||||
public enum ArtifactDocumentFormat
|
||||
@@ -26,7 +27,8 @@ public enum ArtifactDocumentFormat
|
||||
EntryTraceNdjson,
|
||||
EntryTraceGraphJson,
|
||||
ComponentFragmentJson,
|
||||
ObservationJson
|
||||
ObservationJson,
|
||||
CompositionRecipeJson
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- Stay scoped: disable implicit restore sources beyond local nugets -->
|
||||
<RestoreSources>$(StellaOpsLocalNuGetSource)</RestoreSources>
|
||||
<DisableImplicitNuGetFallbackFolder>true</DisableImplicitNuGetFallbackFolder>
|
||||
<RestoreNoCache>true</RestoreNoCache>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -79,8 +79,9 @@ public sealed class CycloneDxComposerTests
|
||||
Assert.Equal(first.Inventory.ContentHash, first.Inventory.JsonSha256);
|
||||
Assert.Equal(first.Inventory.ProtobufSha256, second.Inventory.ProtobufSha256);
|
||||
Assert.Equal(first.Inventory.SerialNumber, second.Inventory.SerialNumber);
|
||||
Assert.Null(first.Inventory.MerkleRoot);
|
||||
Assert.False(string.IsNullOrWhiteSpace(first.Inventory.MerkleRoot));
|
||||
Assert.Null(first.Inventory.CompositionUri);
|
||||
Assert.Null(first.Inventory.CompositionRecipeUri);
|
||||
|
||||
Assert.NotNull(first.Usage);
|
||||
Assert.NotNull(second.Usage);
|
||||
@@ -88,8 +89,15 @@ public sealed class CycloneDxComposerTests
|
||||
Assert.Equal(first.Usage.ContentHash, first.Usage.JsonSha256);
|
||||
Assert.Equal(first.Usage.ProtobufSha256, second.Usage.ProtobufSha256);
|
||||
Assert.Equal(first.Usage.SerialNumber, second.Usage.SerialNumber);
|
||||
Assert.Null(first.Usage.MerkleRoot);
|
||||
Assert.False(string.IsNullOrWhiteSpace(first.Usage.MerkleRoot));
|
||||
Assert.Null(first.Usage.CompositionUri);
|
||||
Assert.Null(first.Usage.CompositionRecipeUri);
|
||||
|
||||
Assert.Equal(first.Inventory.MerkleRoot, first.Usage.MerkleRoot);
|
||||
Assert.Equal(first.Inventory.MerkleRoot, result.CompositionRecipeSha256);
|
||||
Assert.Equal(first.Inventory.ContentHash.Length, first.Inventory.MerkleRoot!.Length);
|
||||
Assert.Equal(result.CompositionRecipeSha256.Length, 64);
|
||||
Assert.NotEmpty(result.CompositionRecipeJson);
|
||||
}
|
||||
|
||||
private static SbomCompositionRequest BuildRequest()
|
||||
|
||||
@@ -64,16 +64,16 @@ public sealed class ScannerArtifactPackageBuilderTests
|
||||
var packageBuilder = new ScannerArtifactPackageBuilder();
|
||||
var package = packageBuilder.Build(request.Image.ImageDigest, request.GeneratedAt, composition, bomIndex);
|
||||
|
||||
Assert.Equal(5, package.Artifacts.Length); // inventory JSON+PB, usage JSON+PB, index
|
||||
|
||||
var kinds = package.Manifest.Artifacts.Select(entry => entry.Kind).ToArray();
|
||||
Assert.Equal(new[] { "bom-index", "sbom-inventory", "sbom-inventory", "sbom-usage", "sbom-usage" }, kinds);
|
||||
|
||||
var manifestJson = package.Manifest.ToJsonBytes();
|
||||
using var document = JsonDocument.Parse(manifestJson);
|
||||
var root = document.RootElement;
|
||||
Assert.Equal("sha256:image", root.GetProperty("imageDigest").GetString());
|
||||
Assert.Equal(5, root.GetProperty("artifacts").GetArrayLength());
|
||||
Assert.Equal(6, package.Artifacts.Length); // inventory JSON+PB, usage JSON+PB, index, composition recipe
|
||||
|
||||
var kinds = package.Manifest.Artifacts.Select(entry => entry.Kind).ToArray();
|
||||
Assert.Equal(new[] { "bom-index", "composition-recipe", "sbom-inventory", "sbom-inventory", "sbom-usage", "sbom-usage" }, kinds);
|
||||
|
||||
var manifestJson = package.Manifest.ToJsonBytes();
|
||||
using var document = JsonDocument.Parse(manifestJson);
|
||||
var root = document.RootElement;
|
||||
Assert.Equal("sha256:image", root.GetProperty("imageDigest").GetString());
|
||||
Assert.Equal(6, root.GetProperty("artifacts").GetArrayLength());
|
||||
|
||||
var usageEntry = root.GetProperty("artifacts").EnumerateArray().First(element => element.GetProperty("kind").GetString() == "sbom-usage");
|
||||
Assert.Equal("application/vnd.cyclonedx+json; version=1.6; view=usage", usageEntry.GetProperty("mediaType").GetString());
|
||||
|
||||
@@ -102,10 +102,12 @@ public sealed class SurfaceManifestStageExecutorTests
|
||||
Assert.Equal(publisher.LastManifestDigest, result!.ManifestDigest);
|
||||
Assert.Equal(result.DeterminismMerkleRoot, publisher.LastRequest!.DeterminismMerkleRoot);
|
||||
|
||||
Assert.Equal(4, cache.Entries.Count);
|
||||
Assert.Equal(6, cache.Entries.Count);
|
||||
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.entrytrace.graph" && key.Tenant == "tenant-a");
|
||||
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.entrytrace.ndjson" && key.Tenant == "tenant-a");
|
||||
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.layer.fragments" && key.Tenant == "tenant-a");
|
||||
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.determinism.json" && key.Tenant == "tenant-a");
|
||||
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.composition.recipe" && key.Tenant == "tenant-a");
|
||||
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.manifests" && key.Tenant == "tenant-a");
|
||||
|
||||
var publishedMetrics = listener.Measurements
|
||||
@@ -114,7 +116,7 @@ public sealed class SurfaceManifestStageExecutorTests
|
||||
Assert.Single(publishedMetrics);
|
||||
Assert.Equal(1, publishedMetrics[0].Value);
|
||||
Assert.Equal("published", publishedMetrics[0]["surface.result"]);
|
||||
Assert.Equal(3, Convert.ToInt32(publishedMetrics[0]["surface.payload_count"]));
|
||||
Assert.Equal(5, Convert.ToInt32(publishedMetrics[0]["surface.payload_count"]));
|
||||
|
||||
var payloadMetrics = listener.Measurements
|
||||
.Where(m => m.InstrumentName == "scanner_worker_surface_payload_persisted_total")
|
||||
@@ -608,7 +610,8 @@ public sealed class SurfaceManifestStageExecutorTests
|
||||
WorkerInstance = request.WorkerInstance,
|
||||
Attempt = request.Attempt
|
||||
},
|
||||
Artifacts = artifacts
|
||||
Artifacts = artifacts,
|
||||
DeterminismMerkleRoot = request.DeterminismMerkleRoot
|
||||
};
|
||||
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(document, _options);
|
||||
|
||||
@@ -5,4 +5,8 @@
|
||||
| WEB-AOC-19-002 | DONE (2025-11-30) | Added provenance builder, checksum utilities, and DSSE/CMS signature verification helpers with unit tests. |
|
||||
| WEB-AOC-19-003 | DONE (2025-11-30) | Added client-side guard validator (forbidden/derived/unknown fields, provenance/signature checks) with unit fixtures. |
|
||||
| WEB-CONSOLE-23-002 | DOING (2025-12-01) | Console status polling + SSE run stream client/store/UI added; tests pending once env fixed. |
|
||||
| WEB-RISK-66-001 | DOING (2025-12-01) | Added risk gateway mock client/models + tests; wire to real gateway once endpoints land. |
|
||||
| WEB-EXC-25-001 | TODO | Exceptions workflow CRUD pending policy scopes. |
|
||||
| WEB-TEN-47-CONTRACT | DONE (2025-12-01) | Gateway tenant auth/ABAC contract doc v1.0 published (`docs/api/gateway/tenant-auth.md`). |
|
||||
| WEB-VULN-29-LEDGER-DOC | DONE (2025-12-01) | Findings Ledger proxy contract doc v1.0 with idempotency + retries (`docs/api/gateway/findings-ledger-proxy.md`). |
|
||||
| WEB-RISK-68-NOTIFY-DOC | DONE (2025-12-01) | Notifications severity transition event schema v1.0 published (`docs/api/gateway/notifications-severity.md`). |
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
NOTIFY_TENANT_ID,
|
||||
} from './core/api/notify.client';
|
||||
import { CONSOLE_API_BASE_URL } from './core/api/console-status.client';
|
||||
import { RISK_API } from './core/api/risk.client';
|
||||
import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
|
||||
import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor';
|
||||
@@ -65,11 +67,31 @@ export const appConfig: ApplicationConfig = {
|
||||
}
|
||||
},
|
||||
},
|
||||
AuthorityConsoleApiHttpClient,
|
||||
{
|
||||
provide: AUTHORITY_CONSOLE_API,
|
||||
useExisting: AuthorityConsoleApiHttpClient,
|
||||
},
|
||||
AuthorityConsoleApiHttpClient,
|
||||
{
|
||||
provide: AUTHORITY_CONSOLE_API,
|
||||
useExisting: AuthorityConsoleApiHttpClient,
|
||||
},
|
||||
{
|
||||
provide: RISK_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
useFactory: (config: AppConfigService) => {
|
||||
const authorityBase = config.config.apiBaseUrls.authority;
|
||||
try {
|
||||
return new URL('/risk', authorityBase).toString();
|
||||
} catch {
|
||||
const normalized = authorityBase.endsWith('/')
|
||||
? authorityBase.slice(0, -1)
|
||||
: authorityBase;
|
||||
return `${normalized}/risk`;
|
||||
}
|
||||
},
|
||||
},
|
||||
RiskHttpClient,
|
||||
{
|
||||
provide: RISK_API,
|
||||
useExisting: RiskHttpClient,
|
||||
},
|
||||
{
|
||||
provide: NOTIFY_API_BASE_URL,
|
||||
useValue: '/api/v1/notify',
|
||||
|
||||
62
src/Web/StellaOps.Web/src/app/core/api/risk-http.client.ts
Normal file
62
src/Web/StellaOps.Web/src/app/core/api/risk-http.client.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { RiskApi } from './risk.client';
|
||||
import { RiskQueryOptions, RiskResultPage, RiskStats } from './risk.models';
|
||||
|
||||
export const RISK_API_BASE_URL = new InjectionToken<string>('RISK_API_BASE_URL');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RiskHttpClient implements RiskApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly authSession: AuthSessionStore,
|
||||
@Inject(RISK_API_BASE_URL) private readonly baseUrl: string
|
||||
) {}
|
||||
|
||||
list(options: RiskQueryOptions): Observable<RiskResultPage> {
|
||||
const tenant = this.resolveTenant(options.tenantId);
|
||||
const headers = this.buildHeaders(tenant, options.projectId, options.traceId);
|
||||
|
||||
let params = new HttpParams();
|
||||
if (options.page) params = params.set('page', options.page);
|
||||
if (options.pageSize) params = params.set('pageSize', options.pageSize);
|
||||
if (options.severity) params = params.set('severity', options.severity);
|
||||
if (options.search) params = params.set('search', options.search);
|
||||
|
||||
return this.http
|
||||
.get<RiskResultPage>(`${this.baseUrl}/risk`, { headers, params })
|
||||
.pipe(map((page) => ({ ...page, page: page.page ?? 1, pageSize: page.pageSize ?? 20 })));
|
||||
}
|
||||
|
||||
stats(options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<RiskStats> {
|
||||
const tenant = this.resolveTenant(options.tenantId);
|
||||
const headers = this.buildHeaders(tenant, options.projectId, options.traceId);
|
||||
|
||||
return this.http
|
||||
.get<RiskStats>(`${this.baseUrl}/risk/status`, { headers })
|
||||
.pipe(
|
||||
map((stats) => ({
|
||||
countsBySeverity: stats.countsBySeverity,
|
||||
lastComputation: stats.lastComputation ?? '1970-01-01T00:00:00Z',
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(tenantId: string, projectId?: string, traceId?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders({ 'X-Stella-Tenant': tenantId });
|
||||
if (projectId) headers = headers.set('X-Stella-Project', projectId);
|
||||
if (traceId) headers = headers.set('X-Stella-Trace-Id', traceId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private resolveTenant(tenantId?: string): string {
|
||||
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||
if (!tenant) {
|
||||
throw new Error('RiskHttpClient requires an active tenant identifier.');
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
40
src/Web/StellaOps.Web/src/app/core/api/risk.client.spec.ts
Normal file
40
src/Web/StellaOps.Web/src/app/core/api/risk.client.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { MockRiskApi } from './risk.client';
|
||||
|
||||
describe('MockRiskApi', () => {
|
||||
let api: MockRiskApi;
|
||||
|
||||
beforeEach(() => {
|
||||
api = new MockRiskApi();
|
||||
});
|
||||
|
||||
it('requires tenantId for list', () => {
|
||||
expect(() => api.list({ tenantId: '' })).toThrow('tenantId is required');
|
||||
});
|
||||
|
||||
it('returns deterministic ordering by score then id', (done) => {
|
||||
api.list({ tenantId: 'acme-tenant', pageSize: 10 }).subscribe((page) => {
|
||||
const scores = page.items.map((r) => r.score);
|
||||
expect(scores).toEqual([...scores].sort((a, b) => b - a));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by project and severity', (done) => {
|
||||
api
|
||||
.list({ tenantId: 'acme-tenant', projectId: 'proj-ops', severity: 'high' })
|
||||
.subscribe((page) => {
|
||||
expect(page.items.every((r) => r.projectId === 'proj-ops')).toBeTrue();
|
||||
expect(page.items.every((r) => r.severity === 'high')).toBeTrue();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('computes stats with zeroed severities present', (done) => {
|
||||
api.stats({ tenantId: 'acme-tenant' }).subscribe((stats) => {
|
||||
expect(stats.countsBySeverity.none).toBe(0);
|
||||
expect(stats.countsBySeverity.critical).toBeGreaterThan(0);
|
||||
expect(stats.lastComputation).toMatch(/T/);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
112
src/Web/StellaOps.Web/src/app/core/api/risk.client.ts
Normal file
112
src/Web/StellaOps.Web/src/app/core/api/risk.client.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, delay, map, of } from 'rxjs';
|
||||
|
||||
import { RiskProfile, RiskQueryOptions, RiskResultPage, RiskStats, RiskSeverity } from './risk.models';
|
||||
|
||||
export interface RiskApi {
|
||||
list(options: RiskQueryOptions): Observable<RiskResultPage>;
|
||||
stats(options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<RiskStats>;
|
||||
}
|
||||
|
||||
export const RISK_API = new InjectionToken<RiskApi>('RISK_API');
|
||||
|
||||
const MOCK_RISKS: RiskProfile[] = [
|
||||
{
|
||||
id: 'risk-001',
|
||||
title: 'RCE on internet-facing API',
|
||||
description: 'Critical RCE on public API gateway impacting tenants acme, globally exposed.',
|
||||
severity: 'critical',
|
||||
score: 97,
|
||||
lastEvaluatedAt: '2025-11-30T12:00:00Z',
|
||||
tenantId: 'acme-tenant',
|
||||
},
|
||||
{
|
||||
id: 'risk-002',
|
||||
title: 'Expired token audience',
|
||||
description: 'Tokens minted without correct audience allow cross-tenant reuse.',
|
||||
severity: 'high',
|
||||
score: 81,
|
||||
lastEvaluatedAt: '2025-11-30T11:00:00Z',
|
||||
tenantId: 'acme-tenant',
|
||||
projectId: 'proj-ops',
|
||||
},
|
||||
{
|
||||
id: 'risk-003',
|
||||
title: 'Missing SBOM attestation',
|
||||
description: 'Builds lack SBOM attestations; export blocked in sealed mode.',
|
||||
severity: 'medium',
|
||||
score: 55,
|
||||
lastEvaluatedAt: '2025-11-29T19:30:00Z',
|
||||
tenantId: 'acme-tenant',
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockRiskApi implements RiskApi {
|
||||
list(options: RiskQueryOptions): Observable<RiskResultPage> {
|
||||
if (!options.tenantId) {
|
||||
throw new Error('tenantId is required');
|
||||
}
|
||||
|
||||
const page = options.page ?? 1;
|
||||
const pageSize = options.pageSize ?? 20;
|
||||
const filtered = MOCK_RISKS.filter((r) => {
|
||||
if (r.tenantId !== options.tenantId) {
|
||||
return false;
|
||||
}
|
||||
if (options.projectId && r.projectId !== options.projectId) {
|
||||
return false;
|
||||
}
|
||||
if (options.severity && r.severity !== options.severity) {
|
||||
return false;
|
||||
}
|
||||
if (options.search && !r.title.toLowerCase().includes(options.search.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
const items = filtered
|
||||
.slice()
|
||||
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
|
||||
.slice(start, start + pageSize);
|
||||
|
||||
const response: RiskResultPage = {
|
||||
items,
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
|
||||
return of(response).pipe(delay(50));
|
||||
}
|
||||
|
||||
stats(options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<RiskStats> {
|
||||
if (!options.tenantId) {
|
||||
throw new Error('tenantId is required');
|
||||
}
|
||||
|
||||
const relevant = MOCK_RISKS.filter((r) => r.tenantId === options.tenantId);
|
||||
const emptyCounts: Record<RiskSeverity, number> = {
|
||||
none: 0,
|
||||
info: 0,
|
||||
low: 0,
|
||||
medium: 0,
|
||||
high: 0,
|
||||
critical: 0,
|
||||
};
|
||||
|
||||
const counts = relevant.reduce((acc, curr) => {
|
||||
acc[curr.severity] = (acc[curr.severity] ?? 0) + 1;
|
||||
return acc;
|
||||
}, { ...emptyCounts });
|
||||
|
||||
const lastEvaluatedAt = relevant
|
||||
.map((r) => r.lastEvaluatedAt)
|
||||
.sort()
|
||||
.reverse()[0] ?? '1970-01-01T00:00:00Z';
|
||||
|
||||
return of({ countsBySeverity: counts, lastComputation: lastEvaluatedAt }).pipe(delay(25));
|
||||
}
|
||||
}
|
||||
34
src/Web/StellaOps.Web/src/app/core/api/risk.models.ts
Normal file
34
src/Web/StellaOps.Web/src/app/core/api/risk.models.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type RiskSeverity = 'none' | 'info' | 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export interface RiskProfile {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
severity: RiskSeverity;
|
||||
score: number;
|
||||
lastEvaluatedAt: string; // UTC ISO-8601
|
||||
tenantId: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface RiskResultPage {
|
||||
items: RiskProfile[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface RiskQueryOptions {
|
||||
tenantId: string;
|
||||
projectId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
severity?: RiskSeverity;
|
||||
search?: string;
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export interface RiskStats {
|
||||
countsBySeverity: Record<RiskSeverity, number>;
|
||||
lastComputation: string; // UTC ISO-8601
|
||||
}
|
||||
Reference in New Issue
Block a user