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:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using StellaOps.JobEngine.Core.Domain;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Services;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
|
||||
""";
|
||||
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user