From e1f5341c821da261640dfe37c6a8657a79a69a62 Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 8 Apr 2026 18:18:26 +0300 Subject: [PATCH] fix: dead jobengine route path rewriting + legacy endpoint delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../MigrationModulePlugins.cs | 9 + .../StellaOps.Platform.Database.csproj | 1 + .../Contracts/AuditLedgerContracts.cs | 4 +- .../Endpoints/AuditEndpoints.cs | 4 +- .../Endpoints/FirstSignalEndpoints.cs | 4 +- .../Endpoints/JobEngineLegacyEndpoints.cs | 29 +- .../Program.cs | 6 +- .../Services/EndpointHelpers.cs | 1 - ...tellaOps.ReleaseOrchestrator.WebApi.csproj | 3 +- .../Extensions/ServiceCollectionExtensions.cs | 111 +++++ .../Migrations/001_initial.sql | 232 ++++++++++ .../Postgres/PostgresAuditRepository.cs | 24 +- .../PostgresFirstSignalSnapshotRepository.cs | 176 ++++++++ .../IFirstSignalSnapshotRepository.cs | 43 ++ .../Services/FirstSignalService.cs | 238 ++++++++++ .../appsettings.json | 2 +- .../TenantAwareCryptoProviderRegistryTests.cs | 410 ++++++++++++++++++ 17 files changed, 1272 insertions(+), 25 deletions(-) create mode 100644 src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Migrations/001_initial.sql create mode 100644 src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresFirstSignalSnapshotRepository.cs create mode 100644 src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Repositories/IFirstSignalSnapshotRepository.cs create mode 100644 src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Services/FirstSignalService.cs create mode 100644 src/__Libraries/__Tests/StellaOps.Cryptography.Tests/TenantAwareCryptoProviderRegistryTests.cs diff --git a/src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs b/src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs index 3c6f00795..4512ec41b 100644 --- a/src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs +++ b/src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs @@ -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( diff --git a/src/Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.csproj b/src/Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.csproj index aacc9db90..31d627680 100644 --- a/src/Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.csproj +++ b/src/Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.csproj @@ -51,6 +51,7 @@ + diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/AuditLedgerContracts.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/AuditLedgerContracts.cs index 10d057699..d975643c3 100644 --- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/AuditLedgerContracts.cs +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/AuditLedgerContracts.cs @@ -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; diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs index faf7cc826..7f208c42c 100644 --- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs @@ -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; diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/FirstSignalEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/FirstSignalEndpoints.cs index 1cb2dc433..6f0410c19 100644 --- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/FirstSignalEndpoints.cs +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/FirstSignalEndpoints.cs @@ -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 GetFirstSignal( + internal static async Task GetFirstSignal( HttpContext context, [FromRoute] Guid runId, [FromHeader(Name = "If-None-Match")] string? ifNoneMatch, diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/JobEngineLegacyEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/JobEngineLegacyEndpoints.cs index babf6466d..9a73eae43 100644 --- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/JobEngineLegacyEndpoints.cs +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/JobEngineLegacyEndpoints.cs @@ -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", diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs index d971c32b9..bbd182108 100644 --- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs @@ -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((sp, client) => diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/EndpointHelpers.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/EndpointHelpers.cs index 726416bb0..9f2ea00e8 100644 --- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/EndpointHelpers.cs +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/EndpointHelpers.cs @@ -1,4 +1,3 @@ -using StellaOps.JobEngine.Core.Domain; using System.Text; namespace StellaOps.ReleaseOrchestrator.WebApi.Services; diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/StellaOps.ReleaseOrchestrator.WebApi.csproj b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/StellaOps.ReleaseOrchestrator.WebApi.csproj index 91f6ec30e..83383a8a7 100644 --- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/StellaOps.ReleaseOrchestrator.WebApi.csproj +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/StellaOps.ReleaseOrchestrator.WebApi.csproj @@ -17,8 +17,7 @@ - - + diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Extensions/ServiceCollectionExtensions.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..5a4fe4e85 --- /dev/null +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Extensions/ServiceCollectionExtensions.cs @@ -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; + +/// +/// Extension methods for registering ReleaseOrchestrator persistence services. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds ReleaseOrchestrator persistence services (audit + first-signal) to the service collection. + /// Replaces the previous AddJobEngineInfrastructure() call with a lean, schema-isolated alternative. + /// + public static IServiceCollection AddReleaseOrchestratorPersistence( + this IServiceCollection services, + IConfiguration configuration) + { + // Bind PostgresOptions from config, with connection-string fallback chain + services.AddOptions() + .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(); + + // Hashing (for audit hash chain) + services.AddSingleton(); + + // Repositories + services.AddScoped(); + services.AddScoped(); + + // Services + services.AddScoped(); + + 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); + } +} diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Migrations/001_initial.sql b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Migrations/001_initial.sql new file mode 100644 index 000000000..db1e4746e --- /dev/null +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Migrations/001_initial.sql @@ -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'; diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresAuditRepository.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresAuditRepository.cs index 4d7c96441..1afcfc55d 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresAuditRepository.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresAuditRepository.cs @@ -12,8 +12,10 @@ namespace StellaOps.ReleaseOrchestrator.Persistence.Postgres; /// 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 """; diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresFirstSignalSnapshotRepository.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresFirstSignalSnapshotRepository.cs new file mode 100644 index 000000000..0d44eda5b --- /dev/null +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresFirstSignalSnapshotRepository.cs @@ -0,0 +1,176 @@ +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using StellaOps.ReleaseOrchestrator.Persistence.Repositories; + +namespace StellaOps.ReleaseOrchestrator.Persistence.Postgres; + +/// +/// PostgreSQL implementation of first signal snapshot repository. +/// Uses raw SQL (no EF Core) for a lean dependency footprint. +/// +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 _logger; + + public PostgresFirstSignalSnapshotRepository( + ReleaseOrchestratorDataSource dataSource, + ILogger logger) + { + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task 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(3), + UpdatedAt = reader.GetFieldValue(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) + }; +} diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Repositories/IFirstSignalSnapshotRepository.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Repositories/IFirstSignalSnapshotRepository.cs new file mode 100644 index 000000000..136b9c76e --- /dev/null +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Repositories/IFirstSignalSnapshotRepository.cs @@ -0,0 +1,43 @@ +namespace StellaOps.ReleaseOrchestrator.Persistence.Repositories; + +/// +/// Repository for first-signal snapshot persistence. +/// +public interface IFirstSignalSnapshotRepository +{ + Task GetByRunIdAsync( + string tenantId, + Guid runId, + CancellationToken cancellationToken = default); + + Task UpsertAsync( + FirstSignalSnapshot snapshot, + CancellationToken cancellationToken = default); + + Task DeleteByRunIdAsync( + string tenantId, + Guid runId, + CancellationToken cancellationToken = default); +} + +/// +/// First-signal snapshot storage model. +/// +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; } +} diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Services/FirstSignalService.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Services/FirstSignalService.cs new file mode 100644 index 000000000..2b6886a51 --- /dev/null +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Services/FirstSignalService.cs @@ -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; + +/// +/// 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. +/// +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 _logger; + + public FirstSignalService( + IFirstSignalSnapshotRepository snapshotRepository, + TimeProvider timeProvider, + ILogger logger) + { + _snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository)); + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task 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(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)); + } +} diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json index c9af42840..7c25e979d 100644 --- a/src/Router/StellaOps.Gateway.WebService/appsettings.json +++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json @@ -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" }, diff --git a/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/TenantAwareCryptoProviderRegistryTests.cs b/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/TenantAwareCryptoProviderRegistryTests.cs new file mode 100644 index 000000000..e8518424c --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/TenantAwareCryptoProviderRegistryTests.cs @@ -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(); + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private sealed class FakePreferenceProvider : ITenantCryptoPreferenceProvider + { + private readonly Dictionary> preferences = new(StringComparer.OrdinalIgnoreCase); + public int CallCount { get; private set; } + + public void Set(string tenantId, string scope, IReadOnlyList providers) + { + preferences[$"{tenantId}:{scope}"] = providers; + } + + public Task> GetPreferredProvidersAsync( + string tenantId, string algorithmScope = "*", CancellationToken cancellationToken = default) + { + CallCount++; + var key = $"{tenantId}:{algorithmScope}"; + return Task.FromResult( + preferences.TryGetValue(key, out var list) ? list : (IReadOnlyList)Array.Empty()); + } + } + + 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 GetSigningKeys() => Array.Empty(); + } + + private sealed class StubHasher : ICryptoHasher + { + public StubHasher(string algorithmId) => AlgorithmId = algorithmId; + public string AlgorithmId { get; } + public byte[] ComputeHash(ReadOnlySpan data) => Array.Empty(); + public string ComputeHashHex(ReadOnlySpan 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 SignAsync(ReadOnlyMemory data, CancellationToken ct = default) + => ValueTask.FromResult(Array.Empty()); + public ValueTask VerifyAsync(ReadOnlyMemory data, ReadOnlyMemory 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 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); + } +}