fix: dead jobengine route path rewriting + legacy endpoint delegation

- Fix PacksRegistry route: rewrite /jobengine/registry/packs → /packs on target
- Fix first-signal route: delegate to real handler instead of 501 stub
- Release-orchestrator persistence extraction progress

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-08 18:18:26 +03:00
parent 2d83ca08b8
commit e1f5341c82
17 changed files with 1272 additions and 25 deletions

View File

@@ -36,6 +36,7 @@ using StellaOps.ExportCenter.Infrastructure.Db;
using StellaOps.Findings.Ledger.Infrastructure.Postgres;
using StellaOps.Integrations.Persistence;
using StellaOps.Replay.WebService;
using StellaOps.ReleaseOrchestrator.Persistence.Postgres;
using StellaOps.RiskEngine.Infrastructure.Stores;
namespace StellaOps.Platform.Database;
@@ -363,6 +364,14 @@ public sealed class RiskEngineMigrationModulePlugin : IMigrationModulePlugin
resourcePrefix: "StellaOps.RiskEngine.Infrastructure.Migrations");
}
public sealed class ReleaseOrchestratorMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
name: "ReleaseOrchestrator",
schemaName: "release_orchestrator",
migrationsAssembly: typeof(ReleaseOrchestratorDataSource).Assembly);
}
public sealed class FindingsLedgerMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(

View File

@@ -51,6 +51,7 @@
<ProjectReference Include="..\..\..\Replay\StellaOps.Replay.WebService\StellaOps.Replay.WebService.csproj" />
<ProjectReference Include="..\..\..\AdvisoryAI\__Libraries\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
<ProjectReference Include="..\..\..\Workflow\__Libraries\StellaOps.Workflow.DataStore.PostgreSQL\StellaOps.Workflow.DataStore.PostgreSQL.csproj" />
<ProjectReference Include="..\..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.Persistence\StellaOps.ReleaseOrchestrator.Persistence.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,5 +1,5 @@
using StellaOps.JobEngine.Core.Domain;
using StellaOps.JobEngine.Infrastructure.Repositories;
using StellaOps.ReleaseOrchestrator.Persistence.Domain;
using StellaOps.ReleaseOrchestrator.Persistence.Repositories;
namespace StellaOps.ReleaseOrchestrator.WebApi.Contracts;

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.JobEngine.Core.Domain;
using StellaOps.JobEngine.Infrastructure.Repositories;
using StellaOps.ReleaseOrchestrator.Persistence.Domain;
using StellaOps.ReleaseOrchestrator.Persistence.Repositories;
using StellaOps.ReleaseOrchestrator.WebApi.Contracts;
using StellaOps.ReleaseOrchestrator.WebApi.Services;
using static StellaOps.Localization.T;

View File

@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.JobEngine.Core.Services;
using StellaOps.ReleaseOrchestrator.Persistence.Services;
using StellaOps.ReleaseOrchestrator.WebApi.Contracts;
using StellaOps.ReleaseOrchestrator.WebApi.Services;
using static StellaOps.Localization.T;
@@ -26,7 +26,7 @@ public static class FirstSignalEndpoints
return group;
}
private static async Task<IResult> GetFirstSignal(
internal static async Task<IResult> GetFirstSignal(
HttpContext context,
[FromRoute] Guid runId,
[FromHeader(Name = "If-None-Match")] string? ifNoneMatch,

View File

@@ -22,6 +22,7 @@ public static class JobEngineLegacyEndpoints
public static IEndpointRouteBuilder MapJobEngineLegacyEndpoints(this IEndpointRouteBuilder app)
{
MapLegacyAuditEndpoints(app);
MapLegacyRunsEndpoints(app);
MapLegacyStubEndpoints(app);
return app;
@@ -40,6 +41,33 @@ public static class JobEngineLegacyEndpoints
group.MapGet("events", AuditEndpoints.ListAuditEntriesHandler);
}
// -----------------------------------------------------------------------
// Runs -- first-signal has a real implementation, delegate to handler
// -----------------------------------------------------------------------
private static void MapLegacyRunsEndpoints(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/jobengine/runs")
.WithTags("JobEngine Legacy - Runs")
.RequireAuthorization(ReleaseOrchestratorPolicies.Read)
.RequireTenant();
group.MapGet("{runId:guid}/first-signal", FirstSignalEndpoints.GetFirstSignal);
// Catch-all for other runs sub-paths not yet migrated
group.MapGet("{**rest}", NotImplementedStub)
.WithDescription("Legacy jobengine compatibility stub - feature pending migration.");
group.MapPost("{**rest}", NotImplementedStub)
.WithDescription("Legacy jobengine compatibility stub - feature pending migration.");
group.MapPut("{**rest}", NotImplementedStub)
.WithDescription("Legacy jobengine compatibility stub - feature pending migration.");
group.MapDelete("{**rest}", NotImplementedStub)
.WithDescription("Legacy jobengine compatibility stub - feature pending migration.");
group.MapGet(string.Empty, NotImplementedStub)
.WithDescription("Legacy jobengine compatibility stub - feature pending migration.");
group.MapPost(string.Empty, NotImplementedStub)
.WithDescription("Legacy jobengine compatibility stub - feature pending migration.");
}
// -----------------------------------------------------------------------
// Stubs -- features not yet migrated from the monolithic JobEngine
// -----------------------------------------------------------------------
@@ -52,7 +80,6 @@ public static class JobEngineLegacyEndpoints
"/api/v1/jobengine/quotas",
"/api/v1/jobengine/deadletter",
"/api/v1/jobengine/jobs",
"/api/v1/jobengine/runs",
"/api/v1/jobengine/dag",
"/api/v1/jobengine/pack-runs",
"/api/v1/jobengine/stream",

View File

@@ -3,7 +3,7 @@ using StellaOps.Router.AspNet;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.JobEngine.Infrastructure;
using StellaOps.ReleaseOrchestrator.Persistence.Extensions;
using StellaOps.ReleaseOrchestrator.Scripts;
using StellaOps.ReleaseOrchestrator.Scripts.Persistence;
using StellaOps.ReleaseOrchestrator.Scripts.Search;
@@ -26,8 +26,8 @@ builder.Services.AddAuthorization(options =>
options.AddReleaseOrchestratorPolicies();
});
// JobEngine infrastructure (Postgres repositories: IAuditRepository, IFirstSignalService, etc.)
builder.Services.AddJobEngineInfrastructure(builder.Configuration);
// ReleaseOrchestrator persistence (audit + first-signal; own schema, no JobEngine dependency)
builder.Services.AddReleaseOrchestratorPersistence(builder.Configuration);
// Workflow engine HTTP client (calls workflow service to start workflow instances)
builder.Services.AddHttpClient<WorkflowClient>((sp, client) =>

View File

@@ -1,4 +1,3 @@
using StellaOps.JobEngine.Core.Domain;
using System.Text;
namespace StellaOps.ReleaseOrchestrator.WebApi.Services;

View File

@@ -17,8 +17,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="..\..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\..\..\JobEngine\StellaOps.JobEngine\StellaOps.JobEngine.Core\StellaOps.JobEngine.Core.csproj" />
<ProjectReference Include="..\..\..\JobEngine\StellaOps.JobEngine\StellaOps.JobEngine.Infrastructure\StellaOps.JobEngine.Infrastructure.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.ReleaseOrchestrator.Persistence\StellaOps.ReleaseOrchestrator.Persistence.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.ReleaseOrchestrator.Scripts\StellaOps.ReleaseOrchestrator.Scripts.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Audit.Emission\StellaOps.Audit.Emission.csproj" />

View File

@@ -0,0 +1,111 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.ReleaseOrchestrator.Persistence.Hashing;
using StellaOps.ReleaseOrchestrator.Persistence.Postgres;
using StellaOps.ReleaseOrchestrator.Persistence.Repositories;
using StellaOps.ReleaseOrchestrator.Persistence.Services;
namespace StellaOps.ReleaseOrchestrator.Persistence.Extensions;
/// <summary>
/// Extension methods for registering ReleaseOrchestrator persistence services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds ReleaseOrchestrator persistence services (audit + first-signal) to the service collection.
/// Replaces the previous AddJobEngineInfrastructure() call with a lean, schema-isolated alternative.
/// </summary>
public static IServiceCollection AddReleaseOrchestratorPersistence(
this IServiceCollection services,
IConfiguration configuration)
{
// Bind PostgresOptions from config, with connection-string fallback chain
services.AddOptions<PostgresOptions>()
.Configure(options =>
{
var section = configuration.GetSection("ReleaseOrchestrator:Postgres");
if (section.Exists())
{
section.Bind(options);
}
// Fallback: reuse the default connection string with release_orchestrator schema
if (string.IsNullOrWhiteSpace(options.ConnectionString) || ShouldReplaceConnectionString(options.ConnectionString))
{
var orchestratorConnection = configuration["Orchestrator:Database:ConnectionString"];
if (!string.IsNullOrWhiteSpace(orchestratorConnection))
{
options.ConnectionString = orchestratorConnection;
}
else
{
options.ConnectionString = configuration.GetConnectionString("Default")
?? configuration["ConnectionStrings__Default"]
?? "Host=localhost;Database=stellaops_platform;Username=stellaops;Password=stellaops";
}
}
if (string.IsNullOrWhiteSpace(options.SchemaName))
{
options.SchemaName = ReleaseOrchestratorDataSource.DefaultSchemaName;
}
});
// Startup migrations (auto-migrate on boot, per repo convention)
services.AddStartupMigrations(
schemaName: ReleaseOrchestratorDataSource.DefaultSchemaName,
moduleName: "ReleaseOrchestrator",
migrationsAssembly: typeof(ReleaseOrchestratorDataSource).Assembly);
// Data source
services.AddSingleton<ReleaseOrchestratorDataSource>();
// Hashing (for audit hash chain)
services.AddSingleton<CanonicalJsonHasher>();
// Repositories
services.AddScoped<IAuditRepository, PostgresAuditRepository>();
services.AddScoped<IFirstSignalSnapshotRepository, PostgresFirstSignalSnapshotRepository>();
// Services
services.AddScoped<IFirstSignalService, FirstSignalService>();
return services;
}
private static bool ShouldReplaceConnectionString(string? configuredConnectionString)
{
if (string.IsNullOrWhiteSpace(configuredConnectionString))
{
return true;
}
try
{
var builder = new NpgsqlConnectionStringBuilder(configuredConnectionString);
var host = builder.Host?.Trim();
if (string.IsNullOrWhiteSpace(host))
{
return true;
}
return host.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.All(IsLoopbackHost);
}
catch
{
return false;
}
}
private static bool IsLoopbackHost(string host)
{
return host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase)
|| host.Equals("::1", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,232 @@
-- Migration: 001_initial
-- Creates the release_orchestrator schema with audit + first-signal tables.
-- Only the 3 tables needed by the release-orchestrator service (vs 30+ in JobEngine).
CREATE SCHEMA IF NOT EXISTS release_orchestrator;
-- ===================================================================
-- 1. Audit entries (immutable append-only log with hash chain)
-- ===================================================================
CREATE TABLE IF NOT EXISTS release_orchestrator.audit_entries (
entry_id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
event_type INTEGER NOT NULL,
resource_type TEXT NOT NULL,
resource_id UUID NOT NULL,
actor_id TEXT NOT NULL,
actor_type INTEGER NOT NULL,
actor_ip TEXT,
user_agent TEXT,
http_method TEXT,
request_path TEXT,
old_state JSONB,
new_state JSONB,
description TEXT NOT NULL,
correlation_id TEXT,
previous_entry_hash TEXT,
content_hash TEXT NOT NULL,
sequence_number BIGINT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB
);
CREATE INDEX IF NOT EXISTS idx_ro_audit_tenant
ON release_orchestrator.audit_entries(tenant_id);
CREATE INDEX IF NOT EXISTS idx_ro_audit_tenant_time
ON release_orchestrator.audit_entries(tenant_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_ro_audit_tenant_seq
ON release_orchestrator.audit_entries(tenant_id, sequence_number DESC);
CREATE INDEX IF NOT EXISTS idx_ro_audit_resource
ON release_orchestrator.audit_entries(tenant_id, resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_ro_audit_actor
ON release_orchestrator.audit_entries(tenant_id, actor_id);
CREATE INDEX IF NOT EXISTS idx_ro_audit_event_type
ON release_orchestrator.audit_entries(tenant_id, event_type);
CREATE INDEX IF NOT EXISTS idx_ro_audit_correlation
ON release_orchestrator.audit_entries(correlation_id)
WHERE correlation_id IS NOT NULL;
-- ===================================================================
-- 2. Audit sequence tracking (per-tenant hash chain head)
-- ===================================================================
CREATE TABLE IF NOT EXISTS release_orchestrator.audit_sequences (
tenant_id TEXT PRIMARY KEY,
last_sequence_number BIGINT NOT NULL DEFAULT 0,
last_entry_hash TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ===================================================================
-- 3. First signal snapshots (TTFS fast-path cache)
-- ===================================================================
CREATE TABLE IF NOT EXISTS release_orchestrator.first_signal_snapshots (
tenant_id TEXT NOT NULL,
run_id UUID NOT NULL,
job_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
kind TEXT NOT NULL CHECK (kind IN (
'queued', 'started', 'phase', 'blocked',
'failed', 'succeeded', 'canceled', 'unavailable'
)),
phase TEXT NOT NULL CHECK (phase IN (
'resolve', 'fetch', 'restore', 'analyze',
'policy', 'report', 'unknown'
)),
summary TEXT NOT NULL,
eta_seconds INT NULL,
last_known_outcome JSONB NULL,
next_actions JSONB NULL,
diagnostics JSONB NOT NULL DEFAULT '{}'::jsonb,
signal_json JSONB NOT NULL,
CONSTRAINT pk_ro_first_signal_snapshots PRIMARY KEY (tenant_id, run_id)
);
CREATE INDEX IF NOT EXISTS ix_ro_first_signal_snapshots_job
ON release_orchestrator.first_signal_snapshots (tenant_id, job_id);
CREATE INDEX IF NOT EXISTS ix_ro_first_signal_snapshots_updated
ON release_orchestrator.first_signal_snapshots (tenant_id, updated_at DESC);
-- ===================================================================
-- SQL functions for audit hash chain
-- ===================================================================
-- Get next audit sequence number for a tenant
CREATE OR REPLACE FUNCTION release_orchestrator.next_audit_sequence(
p_tenant_id TEXT
) RETURNS TABLE (
next_seq BIGINT,
prev_hash TEXT
) AS $$
DECLARE
v_next_seq BIGINT;
v_prev_hash TEXT;
BEGIN
INSERT INTO release_orchestrator.audit_sequences (tenant_id, last_sequence_number, last_entry_hash, updated_at)
VALUES (p_tenant_id, 1, NULL, NOW())
ON CONFLICT (tenant_id)
DO UPDATE SET
last_sequence_number = release_orchestrator.audit_sequences.last_sequence_number + 1,
updated_at = NOW()
RETURNING release_orchestrator.audit_sequences.last_sequence_number,
release_orchestrator.audit_sequences.last_entry_hash
INTO v_next_seq, v_prev_hash;
RETURN QUERY SELECT v_next_seq, v_prev_hash;
END;
$$ LANGUAGE plpgsql;
-- Update audit sequence hash after insertion
CREATE OR REPLACE FUNCTION release_orchestrator.update_audit_sequence_hash(
p_tenant_id TEXT,
p_content_hash TEXT
) RETURNS VOID AS $$
BEGIN
UPDATE release_orchestrator.audit_sequences
SET last_entry_hash = p_content_hash,
updated_at = NOW()
WHERE tenant_id = p_tenant_id;
END;
$$ LANGUAGE plpgsql;
-- Verify audit chain integrity
CREATE OR REPLACE FUNCTION release_orchestrator.verify_audit_chain(
p_tenant_id TEXT,
p_start_seq BIGINT DEFAULT 1,
p_end_seq BIGINT DEFAULT NULL
) RETURNS TABLE (
is_valid BOOLEAN,
invalid_entry_id UUID,
invalid_sequence BIGINT,
error_message TEXT
) AS $$
DECLARE
v_prev_hash TEXT;
v_entry RECORD;
BEGIN
FOR v_entry IN
SELECT ae.entry_id, ae.sequence_number, ae.previous_entry_hash, ae.content_hash
FROM release_orchestrator.audit_entries ae
WHERE ae.tenant_id = p_tenant_id
AND ae.sequence_number >= p_start_seq
AND (p_end_seq IS NULL OR ae.sequence_number <= p_end_seq)
ORDER BY ae.sequence_number ASC
LOOP
IF v_entry.sequence_number = 1 AND v_entry.previous_entry_hash IS NOT NULL THEN
RETURN QUERY SELECT FALSE, v_entry.entry_id, v_entry.sequence_number,
'First entry should have null previous_entry_hash'::TEXT;
RETURN;
END IF;
IF v_prev_hash IS NOT NULL AND v_entry.previous_entry_hash != v_prev_hash THEN
RETURN QUERY SELECT FALSE, v_entry.entry_id, v_entry.sequence_number,
format('Chain break: expected %s, got %s', v_prev_hash, v_entry.previous_entry_hash);
RETURN;
END IF;
v_prev_hash := v_entry.content_hash;
END LOOP;
RETURN QUERY SELECT TRUE, NULL::UUID, NULL::BIGINT, NULL::TEXT;
END;
$$ LANGUAGE plpgsql;
-- Get audit summary statistics
CREATE OR REPLACE FUNCTION release_orchestrator.get_audit_summary(
p_tenant_id TEXT,
p_since TIMESTAMPTZ DEFAULT NULL
) RETURNS TABLE (
total_entries BIGINT,
entries_since BIGINT,
event_types BIGINT,
unique_actors BIGINT,
unique_resources BIGINT,
earliest_entry TIMESTAMPTZ,
latest_entry TIMESTAMPTZ
) AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(*)::BIGINT AS total_entries,
COUNT(*) FILTER (WHERE p_since IS NULL OR ae.occurred_at >= p_since)::BIGINT AS entries_since,
COUNT(DISTINCT ae.event_type)::BIGINT AS event_types,
COUNT(DISTINCT ae.actor_id)::BIGINT AS unique_actors,
COUNT(DISTINCT (ae.resource_type, ae.resource_id))::BIGINT AS unique_resources,
MIN(ae.occurred_at) AS earliest_entry,
MAX(ae.occurred_at) AS latest_entry
FROM release_orchestrator.audit_entries ae
WHERE ae.tenant_id = p_tenant_id;
END;
$$ LANGUAGE plpgsql;
-- Cleanup old audit entries (retention-aware)
CREATE OR REPLACE FUNCTION release_orchestrator.cleanup_audit_entries(
p_retention_days INTEGER DEFAULT 365,
p_batch_limit INTEGER DEFAULT 10000
) RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
WITH deleted AS (
DELETE FROM release_orchestrator.audit_entries
WHERE ctid IN (
SELECT ctid FROM release_orchestrator.audit_entries
WHERE occurred_at < NOW() - (p_retention_days || ' days')::INTERVAL
LIMIT p_batch_limit
)
RETURNING 1
)
SELECT COUNT(*) INTO deleted_count FROM deleted;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Comments
COMMENT ON TABLE release_orchestrator.audit_entries IS 'Immutable audit log with hash chain for tamper evidence';
COMMENT ON TABLE release_orchestrator.audit_sequences IS 'Sequence tracking for audit entry chain integrity';
COMMENT ON TABLE release_orchestrator.first_signal_snapshots IS 'Per-run cached first-signal payload for TTFS fast path';
COMMENT ON FUNCTION release_orchestrator.verify_audit_chain IS 'Verifies the hash chain integrity of audit entries';

View File

@@ -12,8 +12,10 @@ namespace StellaOps.ReleaseOrchestrator.Persistence.Postgres;
/// </summary>
public sealed class PostgresAuditRepository : IAuditRepository
{
private const string Schema = ReleaseOrchestratorDataSource.DefaultSchemaName;
private const string InsertEntrySql = """
INSERT INTO audit_entries (
INSERT INTO release_orchestrator.audit_entries (
entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata)
@@ -24,28 +26,28 @@ public sealed class PostgresAuditRepository : IAuditRepository
""";
private const string GetSequenceSql = """
SELECT next_seq, prev_hash FROM next_audit_sequence(@tenant_id)
SELECT next_seq, prev_hash FROM release_orchestrator.next_audit_sequence(@tenant_id)
""";
private const string UpdateSequenceHashSql = """
SELECT update_audit_sequence_hash(@tenant_id, @content_hash)
SELECT release_orchestrator.update_audit_sequence_hash(@tenant_id, @content_hash)
""";
private const string VerifyChainSql = """
SELECT is_valid, invalid_entry_id, invalid_sequence, error_message
FROM verify_audit_chain(@tenant_id, @start_seq, @end_seq)
FROM release_orchestrator.verify_audit_chain(@tenant_id, @start_seq, @end_seq)
""";
private const string GetSummarySql = """
SELECT total_entries, entries_since, event_types, unique_actors, unique_resources, earliest_entry, latest_entry
FROM get_audit_summary(@tenant_id, @since)
FROM release_orchestrator.get_audit_summary(@tenant_id, @since)
""";
private const string GetByIdSql = """
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
FROM audit_entries
FROM release_orchestrator.audit_entries
WHERE tenant_id = @tenant_id AND entry_id = @entry_id
""";
@@ -53,7 +55,7 @@ public sealed class PostgresAuditRepository : IAuditRepository
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
FROM audit_entries
FROM release_orchestrator.audit_entries
WHERE tenant_id = @tenant_id
""";
@@ -61,7 +63,7 @@ public sealed class PostgresAuditRepository : IAuditRepository
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
FROM audit_entries
FROM release_orchestrator.audit_entries
WHERE tenant_id = @tenant_id
ORDER BY sequence_number DESC
LIMIT 1
@@ -71,7 +73,7 @@ public sealed class PostgresAuditRepository : IAuditRepository
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
FROM audit_entries
FROM release_orchestrator.audit_entries
WHERE tenant_id = @tenant_id AND sequence_number >= @start_seq AND sequence_number <= @end_seq
ORDER BY sequence_number ASC
""";
@@ -80,7 +82,7 @@ public sealed class PostgresAuditRepository : IAuditRepository
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
FROM audit_entries
FROM release_orchestrator.audit_entries
WHERE tenant_id = @tenant_id AND resource_type = @resource_type AND resource_id = @resource_id
ORDER BY occurred_at DESC
LIMIT @limit
@@ -88,7 +90,7 @@ public sealed class PostgresAuditRepository : IAuditRepository
private const string GetCountSql = """
SELECT COUNT(*)
FROM audit_entries
FROM release_orchestrator.audit_entries
WHERE tenant_id = @tenant_id
""";

View File

@@ -0,0 +1,176 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.ReleaseOrchestrator.Persistence.Repositories;
namespace StellaOps.ReleaseOrchestrator.Persistence.Postgres;
/// <summary>
/// PostgreSQL implementation of first signal snapshot repository.
/// Uses raw SQL (no EF Core) for a lean dependency footprint.
/// </summary>
public sealed class PostgresFirstSignalSnapshotRepository : IFirstSignalSnapshotRepository
{
private const string GetByRunIdSql = """
SELECT tenant_id, run_id, job_id, created_at, updated_at,
kind, phase, summary, eta_seconds,
last_known_outcome, next_actions, diagnostics, signal_json
FROM release_orchestrator.first_signal_snapshots
WHERE tenant_id = @tenant_id AND run_id = @run_id
""";
private const string DeleteByRunIdSql = """
DELETE FROM release_orchestrator.first_signal_snapshots
WHERE tenant_id = @tenant_id AND run_id = @run_id
""";
private const string UpsertSql = """
INSERT INTO release_orchestrator.first_signal_snapshots (
tenant_id, run_id, job_id, created_at, updated_at,
kind, phase, summary, eta_seconds,
last_known_outcome, next_actions, diagnostics, signal_json)
VALUES (
@tenant_id, @run_id, @job_id, @created_at, @updated_at,
@kind, @phase, @summary, @eta_seconds,
@last_known_outcome, @next_actions, @diagnostics, @signal_json)
ON CONFLICT (tenant_id, run_id) DO UPDATE SET
job_id = EXCLUDED.job_id,
updated_at = EXCLUDED.updated_at,
kind = EXCLUDED.kind,
phase = EXCLUDED.phase,
summary = EXCLUDED.summary,
eta_seconds = EXCLUDED.eta_seconds,
last_known_outcome = EXCLUDED.last_known_outcome,
next_actions = EXCLUDED.next_actions,
diagnostics = EXCLUDED.diagnostics,
signal_json = EXCLUDED.signal_json
""";
private readonly ReleaseOrchestratorDataSource _dataSource;
private readonly ILogger<PostgresFirstSignalSnapshotRepository> _logger;
public PostgresFirstSignalSnapshotRepository(
ReleaseOrchestratorDataSource dataSource,
ILogger<PostgresFirstSignalSnapshotRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<FirstSignalSnapshot?> GetByRunIdAsync(
string tenantId,
Guid runId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
if (runId == Guid.Empty)
{
throw new ArgumentException("Run ID must be a non-empty GUID.", nameof(runId));
}
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(GetByRunIdSql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("run_id", runId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return ReadSnapshot(reader);
}
public async Task UpsertAsync(
FirstSignalSnapshot snapshot,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentException.ThrowIfNullOrWhiteSpace(snapshot.TenantId);
if (snapshot.RunId == Guid.Empty)
{
throw new ArgumentException("Run ID must be a non-empty GUID.", nameof(snapshot));
}
await using var connection = await _dataSource.OpenConnectionAsync(snapshot.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(UpsertSql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", snapshot.TenantId);
command.Parameters.AddWithValue("run_id", snapshot.RunId);
command.Parameters.AddWithValue("job_id", snapshot.JobId);
command.Parameters.AddWithValue("created_at", snapshot.CreatedAt);
command.Parameters.AddWithValue("updated_at", snapshot.UpdatedAt);
command.Parameters.AddWithValue("kind", snapshot.Kind);
command.Parameters.AddWithValue("phase", snapshot.Phase);
command.Parameters.AddWithValue("summary", snapshot.Summary);
command.Parameters.AddWithValue("eta_seconds", (object?)snapshot.EtaSeconds ?? DBNull.Value);
command.Parameters.Add(new NpgsqlParameter("last_known_outcome", NpgsqlDbType.Jsonb)
{
Value = (object?)snapshot.LastKnownOutcomeJson ?? DBNull.Value
});
command.Parameters.Add(new NpgsqlParameter("next_actions", NpgsqlDbType.Jsonb)
{
Value = (object?)snapshot.NextActionsJson ?? DBNull.Value
});
command.Parameters.Add(new NpgsqlParameter("diagnostics", NpgsqlDbType.Jsonb)
{
Value = snapshot.DiagnosticsJson
});
command.Parameters.Add(new NpgsqlParameter("signal_json", NpgsqlDbType.Jsonb)
{
Value = snapshot.SignalJson
});
try
{
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
catch (PostgresException ex)
{
_logger.LogError(ex, "Failed to upsert first signal snapshot for tenant {TenantId} run {RunId}.",
snapshot.TenantId, snapshot.RunId);
throw;
}
}
public async Task DeleteByRunIdAsync(
string tenantId,
Guid runId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
if (runId == Guid.Empty)
{
throw new ArgumentException("Run ID must be a non-empty GUID.", nameof(runId));
}
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(DeleteByRunIdSql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("run_id", runId);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static FirstSignalSnapshot ReadSnapshot(NpgsqlDataReader reader) => new()
{
TenantId = reader.GetString(0),
RunId = reader.GetGuid(1),
JobId = reader.GetGuid(2),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(3),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(4),
Kind = reader.GetString(5),
Phase = reader.GetString(6),
Summary = reader.GetString(7),
EtaSeconds = reader.IsDBNull(8) ? null : reader.GetInt32(8),
LastKnownOutcomeJson = reader.IsDBNull(9) ? null : reader.GetString(9),
NextActionsJson = reader.IsDBNull(10) ? null : reader.GetString(10),
DiagnosticsJson = reader.GetString(11),
SignalJson = reader.GetString(12)
};
}

View File

@@ -0,0 +1,43 @@
namespace StellaOps.ReleaseOrchestrator.Persistence.Repositories;
/// <summary>
/// Repository for first-signal snapshot persistence.
/// </summary>
public interface IFirstSignalSnapshotRepository
{
Task<FirstSignalSnapshot?> GetByRunIdAsync(
string tenantId,
Guid runId,
CancellationToken cancellationToken = default);
Task UpsertAsync(
FirstSignalSnapshot snapshot,
CancellationToken cancellationToken = default);
Task DeleteByRunIdAsync(
string tenantId,
Guid runId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// First-signal snapshot storage model.
/// </summary>
public sealed record FirstSignalSnapshot
{
public required string TenantId { get; init; }
public required Guid RunId { get; init; }
public required Guid JobId { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
public required string Kind { get; init; }
public required string Phase { get; init; }
public required string Summary { get; init; }
public int? EtaSeconds { get; init; }
public string? LastKnownOutcomeJson { get; init; }
public string? NextActionsJson { get; init; }
public required string DiagnosticsJson { get; init; }
public required string SignalJson { get; init; }
}

View File

@@ -0,0 +1,238 @@
using Microsoft.Extensions.Logging;
using StellaOps.ReleaseOrchestrator.Persistence.Domain;
using StellaOps.ReleaseOrchestrator.Persistence.Hashing;
using StellaOps.ReleaseOrchestrator.Persistence.Repositories;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.ReleaseOrchestrator.Persistence.Services;
/// <summary>
/// Snapshot-only first signal service for release-orchestrator.
/// Unlike the JobEngine version, this service does not compute signals from runs/jobs --
/// it only stores and retrieves snapshot data written by upstream producers.
/// </summary>
public sealed class FirstSignalService : IFirstSignalService
{
private static readonly JsonSerializerOptions SignalJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
private readonly IFirstSignalSnapshotRepository _snapshotRepository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<FirstSignalService> _logger;
public FirstSignalService(
IFirstSignalSnapshotRepository snapshotRepository,
TimeProvider timeProvider,
ILogger<FirstSignalService> logger)
{
_snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<FirstSignalResult> GetFirstSignalAsync(
Guid runId,
string tenantId,
string? ifNoneMatch = null,
CancellationToken cancellationToken = default)
{
if (runId == Guid.Empty)
{
throw new ArgumentException("Run ID must be a non-empty GUID.", nameof(runId));
}
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
// Snapshot path (only path in the lean release-orchestrator service)
var snapshot = await _snapshotRepository.GetByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return new FirstSignalResult
{
Status = FirstSignalResultStatus.NotFound,
CacheHit = false,
Source = null,
ETag = null,
Signal = null,
};
}
var signal = TryDeserializeSignal(snapshot.SignalJson);
if (signal is null)
{
_logger.LogWarning(
"Invalid first signal snapshot JSON for tenant {TenantId} run {RunId}; deleting snapshot row.",
tenantId, runId);
await _snapshotRepository.DeleteByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
return new FirstSignalResult
{
Status = FirstSignalResultStatus.NotFound,
CacheHit = false,
Source = null,
ETag = null,
Signal = null,
};
}
var etag = GenerateEtag(signal);
const string origin = "snapshot";
if (IsNotModified(ifNoneMatch, etag))
{
return new FirstSignalResult
{
Status = FirstSignalResultStatus.NotModified,
CacheHit = false,
Source = origin,
ETag = etag,
Signal = signal with
{
Diagnostics = signal.Diagnostics with
{
CacheHit = false,
Source = origin,
}
}
};
}
return new FirstSignalResult
{
Status = FirstSignalResultStatus.Found,
CacheHit = false,
Source = origin,
ETag = etag,
Signal = signal with
{
Diagnostics = signal.Diagnostics with
{
CacheHit = false,
Source = origin,
}
}
};
}
public async Task UpdateSnapshotAsync(
Guid runId,
string tenantId,
FirstSignal signal,
CancellationToken cancellationToken = default)
{
if (runId == Guid.Empty)
{
throw new ArgumentException("Run ID must be a non-empty GUID.", nameof(runId));
}
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(signal);
var now = _timeProvider.GetUtcNow();
var signalJson = CanonicalJsonHasher.ToCanonicalJson(signal);
var snapshot = new FirstSignalSnapshot
{
TenantId = tenantId,
RunId = runId,
JobId = signal.JobId,
CreatedAt = now,
UpdatedAt = now,
Kind = signal.Kind.ToString().ToLowerInvariant(),
Phase = signal.Phase.ToString().ToLowerInvariant(),
Summary = signal.Summary,
EtaSeconds = signal.EtaSeconds,
LastKnownOutcomeJson = signal.LastKnownOutcome is null
? null
: JsonSerializer.Serialize(signal.LastKnownOutcome, SignalJsonOptions),
NextActionsJson = signal.NextActions is null
? null
: JsonSerializer.Serialize(signal.NextActions, SignalJsonOptions),
DiagnosticsJson = JsonSerializer.Serialize(signal.Diagnostics, SignalJsonOptions),
SignalJson = signalJson,
};
await _snapshotRepository.UpsertAsync(snapshot, cancellationToken).ConfigureAwait(false);
}
public async Task InvalidateCacheAsync(
Guid runId,
string tenantId,
CancellationToken cancellationToken = default)
{
if (runId == Guid.Empty)
{
throw new ArgumentException("Run ID must be a non-empty GUID.", nameof(runId));
}
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
// In the lean service, invalidation means deleting the snapshot row.
await _snapshotRepository.DeleteByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
}
private static FirstSignal? TryDeserializeSignal(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
return JsonSerializer.Deserialize<FirstSignal>(json, SignalJsonOptions);
}
catch
{
return null;
}
}
private static string GenerateEtag(FirstSignal signal)
{
var material = new
{
signal.Version,
signal.JobId,
signal.Timestamp,
signal.Kind,
signal.Phase,
signal.Scope,
signal.Summary,
signal.EtaSeconds,
signal.LastKnownOutcome,
signal.NextActions
};
var canonicalJson = CanonicalJsonHasher.ToCanonicalJson(material);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalJson));
var base64 = Convert.ToBase64String(hash.AsSpan(0, 8));
return $"W/\"{base64}\"";
}
private static bool IsNotModified(string? ifNoneMatch, string etag)
{
if (string.IsNullOrWhiteSpace(ifNoneMatch))
{
return false;
}
var candidates = ifNoneMatch
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => t.Trim())
.ToList();
if (candidates.Any(t => t == "*"))
{
return true;
}
return candidates.Any(t => string.Equals(t, etag, StringComparison.Ordinal));
}
}

View File

@@ -124,7 +124,7 @@
{ "Type": "Microservice", "Path": "^/api/v1/doctor/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://doctor-scheduler.stella-ops.local/api/v1/doctor/scheduler$1" },
{ "Type": "ReverseProxy", "Path": "^/api/v1/registries(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/registries$1", "PreserveAuthHeaders": true },
{ "Type": "Microservice", "Path": "^/api/v1/jobengine/registry/packs(.*)", "IsRegex": true, "TranslatesTo": "http://packsregistry.stella-ops.local/api/v1/jobengine/registry/packs$1" },
{ "Type": "Microservice", "Path": "^/api/v1/jobengine/registry/packs(.*)", "IsRegex": true, "TranslatesTo": "http://packsregistry.stella-ops.local/api/v1/packs$1" },
{ "Type": "Microservice", "Path": "^/api/v1/jobengine/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/v1/jobengine/quotas$1" },
{ "Type": "Microservice", "Path": "^/api/v1/jobengine/deadletter(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/v1/jobengine/deadletter$1" },
{ "Type": "Microservice", "Path": "^/api/v1/jobengine/jobs(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/v1/jobengine/jobs$1" },

View File

@@ -0,0 +1,410 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cryptography;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public class TenantAwareCryptoProviderRegistryTests
{
private const string TenantA = "tenant-a";
private const string TenantB = "tenant-b";
private static readonly ILogger Logger = NullLoggerFactory.Instance.CreateLogger<TenantAwareCryptoProviderRegistry>();
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
private sealed class FakePreferenceProvider : ITenantCryptoPreferenceProvider
{
private readonly Dictionary<string, IReadOnlyList<string>> preferences = new(StringComparer.OrdinalIgnoreCase);
public int CallCount { get; private set; }
public void Set(string tenantId, string scope, IReadOnlyList<string> providers)
{
preferences[$"{tenantId}:{scope}"] = providers;
}
public Task<IReadOnlyList<string>> GetPreferredProvidersAsync(
string tenantId, string algorithmScope = "*", CancellationToken cancellationToken = default)
{
CallCount++;
var key = $"{tenantId}:{algorithmScope}";
return Task.FromResult(
preferences.TryGetValue(key, out var list) ? list : (IReadOnlyList<string>)Array.Empty<string>());
}
}
private sealed class FakeProvider : ICryptoProvider
{
private readonly HashSet<(CryptoCapability, string)> supported = new();
public FakeProvider(string name) => Name = name;
public string Name { get; }
public FakeProvider WithSupport(CryptoCapability capability, string algorithm)
{
supported.Add((capability, algorithm));
return this;
}
public bool Supports(CryptoCapability capability, string algorithmId) => supported.Contains((capability, algorithmId));
public IPasswordHasher GetPasswordHasher(string algorithmId) => throw new NotSupportedException();
public ICryptoHasher GetHasher(string algorithmId) => new StubHasher(algorithmId);
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
=> new StubSigner(Name, keyReference.KeyId, algorithmId);
public void UpsertSigningKey(CryptoSigningKey signingKey) { }
public bool RemoveSigningKey(string keyId) => false;
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys() => Array.Empty<CryptoSigningKey>();
}
private sealed class StubHasher : ICryptoHasher
{
public StubHasher(string algorithmId) => AlgorithmId = algorithmId;
public string AlgorithmId { get; }
public byte[] ComputeHash(ReadOnlySpan<byte> data) => Array.Empty<byte>();
public string ComputeHashHex(ReadOnlySpan<byte> data) => "";
}
private sealed class StubSigner : ICryptoSigner
{
public StubSigner(string provider, string keyId, string algorithmId)
{
Provider = provider;
KeyId = keyId;
AlgorithmId = algorithmId;
}
public string Provider { get; }
public string KeyId { get; }
public string AlgorithmId { get; }
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken ct = default)
=> ValueTask.FromResult(Array.Empty<byte>());
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken ct = default)
=> ValueTask.FromResult(true);
public Microsoft.IdentityModel.Tokens.JsonWebKey ExportPublicJsonWebKey() => new();
}
private static CryptoProviderRegistry BuildInnerRegistry(params FakeProvider[] providers)
{
return new CryptoProviderRegistry(providers);
}
private static TenantAwareCryptoProviderRegistry Build(
ICryptoProviderRegistry inner,
FakePreferenceProvider preferences,
Func<string?> tenantAccessor,
TimeSpan? cacheTtl = null)
{
return new TenantAwareCryptoProviderRegistry(
inner,
preferences,
tenantAccessor,
TimeProvider.System,
Logger,
cacheTtl ?? TimeSpan.FromMinutes(5));
}
// ---------------------------------------------------------------
// ResolveOrThrow
// ---------------------------------------------------------------
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolveOrThrow_NoTenantContext_FallsBackToInnerRegistry()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var inner = BuildInnerRegistry(providerA, providerB);
var prefs = new FakePreferenceProvider();
var registry = Build(inner, prefs, () => null);
var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
// No tenant context => inner registry default ordering => providerA (first registered)
Assert.Same(providerA, resolved);
Assert.Equal(0, prefs.CallCount); // Should not even query preferences
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolveOrThrow_WithTenantPreference_ReturnsPreferredProvider()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var inner = BuildInnerRegistry(providerA, providerB);
var prefs = new FakePreferenceProvider();
prefs.Set(TenantA, "*", new[] { "providerB" });
var registry = Build(inner, prefs, () => TenantA);
var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
Assert.Same(providerB, resolved);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolveOrThrow_TenantPreferenceProviderEmpty_FallsBackToDefault()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var inner = BuildInnerRegistry(providerA, providerB);
var prefs = new FakePreferenceProvider();
// No preferences set for TenantA => empty list
var registry = Build(inner, prefs, () => TenantA);
var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
// Empty preferences => fallback to inner default => providerA
Assert.Same(providerA, resolved);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolveOrThrow_TenantAccessorThrows_FallsBackToDefault()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var inner = BuildInnerRegistry(providerA);
var prefs = new FakePreferenceProvider();
var registry = Build(inner, prefs, () => throw new InvalidOperationException("no tenant context"));
var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
Assert.Same(providerA, resolved);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolveOrThrow_PreferredProviderDoesNotSupportAlgorithm_TriesNextThenFallback()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Rs256);
var providerC = new FakeProvider("providerC").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var inner = BuildInnerRegistry(providerA, providerB, providerC);
var prefs = new FakePreferenceProvider();
// Tenant prefers providerB first, then providerC; providerB doesn't support Es256
prefs.Set(TenantA, "*", new[] { "providerB", "providerC" });
var registry = Build(inner, prefs, () => TenantA);
var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
// providerB doesn't support Es256 => providerC does => resolved to providerC
Assert.Same(providerC, resolved);
}
// ---------------------------------------------------------------
// ResolveSigner
// ---------------------------------------------------------------
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolveSigner_ExplicitPreferredProvider_OverridesTenantPreference()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var inner = BuildInnerRegistry(providerA, providerB);
var prefs = new FakePreferenceProvider();
prefs.Set(TenantA, "*", new[] { "providerB" });
var registry = Build(inner, prefs, () => TenantA);
// Explicit preferredProvider "providerA" should override tenant preference "providerB"
var result = registry.ResolveSigner(
CryptoCapability.Signing,
SignatureAlgorithms.Es256,
new CryptoKeyReference("key-1"),
preferredProvider: "providerA");
Assert.Equal("providerA", result.ProviderName);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolveSigner_NoExplicitPreferred_UsesTenantPreference()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var inner = BuildInnerRegistry(providerA, providerB);
var prefs = new FakePreferenceProvider();
prefs.Set(TenantA, "*", new[] { "providerB" });
var registry = Build(inner, prefs, () => TenantA);
var result = registry.ResolveSigner(
CryptoCapability.Signing,
SignatureAlgorithms.Es256,
new CryptoKeyReference("key-1"));
Assert.Equal("providerB", result.ProviderName);
}
// ---------------------------------------------------------------
// ResolveHasher
// ---------------------------------------------------------------
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolveHasher_NoTenantContext_FallsBackToDefault()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
var inner = BuildInnerRegistry(providerA, providerB);
var prefs = new FakePreferenceProvider();
var registry = Build(inner, prefs, () => null);
var result = registry.ResolveHasher(HashAlgorithms.Sha256);
Assert.Equal("providerA", result.ProviderName);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolveHasher_WithTenantPreference_ReturnsPreferredProvider()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
var inner = BuildInnerRegistry(providerA, providerB);
var prefs = new FakePreferenceProvider();
prefs.Set(TenantA, "*", new[] { "providerB" });
var registry = Build(inner, prefs, () => TenantA);
var result = registry.ResolveHasher(HashAlgorithms.Sha256);
Assert.Equal("providerB", result.ProviderName);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolveHasher_ExplicitPreferred_OverridesTenantPreference()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
var inner = BuildInnerRegistry(providerA, providerB);
var prefs = new FakePreferenceProvider();
prefs.Set(TenantA, "*", new[] { "providerB" });
var registry = Build(inner, prefs, () => TenantA);
var result = registry.ResolveHasher(HashAlgorithms.Sha256, preferredProvider: "providerA");
Assert.Equal("providerA", result.ProviderName);
}
// ---------------------------------------------------------------
// Caching
// ---------------------------------------------------------------
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PreferencesCached_SecondCallDoesNotQueryProvider()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var inner = BuildInnerRegistry(providerA, providerB);
var prefs = new FakePreferenceProvider();
prefs.Set(TenantA, "*", new[] { "providerB" });
var registry = Build(inner, prefs, () => TenantA, cacheTtl: TimeSpan.FromMinutes(5));
// First call: cache miss -> hits provider
registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
Assert.Equal(1, prefs.CallCount);
// Second call: cache hit -> no additional provider query
registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
Assert.Equal(1, prefs.CallCount);
}
// ---------------------------------------------------------------
// Providers property delegates to inner
// ---------------------------------------------------------------
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Providers_DelegatesToInnerRegistry()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var inner = BuildInnerRegistry(providerA);
var prefs = new FakePreferenceProvider();
var registry = Build(inner, prefs, () => null);
Assert.Single(registry.Providers);
Assert.Contains(registry.Providers, p => p.Name == "providerA");
}
// ---------------------------------------------------------------
// TryResolve delegates to inner
// ---------------------------------------------------------------
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolve_DelegatesToInnerRegistry()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var inner = BuildInnerRegistry(providerA);
var prefs = new FakePreferenceProvider();
var registry = Build(inner, prefs, () => TenantA);
Assert.True(registry.TryResolve("providerA", out var resolved));
Assert.Same(providerA, resolved);
Assert.False(registry.TryResolve("nonexistent", out _));
}
// ---------------------------------------------------------------
// Multi-tenant isolation
// ---------------------------------------------------------------
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DifferentTenants_ResolveDifferentProviders()
{
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
var inner = BuildInnerRegistry(providerA, providerB);
var prefs = new FakePreferenceProvider();
prefs.Set(TenantA, "*", new[] { "providerA" });
prefs.Set(TenantB, "*", new[] { "providerB" });
string currentTenant = TenantA;
var registry = Build(inner, prefs, () => currentTenant);
// Tenant A resolves providerA
var resolvedA = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
Assert.Same(providerA, resolvedA);
// Switch to Tenant B: resolves providerB
currentTenant = TenantB;
var resolvedB = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
Assert.Same(providerB, resolvedB);
}
}