From 9d47cabc377b4877946a824e810aeed3a03e5447 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 7 Apr 2026 09:57:42 +0300 Subject: [PATCH] Orchestrator decomposition: replace JobEngine with release-orchestrator + workflow services - Remove jobengine and jobengine-worker containers from docker-compose - Create release-orchestrator service (120 endpoints) with full auth, tenant, and infrastructure DI - Wire workflow engine to PostgreSQL with definition store (wf_definitions table) - Deploy 4 canonical workflow definitions on startup (release-promotion, scan-execution, advisory-refresh, compliance-sweep) - Fix workflow definition JSON to match canonical contract schema (set-state, call-transport, decision) - Add WorkflowClient to release-orchestrator for starting workflow instances on promotion - Add WorkflowTriggerClient + endpoint to scheduler for triggering workflows from system schedules - Update gateway routes from jobengine.stella-ops.local to release-orchestrator.stella-ops.local - Remove Platform.Database dependency on JobEngine.Infrastructure - Fix workflow csproj duplicate Content items (EmbeddedResource + SDK default) - System-managed schedules with source column, SystemScheduleBootstrap, inline edit UI Co-Authored-By: Claude Opus 4.6 (1M context) --- devops/compose/docker-compose.stella-ops.yml | 80 +- devops/compose/router-gateway-local.json | 18 +- devops/docker/services-matrix.env | 9 +- .../ConsoleBrandingEndpointExtensions.cs | 7 +- .../Bootstrap/SystemScheduleBootstrap.cs | 105 ++ .../StellaOps.Scheduler.WebService/Program.cs | 25 + .../Schedules/ScheduleContracts.cs | 3 +- .../Schedules/ScheduleEndpoints.cs | 76 +- .../StellaOps.Scheduler.WebService.csproj | 1 + .../Workflow/WorkflowTriggerClient.cs | 104 ++ .../Workflow/WorkflowTriggerEndpoints.cs | 62 + .../StellaOps.Scheduler.Models/Schedule.cs | 12 +- .../Migrations/004_create_scripts_schema.sql | 124 ++ .../Migrations/005_add_source_column.sql | 6 + .../Migrations/S001_demo_seed.sql | 32 +- .../Repositories/ScheduleRepository.cs | 8 +- .../MigrationModulePlugins.cs | 8 - .../StellaOps.Platform.Database.csproj | 1 - .../Contracts/AuditLedgerContracts.cs | 99 ++ .../Contracts/FirstSignalResponse.cs | 45 + .../Contracts/ReleaseControlContractModels.cs | 41 + .../RollbackIntelligenceController.cs | 1033 ----------------- .../Endpoints/ApprovalEndpoints.cs | 501 ++++++++ .../Endpoints/AuditEndpoints.cs | 262 +++++ .../Endpoints/DeploymentEndpoints.cs | 431 +++++++ .../Endpoints/EvidenceEndpoints.cs | 329 ++++++ .../Endpoints/FirstSignalEndpoints.cs | 120 ++ .../Endpoints/ReleaseControlV2Endpoints.cs | 544 +++++++++ .../Endpoints/ReleaseDashboardEndpoints.cs | 180 +++ .../Endpoints/ReleaseEndpoints.cs | 756 ++++++++++++ .../Program.cs | 76 ++ .../ReleaseOrchestratorPolicies.cs | 67 ++ .../Services/DeploymentCompatibilityModels.cs | 120 ++ .../DeploymentCompatibilityStateFactory.cs | 358 ++++++ .../Services/EndpointHelpers.cs | 59 + .../Services/IDeploymentCompatibilityStore.cs | 48 + .../InMemoryDeploymentCompatibilityStore.cs | 169 +++ .../Services/ReleaseControlSignalCatalog.cs | 121 ++ .../ReleaseDashboardSnapshotBuilder.cs | 250 ++++ .../Services/ReleasePromotionDecisionStore.cs | 214 ++++ .../Services/TenantResolver.cs | 35 + .../Services/WorkflowClient.cs | 70 ++ ...tellaOps.ReleaseOrchestrator.WebApi.csproj | 29 + .../Models/ScriptModels.cs | 30 +- .../Persistence/PostgresScriptStore.cs | 311 +++++ .../Persistence/ScriptsDataSource.cs | 30 + .../Search/InMemorySearchIndexer.cs | 35 + ...ellaOps.ReleaseOrchestrator.Scripts.csproj | 40 + .../jobengine-dashboard.component.ts | 520 ++------- .../jobengine/jobengine-jobs.component.ts | 669 ++++------- .../jobengine/jobengine-quotas.component.ts | 45 +- .../scheduler-schedules-panel.component.ts | 459 ++++++++ .../scheduler-workers-panel.component.ts | 245 ++++ .../platform-jobs-queues-page.component.ts | 4 +- .../schedule-management.component.spec.ts | 275 ----- .../schedule-management.component.ts | 1025 ---------------- .../scheduler-ops/scheduler-ops.models.ts | 1 + .../scheduler-ops/scheduler-ops.routes.ts | 14 +- .../scheduler-ops/scheduler-runs.component.ts | 135 ++- .../worker-fleet.component.spec.ts | 279 ----- .../scheduler-ops/worker-fleet.component.ts | 798 ------------- .../app-sidebar/sidebar-nav-item.component.ts | 7 +- .../Bootstrap/WorkflowDefinitionBootstrap.cs | 122 ++ .../Definitions/advisory-refresh.json | 56 + .../Definitions/compliance-sweep.json | 59 + .../Definitions/release-promotion.json | 167 +++ .../Definitions/scan-execution.json | 71 ++ .../StellaOps.Workflow.WebService/Program.cs | 29 +- .../StellaOps.Workflow.WebService.csproj | 6 + .../appsettings.json | 10 +- .../PostgresWorkflowDataStoreExtensions.cs | 1 + .../PostgresWorkflowDefinitionStore.cs | 180 +++ 72 files changed, 7781 insertions(+), 4480 deletions(-) create mode 100644 src/JobEngine/StellaOps.Scheduler.WebService/Bootstrap/SystemScheduleBootstrap.cs create mode 100644 src/JobEngine/StellaOps.Scheduler.WebService/Workflow/WorkflowTriggerClient.cs create mode 100644 src/JobEngine/StellaOps.Scheduler.WebService/Workflow/WorkflowTriggerEndpoints.cs create mode 100644 src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/004_create_scripts_schema.sql create mode 100644 src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/005_add_source_column.sql create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/AuditLedgerContracts.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/FirstSignalResponse.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/ReleaseControlContractModels.cs delete mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Controllers/RollbackIntelligenceController.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ApprovalEndpoints.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/DeploymentEndpoints.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/EvidenceEndpoints.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/FirstSignalEndpoints.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseControlV2Endpoints.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseDashboardEndpoints.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseEndpoints.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/ReleaseOrchestratorPolicies.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/DeploymentCompatibilityModels.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/DeploymentCompatibilityStateFactory.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/EndpointHelpers.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/IDeploymentCompatibilityStore.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/InMemoryDeploymentCompatibilityStore.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ReleaseControlSignalCatalog.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ReleaseDashboardSnapshotBuilder.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ReleasePromotionDecisionStore.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/TenantResolver.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/WorkflowClient.cs create mode 100644 src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/StellaOps.ReleaseOrchestrator.WebApi.csproj create mode 100644 src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/Persistence/PostgresScriptStore.cs create mode 100644 src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/Persistence/ScriptsDataSource.cs create mode 100644 src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/Search/InMemorySearchIndexer.cs create mode 100644 src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/StellaOps.ReleaseOrchestrator.Scripts.csproj create mode 100644 src/Web/StellaOps.Web/src/app/features/jobengine/scheduler-schedules-panel.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/jobengine/scheduler-workers-panel.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.spec.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/scheduler-ops/worker-fleet.component.spec.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/scheduler-ops/worker-fleet.component.ts create mode 100644 src/Workflow/StellaOps.Workflow.WebService/Bootstrap/WorkflowDefinitionBootstrap.cs create mode 100644 src/Workflow/StellaOps.Workflow.WebService/Definitions/advisory-refresh.json create mode 100644 src/Workflow/StellaOps.Workflow.WebService/Definitions/compliance-sweep.json create mode 100644 src/Workflow/StellaOps.Workflow.WebService/Definitions/release-promotion.json create mode 100644 src/Workflow/StellaOps.Workflow.WebService/Definitions/scan-execution.json create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDefinitionStore.cs diff --git a/devops/compose/docker-compose.stella-ops.yml b/devops/compose/docker-compose.stella-ops.yml index 8036757a5..f49618d26 100644 --- a/devops/compose/docker-compose.stella-ops.yml +++ b/devops/compose/docker-compose.stella-ops.yml @@ -855,8 +855,10 @@ services: CONCELIER_AUTHORITY__RESILIENCE__OFFLINECACHETOLERANCE: "${AUTHORITY_OFFLINE_CACHE_TOLERANCE:-00:30:00}" Router__Enabled: "${CONCELIER_ROUTER_ENABLED:-true}" Router__Messaging__ConsumerGroup: "concelier" + CONCELIER_IMPORT__STAGINGROOT: "/var/lib/concelier/import" volumes: - concelier-jobs:/var/lib/concelier/jobs + - ${STELLAOPS_AIRGAP_IMPORT_DIR:-./airgap-import}:/var/lib/concelier/import:ro - *cert-volume - *ca-bundle ports: @@ -1173,16 +1175,16 @@ services: - riskengine-worker.stella-ops.local labels: *release-labels - # --- Slot 17: Orchestrator ------------------------------------------------- - jobengine: - <<: *resources-heavy - image: stellaops/orchestrator:dev - container_name: stellaops-jobengine + # --- Slot 48: Release Orchestrator ------------------------------------------ + release-orchestrator: + <<: *resources-medium + image: stellaops/release-orchestrator:dev + container_name: stellaops-release-orchestrator restart: unless-stopped depends_on: *depends-infra environment: ASPNETCORE_URLS: "http://+:8080" - <<: [*kestrel-cert, *router-microservice-defaults, *gc-heavy] + <<: [*kestrel-cert, *router-microservice-defaults, *gc-medium] ConnectionStrings__Default: *postgres-connection ConnectionStrings__Redis: "cache.stella-ops.local:6379" Authority__ResourceServer__Authority: "https://authority.stella-ops.local/" @@ -1194,43 +1196,28 @@ services: Authority__ResourceServer__BypassNetworks__2: "::1/128" Authority__ResourceServer__BypassNetworks__3: "0.0.0.0/0" Authority__ResourceServer__BypassNetworks__4: "::/0" - Router__Enabled: "${ORCHESTRATOR_ROUTER_ENABLED:-true}" - Router__Messaging__ConsumerGroup: "jobengine" + Router__Enabled: "${RELEASE_ORCHESTRATOR_ROUTER_ENABLED:-true}" + Router__Messaging__ConsumerGroup: "release-orchestrator" volumes: - *cert-volume - *ca-bundle ports: - - "127.1.0.17:80:80" + - "127.1.0.47:80:8080" networks: stellaops: aliases: - - jobengine.stella-ops.local - - orchestrator.stella-ops.local + - release-orchestrator.stella-ops.local frontdoor: {} healthcheck: - test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/80'"] + test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/8080'"] <<: *healthcheck-tcp labels: *release-labels - jobengine-worker: - <<: *resources-medium - image: stellaops/orchestrator-worker:dev - container_name: stellaops-jobengine-worker - restart: unless-stopped - depends_on: *depends-infra - environment: - <<: [*kestrel-cert, *gc-medium] - ConnectionStrings__Default: *postgres-connection - ConnectionStrings__Redis: "cache.stella-ops.local:6379" - volumes: - - *cert-volume - healthcheck: - <<: *healthcheck-worker - networks: - stellaops: - aliases: - - jobengine-worker.stella-ops.local - labels: *release-labels + # --- Slot 17: Orchestrator (DECOMPOSED) ------------------------------------- + # jobengine and jobengine-worker removed. + # Release endpoints → release-orchestrator service (Slot 47) + # Workflow orchestration → workflow service (Slot 46) + # Scheduler remains in Slot 14 (scheduler-web / scheduler-worker) # --- Slot 18: TaskRunner --------------------------------------------------- taskrunner-web: @@ -2428,6 +2415,37 @@ services: <<: *healthcheck-tcp labels: *release-labels + # --- Workflow Engine -------------------------------------------------------- + workflow: + <<: *resources-medium + image: stellaops/workflow-web:dev + container_name: stellaops-workflow + restart: unless-stopped + depends_on: *depends-infra + environment: + ASPNETCORE_URLS: "http://+:8080" + <<: [*kestrel-cert, *router-microservice-defaults, *gc-medium] + ConnectionStrings__WorkflowPostgres: *postgres-connection + WorkflowBackend__Provider: "Postgres" + WorkflowBackend__Postgres__SchemaName: "workflow" + WorkflowBackend__Postgres__ConnectionStringName: "WorkflowPostgres" + WorkflowSignalDriver__Provider: "Native" + Router__Enabled: "true" + Router__Messaging__ConsumerGroup: "workflow" + volumes: + - *cert-volume + ports: + - "127.1.0.46:80:8080" + networks: + stellaops: + aliases: + - workflow.stella-ops.local + frontdoor: {} + healthcheck: + test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/8080'"] + <<: *healthcheck-tcp + labels: *release-labels + # --- Console (Angular frontend) ------------------------------------------- # web-ui is replaced by router-gateway serving static files from console-dist volume. # The console-builder init container copies Angular dist to the shared volume. diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index 1de8f235f..aa4c9d8e9 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -40,7 +40,8 @@ "attestor", "evidencelocker", "sbomservice", - "jobengine", + "release-orchestrator", + "workflow", "authority", "vexhub", "concelier" @@ -67,8 +68,8 @@ { "Type": "Microservice", "Path": "^/api/v1/evidence(.*)", "IsRegex": true, "TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/evidence$1" }, { "Type": "Microservice", "Path": "^/api/v1/proofs(.*)", "IsRegex": true, "TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/proofs$1" }, { "Type": "Microservice", "Path": "^/api/v1/verdicts(.*)", "IsRegex": true, "TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/verdicts$1" }, - { "Type": "Microservice", "Path": "^/api/v1/release-orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator$1" }, - { "Type": "Microservice", "Path": "^/api/v1/approvals(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/approvals$1" }, + { "Type": "Microservice", "Path": "^/api/v1/release-orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/v1/release-orchestrator$1" }, + { "Type": "Microservice", "Path": "^/api/v1/approvals(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/v1/approvals$1" }, { "Type": "Microservice", "Path": "^/api/v1/attestations(.*)", "IsRegex": true, "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations$1" }, { "Type": "Microservice", "Path": "^/api/v1/sbom(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom$1" }, { "Type": "Microservice", "Path": "^/api/v1/lineage(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage$1" }, @@ -77,7 +78,7 @@ { "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy$1" }, { "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" }, { "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" }, - { "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/workflows$1" }, + { "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/v1/workflows$1" }, { "Type": "Microservice", "Path": "^/api/v1/aoc(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/aoc$1" }, { "Type": "Microservice", "Path": "^/api/v1/administration(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/administration$1" }, { "Type": "Microservice", "Path": "^/api/v1/authority/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas$1" }, @@ -105,13 +106,14 @@ { "Type": "Microservice", "Path": "^/api/v2/topology(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/topology$1" }, { "Type": "Microservice", "Path": "^/api/v2/evidence(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/evidence$1" }, { "Type": "Microservice", "Path": "^/api/v2/integrations(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/integrations$1" }, + { "Type": "Microservice", "Path": "^/api/v2/scripts(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/v2/scripts$1" }, { "Type": "Microservice", "Path": "^/api/v1/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v1/$1$2" }, { "Type": "Microservice", "Path": "^/api/v2/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v2/$1$2" }, { "Type": "Microservice", "Path": "^/api/(cvss|gate|exceptions|policy)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/$1$2" }, { "Type": "Microservice", "Path": "^/api/(risk|risk-budget)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/$1$2" }, - { "Type": "Microservice", "Path": "^/api/(release-orchestrator|releases|approvals)(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/$1$2" }, + { "Type": "Microservice", "Path": "^/api/(release-orchestrator|releases|approvals)(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/$1$2" }, { "Type": "Microservice", "Path": "^/api/(compare|change-traces|sbomservice)(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/$1$2" }, { "Type": "Microservice", "Path": "^/api/fix-verification(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification$1" }, { "Type": "Microservice", "Path": "^/api/verdicts(.*)", "IsRegex": true, "TranslatesTo": "https://evidencelocker.stella-ops.local/api/verdicts$1" }, @@ -122,8 +124,8 @@ { "Type": "Microservice", "Path": "^/api/analytics(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/analytics$1" }, { "Type": "Microservice", "Path": "^/scheduler(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local$1" }, { "Type": "Microservice", "Path": "^/doctor(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local$1" }, - { "Type": "Microservice", "Path": "^/api/orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/orchestrator$1" }, - { "Type": "Microservice", "Path": "^/api/jobengine(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/jobengine$1" }, + { "Type": "Microservice", "Path": "^/api/orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/orchestrator$1" }, + { "Type": "Microservice", "Path": "^/api/jobengine(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/jobengine$1" }, { "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" }, { "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" }, @@ -132,7 +134,7 @@ { "Type": "Microservice", "Path": "^/policy(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" }, { "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" }, - { "Type": "Microservice", "Path": "^/v1/runs(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/v1/runs$1" }, + { "Type": "Microservice", "Path": "^/v1/runs(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/v1/runs$1" }, { "Type": "Microservice", "Path": "^/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" }, { "Type": "Microservice", "Path": "^/v1/audit-bundles(.*)", "IsRegex": true, "TranslatesTo": "https://exportcenter.stella-ops.local/v1/audit-bundles$1" }, diff --git a/devops/docker/services-matrix.env b/devops/docker/services-matrix.env index af8dc07e7..8f8fdcdc8 100644 --- a/devops/docker/services-matrix.env +++ b/devops/docker/services-matrix.env @@ -38,9 +38,8 @@ policy|devops/docker/Dockerfile.hardened.template|src/Policy/StellaOps.Policy.Ga # ── Slot 16: RiskEngine ───────────────────────────────────────────────────────── riskengine-web|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps.RiskEngine.WebService/StellaOps.RiskEngine.WebService.csproj|StellaOps.RiskEngine.WebService|8080 riskengine-worker|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps.RiskEngine.Worker/StellaOps.RiskEngine.Worker.csproj|StellaOps.RiskEngine.Worker|8080 -# ── Slot 17: Orchestrator ─────────────────────────────────────────────────────── -orchestrator|devops/docker/Dockerfile.hardened.template|src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/StellaOps.JobEngine.WebService.csproj|StellaOps.JobEngine.WebService|8080 -orchestrator-worker|devops/docker/Dockerfile.hardened.template|src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.Worker/StellaOps.JobEngine.Worker.csproj|StellaOps.JobEngine.Worker|8080 +# ── Slot 17: Orchestrator (DECOMPOSED — see release-orchestrator + workflow) ── +# orchestrator and orchestrator-worker removed; replaced by release-orchestrator (Slot 47) + workflow (Slot 46) # ── Slot 18: TaskRunner ───────────────────────────────────────────────────────── taskrunner-web|devops/docker/Dockerfile.hardened.template|src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/StellaOps.TaskRunner.WebService.csproj|StellaOps.TaskRunner.WebService|8080 taskrunner-worker|devops/docker/Dockerfile.hardened.template|src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/StellaOps.TaskRunner.Worker.csproj|StellaOps.TaskRunner.Worker|8080 @@ -107,5 +106,9 @@ advisory-ai-web|devops/docker/Dockerfile.hardened.template|src/AdvisoryAI/Stella advisory-ai-worker|devops/docker/Dockerfile.hardened.template|src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/StellaOps.AdvisoryAI.Worker.csproj|StellaOps.AdvisoryAI.Worker|8080 # ── Slot 46: Unknowns ─────────────────────────────────────────────────────────── unknowns-web|devops/docker/Dockerfile.hardened.template|src/Unknowns/StellaOps.Unknowns.WebService/StellaOps.Unknowns.WebService.csproj|StellaOps.Unknowns.WebService|8080 +# ── Slot 47: Workflow ─────────────────────────────────────────────────────────── +workflow-web|devops/docker/Dockerfile.hardened.template|src/Workflow/StellaOps.Workflow.WebService/StellaOps.Workflow.WebService.csproj|StellaOps.Workflow.WebService|8080 +# ── Slot 48: ReleaseOrchestrator ──────────────────────────────────────────────── +release-orchestrator|devops/docker/Dockerfile.hardened.template|src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/StellaOps.ReleaseOrchestrator.WebApi.csproj|StellaOps.ReleaseOrchestrator.WebApi|8080 # ── Console (Angular frontend) ────────────────────────────────────────────────── console|devops/docker/Dockerfile.console|src/Web/StellaOps.Web|StellaOps.Web|8080 diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/Admin/ConsoleBrandingEndpointExtensions.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/Admin/ConsoleBrandingEndpointExtensions.cs index b438d0dbd..719e30db4 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/Admin/ConsoleBrandingEndpointExtensions.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/Admin/ConsoleBrandingEndpointExtensions.cs @@ -336,12 +336,7 @@ internal static class ConsoleBrandingEndpointExtensions "StellaOps", null, // No custom logo null, // No custom favicon - new Dictionary - { - ["--theme-bg-primary"] = "#ffffff", - ["--theme-text-primary"] = "#0f172a", - ["--theme-brand-primary"] = "#4328b7" - } + new Dictionary() ); } diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/Bootstrap/SystemScheduleBootstrap.cs b/src/JobEngine/StellaOps.Scheduler.WebService/Bootstrap/SystemScheduleBootstrap.cs new file mode 100644 index 000000000..89ea2cc82 --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.WebService/Bootstrap/SystemScheduleBootstrap.cs @@ -0,0 +1,105 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using StellaOps.Scheduler.Models; +using StellaOps.Scheduler.Persistence.Postgres.Repositories; +using System.Collections.Immutable; + +namespace StellaOps.Scheduler.WebService.Bootstrap; + +/// +/// Creates system-managed schedules on startup for each tenant. +/// Missing schedules are inserted; existing ones are left untouched. +/// +internal sealed class SystemScheduleBootstrap : BackgroundService +{ + private static readonly (string Slug, string Name, string Cron, ScheduleMode Mode, SelectorScope Scope)[] SystemSchedules = + [ + ("nightly-vuln-scan", "Nightly Vulnerability Scan", "0 2 * * *", ScheduleMode.AnalysisOnly, SelectorScope.AllImages), + ("advisory-refresh", "Continuous Advisory Refresh", "0 */4 * * *", ScheduleMode.ContentRefresh, SelectorScope.AllImages), + ("weekly-compliance-sweep", "Weekly Compliance Sweep", "0 3 * * 0", ScheduleMode.AnalysisOnly, SelectorScope.AllImages), + ("epss-score-update", "EPSS Score Update", "0 6 * * *", ScheduleMode.ContentRefresh, SelectorScope.AllImages), + ("reachability-reeval", "Reachability Re-evaluation", "0 5 * * 1-5", ScheduleMode.AnalysisOnly, SelectorScope.AllImages), + ("registry-sync", "Registry Sync", "0 */2 * * *", ScheduleMode.ContentRefresh, SelectorScope.AllImages), + ]; + + // TODO: Replace with real multi-tenant resolution when available. + private static readonly string[] Tenants = ["demo-prod"]; + + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public SystemScheduleBootstrap( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Allow the rest of the host to start before we hit the database. + await Task.Yield(); + + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + + foreach (var tenantId in Tenants) + { + await EnsureSystemSchedulesAsync(repository, tenantId, stoppingToken).ConfigureAwait(false); + } + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + _logger.LogError(ex, "System schedule bootstrap failed."); + } + } + + private async Task EnsureSystemSchedulesAsync( + IScheduleRepository repository, + string tenantId, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + + foreach (var (slug, name, cron, mode, selectorScope) in SystemSchedules) + { + var scheduleId = $"sys-{tenantId}-{slug}"; + + var existing = await repository.GetAsync(tenantId, scheduleId, cancellationToken).ConfigureAwait(false); + if (existing is not null) + { + _logger.LogDebug("System schedule {ScheduleId} already exists for tenant {TenantId}, skipping.", scheduleId, tenantId); + continue; + } + + var selection = new Selector(selectorScope, tenantId); + + var schedule = new Schedule( + id: scheduleId, + tenantId: tenantId, + name: name, + enabled: true, + cronExpression: cron, + timezone: "UTC", + mode: mode, + selection: selection, + onlyIf: null, + notify: null, + limits: null, + createdAt: now, + createdBy: "system-bootstrap", + updatedAt: now, + updatedBy: "system-bootstrap", + subscribers: null, + schemaVersion: SchedulerSchemaVersions.Schedule, + source: "system"); + + await repository.UpsertAsync(schedule, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Created system schedule {ScheduleId} ({Name}) for tenant {TenantId}.", scheduleId, name, tenantId); + } + } +} diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/Program.cs b/src/JobEngine/StellaOps.Scheduler.WebService/Program.cs index 19fee2014..3c5050ba4 100644 --- a/src/JobEngine/StellaOps.Scheduler.WebService/Program.cs +++ b/src/JobEngine/StellaOps.Scheduler.WebService/Program.cs @@ -21,6 +21,7 @@ using StellaOps.Scheduler.WebService.EventWebhooks; using StellaOps.Scheduler.WebService.FailureSignatures; using StellaOps.Scheduler.WebService.GraphJobs; using StellaOps.Scheduler.WebService.GraphJobs.Events; +using StellaOps.Scheduler.WebService.Bootstrap; using StellaOps.Scheduler.WebService.Hosting; using StellaOps.Scheduler.WebService.Observability; using StellaOps.Scheduler.WebService.Options; @@ -28,8 +29,12 @@ using StellaOps.Scheduler.WebService.PolicyRuns; using StellaOps.Scheduler.WebService.PolicySimulations; using StellaOps.Scheduler.WebService.Runs; using StellaOps.Scheduler.WebService.Schedules; +using StellaOps.Scheduler.WebService.Scripts; using StellaOps.Scheduler.WebService.Exceptions; using StellaOps.Scheduler.WebService.VulnerabilityResolverJobs; +using StellaOps.ReleaseOrchestrator.Scripts; +using StellaOps.ReleaseOrchestrator.Scripts.Persistence; +using StellaOps.ReleaseOrchestrator.Scripts.Search; using StellaOps.Scheduler.Worker.Exceptions; using StellaOps.Scheduler.Worker.Observability; using StellaOps.Scheduler.Worker.Options; @@ -118,6 +123,23 @@ else builder.Services.AddSingleton(); builder.Services.AddSingleton(); } +// Scripts registry (shares the same Postgres options as Scheduler) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Workflow engine HTTP client (starts workflow instances for system schedules) +builder.Services.AddHttpClient((sp, client) => +{ + client.BaseAddress = new Uri( + builder.Configuration["Workflow:BaseAddress"] ?? "http://workflow.stella-ops.local"); +}); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); if (cartographerOptions.Webhook.Enabled) @@ -147,6 +169,7 @@ builder.Services.AddSingleton(NullExpiringDigestService. builder.Services.AddSingleton(NullExpiringAlertService.Instance); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); var schedulerOptions = builder.Configuration.GetSection("Scheduler").Get() ?? new SchedulerOptions(); schedulerOptions.Validate(); @@ -290,11 +313,13 @@ app.MapGet("/readyz", () => Results.Json(new { status = "ready" })) app.MapGraphJobEndpoints(); ResolverJobEndpointExtensions.MapResolverJobEndpoints(app); app.MapScheduleEndpoints(); +StellaOps.Scheduler.WebService.Workflow.WorkflowTriggerEndpoints.MapWorkflowTriggerEndpoints(app); app.MapRunEndpoints(); app.MapFailureSignatureEndpoints(); app.MapPolicyRunEndpoints(); app.MapPolicySimulationEndpoints(); app.MapSchedulerEventWebhookEndpoints(); +app.MapScriptsEndpoints(); // Refresh Router endpoint cache app.TryRefreshStellaRouterEndpoints(routerEnabled); diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/ScheduleContracts.cs b/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/ScheduleContracts.cs index a8ef57692..fc89d043f 100644 --- a/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/ScheduleContracts.cs +++ b/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/ScheduleContracts.cs @@ -17,7 +17,8 @@ internal sealed record ScheduleCreateRequest( [property: JsonPropertyName("notify")] ScheduleNotify? Notify = null, [property: JsonPropertyName("limits")] ScheduleLimits? Limits = null, [property: JsonPropertyName("subscribers")] ImmutableArray? Subscribers = null, - [property: JsonPropertyName("enabled")] bool Enabled = true); + [property: JsonPropertyName("enabled")] bool Enabled = true, + [property: JsonPropertyName("source")] string? Source = null); internal sealed record ScheduleUpdateRequest( [property: JsonPropertyName("name")] string? Name, diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/ScheduleEndpoints.cs b/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/ScheduleEndpoints.cs index 65fdbf60c..7515ef01c 100644 --- a/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/ScheduleEndpoints.cs +++ b/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/ScheduleEndpoints.cs @@ -41,6 +41,10 @@ internal static class ScheduleEndpoints .WithName("UpdateSchedule") .WithDescription(_t("scheduler.schedule.update_description")) .RequireAuthorization(SchedulerPolicies.Operate); + group.MapDelete("/{scheduleId}", DeleteScheduleAsync) + .WithName("DeleteSchedule") + .WithDescription("Soft-deletes a schedule. System-managed schedules cannot be deleted.") + .RequireAuthorization(SchedulerPolicies.Operate); group.MapPost("/{scheduleId}/pause", PauseScheduleAsync) .WithName("PauseSchedule") .WithDescription(_t("scheduler.schedule.pause_description")) @@ -265,6 +269,69 @@ internal static class ScheduleEndpoints } } + private static async Task DeleteScheduleAsync( + HttpContext httpContext, + string scheduleId, + [FromServices] ITenantContextAccessor tenantAccessor, + [FromServices] IScopeAuthorizer scopeAuthorizer, + [FromServices] IScheduleRepository repository, + [FromServices] ISchedulerAuditService auditService, + [FromServices] TimeProvider timeProvider, + CancellationToken cancellationToken) + { + try + { + scopeAuthorizer.EnsureScope(httpContext, WriteScope); + var tenant = tenantAccessor.GetTenant(httpContext); + + var existing = await repository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false); + if (existing is null) + { + return Results.NotFound(); + } + + if (string.Equals(existing.Source, "system", StringComparison.OrdinalIgnoreCase)) + { + return Results.Conflict(new { error = "System-managed schedules cannot be deleted." }); + } + + var now = timeProvider.GetUtcNow(); + var actor = SchedulerEndpointHelpers.ResolveActorId(httpContext); + var deleted = await repository.SoftDeleteAsync(tenant.TenantId, scheduleId, actor, now, cancellationToken).ConfigureAwait(false); + if (!deleted) + { + return Results.NotFound(); + } + + await auditService.WriteAsync( + new SchedulerAuditEvent( + tenant.TenantId, + "scheduler", + "delete", + SchedulerEndpointHelpers.ResolveAuditActor(httpContext), + ScheduleId: scheduleId, + Metadata: new Dictionary + { + ["deletedAt"] = now.ToString("O", CultureInfo.InvariantCulture) + }), + cancellationToken).ConfigureAwait(false); + + return Results.NoContent(); + } + catch (UnauthorizedAccessException ex) + { + return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized); + } + catch (InvalidOperationException ex) + { + return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden); + } + catch (Exception ex) when (ex is ArgumentException or ValidationException) + { + return Results.BadRequest(new { error = ex.Message }); + } + } + private static async Task PauseScheduleAsync( HttpContext httpContext, string scheduleId, @@ -309,7 +376,8 @@ internal static class ScheduleEndpoints existing.CreatedBy, now, SchedulerEndpointHelpers.ResolveActorId(httpContext), - existing.SchemaVersion); + existing.SchemaVersion, + existing.Source); await repository.UpsertAsync(updated, cancellationToken: cancellationToken).ConfigureAwait(false); await auditService.WriteAsync( @@ -385,7 +453,8 @@ internal static class ScheduleEndpoints existing.CreatedBy, now, SchedulerEndpointHelpers.ResolveActorId(httpContext), - existing.SchemaVersion); + existing.SchemaVersion, + existing.Source); await repository.UpsertAsync(updated, cancellationToken: cancellationToken).ConfigureAwait(false); await auditService.WriteAsync( @@ -461,7 +530,8 @@ internal static class ScheduleEndpoints existing.CreatedBy, updatedAt, actor, - existing.SchemaVersion); + existing.SchemaVersion, + existing.Source); } } diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.csproj b/src/JobEngine/StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.csproj index 26d7982e6..d79a4fa41 100644 --- a/src/JobEngine/StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.csproj +++ b/src/JobEngine/StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.csproj @@ -23,6 +23,7 @@ + diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/Workflow/WorkflowTriggerClient.cs b/src/JobEngine/StellaOps.Scheduler.WebService/Workflow/WorkflowTriggerClient.cs new file mode 100644 index 000000000..5c4d01f2f --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.WebService/Workflow/WorkflowTriggerClient.cs @@ -0,0 +1,104 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StellaOps.Scheduler.WebService.Workflow; + +/// +/// HTTP client for triggering workflow instances from the scheduler. +/// Maps system schedule names to workflow definitions. +/// +public sealed class WorkflowTriggerClient( + HttpClient httpClient, + ILogger logger) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// + /// Maps system schedule names to workflow definition names. + /// + private static readonly Dictionary ScheduleToWorkflow = new(StringComparer.OrdinalIgnoreCase) + { + ["Nightly Vulnerability Scan"] = "scan-execution", + ["Continuous Advisory Refresh"] = "advisory-refresh", + ["Weekly Compliance Sweep"] = "compliance-sweep", + ["EPSS Score Update"] = "scan-execution", + ["Reachability Re-evaluation"] = "scan-execution", + ["Registry Sync"] = "scan-execution", + }; + + /// + /// Tries to resolve a workflow name for the given schedule name. + /// + public static string? ResolveWorkflowName(string scheduleName) + { + return ScheduleToWorkflow.TryGetValue(scheduleName, out var wf) ? wf : null; + } + + /// + /// Starts a workflow instance for the given schedule. + /// + public async Task TriggerAsync( + string scheduleName, + string tenantId, + CancellationToken cancellationToken = default) + { + var workflowName = ResolveWorkflowName(scheduleName); + if (workflowName is null) + { + logger.LogDebug("No workflow mapping for schedule {ScheduleName}", scheduleName); + return null; + } + + var request = new + { + workflowName, + payload = new Dictionary + { + ["triggeredBy"] = "scheduler", + ["scheduleName"] = scheduleName, + ["tenantId"] = tenantId, + ["triggeredAt"] = DateTime.UtcNow.ToString("O"), + }, + }; + + try + { + var response = await httpClient.PostAsJsonAsync( + "/api/workflow/start", request, JsonOptions, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync( + JsonOptions, cancellationToken); + + logger.LogInformation( + "Triggered workflow {WorkflowName} for schedule {ScheduleName} → instance {InstanceId}", + workflowName, scheduleName, result?.WorkflowInstanceId); + + return result; + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogWarning( + "Workflow trigger failed for {ScheduleName} → {WorkflowName}: {StatusCode} {Body}", + scheduleName, workflowName, response.StatusCode, body); + return null; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to trigger workflow for schedule {ScheduleName}", scheduleName); + return null; + } + } +} + +public sealed record WorkflowStartResult +{ + public string? WorkflowInstanceId { get; init; } + public string? WorkflowName { get; init; } + public string? WorkflowVersion { get; init; } +} diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/Workflow/WorkflowTriggerEndpoints.cs b/src/JobEngine/StellaOps.Scheduler.WebService/Workflow/WorkflowTriggerEndpoints.cs new file mode 100644 index 000000000..f3f34c3d4 --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.WebService/Workflow/WorkflowTriggerEndpoints.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Mvc; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.Scheduler.Persistence.Postgres.Repositories; +using StellaOps.Scheduler.WebService.Security; + +namespace StellaOps.Scheduler.WebService.Workflow; + +/// +/// Endpoints for triggering workflow instances from system schedules. +/// +internal static class WorkflowTriggerEndpoints +{ + public static IEndpointRouteBuilder MapWorkflowTriggerEndpoints(this IEndpointRouteBuilder routes) + { + routes.MapPost("/api/v1/scheduler/schedules/{scheduleId}/trigger-workflow", TriggerWorkflowAsync) + .WithName("TriggerScheduleWorkflow") + .WithDescription("Trigger a workflow instance for a system-managed schedule") + .WithTags("Schedules") + .RequireAuthorization(SchedulerPolicies.Operate) + .RequireTenant(); + + return routes; + } + + private static async Task TriggerWorkflowAsync( + string scheduleId, + [FromServices] IStellaOpsTenantAccessor tenant, + [FromServices] ScheduleRepository scheduleRepo, + [FromServices] WorkflowTriggerClient workflowClient, + CancellationToken cancellationToken) + { + var schedule = await scheduleRepo.GetAsync(tenant.TenantId!, scheduleId, cancellationToken); + if (schedule is null) + { + return Results.NotFound(new { error = "Schedule not found" }); + } + + var workflowName = WorkflowTriggerClient.ResolveWorkflowName(schedule.Name); + if (workflowName is null) + { + return Results.BadRequest(new + { + error = "no_workflow_mapping", + message = $"Schedule '{schedule.Name}' does not have a workflow mapping", + }); + } + + var result = await workflowClient.TriggerAsync(schedule.Name, tenant.TenantId!, cancellationToken); + if (result is null) + { + return Results.StatusCode(502); + } + + return Results.Ok(new + { + scheduleId = schedule.Id, + scheduleName = schedule.Name, + workflowName, + workflowInstanceId = result.WorkflowInstanceId, + }); + } +} diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Models/Schedule.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Models/Schedule.cs index 797f2d253..f851dd83d 100644 --- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Models/Schedule.cs +++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Models/Schedule.cs @@ -25,7 +25,8 @@ public sealed record Schedule DateTimeOffset updatedAt, string updatedBy, ImmutableArray? subscribers = null, - string? schemaVersion = null) + string? schemaVersion = null, + string source = "user") : this( id, tenantId, @@ -43,7 +44,8 @@ public sealed record Schedule createdBy, updatedAt, updatedBy, - schemaVersion) + schemaVersion, + source) { } @@ -65,7 +67,8 @@ public sealed record Schedule string createdBy, DateTimeOffset updatedAt, string updatedBy, - string? schemaVersion = null) + string? schemaVersion = null, + string source = "user") { Id = Validation.EnsureId(id, nameof(id)); TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId)); @@ -88,6 +91,7 @@ public sealed record Schedule UpdatedAt = Validation.NormalizeTimestamp(updatedAt); UpdatedBy = Validation.EnsureSimpleIdentifier(updatedBy, nameof(updatedBy)); SchemaVersion = SchedulerSchemaVersions.EnsureSchedule(schemaVersion); + Source = string.IsNullOrWhiteSpace(source) ? "user" : source.Trim(); if (Selection.TenantId is not null && !string.Equals(Selection.TenantId, TenantId, StringComparison.Ordinal)) { @@ -129,6 +133,8 @@ public sealed record Schedule public DateTimeOffset UpdatedAt { get; } public string UpdatedBy { get; } + + public string Source { get; } = "user"; } /// diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/004_create_scripts_schema.sql b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/004_create_scripts_schema.sql new file mode 100644 index 000000000..0b006a225 --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/004_create_scripts_schema.sql @@ -0,0 +1,124 @@ +-- 004_create_scripts_schema.sql +-- Creates the scripts schema for the multi-language script registry. + +CREATE SCHEMA IF NOT EXISTS scripts; + +CREATE TABLE IF NOT EXISTS scripts.scripts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + language TEXT NOT NULL, + content TEXT NOT NULL, + entry_point TEXT, + version INT NOT NULL DEFAULT 1, + dependencies JSONB NOT NULL DEFAULT '[]', + tags TEXT[] NOT NULL DEFAULT '{}', + variables JSONB NOT NULL DEFAULT '[]', + visibility TEXT NOT NULL DEFAULT 'private', + owner_id TEXT NOT NULL, + team_id TEXT, + content_hash TEXT NOT NULL, + is_sample BOOLEAN NOT NULL DEFAULT FALSE, + sample_category TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_scripts_owner ON scripts.scripts (owner_id); +CREATE INDEX IF NOT EXISTS idx_scripts_lang ON scripts.scripts (language); +CREATE INDEX IF NOT EXISTS idx_scripts_vis ON scripts.scripts (visibility); +CREATE INDEX IF NOT EXISTS idx_scripts_sample ON scripts.scripts (is_sample) WHERE is_sample = TRUE; +CREATE INDEX IF NOT EXISTS idx_scripts_updated ON scripts.scripts (updated_at DESC NULLS LAST, created_at DESC); + +CREATE TABLE IF NOT EXISTS scripts.script_versions ( + script_id TEXT NOT NULL REFERENCES scripts.scripts(id) ON DELETE CASCADE, + version INT NOT NULL, + content TEXT NOT NULL, + content_hash TEXT NOT NULL, + dependencies JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by TEXT NOT NULL, + change_note TEXT, + PRIMARY KEY (script_id, version) +); + +-- Seed sample scripts (matching frontend expectations) + +INSERT INTO scripts.scripts (id, name, description, language, content, version, tags, variables, visibility, owner_id, content_hash, is_sample, sample_category, created_at, updated_at) +VALUES +( + 'scr-001', + 'Pre-deploy Health Check', + 'Validates service health endpoints before deployment proceeds. Checks HTTP status, response time, and dependency connectivity.', + 'bash', + E'#!/bin/bash\n# Pre-deploy health check script\nset -euo pipefail\n\nSERVICE_URL=\"${SERVICE_URL:-http://localhost:8080}\"\nTIMEOUT=${TIMEOUT:-10}\n\necho \"Checking health at $SERVICE_URL/health...\"\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time \"$TIMEOUT\" \"$SERVICE_URL/health\")\n\nif [ \"$HTTP_CODE\" -eq 200 ]; then\n echo \"Health check passed (HTTP $HTTP_CODE)\"\n exit 0\nelse\n echo \"Health check failed (HTTP $HTTP_CODE)\"\n exit 1\nfi', + 3, + ARRAY['health-check', 'pre-deploy', 'infrastructure'], + '[{"name":"SERVICE_URL","description":"Target service URL for health check","isRequired":true,"defaultValue":"http://localhost:8080","isSecret":false},{"name":"TIMEOUT","description":"Request timeout in seconds","isRequired":false,"defaultValue":"10","isSecret":false}]'::jsonb, + 'organization', + 'admin', + 'sha256:a1b2c3d4e5f6', + TRUE, + 'deployment', + '2026-01-10T08:00:00Z', + '2026-03-15T14:30:00Z' +), +( + 'scr-002', + 'Database Migration Validator', + 'Validates pending database migrations against schema constraints and checks for backward compatibility.', + 'python', + E'\"\"\"Database migration validator.\"\"\"\nimport sys\nimport hashlib\n\ndef validate_migration(migration_path: str) -> bool:\n \"\"\"Validate a single migration file.\"\"\"\n with open(migration_path, ''r'') as f:\n content = f.read()\n\n destructive_ops = [''DROP TABLE'', ''DROP COLUMN'', ''TRUNCATE'']\n for op in destructive_ops:\n if op in content.upper():\n print(f\"WARNING: Destructive operation found: {op}\")\n return False\n\n checksum = hashlib.sha256(content.encode()).hexdigest()\n print(f\"Migration checksum: {checksum[:16]}\")\n return True\n\nif __name__ == ''__main__'':\n path = sys.argv[1] if len(sys.argv) > 1 else ''migrations/''\n result = validate_migration(path)\n sys.exit(0 if result else 1)', + 2, + ARRAY['database', 'migration', 'validation'], + '[{"name":"DB_CONNECTION","description":"Database connection string","isRequired":true,"isSecret":true},{"name":"MIGRATION_DIR","description":"Path to migrations directory","isRequired":false,"defaultValue":"migrations/","isSecret":false}]'::jsonb, + 'team', + 'admin', + 'sha256:b2c3d4e5f6a7', + TRUE, + 'database', + '2026-02-01T10:00:00Z', + '2026-03-10T09:15:00Z' +), +( + 'scr-003', + 'Release Notes Generator', + 'Generates release notes from git commit history between two tags, grouped by conventional commit type.', + 'typescript', + E'/**\n * Release notes generator.\n * Parses conventional commits and groups them by type.\n */\ninterface CommitEntry {\n hash: string;\n type: string;\n scope?: string;\n message: string;\n}\n\nfunction parseConventionalCommit(line: string): CommitEntry | null {\n const match = line.match(/^(\\w+)(\\((\\w+)\\))?:\\s+(.+)$/);\n if (!match) return null;\n return { hash: '''', type: match[1], scope: match[3], message: match[4] };\n}\n\nconsole.log(''Release notes generator ready.'');', + 1, + ARRAY['release-notes', 'git', 'automation'], + '[]'::jsonb, + 'public', + 'admin', + 'sha256:c3d4e5f6a7b8', + TRUE, + 'release', + '2026-03-01T12:00:00Z', + '2026-03-01T12:00:00Z' +), +( + 'scr-004', + 'Container Image Scan Wrapper', + 'Wraps Trivy container scanning with custom policy checks and outputs results in SARIF format.', + 'csharp', + E'// Container image scan wrapper\nusing System;\nusing System.Diagnostics;\nusing System.Text.Json;\n\nvar imageRef = Environment.GetEnvironmentVariable(\"IMAGE_REF\")\n ?? throw new InvalidOperationException(\"IMAGE_REF not set\");\n\nvar severityThreshold = Environment.GetEnvironmentVariable(\"SEVERITY_THRESHOLD\") ?? \"HIGH\";\n\nConsole.WriteLine($\"Scanning {imageRef} with threshold {severityThreshold}...\");', + 5, + ARRAY['security', 'scanning', 'trivy', 'container'], + '[{"name":"IMAGE_REF","description":"Container image reference to scan","isRequired":true,"isSecret":false},{"name":"SEVERITY_THRESHOLD","description":"Minimum severity to report","isRequired":false,"defaultValue":"HIGH","isSecret":false}]'::jsonb, + 'organization', + 'admin', + 'sha256:d4e5f6a7b8c9', + FALSE, + NULL, + '2026-01-20T16:00:00Z', + '2026-03-20T11:45:00Z' +) +ON CONFLICT (id) DO NOTHING; + +-- Seed version history for each script +INSERT INTO scripts.script_versions (script_id, version, content, content_hash, dependencies, created_at, created_by, change_note) +SELECT id, version, content, content_hash, dependencies, created_at, owner_id, 'Current version' +FROM scripts.scripts +WHERE id IN ('scr-001','scr-002','scr-003','scr-004') +ON CONFLICT (script_id, version) DO NOTHING; diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/005_add_source_column.sql b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/005_add_source_column.sql new file mode 100644 index 000000000..4ebad1234 --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/005_add_source_column.sql @@ -0,0 +1,6 @@ +-- Migration: 005_add_source_column +-- Adds source tracking for system-managed vs user-created schedules. + +ALTER TABLE scheduler.schedules ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'user'; + +COMMENT ON COLUMN scheduler.schedules.source IS 'Origin: system (auto-managed), user (manual), integration (plugin-created)'; diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/S001_demo_seed.sql b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/S001_demo_seed.sql index ae563cd68..35f9df73e 100644 --- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/S001_demo_seed.sql +++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/S001_demo_seed.sql @@ -22,15 +22,23 @@ VALUES ON CONFLICT (tenant_id, name) DO NOTHING; -- ============================================================================ --- Schedules +-- Schedules (system-managed) -- ============================================================================ -INSERT INTO scheduler.schedules (id, tenant_id, name, description, enabled, cron_expression, mode, selection, created_by, updated_by) +INSERT INTO scheduler.schedules (id, tenant_id, name, description, enabled, cron_expression, timezone, mode, selection, created_by, updated_by, source) VALUES - ('demo-sched-001', 'demo-prod', 'production-scan', 'Production artifact scanning schedule', true, - '0 2 * * *', 'analysisonly', '{"tags": ["production"], "registries": ["ghcr.io"]}'::jsonb, 'admin', 'admin'), - ('demo-sched-002', 'demo-prod', 'staging-scan', 'Staging artifact scanning schedule', true, - '0 3 * * *', 'contentrefresh', '{"tags": ["staging"], "registries": ["ghcr.io"]}'::jsonb, 'admin', 'admin') + ('sys-demo-prod-nightly-vuln-scan', 'demo-prod', 'Nightly Vulnerability Scan', 'System-managed nightly vulnerability scan of all images', true, + '0 2 * * *', 'UTC', 'analysisonly', '{"scope": "all-images"}'::jsonb, 'system-bootstrap', 'system-bootstrap', 'system'), + ('sys-demo-prod-advisory-refresh', 'demo-prod', 'Continuous Advisory Refresh', 'System-managed advisory feed refresh every 4 hours', true, + '0 */4 * * *', 'UTC', 'contentrefresh', '{"scope": "all-images"}'::jsonb, 'system-bootstrap', 'system-bootstrap', 'system'), + ('sys-demo-prod-weekly-compliance-sweep', 'demo-prod', 'Weekly Compliance Sweep', 'System-managed weekly compliance sweep on Sundays', true, + '0 3 * * 0', 'UTC', 'analysisonly', '{"scope": "all-images"}'::jsonb, 'system-bootstrap', 'system-bootstrap', 'system'), + ('sys-demo-prod-epss-score-update', 'demo-prod', 'EPSS Score Update', 'System-managed daily EPSS score refresh', true, + '0 6 * * *', 'UTC', 'contentrefresh', '{"scope": "all-images"}'::jsonb, 'system-bootstrap', 'system-bootstrap', 'system'), + ('sys-demo-prod-reachability-reeval', 'demo-prod', 'Reachability Re-evaluation', 'System-managed weekday reachability analysis', true, + '0 5 * * 1-5', 'UTC', 'analysisonly', '{"scope": "all-images"}'::jsonb, 'system-bootstrap', 'system-bootstrap', 'system'), + ('sys-demo-prod-registry-sync', 'demo-prod', 'Registry Sync', 'System-managed registry sync every 2 hours', true, + '0 */2 * * *', 'UTC', 'contentrefresh', '{"scope": "all-images"}'::jsonb, 'system-bootstrap', 'system-bootstrap', 'system') ON CONFLICT DO NOTHING; -- ============================================================================ @@ -77,22 +85,22 @@ ON CONFLICT (tenant_id, idempotency_key) DO NOTHING; INSERT INTO scheduler.runs (id, tenant_id, schedule_id, trigger, state, stats, reason, created_at, started_at, finished_at, deltas) VALUES - ('demo-run-001', 'demo-prod', 'demo-sched-001', - '{"type": "scheduled", "triggerId": "daily-vulnerability-scan"}'::jsonb, + ('demo-run-001', 'demo-prod', 'sys-demo-prod-nightly-vuln-scan', + '{"type": "scheduled", "triggerId": "nightly-vuln-scan"}'::jsonb, 'completed', '{"findingCount": 127, "criticalCount": 3, "highCount": 12, "newFindingCount": 5, "componentCount": 842}'::jsonb, '{"code": "completed", "message": "Scan completed successfully"}'::jsonb, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour 45 minutes', '{"added": 5, "removed": 2, "unchanged": 120}'::jsonb), - ('demo-run-002', 'demo-prod', 'demo-sched-001', - '{"type": "scheduled", "triggerId": "daily-vulnerability-scan"}'::jsonb, + ('demo-run-002', 'demo-prod', 'sys-demo-prod-nightly-vuln-scan', + '{"type": "scheduled", "triggerId": "nightly-vuln-scan"}'::jsonb, 'completed', '{"findingCount": 122, "criticalCount": 2, "highCount": 11, "newFindingCount": 0, "componentCount": 840}'::jsonb, '{"code": "completed", "message": "Scan completed successfully"}'::jsonb, NOW() - INTERVAL '26 hours', NOW() - INTERVAL '26 hours', NOW() - INTERVAL '25 hours 50 minutes', '{"added": 0, "removed": 3, "unchanged": 122}'::jsonb), - ('demo-run-003', 'demo-prod', 'demo-sched-002', - '{"type": "scheduled", "triggerId": "staging-scan"}'::jsonb, + ('demo-run-003', 'demo-prod', 'sys-demo-prod-registry-sync', + '{"type": "scheduled", "triggerId": "registry-sync"}'::jsonb, 'error', '{"findingCount": 0, "criticalCount": 0, "highCount": 0, "newFindingCount": 0, "componentCount": 0}'::jsonb, '{"code": "timeout", "message": "Registry connection timed out after 300s"}'::jsonb, diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Postgres/Repositories/ScheduleRepository.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Postgres/Repositories/ScheduleRepository.cs index 92208b854..2aea3abad 100644 --- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Postgres/Repositories/ScheduleRepository.cs +++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Postgres/Repositories/ScheduleRepository.cs @@ -30,11 +30,11 @@ public sealed class ScheduleRepository : RepositoryBase, IS INSERT INTO scheduler.schedules ( id, tenant_id, name, description, enabled, cron_expression, timezone, mode, selection, only_if, notify, limits, subscribers, created_at, created_by, - updated_at, updated_by, deleted_at, deleted_by, schema_version) + updated_at, updated_by, deleted_at, deleted_by, schema_version, source) VALUES ( @id, @tenant_id, @name, @description, @enabled, @cron_expression, @timezone, @mode, @selection, @only_if, @notify, @limits, @subscribers, @created_at, @created_by, - @updated_at, @updated_by, NULL, NULL, @schema_version) + @updated_at, @updated_by, NULL, NULL, @schema_version, @source) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, @@ -74,6 +74,7 @@ public sealed class ScheduleRepository : RepositoryBase, IS AddParameter(command, "updated_at", schedule.UpdatedAt); AddParameter(command, "updated_by", schedule.UpdatedBy); AddParameter(command, "schema_version", schedule.SchemaVersion ?? (object)DBNull.Value); + AddParameter(command, "source", schedule.Source); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } @@ -178,6 +179,7 @@ public sealed class ScheduleRepository : RepositoryBase, IS reader.GetString(reader.GetOrdinal("created_by")), DateTime.SpecifyKind(reader.GetDateTime(reader.GetOrdinal("updated_at")), DateTimeKind.Utc), reader.GetString(reader.GetOrdinal("updated_by")), - GetNullableString(reader, reader.GetOrdinal("schema_version"))); + GetNullableString(reader, reader.GetOrdinal("schema_version")), + source: GetNullableString(reader, reader.GetOrdinal("source")) ?? "user"); } } diff --git a/src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs b/src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs index d68d65f8a..91d269e89 100644 --- a/src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs +++ b/src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs @@ -28,7 +28,6 @@ using StellaOps.Unknowns.Persistence.Postgres; using StellaOps.VexHub.Persistence.Postgres; using StellaOps.VexLens.Persistence.Postgres; using StellaOps.Findings.Ledger.Infrastructure.Postgres; -using StellaOps.JobEngine.Infrastructure.Postgres; namespace StellaOps.Platform.Database; @@ -278,13 +277,6 @@ public sealed class VerdictMigrationModulePlugin : IMigrationModulePlugin resourcePrefix: "StellaOps.Verdict.Persistence.Migrations"); } -public sealed class OrchestratorMigrationModulePlugin : IMigrationModulePlugin -{ - public MigrationModuleInfo Module { get; } = new( - name: "Orchestrator", - schemaName: "orchestrator", - migrationsAssembly: typeof(JobEngineDataSource).Assembly); -} public sealed class FindingsLedgerMigrationModulePlugin : IMigrationModulePlugin { 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 a28e28665..be0c42c66 100644 --- a/src/Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.csproj +++ b/src/Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.csproj @@ -42,7 +42,6 @@ - diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/AuditLedgerContracts.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/AuditLedgerContracts.cs new file mode 100644 index 000000000..10d057699 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/AuditLedgerContracts.cs @@ -0,0 +1,99 @@ +using StellaOps.JobEngine.Core.Domain; +using StellaOps.JobEngine.Infrastructure.Repositories; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Contracts; + +// ===== Audit Contracts ===== + +/// +/// Response for an audit entry. +/// +public sealed record AuditEntryResponse( + Guid EntryId, + string TenantId, + string EventType, + string ResourceType, + Guid ResourceId, + string ActorId, + string ActorType, + string? ActorIp, + string? UserAgent, + string? HttpMethod, + string? RequestPath, + string? OldState, + string? NewState, + string Description, + string? CorrelationId, + string? PreviousEntryHash, + string ContentHash, + long SequenceNumber, + DateTimeOffset OccurredAt, + string? Metadata) +{ + public static AuditEntryResponse FromDomain(AuditEntry entry) => new( + EntryId: entry.EntryId, + TenantId: entry.TenantId, + EventType: entry.EventType.ToString(), + ResourceType: entry.ResourceType, + ResourceId: entry.ResourceId, + ActorId: entry.ActorId, + ActorType: entry.ActorType.ToString(), + ActorIp: entry.ActorIp, + UserAgent: entry.UserAgent, + HttpMethod: entry.HttpMethod, + RequestPath: entry.RequestPath, + OldState: entry.OldState, + NewState: entry.NewState, + Description: entry.Description, + CorrelationId: entry.CorrelationId, + PreviousEntryHash: entry.PreviousEntryHash, + ContentHash: entry.ContentHash, + SequenceNumber: entry.SequenceNumber, + OccurredAt: entry.OccurredAt, + Metadata: entry.Metadata); +} + +/// +/// List response for audit entries. +/// +public sealed record AuditEntryListResponse( + IReadOnlyList Entries, + string? NextCursor); + +/// +/// Response for audit summary. +/// +public sealed record AuditSummaryResponse( + long TotalEntries, + long EntriesSince, + long EventTypes, + long UniqueActors, + long UniqueResources, + DateTimeOffset? EarliestEntry, + DateTimeOffset? LatestEntry) +{ + public static AuditSummaryResponse FromDomain(AuditSummary summary) => new( + TotalEntries: summary.TotalEntries, + EntriesSince: summary.EntriesSince, + EventTypes: summary.EventTypes, + UniqueActors: summary.UniqueActors, + UniqueResources: summary.UniqueResources, + EarliestEntry: summary.EarliestEntry, + LatestEntry: summary.LatestEntry); +} + +/// +/// Response for chain verification. +/// +public sealed record ChainVerificationResponse( + bool IsValid, + Guid? InvalidEntryId, + long? InvalidSequence, + string? ErrorMessage) +{ + public static ChainVerificationResponse FromDomain(ChainVerificationResult result) => new( + IsValid: result.IsValid, + InvalidEntryId: result.InvalidEntryId, + InvalidSequence: result.InvalidSequence, + ErrorMessage: result.ErrorMessage); +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/FirstSignalResponse.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/FirstSignalResponse.cs new file mode 100644 index 000000000..b0bd16f09 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/FirstSignalResponse.cs @@ -0,0 +1,45 @@ +namespace StellaOps.ReleaseOrchestrator.WebApi.Contracts; + +/// +/// API response for first signal endpoint. +/// +public sealed record FirstSignalResponse +{ + public required Guid RunId { get; init; } + public required FirstSignalDto? FirstSignal { get; init; } + public required string SummaryEtag { get; init; } +} + +public sealed record FirstSignalDto +{ + public required string Type { get; init; } + public string? Stage { get; init; } + public string? Step { get; init; } + public required string Message { get; init; } + public required DateTimeOffset At { get; init; } + public FirstSignalArtifactDto? Artifact { get; init; } + public FirstSignalLastKnownOutcomeDto? LastKnownOutcome { get; init; } +} + +public sealed record FirstSignalArtifactDto +{ + public required string Kind { get; init; } + public FirstSignalRangeDto? Range { get; init; } +} + +public sealed record FirstSignalLastKnownOutcomeDto +{ + public required string SignatureId { get; init; } + public string? ErrorCode { get; init; } + public required string Token { get; init; } + public string? Excerpt { get; init; } + public required string Confidence { get; init; } + public required DateTimeOffset FirstSeenAt { get; init; } + public required int HitCount { get; init; } +} + +public sealed record FirstSignalRangeDto +{ + public required int Start { get; init; } + public required int End { get; init; } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/ReleaseControlContractModels.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/ReleaseControlContractModels.cs new file mode 100644 index 000000000..eac2abc50 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/ReleaseControlContractModels.cs @@ -0,0 +1,41 @@ +namespace StellaOps.ReleaseOrchestrator.WebApi.Contracts; + +/// +/// Risk snapshot surfaced in promotion/approval contracts (Pack 13/17). +/// +public sealed record PromotionRiskSnapshot( + string EnvironmentId, + int CriticalReachable, + int HighReachable, + int HighNotReachable, + decimal VexCoveragePercent, + string Severity); + +/// +/// Hybrid reachability coverage (build/image/runtime) surfaced as confidence. +/// +public sealed record HybridReachabilityCoverage( + int BuildCoveragePercent, + int ImageCoveragePercent, + int RuntimeCoveragePercent, + int EvidenceAgeHours); + +/// +/// Operations/data confidence summary consumed by approvals and promotions. +/// +public sealed record OpsDataConfidence( + string Status, + string Summary, + int TrustScore, + DateTimeOffset DataAsOf, + IReadOnlyList Signals); + +/// +/// Evidence packet summary for approval decision packets. +/// +public sealed record ApprovalEvidencePacket( + string DecisionDigest, + string PolicyDecisionDsse, + string SbomSnapshotId, + string ReachabilitySnapshotId, + string DataIntegritySnapshotId); diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Controllers/RollbackIntelligenceController.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Controllers/RollbackIntelligenceController.cs deleted file mode 100644 index 91c66d975..000000000 --- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Controllers/RollbackIntelligenceController.cs +++ /dev/null @@ -1,1033 +0,0 @@ -// ----------------------------------------------------------------------------- -// RollbackIntelligenceController.cs -// Sprint: SPRINT_20260117_033_ReleaseOrchestrator_rollback_intelligence -// Task: TASK-033-09 - REST API for rollback intelligence -// Description: API endpoints for health, predictions, impact analysis, and rollback -// ----------------------------------------------------------------------------- - -using System.Collections.Immutable; -using System.ComponentModel.DataAnnotations; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace StellaOps.ReleaseOrchestrator.Deployment.Controllers; - -/// -/// REST API for rollback intelligence features including health analysis, -/// failure prediction, impact analysis, and rollback planning. -/// -[ApiController] -[Route("api/v1/rollback-intelligence")] -[Authorize] -public sealed class RollbackIntelligenceController : ControllerBase -{ - private readonly IHealthAnalyzer _healthAnalyzer; - private readonly IPredictiveEngine _predictiveEngine; - private readonly IImpactAnalyzer _impactAnalyzer; - private readonly IPartialRollbackPlanner _rollbackPlanner; - private readonly IRollbackExecutor _rollbackExecutor; - private readonly ILogger _logger; - - public RollbackIntelligenceController( - IHealthAnalyzer healthAnalyzer, - IPredictiveEngine predictiveEngine, - IImpactAnalyzer impactAnalyzer, - IPartialRollbackPlanner rollbackPlanner, - IRollbackExecutor rollbackExecutor, - ILogger logger) - { - _healthAnalyzer = healthAnalyzer; - _predictiveEngine = predictiveEngine; - _impactAnalyzer = impactAnalyzer; - _rollbackPlanner = rollbackPlanner; - _rollbackExecutor = rollbackExecutor; - _logger = logger; - } - - #region Health Endpoints - - /// - /// Gets current health evaluation for a deployment. - /// - [HttpGet("deployments/{deploymentId:guid}/health")] - [ProducesResponseType(typeof(HealthEvaluationResponse), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetDeploymentHealth( - Guid deploymentId, - CancellationToken ct) - { - _logger.LogDebug("Getting health for deployment {DeploymentId}", deploymentId); - - try - { - var evaluation = await _healthAnalyzer.EvaluateHealthAsync(deploymentId, ct); - return Ok(MapToResponse(evaluation)); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Deployment {DeploymentId} not found", deploymentId); - return NotFound(new ProblemDetails - { - Title = "Deployment not found", - Detail = ex.Message - }); - } - } - - /// - /// Gets health evaluation for all deployments in a release. - /// - [HttpGet("releases/{releaseId:guid}/health")] - [ProducesResponseType(typeof(ReleaseHealthResponse), StatusCodes.Status200OK)] - public async Task> GetReleaseHealth( - Guid releaseId, - [FromQuery] ImmutableArray deploymentIds, - CancellationToken ct) - { - var evaluation = await _healthAnalyzer.EvaluateReleaseHealthAsync( - releaseId, deploymentIds, ct); - - return Ok(new ReleaseHealthResponse - { - ReleaseId = evaluation.ReleaseId, - OverallStatus = evaluation.OverallStatus.ToString(), - Deployments = evaluation.DeploymentEvaluations.Select(MapToResponse).ToList(), - CriticalDeployments = evaluation.CriticalDeployments, - EvaluatedAt = evaluation.EvaluatedAt - }); - } - - /// - /// Gets health signal history for a deployment. - /// - [HttpGet("deployments/{deploymentId:guid}/health/history")] - [ProducesResponseType(typeof(HealthHistoryResponse), StatusCodes.Status200OK)] - public async Task> GetHealthHistory( - Guid deploymentId, - [FromQuery] TimeSpan? window, - CancellationToken ct) - { - var lookbackWindow = window ?? TimeSpan.FromHours(1); - var history = new List(); - - await foreach (var evaluation in _healthAnalyzer.MonitorHealthAsync( - deploymentId, TimeSpan.FromMinutes(5), ct).Take(12)) - { - history.Add(MapToResponse(evaluation)); - } - - return Ok(new HealthHistoryResponse - { - DeploymentId = deploymentId, - Window = lookbackWindow, - Evaluations = history - }); - } - - #endregion - - #region Prediction Endpoints - - /// - /// Gets failure prediction for a deployment. - /// - [HttpGet("deployments/{deploymentId:guid}/predictions")] - [ProducesResponseType(typeof(FailurePredictionResponse), StatusCodes.Status200OK)] - public async Task> GetPrediction( - Guid deploymentId, - CancellationToken ct) - { - var prediction = await _predictiveEngine.PredictFailureAsync(deploymentId, ct); - return Ok(MapToResponse(prediction)); - } - - /// - /// Gets early warning signals for a deployment. - /// - [HttpGet("deployments/{deploymentId:guid}/warnings")] - [ProducesResponseType(typeof(EarlyWarningsResponse), StatusCodes.Status200OK)] - public async Task> GetEarlyWarnings( - Guid deploymentId, - CancellationToken ct) - { - var warnings = await _predictiveEngine.GetEarlyWarningsAsync(deploymentId, ct); - - return Ok(new EarlyWarningsResponse - { - DeploymentId = deploymentId, - Warnings = warnings.Select(w => new EarlyWarningDto - { - MetricName = w.MetricName, - SignalType = w.SignalType.ToString(), - Severity = w.Severity.ToString(), - TrendDirection = w.TrendDirection.ToString(), - TrendVelocity = w.TrendVelocity, - TimeToThreshold = w.TimeToThreshold, - DetectedAt = w.DetectedAt, - Message = w.Message - }).ToList() - }); - } - - /// - /// Subscribes to prediction updates via SSE. - /// - [HttpGet("deployments/{deploymentId:guid}/predictions/stream")] - [Produces("text/event-stream")] - public async Task StreamPredictions( - Guid deploymentId, - [FromQuery] int intervalSeconds = 30, - CancellationToken ct = default) - { - Response.ContentType = "text/event-stream"; - - var interval = TimeSpan.FromSeconds(Math.Max(10, intervalSeconds)); - - await foreach (var prediction in _predictiveEngine.MonitorPredictionsAsync( - deploymentId, interval, ct)) - { - var data = System.Text.Json.JsonSerializer.Serialize(MapToResponse(prediction)); - await Response.WriteAsync($"data: {data}\n\n", ct); - await Response.Body.FlushAsync(ct); - } - } - - #endregion - - #region Impact Analysis Endpoints - - /// - /// Analyzes rollback impact for a deployment. - /// - [HttpGet("deployments/{deploymentId:guid}/impact")] - [ProducesResponseType(typeof(ImpactAnalysisResponse), StatusCodes.Status200OK)] - public async Task> GetImpactAnalysis( - Guid deploymentId, - CancellationToken ct) - { - var analysis = await _impactAnalyzer.AnalyzeImpactAsync(deploymentId, ct); - return Ok(MapToResponse(analysis)); - } - - /// - /// Compares full vs partial rollback options. - /// - [HttpPost("deployments/{deploymentId:guid}/impact/compare")] - [ProducesResponseType(typeof(RollbackComparisonResponse), StatusCodes.Status200OK)] - public async Task> CompareRollbackOptions( - Guid deploymentId, - [FromBody] CompareRequest request, - CancellationToken ct) - { - var comparison = await _impactAnalyzer.CompareRollbackOptionsAsync( - deploymentId, request.Components, ct); - - return Ok(new RollbackComparisonResponse - { - DeploymentId = comparison.DeploymentId, - FullRollbackImpact = MapToResponse(comparison.FullRollbackImpact), - ComponentImpacts = comparison.ComponentImpacts.Select(c => new ComponentImpactDto - { - ComponentName = c.ComponentName, - DirectDependencies = c.DirectDependencies, - RequestVolume = c.RequestVolume, - CanRollbackIndependently = c.CanRollbackIndependently, - RollbackComplexity = c.RollbackComplexity.ToString() - }).ToList(), - OptimalStrategy = new RollbackStrategyDto - { - Type = comparison.OptimalStrategy.Type.ToString(), - Components = comparison.OptimalStrategy.Components, - EstimatedImpactReduction = comparison.OptimalStrategy.EstimatedImpactReduction, - Complexity = comparison.OptimalStrategy.Complexity.ToString() - }, - Recommendation = comparison.Recommendation - }); - } - - /// - /// Gets affected dependency chain for a deployment. - /// - [HttpGet("deployments/{deploymentId:guid}/dependencies")] - [ProducesResponseType(typeof(DependencyChainResponse), StatusCodes.Status200OK)] - public async Task> GetDependencyChain( - Guid deploymentId, - CancellationToken ct) - { - var chain = await _impactAnalyzer.GetAffectedDependencyChainAsync(deploymentId, ct); - - return Ok(new DependencyChainResponse - { - ServiceName = chain.ServiceName, - UpstreamDependencies = chain.UpstreamDependencies.Select(d => new DependencyDto - { - ServiceName = d.ServiceName, - DependencyType = d.DependencyType.ToString(), - Depth = d.Depth - }).ToList(), - DownstreamDependencies = chain.DownstreamDependencies.Select(d => new DependencyDto - { - ServiceName = d.ServiceName, - DependencyType = d.DependencyType.ToString(), - Depth = d.Depth - }).ToList(), - TotalAffectedServices = chain.TotalAffectedServices - }); - } - - #endregion - - #region Rollback Planning Endpoints - - /// - /// Creates a rollback plan for specified components. - /// - [HttpPost("releases/{releaseId:guid}/rollback-plans")] - [ProducesResponseType(typeof(RollbackPlanResponse), StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task> CreateRollbackPlan( - Guid releaseId, - [FromBody] CreateRollbackPlanRequest request, - CancellationToken ct) - { - var planRequest = new RollbackPlanRequest - { - ReleaseId = releaseId, - TargetComponents = request.Components, - Reason = Enum.Parse(request.Reason, ignoreCase: true) - }; - - var plan = await _rollbackPlanner.CreatePlanAsync(planRequest, ct); - - if (!plan.Validation.IsValid) - { - return BadRequest(new ProblemDetails - { - Title = "Invalid rollback plan", - Detail = "Rollback validation failed", - Extensions = { ["issues"] = plan.Validation.Issues } - }); - } - - return CreatedAtAction( - nameof(GetRollbackPlan), - new { planId = plan.PlanId }, - MapToResponse(plan)); - } - - /// - /// Gets a rollback plan by ID. - /// - [HttpGet("rollback-plans/{planId:guid}")] - [ProducesResponseType(typeof(RollbackPlanResponse), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetRollbackPlan( - Guid planId, - CancellationToken ct) - { - var plan = await _rollbackExecutor.GetPlanAsync(planId, ct); - if (plan is null) - return NotFound(); - - return Ok(MapToResponse(plan)); - } - - /// - /// Validates an existing rollback plan. - /// - [HttpPost("rollback-plans/{planId:guid}/validate")] - [ProducesResponseType(typeof(PlanValidationResponse), StatusCodes.Status200OK)] - public async Task> ValidatePlan( - Guid planId, - CancellationToken ct) - { - var plan = await _rollbackExecutor.GetPlanAsync(planId, ct); - if (plan is null) - return NotFound(); - - var validation = await _rollbackPlanner.ValidatePlanAsync(plan, ct); - - return Ok(new PlanValidationResponse - { - PlanId = planId, - IsValid = validation.IsValid, - Issues = validation.Issues.Select(i => new ValidationIssueDto - { - Severity = i.Severity.ToString(), - Code = i.Code, - Message = i.Message, - Component = i.Component - }).ToList(), - ValidatedAt = validation.ValidatedAt - }); - } - - /// - /// Optimizes a rollback plan for a specific goal. - /// - [HttpPost("rollback-plans/{planId:guid}/optimize")] - [ProducesResponseType(typeof(RollbackPlanResponse), StatusCodes.Status200OK)] - public async Task> OptimizePlan( - Guid planId, - [FromBody] OptimizePlanRequest request, - CancellationToken ct) - { - var plan = await _rollbackExecutor.GetPlanAsync(planId, ct); - if (plan is null) - return NotFound(); - - var goal = Enum.Parse(request.Goal, ignoreCase: true); - var optimizedPlan = await _rollbackPlanner.OptimizePlanAsync(plan, goal, ct); - - return Ok(MapToResponse(optimizedPlan)); - } - - /// - /// Suggests minimal rollback based on affected metrics. - /// - [HttpPost("releases/{releaseId:guid}/suggest-rollback")] - [ProducesResponseType(typeof(RollbackSuggestionResponse), StatusCodes.Status200OK)] - public async Task> SuggestRollback( - Guid releaseId, - [FromBody] SuggestRollbackRequest request, - CancellationToken ct) - { - var suggestion = await _rollbackPlanner.SuggestMinimalRollbackAsync( - releaseId, request.AffectedMetrics, ct); - - return Ok(new RollbackSuggestionResponse - { - ReleaseId = suggestion.ReleaseId, - Confidence = suggestion.Confidence, - Components = suggestion.Components, - SuspectedCauses = suggestion.SuspectedCauses.Select(s => new SuspectedComponentDto - { - ComponentName = s.ComponentName, - MatchingMetrics = s.MatchingMetrics, - Confidence = s.Confidence - }).ToList(), - Reasoning = suggestion.Reasoning, - FallbackRecommendation = suggestion.FallbackRecommendation - }); - } - - #endregion - - #region Rollback Execution Endpoints - - /// - /// Executes a rollback plan. - /// - [HttpPost("rollback-plans/{planId:guid}/execute")] - [ProducesResponseType(typeof(RollbackExecutionResponse), StatusCodes.Status202Accepted)] - [Authorize(Policy = "RollbackExecution")] - public async Task> ExecuteRollback( - Guid planId, - [FromBody] ExecuteRollbackRequest request, - CancellationToken ct) - { - _logger.LogInformation( - "Executing rollback plan {PlanId} by user {UserId}", - planId, User.Identity?.Name); - - var plan = await _rollbackExecutor.GetPlanAsync(planId, ct); - if (plan is null) - return NotFound(); - - // Validate before execution - var validation = await _rollbackPlanner.ValidatePlanAsync(plan, ct); - if (!validation.IsValid) - { - return BadRequest(new ProblemDetails - { - Title = "Plan validation failed", - Detail = "Rollback plan is no longer valid", - Extensions = { ["issues"] = validation.Issues } - }); - } - - var executionId = await _rollbackExecutor.ExecuteAsync( - planId, - new RollbackExecutionOptions - { - DryRun = request.DryRun, - ApprovalToken = request.ApprovalToken, - NotifyOnCompletion = request.NotifyOnCompletion - }, - ct); - - return AcceptedAtAction( - nameof(GetExecutionStatus), - new { executionId }, - new RollbackExecutionResponse - { - ExecutionId = executionId, - PlanId = planId, - Status = "Executing", - StartedAt = DateTimeOffset.UtcNow, - DryRun = request.DryRun - }); - } - - /// - /// Gets rollback execution status. - /// - [HttpGet("executions/{executionId:guid}")] - [ProducesResponseType(typeof(ExecutionStatusResponse), StatusCodes.Status200OK)] - public async Task> GetExecutionStatus( - Guid executionId, - CancellationToken ct) - { - var status = await _rollbackExecutor.GetExecutionStatusAsync(executionId, ct); - if (status is null) - return NotFound(); - - return Ok(new ExecutionStatusResponse - { - ExecutionId = status.ExecutionId, - PlanId = status.PlanId, - Status = status.Status.ToString(), - CurrentStep = status.CurrentStep, - TotalSteps = status.TotalSteps, - StartedAt = status.StartedAt, - CompletedAt = status.CompletedAt, - StepResults = status.StepResults.Select(r => new StepResultDto - { - StepNumber = r.StepNumber, - ComponentName = r.ComponentName, - Status = r.Status.ToString(), - Duration = r.Duration, - ErrorMessage = r.ErrorMessage - }).ToList() - }); - } - - /// - /// Cancels a running rollback execution. - /// - [HttpPost("executions/{executionId:guid}/cancel")] - [ProducesResponseType(StatusCodes.Status202Accepted)] - [Authorize(Policy = "RollbackExecution")] - public async Task CancelExecution( - Guid executionId, - CancellationToken ct) - { - await _rollbackExecutor.CancelAsync(executionId, ct); - return Accepted(); - } - - #endregion - - #region Mapping Methods - - private static HealthEvaluationResponse MapToResponse(HealthEvaluation evaluation) - { - return new HealthEvaluationResponse - { - DeploymentId = evaluation.DeploymentId, - Status = evaluation.Status.ToString(), - OverallScore = evaluation.OverallScore, - Signals = evaluation.Signals.Select(s => new SignalEvaluationDto - { - SignalName = s.SignalName, - MetricName = s.MetricName, - CurrentValue = s.CurrentValue, - BaselineValue = s.BaselineValue, - DeviationPercent = s.DeviationPercent, - IsAnomaly = s.IsAnomaly, - Score = s.Score, - Status = s.Status.ToString(), - Message = s.Message - }).ToList(), - Recommendation = new RecommendationDto - { - Action = evaluation.Recommendation.Action.ToString(), - Reason = evaluation.Recommendation.Reason, - Confidence = evaluation.Recommendation.Confidence - }, - EvaluatedAt = evaluation.EvaluatedAt - }; - } - - private static FailurePredictionResponse MapToResponse(FailurePrediction prediction) - { - return new FailurePredictionResponse - { - DeploymentId = prediction.DeploymentId, - FailureProbability = prediction.FailureProbability, - Confidence = prediction.Confidence, - RiskLevel = prediction.RiskLevel.ToString(), - EstimatedTimeToFailure = prediction.EstimatedTimeToFailure, - ContributingFactors = prediction.ContributingFactors.Select(f => new ContributingFactorDto - { - Source = f.Source.ToString(), - MetricName = f.MetricName, - Contribution = f.Contribution, - Description = f.Description - }).ToList(), - Recommendation = new PredictionRecommendationDto - { - Action = prediction.Recommendation.Action.ToString(), - Urgency = prediction.Recommendation.Urgency.ToString(), - Message = prediction.Recommendation.Message - }, - GeneratedAt = prediction.GeneratedAt - }; - } - - private static ImpactAnalysisResponse MapToResponse(ImpactAnalysis analysis) - { - return new ImpactAnalysisResponse - { - DeploymentId = analysis.DeploymentId, - ServiceName = analysis.ServiceName, - BlastRadius = new BlastRadiusDto - { - Score = analysis.BlastRadius.Score, - Category = analysis.BlastRadius.Category.ToString(), - AffectedServiceCount = analysis.BlastRadius.AffectedServiceCount, - AffectedUserCount = analysis.BlastRadius.AffectedUserCount, - CriticalServiceCount = analysis.BlastRadius.CriticalServiceCount - }, - DependencyImpact = new DependencyImpactDto - { - DirectDependencies = analysis.DependencyImpact.DirectDependencies, - TransitiveDependencies = analysis.DependencyImpact.TransitiveDependencies, - TotalRequestsAffected = analysis.DependencyImpact.TotalRequestsAffected, - CriticalServicesAffected = analysis.DependencyImpact.CriticalServicesAffected - }, - TrafficImpact = new TrafficImpactDto - { - CurrentRequestsPerSecond = analysis.TrafficImpact.CurrentRequestsPerSecond, - ActiveUserSessions = analysis.TrafficImpact.ActiveUserSessions, - EstimatedUsersAffected = analysis.TrafficImpact.EstimatedUsersAffected, - IsHighTrafficPeriod = analysis.TrafficImpact.IsHighTrafficPeriod - }, - DowntimeEstimate = new DowntimeEstimateDto - { - TotalEstimatedDowntime = analysis.DowntimeEstimate.TotalEstimatedDowntime, - RollbackDuration = analysis.DowntimeEstimate.RollbackDuration, - EstimatedRevenueLoss = analysis.DowntimeEstimate.EstimatedRevenueLoss - }, - RiskAssessment = new RiskAssessmentDto - { - OverallRisk = analysis.RiskAssessment.OverallRisk, - RiskLevel = analysis.RiskAssessment.RiskLevel.ToString(), - RequiresApproval = analysis.RiskAssessment.RequiresApproval, - ApprovalLevel = analysis.RiskAssessment.ApprovalLevel.ToString() - }, - Mitigations = analysis.Mitigations.Select(m => new MitigationDto - { - Type = m.Type.ToString(), - Description = m.Description, - EffectivenessScore = m.EffectivenessScore - }).ToList(), - AnalyzedAt = analysis.AnalyzedAt - }; - } - - private static RollbackPlanResponse MapToResponse(RollbackPlan plan) - { - return new RollbackPlanResponse - { - PlanId = plan.PlanId, - ReleaseId = plan.ReleaseId, - Type = plan.Type.ToString(), - Status = plan.Status.ToString(), - Components = plan.Components, - Steps = plan.Steps.Select(s => new RollbackStepDto - { - StepNumber = s.StepNumber, - ComponentName = s.ComponentName, - CurrentVersion = s.CurrentVersion, - TargetVersion = s.TargetVersion, - Action = s.Action.ToString(), - EstimatedDuration = s.EstimatedDuration, - ParallelGroup = s.ParallelGroup - }).ToList(), - EstimatedDuration = plan.EstimatedDuration, - AggregateImpact = new AggregateImpactDto - { - TotalDowntime = plan.AggregateImpact.TotalDowntime, - TotalAffectedServices = plan.AggregateImpact.TotalAffectedServices, - MaxAffectedUsers = plan.AggregateImpact.MaxAffectedUsers, - OverallRiskLevel = plan.AggregateImpact.OverallRiskLevel.ToString() - }, - CreatedAt = plan.CreatedAt, - ExpiresAt = plan.ExpiresAt, - OptimizedFor = plan.OptimizedFor?.ToString() - }; - } - - #endregion -} - -#region Request/Response DTOs - -public sealed record CreateRollbackPlanRequest -{ - [Required] - public required ImmutableArray Components { get; init; } - public string Reason { get; init; } = "HealthDegradation"; -} - -public sealed record CompareRequest -{ - [Required] - public required ImmutableArray Components { get; init; } -} - -public sealed record OptimizePlanRequest -{ - [Required] - public required string Goal { get; init; } -} - -public sealed record SuggestRollbackRequest -{ - [Required] - public required ImmutableArray AffectedMetrics { get; init; } -} - -public sealed record ExecuteRollbackRequest -{ - public bool DryRun { get; init; } = false; - public string? ApprovalToken { get; init; } - public bool NotifyOnCompletion { get; init; } = true; -} - -public sealed record HealthEvaluationResponse -{ - public required Guid DeploymentId { get; init; } - public required string Status { get; init; } - public required double OverallScore { get; init; } - public required List Signals { get; init; } - public required RecommendationDto Recommendation { get; init; } - public required DateTimeOffset EvaluatedAt { get; init; } -} - -public sealed record ReleaseHealthResponse -{ - public required Guid ReleaseId { get; init; } - public required string OverallStatus { get; init; } - public required List Deployments { get; init; } - public required ImmutableArray CriticalDeployments { get; init; } - public required DateTimeOffset EvaluatedAt { get; init; } -} - -public sealed record HealthHistoryResponse -{ - public required Guid DeploymentId { get; init; } - public required TimeSpan Window { get; init; } - public required List Evaluations { get; init; } -} - -public sealed record SignalEvaluationDto -{ - public required string SignalName { get; init; } - public required string MetricName { get; init; } - public double? CurrentValue { get; init; } - public double? BaselineValue { get; init; } - public double DeviationPercent { get; init; } - public bool IsAnomaly { get; init; } - public required double Score { get; init; } - public required string Status { get; init; } - public string? Message { get; init; } -} - -public sealed record RecommendationDto -{ - public required string Action { get; init; } - public required string Reason { get; init; } - public required double Confidence { get; init; } -} - -public sealed record FailurePredictionResponse -{ - public required Guid DeploymentId { get; init; } - public required double FailureProbability { get; init; } - public required double Confidence { get; init; } - public required string RiskLevel { get; init; } - public TimeSpan? EstimatedTimeToFailure { get; init; } - public required List ContributingFactors { get; init; } - public required PredictionRecommendationDto Recommendation { get; init; } - public required DateTimeOffset GeneratedAt { get; init; } -} - -public sealed record ContributingFactorDto -{ - public required string Source { get; init; } - public required string MetricName { get; init; } - public required double Contribution { get; init; } - public required string Description { get; init; } -} - -public sealed record PredictionRecommendationDto -{ - public required string Action { get; init; } - public required string Urgency { get; init; } - public required string Message { get; init; } -} - -public sealed record EarlyWarningsResponse -{ - public required Guid DeploymentId { get; init; } - public required List Warnings { get; init; } -} - -public sealed record EarlyWarningDto -{ - public required string MetricName { get; init; } - public required string SignalType { get; init; } - public required string Severity { get; init; } - public required string TrendDirection { get; init; } - public required double TrendVelocity { get; init; } - public TimeSpan? TimeToThreshold { get; init; } - public required DateTimeOffset DetectedAt { get; init; } - public required string Message { get; init; } -} - -public sealed record ImpactAnalysisResponse -{ - public required Guid DeploymentId { get; init; } - public required string ServiceName { get; init; } - public required BlastRadiusDto BlastRadius { get; init; } - public required DependencyImpactDto DependencyImpact { get; init; } - public required TrafficImpactDto TrafficImpact { get; init; } - public required DowntimeEstimateDto DowntimeEstimate { get; init; } - public required RiskAssessmentDto RiskAssessment { get; init; } - public required List Mitigations { get; init; } - public required DateTimeOffset AnalyzedAt { get; init; } -} - -public sealed record BlastRadiusDto -{ - public required double Score { get; init; } - public required string Category { get; init; } - public required int AffectedServiceCount { get; init; } - public required int AffectedUserCount { get; init; } - public required int CriticalServiceCount { get; init; } -} - -public sealed record DependencyImpactDto -{ - public required int DirectDependencies { get; init; } - public required int TransitiveDependencies { get; init; } - public required long TotalRequestsAffected { get; init; } - public required int CriticalServicesAffected { get; init; } -} - -public sealed record TrafficImpactDto -{ - public required long CurrentRequestsPerSecond { get; init; } - public required int ActiveUserSessions { get; init; } - public required int EstimatedUsersAffected { get; init; } - public required bool IsHighTrafficPeriod { get; init; } -} - -public sealed record DowntimeEstimateDto -{ - public required TimeSpan TotalEstimatedDowntime { get; init; } - public required TimeSpan RollbackDuration { get; init; } - public required decimal EstimatedRevenueLoss { get; init; } -} - -public sealed record RiskAssessmentDto -{ - public required double OverallRisk { get; init; } - public required string RiskLevel { get; init; } - public required bool RequiresApproval { get; init; } - public required string ApprovalLevel { get; init; } -} - -public sealed record MitigationDto -{ - public required string Type { get; init; } - public required string Description { get; init; } - public required double EffectivenessScore { get; init; } -} - -public sealed record RollbackComparisonResponse -{ - public required Guid DeploymentId { get; init; } - public required ImpactAnalysisResponse FullRollbackImpact { get; init; } - public required List ComponentImpacts { get; init; } - public required RollbackStrategyDto OptimalStrategy { get; init; } - public required string Recommendation { get; init; } -} - -public sealed record ComponentImpactDto -{ - public required string ComponentName { get; init; } - public required int DirectDependencies { get; init; } - public required long RequestVolume { get; init; } - public required bool CanRollbackIndependently { get; init; } - public required string RollbackComplexity { get; init; } -} - -public sealed record RollbackStrategyDto -{ - public required string Type { get; init; } - public required ImmutableArray Components { get; init; } - public required double EstimatedImpactReduction { get; init; } - public required string Complexity { get; init; } -} - -public sealed record DependencyChainResponse -{ - public required string ServiceName { get; init; } - public required List UpstreamDependencies { get; init; } - public required List DownstreamDependencies { get; init; } - public required int TotalAffectedServices { get; init; } -} - -public sealed record DependencyDto -{ - public required string ServiceName { get; init; } - public required string DependencyType { get; init; } - public required int Depth { get; init; } -} - -public sealed record RollbackPlanResponse -{ - public required Guid PlanId { get; init; } - public required Guid ReleaseId { get; init; } - public required string Type { get; init; } - public required string Status { get; init; } - public required ImmutableArray Components { get; init; } - public required List Steps { get; init; } - public required TimeSpan EstimatedDuration { get; init; } - public required AggregateImpactDto AggregateImpact { get; init; } - public required DateTimeOffset CreatedAt { get; init; } - public required DateTimeOffset ExpiresAt { get; init; } - public string? OptimizedFor { get; init; } -} - -public sealed record RollbackStepDto -{ - public required int StepNumber { get; init; } - public required string ComponentName { get; init; } - public required string CurrentVersion { get; init; } - public required string TargetVersion { get; init; } - public required string Action { get; init; } - public required TimeSpan EstimatedDuration { get; init; } - public int? ParallelGroup { get; init; } -} - -public sealed record AggregateImpactDto -{ - public required TimeSpan TotalDowntime { get; init; } - public required int TotalAffectedServices { get; init; } - public required int MaxAffectedUsers { get; init; } - public required string OverallRiskLevel { get; init; } -} - -public sealed record PlanValidationResponse -{ - public required Guid PlanId { get; init; } - public required bool IsValid { get; init; } - public required List Issues { get; init; } - public required DateTimeOffset ValidatedAt { get; init; } -} - -public sealed record ValidationIssueDto -{ - public required string Severity { get; init; } - public required string Code { get; init; } - public required string Message { get; init; } - public string? Component { get; init; } -} - -public sealed record RollbackSuggestionResponse -{ - public required Guid ReleaseId { get; init; } - public required double Confidence { get; init; } - public required ImmutableArray Components { get; init; } - public required List SuspectedCauses { get; init; } - public required string Reasoning { get; init; } - public string? FallbackRecommendation { get; init; } -} - -public sealed record SuspectedComponentDto -{ - public required string ComponentName { get; init; } - public required ImmutableArray MatchingMetrics { get; init; } - public required double Confidence { get; init; } -} - -public sealed record RollbackExecutionResponse -{ - public required Guid ExecutionId { get; init; } - public required Guid PlanId { get; init; } - public required string Status { get; init; } - public required DateTimeOffset StartedAt { get; init; } - public required bool DryRun { get; init; } -} - -public sealed record ExecutionStatusResponse -{ - public required Guid ExecutionId { get; init; } - public required Guid PlanId { get; init; } - public required string Status { get; init; } - public required int CurrentStep { get; init; } - public required int TotalSteps { get; init; } - public required DateTimeOffset StartedAt { get; init; } - public DateTimeOffset? CompletedAt { get; init; } - public required List StepResults { get; init; } -} - -public sealed record StepResultDto -{ - public required int StepNumber { get; init; } - public required string ComponentName { get; init; } - public required string Status { get; init; } - public required TimeSpan Duration { get; init; } - public string? ErrorMessage { get; init; } -} - -#endregion - -#region Interfaces - -public interface IRollbackExecutor -{ - Task GetPlanAsync(Guid planId, CancellationToken ct = default); - Task ExecuteAsync(Guid planId, RollbackExecutionOptions options, CancellationToken ct = default); - Task GetExecutionStatusAsync(Guid executionId, CancellationToken ct = default); - Task CancelAsync(Guid executionId, CancellationToken ct = default); -} - -public sealed record RollbackExecutionOptions -{ - public bool DryRun { get; init; } - public string? ApprovalToken { get; init; } - public bool NotifyOnCompletion { get; init; } -} - -public sealed record ExecutionStatus -{ - public required Guid ExecutionId { get; init; } - public required Guid PlanId { get; init; } - public required ExecutionState Status { get; init; } - public required int CurrentStep { get; init; } - public required int TotalSteps { get; init; } - public required DateTimeOffset StartedAt { get; init; } - public DateTimeOffset? CompletedAt { get; init; } - public required ImmutableArray StepResults { get; init; } -} - -public enum ExecutionState { Pending, Executing, Completed, Failed, Cancelled } - -public sealed record StepExecutionResult -{ - public required int StepNumber { get; init; } - public required string ComponentName { get; init; } - public required StepStatus Status { get; init; } - public required TimeSpan Duration { get; init; } - public string? ErrorMessage { get; init; } -} - -public enum StepStatus { Pending, Running, Completed, Failed, Skipped } - -#endregion diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ApprovalEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ApprovalEndpoints.cs new file mode 100644 index 000000000..74a0182c6 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ApprovalEndpoints.cs @@ -0,0 +1,501 @@ +using Microsoft.AspNetCore.Mvc; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.ReleaseOrchestrator.WebApi.Contracts; +using StellaOps.ReleaseOrchestrator.WebApi.Services; +using static StellaOps.Localization.T; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Endpoints; + +/// +/// Approval endpoints for the release orchestrator. +/// Routes: /api/release-orchestrator/approvals +/// +public static class ApprovalEndpoints +{ + public static IEndpointRouteBuilder MapApprovalEndpoints(this IEndpointRouteBuilder app) + { + MapApprovalGroup(app, "/api/release-orchestrator/approvals", includeRouteNames: true); + MapApprovalGroup(app, "/api/v1/release-orchestrator/approvals", includeRouteNames: false); + + return app; + } + + private static void MapApprovalGroup( + IEndpointRouteBuilder app, + string prefix, + bool includeRouteNames) + { + var group = app.MapGroup(prefix) + .WithTags("Approvals") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseRead) + .RequireTenant(); + + var list = group.MapGet(string.Empty, ListApprovals) + .WithDescription(_t("orchestrator.approval.list_description")); + if (includeRouteNames) + { + list.WithName("Approval_List"); + } + + var detail = group.MapGet("/{id}", GetApproval) + .WithDescription(_t("orchestrator.approval.get_description")); + if (includeRouteNames) + { + detail.WithName("Approval_Get"); + } + + var approve = group.MapPost("/{id}/approve", Approve) + .WithDescription(_t("orchestrator.approval.approve_description")) + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseApprove); + if (includeRouteNames) + { + approve.WithName("Approval_Approve"); + } + + var reject = group.MapPost("/{id}/reject", Reject) + .WithDescription(_t("orchestrator.approval.reject_description")) + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseApprove); + if (includeRouteNames) + { + reject.WithName("Approval_Reject"); + } + + var batchApprove = group.MapPost("/batch-approve", BatchApprove) + .WithDescription(_t("orchestrator.approval.create_description")) + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseApprove); + if (includeRouteNames) + { + batchApprove.WithName("Approval_BatchApprove"); + } + + var batchReject = group.MapPost("/batch-reject", BatchReject) + .WithDescription(_t("orchestrator.approval.cancel_description")) + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseApprove); + if (includeRouteNames) + { + batchReject.WithName("Approval_BatchReject"); + } + } + + private static IResult ListApprovals( + [FromQuery] string? statuses, + [FromQuery] string? urgencies, + [FromQuery] string? environment) + { + var approvals = SeedData.Approvals + .Select(WithDerivedSignals) + .Select(ToSummary) + .AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(statuses)) + { + var statusList = statuses.Split(',', StringSplitOptions.RemoveEmptyEntries); + approvals = approvals.Where(a => statusList.Contains(a.Status, StringComparer.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(urgencies)) + { + var urgencyList = urgencies.Split(',', StringSplitOptions.RemoveEmptyEntries); + approvals = approvals.Where(a => urgencyList.Contains(a.Urgency, StringComparer.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(environment)) + { + approvals = approvals.Where(a => + string.Equals(a.TargetEnvironment, environment, StringComparison.OrdinalIgnoreCase)); + } + + return Results.Ok(approvals.ToList()); + } + + private static IResult GetApproval(string id) + { + var approval = SeedData.Approvals.FirstOrDefault(a => a.Id == id); + return approval is not null + ? Results.Ok(WithDerivedSignals(approval)) + : Results.NotFound(); + } + + private static IResult Approve(string id, [FromBody] ApprovalActionDto request) + { + var approval = SeedData.Approvals.FirstOrDefault(a => a.Id == id); + if (approval is null) return Results.NotFound(); + + return Results.Ok(WithDerivedSignals(approval with + { + CurrentApprovals = approval.CurrentApprovals + 1, + Status = approval.CurrentApprovals + 1 >= approval.RequiredApprovals ? "approved" : approval.Status, + })); + } + + private static IResult Reject(string id, [FromBody] ApprovalActionDto request) + { + var approval = SeedData.Approvals.FirstOrDefault(a => a.Id == id); + if (approval is null) return Results.NotFound(); + + return Results.Ok(WithDerivedSignals(approval with { Status = "rejected" })); + } + + private static IResult BatchApprove([FromBody] BatchActionDto request) + { + return Results.NoContent(); + } + + private static IResult BatchReject([FromBody] BatchActionDto request) + { + return Results.NoContent(); + } + + public static ApprovalDto WithDerivedSignals(ApprovalDto approval) + { + var manifestDigest = approval.ManifestDigest + ?? approval.ReleaseComponents.FirstOrDefault()?.Digest + ?? $"sha256:{approval.ReleaseId.Replace("-", string.Empty, StringComparison.Ordinal)}"; + + var risk = approval.RiskSnapshot + ?? ReleaseControlSignalCatalog.GetRiskSnapshot(approval.ReleaseId, approval.TargetEnvironment); + + var coverage = approval.ReachabilityCoverage + ?? ReleaseControlSignalCatalog.GetCoverage(approval.ReleaseId); + + var opsConfidence = approval.OpsConfidence + ?? ReleaseControlSignalCatalog.GetOpsConfidence(approval.TargetEnvironment); + + var evidencePacket = approval.EvidencePacket + ?? ReleaseControlSignalCatalog.BuildEvidencePacket(approval.Id, approval.ReleaseId); + + return approval with + { + ManifestDigest = manifestDigest, + RiskSnapshot = risk, + ReachabilityCoverage = coverage, + OpsConfidence = opsConfidence, + EvidencePacket = evidencePacket, + DecisionDigest = approval.DecisionDigest ?? evidencePacket.DecisionDigest, + }; + } + + public static ApprovalSummaryDto ToSummary(ApprovalDto approval) + { + var enriched = WithDerivedSignals(approval); + return new ApprovalSummaryDto + { + Id = enriched.Id, + ReleaseId = enriched.ReleaseId, + ReleaseName = enriched.ReleaseName, + ReleaseVersion = enriched.ReleaseVersion, + SourceEnvironment = enriched.SourceEnvironment, + TargetEnvironment = enriched.TargetEnvironment, + RequestedBy = enriched.RequestedBy, + RequestedAt = enriched.RequestedAt, + Urgency = enriched.Urgency, + Justification = enriched.Justification, + Status = enriched.Status, + CurrentApprovals = enriched.CurrentApprovals, + RequiredApprovals = enriched.RequiredApprovals, + GatesPassed = enriched.GatesPassed, + ScheduledTime = enriched.ScheduledTime, + ExpiresAt = enriched.ExpiresAt, + ManifestDigest = enriched.ManifestDigest, + RiskSnapshot = enriched.RiskSnapshot, + ReachabilityCoverage = enriched.ReachabilityCoverage, + OpsConfidence = enriched.OpsConfidence, + DecisionDigest = enriched.DecisionDigest, + }; + } + + // ---- DTOs ---- + + public sealed record ApprovalSummaryDto + { + public required string Id { get; init; } + public required string ReleaseId { get; init; } + public required string ReleaseName { get; init; } + public required string ReleaseVersion { get; init; } + public required string SourceEnvironment { get; init; } + public required string TargetEnvironment { get; init; } + public required string RequestedBy { get; init; } + public required string RequestedAt { get; init; } + public required string Urgency { get; init; } + public required string Justification { get; init; } + public required string Status { get; init; } + public int CurrentApprovals { get; init; } + public int RequiredApprovals { get; init; } + public bool GatesPassed { get; init; } + public string? ScheduledTime { get; init; } + public string? ExpiresAt { get; init; } + public string? ManifestDigest { get; init; } + public PromotionRiskSnapshot? RiskSnapshot { get; init; } + public HybridReachabilityCoverage? ReachabilityCoverage { get; init; } + public OpsDataConfidence? OpsConfidence { get; init; } + public string? DecisionDigest { get; init; } + } + + public sealed record ApprovalDto + { + public required string Id { get; init; } + public required string ReleaseId { get; init; } + public required string ReleaseName { get; init; } + public required string ReleaseVersion { get; init; } + public required string SourceEnvironment { get; init; } + public required string TargetEnvironment { get; init; } + public required string RequestedBy { get; init; } + public required string RequestedAt { get; init; } + public required string Urgency { get; init; } + public required string Justification { get; init; } + public required string Status { get; init; } + public int CurrentApprovals { get; init; } + public int RequiredApprovals { get; init; } + public bool GatesPassed { get; init; } + public string? ScheduledTime { get; init; } + public string? ExpiresAt { get; init; } + public List GateResults { get; init; } = new(); + public List Actions { get; init; } = new(); + public List Approvers { get; init; } = new(); + public List ReleaseComponents { get; init; } = new(); + public string? ManifestDigest { get; init; } + public PromotionRiskSnapshot? RiskSnapshot { get; init; } + public HybridReachabilityCoverage? ReachabilityCoverage { get; init; } + public OpsDataConfidence? OpsConfidence { get; init; } + public ApprovalEvidencePacket? EvidencePacket { get; init; } + public string? DecisionDigest { get; init; } + } + + public sealed record GateResultDto + { + public required string GateId { get; init; } + public required string GateName { get; init; } + public required string Type { get; init; } + public required string Status { get; init; } + public required string Message { get; init; } + public Dictionary Details { get; init; } = new(); + public string? EvaluatedAt { get; init; } + } + + public sealed record ApprovalActionRecordDto + { + public required string Id { get; init; } + public required string ApprovalId { get; init; } + public required string Action { get; init; } + public required string Actor { get; init; } + public required string Comment { get; init; } + public required string Timestamp { get; init; } + } + + public sealed record ApproverDto + { + public required string Id { get; init; } + public required string Name { get; init; } + public required string Email { get; init; } + public bool HasApproved { get; init; } + public string? ApprovedAt { get; init; } + } + + public sealed record ReleaseComponentSummaryDto + { + public required string Name { get; init; } + public required string Version { get; init; } + public required string Digest { get; init; } + } + + public sealed record ApprovalActionDto + { + public string? Comment { get; init; } + } + + public sealed record BatchActionDto + { + public string[]? Ids { get; init; } + public string? Comment { get; init; } + } + + // ---- Seed Data ---- + // Generates relative dates so approvals always look fresh regardless of when the service starts. + + internal static class SeedData + { + private static string Ago(int hours) => DateTimeOffset.UtcNow.AddHours(-hours).ToString("o"); + private static string FromNow(int hours) => DateTimeOffset.UtcNow.AddHours(hours).ToString("o"); + + public static readonly List Approvals = new() + { + // -- Pending: 1/2 approved, gates OK, normal priority -- + new() + { + Id = "apr-001", ReleaseId = "rel-001", ReleaseName = "API Gateway", ReleaseVersion = "2.4.1", + SourceEnvironment = "staging", TargetEnvironment = "production", + RequestedBy = "alice.johnson", RequestedAt = Ago(3), + Urgency = "normal", Justification = "Scheduled release with new rate limiting feature and bug fixes.", + Status = "pending", CurrentApprovals = 1, RequiredApprovals = 2, GatesPassed = true, + ExpiresAt = FromNow(45), + GateResults = new() + { + new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "passed", Message = "No vulnerabilities found", EvaluatedAt = Ago(3) }, + new() { GateId = "g2", GateName = "Policy Compliance", Type = "policy", Status = "passed", Message = "All policies satisfied", EvaluatedAt = Ago(3) }, + new() { GateId = "g3", GateName = "Quality Gates", Type = "quality", Status = "passed", Message = "Code coverage: 85%", EvaluatedAt = Ago(3) }, + }, + Actions = new() + { + new() { Id = "act-1", ApprovalId = "apr-001", Action = "approved", Actor = "bob.smith", Comment = "Looks good, tests are passing.", Timestamp = Ago(2) }, + }, + Approvers = new() + { + new() { Id = "u1", Name = "Bob Smith", Email = "bob.smith@example.com", HasApproved = true, ApprovedAt = Ago(2) }, + new() { Id = "u2", Name = "Carol Davis", Email = "carol.davis@example.com" }, + }, + ReleaseComponents = new() + { + new() { Name = "api-gateway", Version = "2.4.1", Digest = "sha256:abc123def456" }, + new() { Name = "rate-limiter", Version = "1.0.5", Digest = "sha256:789xyz012abc" }, + }, + }, + + // -- Pending: 0/2 approved, gates FAILING, high priority -- + new() + { + Id = "apr-002", ReleaseId = "rel-002", ReleaseName = "User Service", ReleaseVersion = "3.0.0-rc1", + SourceEnvironment = "staging", TargetEnvironment = "production", + RequestedBy = "david.wilson", RequestedAt = Ago(1), + Urgency = "high", Justification = "Critical fix for user authentication timeout issue.", + Status = "pending", CurrentApprovals = 0, RequiredApprovals = 2, GatesPassed = false, + ExpiresAt = FromNow(23), + GateResults = new() + { + new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "warning", Message = "2 low severity vulnerabilities", EvaluatedAt = Ago(1) }, + new() { GateId = "g2", GateName = "Policy Compliance", Type = "policy", Status = "passed", Message = "All policies satisfied", EvaluatedAt = Ago(1) }, + new() { GateId = "g3", GateName = "Quality Gates", Type = "quality", Status = "failed", Message = "Code coverage: 72% (min 80%)", EvaluatedAt = Ago(1) }, + }, + Approvers = new() + { + new() { Id = "u1", Name = "Bob Smith", Email = "bob.smith@example.com" }, + new() { Id = "u3", Name = "Emily Chen", Email = "emily.chen@example.com" }, + }, + ReleaseComponents = new() + { + new() { Name = "user-service", Version = "3.0.0-rc1", Digest = "sha256:user123def456" }, + }, + }, + + // -- Pending: 0/1 approved, gates OK, critical, expiring soon -- + new() + { + Id = "apr-005", ReleaseId = "rel-005", ReleaseName = "Auth Service", ReleaseVersion = "1.8.3-hotfix", + SourceEnvironment = "staging", TargetEnvironment = "production", + RequestedBy = "frank.miller", RequestedAt = Ago(6), + Urgency = "critical", Justification = "Hotfix: OAuth token refresh loop causing 503 cascade.", + Status = "pending", CurrentApprovals = 0, RequiredApprovals = 1, GatesPassed = true, + ExpiresAt = FromNow(2), + GateResults = new() + { + new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "passed", Message = "No vulnerabilities", EvaluatedAt = Ago(6) }, + new() { GateId = "g2", GateName = "Policy Compliance", Type = "policy", Status = "passed", Message = "Hotfix policy waiver applied", EvaluatedAt = Ago(6) }, + }, + Approvers = new() + { + new() { Id = "u4", Name = "Grace Lee", Email = "grace.lee@example.com" }, + }, + ReleaseComponents = new() + { + new() { Name = "auth-service", Version = "1.8.3-hotfix", Digest = "sha256:auth789ghi012" }, + }, + }, + + // -- Pending: dev -> staging, gates OK, low priority -- + new() + { + Id = "apr-006", ReleaseId = "rel-006", ReleaseName = "Billing Dashboard", ReleaseVersion = "4.2.0", + SourceEnvironment = "dev", TargetEnvironment = "staging", + RequestedBy = "alice.johnson", RequestedAt = Ago(12), + Urgency = "low", Justification = "New billing analytics dashboard with chart components.", + Status = "pending", CurrentApprovals = 0, RequiredApprovals = 1, GatesPassed = true, + ExpiresAt = FromNow(60), + GateResults = new() + { + new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "passed", Message = "Clean scan", EvaluatedAt = Ago(12) }, + new() { GateId = "g2", GateName = "Quality Gates", Type = "quality", Status = "passed", Message = "Coverage 91%", EvaluatedAt = Ago(12) }, + }, + Approvers = new() + { + new() { Id = "u3", Name = "Emily Chen", Email = "emily.chen@example.com" }, + }, + ReleaseComponents = new() + { + new() { Name = "billing-dashboard", Version = "4.2.0", Digest = "sha256:bill456def789" }, + }, + }, + + // -- Approved (completed): critical hotfix -- + new() + { + Id = "apr-003", ReleaseId = "rel-003", ReleaseName = "Payment Gateway", ReleaseVersion = "1.5.2", + SourceEnvironment = "dev", TargetEnvironment = "staging", + RequestedBy = "frank.miller", RequestedAt = Ago(48), + Urgency = "critical", Justification = "Emergency fix for payment processing failure.", + Status = "approved", CurrentApprovals = 2, RequiredApprovals = 2, GatesPassed = true, + ScheduledTime = Ago(46), ExpiresAt = Ago(24), + Actions = new() + { + new() { Id = "act-2", ApprovalId = "apr-003", Action = "approved", Actor = "carol.davis", Comment = "Urgent fix approved.", Timestamp = Ago(47) }, + new() { Id = "act-3", ApprovalId = "apr-003", Action = "approved", Actor = "grace.lee", Comment = "Confirmed, proceed.", Timestamp = Ago(46) }, + }, + Approvers = new() + { + new() { Id = "u2", Name = "Carol Davis", Email = "carol.davis@example.com", HasApproved = true, ApprovedAt = Ago(47) }, + new() { Id = "u4", Name = "Grace Lee", Email = "grace.lee@example.com", HasApproved = true, ApprovedAt = Ago(46) }, + }, + ReleaseComponents = new() + { + new() { Name = "payment-gateway", Version = "1.5.2", Digest = "sha256:pay456abc789" }, + }, + }, + + // -- Rejected: missing tests -- + new() + { + Id = "apr-004", ReleaseId = "rel-004", ReleaseName = "Notification Service", ReleaseVersion = "2.0.0", + SourceEnvironment = "staging", TargetEnvironment = "production", + RequestedBy = "alice.johnson", RequestedAt = Ago(72), + Urgency = "low", Justification = "Feature release with new email templates.", + Status = "rejected", CurrentApprovals = 0, RequiredApprovals = 2, GatesPassed = true, + ExpiresAt = Ago(24), + Actions = new() + { + new() { Id = "act-4", ApprovalId = "apr-004", Action = "rejected", Actor = "bob.smith", Comment = "Missing integration tests for the email template renderer.", Timestamp = Ago(70) }, + }, + Approvers = new() + { + new() { Id = "u1", Name = "Bob Smith", Email = "bob.smith@example.com" }, + }, + ReleaseComponents = new() + { + new() { Name = "notification-service", Version = "2.0.0", Digest = "sha256:notify789abc" }, + }, + }, + + // -- Approved: routine promotion -- + new() + { + Id = "apr-007", ReleaseId = "rel-007", ReleaseName = "Config Service", ReleaseVersion = "1.12.0", + SourceEnvironment = "staging", TargetEnvironment = "production", + RequestedBy = "david.wilson", RequestedAt = Ago(96), + Urgency = "normal", Justification = "Routine config service update with new environment variable support.", + Status = "approved", CurrentApprovals = 2, RequiredApprovals = 2, GatesPassed = true, + ExpiresAt = Ago(48), + Actions = new() + { + new() { Id = "act-5", ApprovalId = "apr-007", Action = "approved", Actor = "emily.chen", Comment = "LGTM.", Timestamp = Ago(94) }, + new() { Id = "act-6", ApprovalId = "apr-007", Action = "approved", Actor = "bob.smith", Comment = "Approved.", Timestamp = Ago(93) }, + }, + Approvers = new() + { + new() { Id = "u3", Name = "Emily Chen", Email = "emily.chen@example.com", HasApproved = true, ApprovedAt = Ago(94) }, + new() { Id = "u1", Name = "Bob Smith", Email = "bob.smith@example.com", HasApproved = true, ApprovedAt = Ago(93) }, + }, + ReleaseComponents = new() + { + new() { Name = "config-service", Version = "1.12.0", Digest = "sha256:cfg012xyz345" }, + }, + }, + }; + } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs new file mode 100644 index 000000000..93d10a0cb --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs @@ -0,0 +1,262 @@ +using Microsoft.AspNetCore.Mvc; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.JobEngine.Core.Domain; +using StellaOps.JobEngine.Infrastructure.Repositories; +using StellaOps.ReleaseOrchestrator.WebApi.Contracts; +using StellaOps.ReleaseOrchestrator.WebApi.Services; +using static StellaOps.Localization.T; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Endpoints; + +/// +/// REST API endpoints for audit log operations. +/// +public static class AuditEndpoints +{ + /// + /// Maps audit endpoints to the route builder. + /// + public static RouteGroupBuilder MapAuditEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/release-orchestrator/audit") + .WithTags("Release Orchestrator Audit") + .RequireAuthorization(ReleaseOrchestratorPolicies.Read) + .RequireTenant(); + + // List and get operations + group.MapGet(string.Empty, ListAuditEntries) + .WithName("ReleaseOrchestrator_ListAuditEntries") + .WithDescription(_t("orchestrator.audit.list_description")); + + group.MapGet("{entryId:guid}", GetAuditEntry) + .WithName("ReleaseOrchestrator_GetAuditEntry") + .WithDescription(_t("orchestrator.audit.get_description")); + + group.MapGet("resource/{resourceType}/{resourceId:guid}", GetResourceHistory) + .WithName("ReleaseOrchestrator_GetResourceHistory") + .WithDescription(_t("orchestrator.audit.get_resource_history_description")); + + group.MapGet("latest", GetLatestEntry) + .WithName("ReleaseOrchestrator_GetLatestAuditEntry") + .WithDescription(_t("orchestrator.audit.get_latest_description")); + + group.MapGet("sequence/{startSeq:long}/{endSeq:long}", GetBySequenceRange) + .WithName("ReleaseOrchestrator_GetAuditBySequence") + .WithDescription(_t("orchestrator.audit.get_by_sequence_description")); + + // Summary and verification + group.MapGet("summary", GetAuditSummary) + .WithName("ReleaseOrchestrator_GetAuditSummary") + .WithDescription(_t("orchestrator.audit.summary_description")); + + group.MapGet("verify", VerifyAuditChain) + .WithName("ReleaseOrchestrator_VerifyAuditChain") + .WithDescription(_t("orchestrator.audit.verify_description")); + + return group; + } + + private static async Task ListAuditEntries( + HttpContext context, + [FromServices] TenantResolver tenantResolver, + [FromServices] IAuditRepository repository, + [FromQuery] string? eventType = null, + [FromQuery] string? resourceType = null, + [FromQuery] Guid? resourceId = null, + [FromQuery] string? actorId = null, + [FromQuery] DateTimeOffset? startTime = null, + [FromQuery] DateTimeOffset? endTime = null, + [FromQuery] int? limit = null, + [FromQuery] string? cursor = null, + CancellationToken cancellationToken = default) + { + try + { + var tenantId = tenantResolver.Resolve(context); + var effectiveLimit = EndpointHelpers.GetLimit(limit); + var offset = EndpointHelpers.ParseCursorOffset(cursor); + + AuditEventType? parsedEventType = null; + if (!string.IsNullOrEmpty(eventType) && Enum.TryParse(eventType, true, out var et)) + { + parsedEventType = et; + } + + var entries = await repository.ListAsync( + tenantId, + parsedEventType, + resourceType, + resourceId, + actorId, + startTime, + endTime, + effectiveLimit, + offset, + cancellationToken).ConfigureAwait(false); + + var responses = entries.Select(AuditEntryResponse.FromDomain).ToList(); + var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count); + + return Results.Ok(new AuditEntryListResponse(responses, nextCursor)); + } + catch (InvalidOperationException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + } + + private static async Task GetAuditEntry( + HttpContext context, + [FromRoute] Guid entryId, + [FromServices] TenantResolver tenantResolver, + [FromServices] IAuditRepository repository, + CancellationToken cancellationToken = default) + { + try + { + var tenantId = tenantResolver.Resolve(context); + var entry = await repository.GetByIdAsync(tenantId, entryId, cancellationToken).ConfigureAwait(false); + + if (entry is null) + { + return Results.NotFound(); + } + + return Results.Ok(AuditEntryResponse.FromDomain(entry)); + } + catch (InvalidOperationException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + } + + private static async Task GetResourceHistory( + HttpContext context, + [FromRoute] string resourceType, + [FromRoute] Guid resourceId, + [FromServices] TenantResolver tenantResolver, + [FromServices] IAuditRepository repository, + [FromQuery] int? limit = null, + CancellationToken cancellationToken = default) + { + try + { + var tenantId = tenantResolver.Resolve(context); + var effectiveLimit = EndpointHelpers.GetLimit(limit); + + var entries = await repository.GetByResourceAsync( + tenantId, + resourceType, + resourceId, + effectiveLimit, + cancellationToken).ConfigureAwait(false); + + var responses = entries.Select(AuditEntryResponse.FromDomain).ToList(); + return Results.Ok(new AuditEntryListResponse(responses, null)); + } + catch (InvalidOperationException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + } + + private static async Task GetLatestEntry( + HttpContext context, + [FromServices] TenantResolver tenantResolver, + [FromServices] IAuditRepository repository, + CancellationToken cancellationToken = default) + { + try + { + var tenantId = tenantResolver.Resolve(context); + var entry = await repository.GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false); + + if (entry is null) + { + return Results.NotFound(); + } + + return Results.Ok(AuditEntryResponse.FromDomain(entry)); + } + catch (InvalidOperationException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + } + + private static async Task GetBySequenceRange( + HttpContext context, + [FromRoute] long startSeq, + [FromRoute] long endSeq, + [FromServices] TenantResolver tenantResolver, + [FromServices] IAuditRepository repository, + CancellationToken cancellationToken = default) + { + try + { + var tenantId = tenantResolver.Resolve(context); + + if (startSeq < 1 || endSeq < startSeq) + { + return Results.BadRequest(new { error = _t("orchestrator.audit.error.invalid_sequence_range") }); + } + + var entries = await repository.GetBySequenceRangeAsync( + tenantId, + startSeq, + endSeq, + cancellationToken).ConfigureAwait(false); + + var responses = entries.Select(AuditEntryResponse.FromDomain).ToList(); + return Results.Ok(new AuditEntryListResponse(responses, null)); + } + catch (InvalidOperationException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + } + + private static async Task GetAuditSummary( + HttpContext context, + [FromServices] TenantResolver tenantResolver, + [FromServices] IAuditRepository repository, + [FromQuery] DateTimeOffset? since = null, + CancellationToken cancellationToken = default) + { + try + { + var tenantId = tenantResolver.Resolve(context); + var summary = await repository.GetSummaryAsync(tenantId, since, cancellationToken).ConfigureAwait(false); + + return Results.Ok(AuditSummaryResponse.FromDomain(summary)); + } + catch (InvalidOperationException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + } + + private static async Task VerifyAuditChain( + HttpContext context, + [FromServices] TenantResolver tenantResolver, + [FromServices] IAuditRepository repository, + [FromQuery] long? startSeq = null, + [FromQuery] long? endSeq = null, + CancellationToken cancellationToken = default) + { + try + { + var tenantId = tenantResolver.Resolve(context); + var result = await repository.VerifyChainAsync(tenantId, startSeq, endSeq, cancellationToken).ConfigureAwait(false); + + // TODO: Add release-orchestrator metrics when extracted from JobEngine + // Infrastructure.JobEngineMetrics.AuditChainVerified(tenantId, result.IsValid); + + return Results.Ok(ChainVerificationResponse.FromDomain(result)); + } + catch (InvalidOperationException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/DeploymentEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/DeploymentEndpoints.cs new file mode 100644 index 000000000..f3295f048 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/DeploymentEndpoints.cs @@ -0,0 +1,431 @@ +using Microsoft.AspNetCore.Mvc; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.ReleaseOrchestrator.WebApi.Services; +using System.Security.Claims; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Endpoints; + +public static class DeploymentEndpoints +{ + public static IEndpointRouteBuilder MapDeploymentEndpoints(this IEndpointRouteBuilder app) + { + Map(app, "/api/release-orchestrator/deployments", true); + Map(app, "/api/v1/release-orchestrator/deployments", false); + return app; + } + + private static void Map(IEndpointRouteBuilder app, string prefix, bool named) + { + var group = app.MapGroup(prefix) + .WithTags("Deployments") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseRead) + .RequireTenant(); + + var create = group.MapPost("", CreateAsync).RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseWrite); + var list = group.MapGet("", ListAsync); + var detail = group.MapGet("/{id}", GetAsync); + var logs = group.MapGet("/{id}/logs", GetLogsAsync); + var targetLogs = group.MapGet("/{id}/targets/{targetId}/logs", GetTargetLogsAsync); + var events = group.MapGet("/{id}/events", GetEventsAsync); + var metrics = group.MapGet("/{id}/metrics", GetMetricsAsync); + var pause = group.MapPost("/{id}/pause", PauseAsync).RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseWrite); + var resume = group.MapPost("/{id}/resume", ResumeAsync).RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseWrite); + var cancel = group.MapPost("/{id}/cancel", CancelAsync).RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseWrite); + var rollback = group.MapPost("/{id}/rollback", RollbackAsync).RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseApprove); + var retry = group.MapPost("/{id}/targets/{targetId}/retry", RetryTargetAsync).RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseWrite); + + if (!named) + { + return; + } + + create.WithName("Deployment_Create"); + list.WithName("Deployment_List"); + detail.WithName("Deployment_Get"); + logs.WithName("Deployment_GetLogs"); + targetLogs.WithName("Deployment_GetTargetLogs"); + events.WithName("Deployment_GetEvents"); + metrics.WithName("Deployment_GetMetrics"); + pause.WithName("Deployment_Pause"); + resume.WithName("Deployment_Resume"); + cancel.WithName("Deployment_Cancel"); + rollback.WithName("Deployment_Rollback"); + retry.WithName("Deployment_RetryTarget"); + } + + private static async Task CreateAsync( + HttpContext context, + IStellaOpsTenantAccessor tenantAccessor, + IDeploymentCompatibilityStore store, + ClaimsPrincipal user, + [FromBody] CreateDeploymentRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.ReleaseId)) + { + return Results.BadRequest(new { message = "releaseId is required." }); + } + + if (string.IsNullOrWhiteSpace(request.EnvironmentId)) + { + return Results.BadRequest(new { message = "environmentId is required." }); + } + + var strategy = NormalizeStrategy(request.Strategy); + if (strategy is null) + { + return Results.BadRequest(new { message = "strategy must be one of rolling, canary, blue_green, or all_at_once." }); + } + + var actor = user.FindFirstValue(ClaimTypes.NameIdentifier) + ?? user.FindFirstValue(ClaimTypes.Name) + ?? "release-operator"; + var deployment = await store.CreateAsync( + ResolveTenant(tenantAccessor, context), + request with { Strategy = strategy }, + actor, + cancellationToken).ConfigureAwait(false); + + return Results.Created($"/api/v1/release-orchestrator/deployments/{deployment.Id}", deployment); + } + + private static async Task ListAsync( + HttpContext context, + IStellaOpsTenantAccessor tenantAccessor, + IDeploymentCompatibilityStore store, + [FromQuery] string? status, + [FromQuery] string? statuses, + [FromQuery] string? environment, + [FromQuery] string? environments, + [FromQuery] string? releaseId, + [FromQuery] string? releases, + [FromQuery] string? sortField, + [FromQuery] string? sortOrder, + [FromQuery] int? page, + [FromQuery] int? pageSize, + CancellationToken cancellationToken) + { + IEnumerable items = (await store.ListAsync( + ResolveTenant(tenantAccessor, context), + cancellationToken).ConfigureAwait(false)).Select(ToSummary); + + var statusSet = Csv(statuses, status); + if (statusSet.Count > 0) + { + items = items.Where(item => statusSet.Contains(item.Status, StringComparer.OrdinalIgnoreCase)); + } + + var environmentSet = Csv(environments, environment); + if (environmentSet.Count > 0) + { + items = items.Where(item => + environmentSet.Contains(item.EnvironmentId, StringComparer.OrdinalIgnoreCase) + || environmentSet.Contains(item.EnvironmentName, StringComparer.OrdinalIgnoreCase)); + } + + var releaseSet = Csv(releases, releaseId); + if (releaseSet.Count > 0) + { + items = items.Where(item => releaseSet.Contains(item.ReleaseId, StringComparer.OrdinalIgnoreCase)); + } + + items = (sortField?.ToLowerInvariant(), sortOrder?.ToLowerInvariant()) switch + { + ("status", "asc") => items.OrderBy(item => item.Status, StringComparer.OrdinalIgnoreCase), + ("status", _) => items.OrderByDescending(item => item.Status, StringComparer.OrdinalIgnoreCase), + ("environment", "asc") => items.OrderBy(item => item.EnvironmentName, StringComparer.OrdinalIgnoreCase), + ("environment", _) => items.OrderByDescending(item => item.EnvironmentName, StringComparer.OrdinalIgnoreCase), + (_, "asc") => items.OrderBy(item => item.StartedAt), + _ => items.OrderByDescending(item => item.StartedAt), + }; + + var list = items.ToList(); + var resolvedPage = Math.Max(page ?? 1, 1); + var resolvedPageSize = Math.Clamp(pageSize ?? 20, 1, 100); + return Results.Ok(new + { + items = list.Skip((resolvedPage - 1) * resolvedPageSize).Take(resolvedPageSize).ToList(), + totalCount = list.Count, + page = resolvedPage, + pageSize = resolvedPageSize, + }); + } + + private static async Task GetAsync( + string id, + HttpContext context, + IStellaOpsTenantAccessor tenantAccessor, + IDeploymentCompatibilityStore store, + CancellationToken cancellationToken) + { + var deployment = await store.GetAsync( + ResolveTenant(tenantAccessor, context), + id, + cancellationToken).ConfigureAwait(false); + return deployment is null ? Results.NotFound() : Results.Ok(deployment); + } + + private static async Task GetLogsAsync( + string id, + HttpContext context, + IStellaOpsTenantAccessor tenantAccessor, + IDeploymentCompatibilityStore store, + [FromQuery] string? level, + [FromQuery] int? limit, + CancellationToken cancellationToken) + { + var entries = await store.GetLogsAsync( + ResolveTenant(tenantAccessor, context), + id, + targetId: null, + level, + limit, + cancellationToken).ConfigureAwait(false); + return entries is null ? Results.NotFound() : Results.Ok(new { entries }); + } + + private static async Task GetTargetLogsAsync( + string id, + string targetId, + HttpContext context, + IStellaOpsTenantAccessor tenantAccessor, + IDeploymentCompatibilityStore store, + [FromQuery] string? level, + [FromQuery] int? limit, + CancellationToken cancellationToken) + { + var entries = await store.GetLogsAsync( + ResolveTenant(tenantAccessor, context), + id, + targetId, + level, + limit, + cancellationToken).ConfigureAwait(false); + return entries is null ? Results.NotFound() : Results.Ok(new { entries }); + } + + private static async Task GetEventsAsync( + string id, + HttpContext context, + IStellaOpsTenantAccessor tenantAccessor, + IDeploymentCompatibilityStore store, + CancellationToken cancellationToken) + { + var events = await store.GetEventsAsync( + ResolveTenant(tenantAccessor, context), + id, + cancellationToken).ConfigureAwait(false); + return events is null ? Results.NotFound() : Results.Ok(new { events }); + } + + private static async Task GetMetricsAsync( + string id, + HttpContext context, + IStellaOpsTenantAccessor tenantAccessor, + IDeploymentCompatibilityStore store, + CancellationToken cancellationToken) + { + var metrics = await store.GetMetricsAsync( + ResolveTenant(tenantAccessor, context), + id, + cancellationToken).ConfigureAwait(false); + return metrics is null ? Results.NotFound() : Results.Ok(new { metrics }); + } + + private static Task PauseAsync( + string id, + HttpContext context, + IStellaOpsTenantAccessor tenantAccessor, + IDeploymentCompatibilityStore store, + CancellationToken cancellationToken) + => TransitionAsync( + context, + tenantAccessor, + store, + id, + ["running", "pending"], + "paused", + "paused", + $"Deployment {id} paused.", + complete: false, + cancellationToken); + + private static Task ResumeAsync( + string id, + HttpContext context, + IStellaOpsTenantAccessor tenantAccessor, + IDeploymentCompatibilityStore store, + CancellationToken cancellationToken) + => TransitionAsync( + context, + tenantAccessor, + store, + id, + ["paused"], + "running", + "resumed", + $"Deployment {id} resumed.", + complete: false, + cancellationToken); + + private static Task CancelAsync( + string id, + HttpContext context, + IStellaOpsTenantAccessor tenantAccessor, + IDeploymentCompatibilityStore store, + CancellationToken cancellationToken) + => TransitionAsync( + context, + tenantAccessor, + store, + id, + ["running", "pending", "paused"], + "cancelled", + "cancelled", + $"Deployment {id} cancelled.", + complete: true, + cancellationToken); + + private static Task RollbackAsync( + string id, + HttpContext context, + IStellaOpsTenantAccessor tenantAccessor, + IDeploymentCompatibilityStore store, + CancellationToken cancellationToken) + => TransitionAsync( + context, + tenantAccessor, + store, + id, + ["completed", "failed", "running", "paused"], + "rolling_back", + "rollback_started", + $"Rollback initiated for deployment {id}.", + complete: false, + cancellationToken); + + private static async Task RetryTargetAsync( + string id, + string targetId, + HttpContext context, + IStellaOpsTenantAccessor tenantAccessor, + IDeploymentCompatibilityStore store, + CancellationToken cancellationToken) + { + var result = await store.RetryAsync( + ResolveTenant(tenantAccessor, context), + id, + targetId, + cancellationToken).ConfigureAwait(false); + return ToMutationResult(result); + } + + private static async Task TransitionAsync( + HttpContext context, + IStellaOpsTenantAccessor tenantAccessor, + IDeploymentCompatibilityStore store, + string deploymentId, + IReadOnlyCollection allowedStatuses, + string nextStatus, + string eventType, + string message, + bool complete, + CancellationToken cancellationToken) + { + var result = await store.TransitionAsync( + ResolveTenant(tenantAccessor, context), + deploymentId, + allowedStatuses, + nextStatus, + eventType, + message, + complete, + cancellationToken).ConfigureAwait(false); + return ToMutationResult(result); + } + + private static IResult ToMutationResult(DeploymentMutationResult result) + { + return result.Status switch + { + DeploymentMutationStatus.Success => Results.Ok(new + { + success = true, + message = result.Message, + deployment = result.Deployment, + }), + DeploymentMutationStatus.Conflict => Results.Conflict(new + { + success = false, + message = result.Message, + }), + _ => Results.NotFound(), + }; + } + + private static string ResolveTenant(IStellaOpsTenantAccessor tenantAccessor, HttpContext context) + { + if (!string.IsNullOrWhiteSpace(tenantAccessor.TenantId)) + { + return tenantAccessor.TenantId; + } + + throw new InvalidOperationException( + $"A tenant is required for deployment compatibility operations on route '{context.Request.Path}'."); + } + + private static string? NormalizeStrategy(string? strategy) + { + return (strategy ?? string.Empty).Trim().ToLowerInvariant() switch + { + "rolling" => "rolling", + "canary" => "canary", + "blue_green" => "blue_green", + "all_at_once" => "all_at_once", + "recreate" => "all_at_once", + "ab-release" => "blue_green", + _ => null, + }; + } + + private static HashSet Csv(params string?[] values) + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + foreach (var part in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + set.Add(part); + } + } + + return set; + } + + private static DeploymentSummaryDto ToSummary(DeploymentDto deployment) + { + return new DeploymentSummaryDto + { + Id = deployment.Id, + ReleaseId = deployment.ReleaseId, + ReleaseName = deployment.ReleaseName, + ReleaseVersion = deployment.ReleaseVersion, + EnvironmentId = deployment.EnvironmentId, + EnvironmentName = deployment.EnvironmentName, + Status = deployment.Status, + Strategy = deployment.Strategy, + Progress = deployment.Progress, + StartedAt = deployment.StartedAt, + CompletedAt = deployment.CompletedAt, + InitiatedBy = deployment.InitiatedBy, + TargetCount = deployment.TargetCount, + CompletedTargets = deployment.CompletedTargets, + FailedTargets = deployment.FailedTargets, + }; + } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/EvidenceEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/EvidenceEndpoints.cs new file mode 100644 index 000000000..7ec369b95 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/EvidenceEndpoints.cs @@ -0,0 +1,329 @@ +using Microsoft.AspNetCore.Mvc; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using StellaOps.Auth.ServerIntegration.Tenancy; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Endpoints; + +/// +/// Evidence management endpoints for the release orchestrator. +/// Provides listing, inspection, verification, export, and timeline +/// operations for release evidence packets. +/// Routes: /api/release-orchestrator/evidence +/// +public static class EvidenceEndpoints +{ + public static IEndpointRouteBuilder MapEvidenceEndpoints(this IEndpointRouteBuilder app) + { + MapEvidenceGroup(app, "/api/release-orchestrator/evidence", includeRouteNames: true); + MapEvidenceGroup(app, "/api/v1/release-orchestrator/evidence", includeRouteNames: false); + + return app; + } + + private static void MapEvidenceGroup( + IEndpointRouteBuilder app, + string prefix, + bool includeRouteNames) + { + var group = app.MapGroup(prefix) + .WithTags("Evidence") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseRead) + .RequireTenant(); + + var list = group.MapGet(string.Empty, ListEvidence) + .WithDescription("Return a paginated list of evidence packets for the calling tenant, optionally filtered by release, type, and creation time window. Each packet includes its identifier, associated release, evidence type, content hash, and creation timestamp."); + if (includeRouteNames) + { + list.WithName("Evidence_List"); + } + + var detail = group.MapGet("/{id}", GetEvidence) + .WithDescription("Return the full evidence packet record for the specified ID including release association, evidence type, content hash, algorithm, size, and metadata. Returns 404 when the evidence packet does not exist in the tenant."); + if (includeRouteNames) + { + detail.WithName("Evidence_Get"); + } + + var verify = group.MapPost("/{id}/verify", VerifyEvidence) + .WithDescription("Verify the integrity of the specified evidence packet by recomputing and comparing its content hash. Returns the verification result including the computed hash, algorithm used, and whether the content matches the stored digest."); + if (includeRouteNames) + { + verify.WithName("Evidence_Verify"); + } + + var export = group.MapGet("/{id}/export", ExportEvidence) + .WithDescription("Export the specified evidence packet as a self-contained JSON bundle suitable for offline audit. The bundle includes the evidence metadata, content, and verification hashes."); + if (includeRouteNames) + { + export.WithName("Evidence_Export"); + } + + var raw = group.MapGet("/{id}/raw", DownloadRaw) + .WithDescription("Download the raw binary content of the specified evidence packet. Returns the unprocessed evidence payload with Content-Type application/octet-stream. Returns 404 when the evidence packet does not exist."); + if (includeRouteNames) + { + raw.WithName("Evidence_DownloadRaw"); + } + + var timeline = group.MapGet("/{id}/timeline", GetTimeline) + .WithDescription("Return the chronological event timeline for the specified evidence packet including creation, verification, export, and access events. Useful for audit trails and provenance tracking."); + if (includeRouteNames) + { + timeline.WithName("Evidence_Timeline"); + } + } + + // ---- Handlers ---- + + private static IResult ListEvidence( + [FromQuery] string? releaseId, + [FromQuery] string? type, + [FromQuery] string? search, + [FromQuery] string? sortField, + [FromQuery] string? sortOrder, + [FromQuery] int? page, + [FromQuery] int? pageSize) + { + var packets = SeedData.EvidencePackets.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(releaseId)) + { + packets = packets.Where(e => + string.Equals(e.ReleaseId, releaseId, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(type)) + { + packets = packets.Where(e => + string.Equals(e.Type, type, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.ToLowerInvariant(); + packets = packets.Where(e => + e.Id.Contains(term, StringComparison.OrdinalIgnoreCase) || + e.ReleaseId.Contains(term, StringComparison.OrdinalIgnoreCase) || + e.Type.Contains(term, StringComparison.OrdinalIgnoreCase) || + e.Description.Contains(term, StringComparison.OrdinalIgnoreCase)); + } + + var sorted = (sortField?.ToLowerInvariant(), sortOrder?.ToLowerInvariant()) switch + { + ("type", "asc") => packets.OrderBy(e => e.Type), + ("type", _) => packets.OrderByDescending(e => e.Type), + ("releaseId", "asc") => packets.OrderBy(e => e.ReleaseId), + ("releaseId", _) => packets.OrderByDescending(e => e.ReleaseId), + (_, "asc") => packets.OrderBy(e => e.CreatedAt), + _ => packets.OrderByDescending(e => e.CreatedAt), + }; + + var all = sorted.ToList(); + var effectivePage = Math.Max(page ?? 1, 1); + var effectivePageSize = Math.Clamp(pageSize ?? 20, 1, 100); + var items = all.Skip((effectivePage - 1) * effectivePageSize).Take(effectivePageSize).ToList(); + + return Results.Ok(new + { + items, + totalCount = all.Count, + page = effectivePage, + pageSize = effectivePageSize, + }); + } + + private static IResult GetEvidence(string id) + { + var packet = SeedData.EvidencePackets.FirstOrDefault(e => e.Id == id); + return packet is not null ? Results.Ok(packet) : Results.NotFound(); + } + + private static IResult VerifyEvidence(string id) + { + var packet = SeedData.EvidencePackets.FirstOrDefault(e => e.Id == id); + if (packet is null) return Results.NotFound(); + + var content = BuildRawContent(packet); + var computedHash = ComputeHash(content, packet.Algorithm); + var verified = string.Equals(packet.Hash, computedHash, StringComparison.OrdinalIgnoreCase); + + return Results.Ok(new + { + evidenceId = packet.Id, + verified, + hash = packet.Hash, + computedHash, + algorithm = packet.Algorithm, + verifiedAt = packet.VerifiedAt ?? packet.CreatedAt, + message = verified + ? "Evidence integrity verified successfully." + : "Evidence integrity verification failed.", + }); + } + + private static IResult ExportEvidence(string id) + { + var packet = SeedData.EvidencePackets.FirstOrDefault(e => e.Id == id); + if (packet is null) return Results.NotFound(); + + var content = BuildRawContent(packet); + var computedHash = ComputeHash(content, packet.Algorithm); + var exportedAt = packet.VerifiedAt ?? packet.CreatedAt; + + var bundle = new + { + exportVersion = "1.0", + exportedAt, + evidence = packet, + contentBase64 = Convert.ToBase64String(content), + verification = new + { + hash = packet.Hash, + computedHash, + algorithm = packet.Algorithm, + verified = string.Equals(packet.Hash, computedHash, StringComparison.OrdinalIgnoreCase), + }, + }; + + return Results.Json(bundle, contentType: "application/json"); + } + + private static IResult DownloadRaw(string id) + { + var packet = SeedData.EvidencePackets.FirstOrDefault(e => e.Id == id); + if (packet is null) return Results.NotFound(); + + var content = BuildRawContent(packet); + + return Results.Bytes(content, contentType: "application/octet-stream", + fileDownloadName: $"{packet.Id}.bin"); + } + + private static IResult GetTimeline(string id) + { + var packet = SeedData.EvidencePackets.FirstOrDefault(e => e.Id == id); + if (packet is null) return Results.NotFound(); + + if (SeedData.Timelines.TryGetValue(id, out var events)) + { + return Results.Ok(new { evidenceId = id, events }); + } + + return Results.Ok(new { evidenceId = id, events = Array.Empty() }); + } + + private static byte[] BuildRawContent(EvidencePacketDto packet) + { + return JsonSerializer.SerializeToUtf8Bytes(new + { + evidenceId = packet.Id, + releaseId = packet.ReleaseId, + type = packet.Type, + description = packet.Description, + status = packet.Status, + createdBy = packet.CreatedBy, + createdAt = packet.CreatedAt, + }); + } + + private static string ComputeHash(byte[] content, string algorithm) + { + var normalized = algorithm.Trim().ToUpperInvariant(); + return normalized switch + { + "SHA-256" => $"sha256:{Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant()}", + _ => throw new NotSupportedException($"Unsupported evidence hash algorithm '{algorithm}'."), + }; + } + + // ---- DTOs ---- + + public sealed record EvidencePacketDto + { + public required string Id { get; init; } + public required string ReleaseId { get; init; } + public required string Type { get; init; } + public required string Description { get; init; } + public required string Hash { get; init; } + public required string Algorithm { get; init; } + public long SizeBytes { get; init; } + public required string Status { get; init; } + public required string CreatedBy { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? VerifiedAt { get; init; } + } + + public sealed record EvidenceTimelineEventDto + { + public required string Id { get; init; } + public required string EvidenceId { get; init; } + public required string EventType { get; init; } + public required string Actor { get; init; } + public required string Message { get; init; } + public DateTimeOffset Timestamp { get; init; } + } + + // ---- Seed Data ---- + + internal static class SeedData + { + public static readonly List EvidencePackets = new() + { + CreatePacket("evi-001", "rel-001", "sbom", "Software Bill of Materials for Platform Release v1.2.3", 24576, "verified", "ci-pipeline", "2026-01-10T08:15:00Z", "2026-01-10T08:16:00Z"), + CreatePacket("evi-002", "rel-001", "attestation", "Build provenance attestation for Platform Release v1.2.3", 8192, "verified", "attestor-service", "2026-01-10T08:20:00Z", "2026-01-10T08:21:00Z"), + CreatePacket("evi-003", "rel-002", "scan-result", "Security scan results for Platform Release v1.3.0-rc1", 16384, "verified", "scanner-service", "2026-01-11T10:30:00Z", "2026-01-11T10:31:00Z"), + CreatePacket("evi-004", "rel-003", "policy-decision", "Policy gate evaluation for Hotfix v1.2.4", 4096, "pending", "policy-engine", "2026-01-12T06:15:00Z", null), + CreatePacket("evi-005", "rel-001", "deployment-log", "Production deployment log for Platform Release v1.2.3", 32768, "verified", "deploy-bot", "2026-01-11T14:35:00Z", "2026-01-11T14:36:00Z"), + }; + + public static readonly Dictionary> Timelines = new() + { + ["evi-001"] = new() + { + new() { Id = "evt-e001", EvidenceId = "evi-001", EventType = "created", Actor = "ci-pipeline", Message = "SBOM evidence packet created from build pipeline", Timestamp = DateTimeOffset.Parse("2026-01-10T08:15:00Z") }, + new() { Id = "evt-e002", EvidenceId = "evi-001", EventType = "hashed", Actor = "evidence-locker", Message = "Content hash computed: SHA-256", Timestamp = DateTimeOffset.Parse("2026-01-10T08:15:30Z") }, + new() { Id = "evt-e003", EvidenceId = "evi-001", EventType = "verified", Actor = "attestor-service", Message = "Integrity verification passed", Timestamp = DateTimeOffset.Parse("2026-01-10T08:16:00Z") }, + new() { Id = "evt-e004", EvidenceId = "evi-001", EventType = "exported", Actor = "admin", Message = "Evidence bundle exported for audit", Timestamp = DateTimeOffset.Parse("2026-01-10T12:00:00Z") }, + }, + ["evi-002"] = new() + { + new() { Id = "evt-e005", EvidenceId = "evi-002", EventType = "created", Actor = "attestor-service", Message = "Build provenance attestation generated", Timestamp = DateTimeOffset.Parse("2026-01-10T08:20:00Z") }, + new() { Id = "evt-e006", EvidenceId = "evi-002", EventType = "verified", Actor = "attestor-service", Message = "Attestation signature verified", Timestamp = DateTimeOffset.Parse("2026-01-10T08:21:00Z") }, + }, + }; + + private static EvidencePacketDto CreatePacket( + string id, + string releaseId, + string type, + string description, + long sizeBytes, + string status, + string createdBy, + string createdAt, + string? verifiedAt) + { + var packet = new EvidencePacketDto + { + Id = id, + ReleaseId = releaseId, + Type = type, + Description = description, + Algorithm = "SHA-256", + SizeBytes = sizeBytes, + Status = status, + CreatedBy = createdBy, + CreatedAt = DateTimeOffset.Parse(createdAt), + VerifiedAt = verifiedAt is null ? null : DateTimeOffset.Parse(verifiedAt), + Hash = string.Empty, + }; + + return packet with + { + Hash = ComputeHash(BuildRawContent(packet), packet.Algorithm), + }; + } + } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/FirstSignalEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/FirstSignalEndpoints.cs new file mode 100644 index 000000000..1cb2dc433 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/FirstSignalEndpoints.cs @@ -0,0 +1,120 @@ +using Microsoft.AspNetCore.Mvc; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.JobEngine.Core.Services; +using StellaOps.ReleaseOrchestrator.WebApi.Contracts; +using StellaOps.ReleaseOrchestrator.WebApi.Services; +using static StellaOps.Localization.T; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Endpoints; + +/// +/// REST API endpoint for first signal (TTFS). +/// +public static class FirstSignalEndpoints +{ + public static RouteGroupBuilder MapFirstSignalEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/release-orchestrator/runs") + .WithTags("Release Orchestrator Runs") + .RequireAuthorization(ReleaseOrchestratorPolicies.Read) + .RequireTenant(); + + group.MapGet("{runId:guid}/first-signal", GetFirstSignal) + .WithName("ReleaseOrchestrator_GetFirstSignal") + .WithDescription(_t("orchestrator.first_signal.get_description")); + + return group; + } + + private static async Task GetFirstSignal( + HttpContext context, + [FromRoute] Guid runId, + [FromHeader(Name = "If-None-Match")] string? ifNoneMatch, + [FromServices] TenantResolver tenantResolver, + [FromServices] IFirstSignalService firstSignalService, + CancellationToken cancellationToken) + { + try + { + var tenantId = tenantResolver.Resolve(context); + var result = await firstSignalService + .GetFirstSignalAsync(runId, tenantId, ifNoneMatch, cancellationToken) + .ConfigureAwait(false); + + context.Response.Headers["Cache-Status"] = result.CacheHit ? "hit" : "miss"; + if (!string.IsNullOrWhiteSpace(result.Source)) + { + context.Response.Headers["X-FirstSignal-Source"] = result.Source; + } + + if (!string.IsNullOrWhiteSpace(result.ETag)) + { + context.Response.Headers.ETag = result.ETag; + context.Response.Headers.CacheControl = "private, max-age=60"; + } + + return result.Status switch + { + FirstSignalResultStatus.Found => Results.Ok(MapToResponse(runId, result)), + FirstSignalResultStatus.NotModified => Results.StatusCode(StatusCodes.Status304NotModified), + FirstSignalResultStatus.NotFound => Results.NotFound(), + FirstSignalResultStatus.NotAvailable => Results.NoContent(), + _ => Results.Problem(_t("orchestrator.first_signal.error.server_error")) + }; + } + catch (InvalidOperationException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + } + + private static FirstSignalResponse MapToResponse(Guid runId, FirstSignalResult result) + { + if (result.Signal is null) + { + return new FirstSignalResponse + { + RunId = runId, + FirstSignal = null, + SummaryEtag = result.ETag ?? string.Empty + }; + } + + var signal = result.Signal; + + return new FirstSignalResponse + { + RunId = runId, + SummaryEtag = result.ETag ?? string.Empty, + FirstSignal = new FirstSignalDto + { + Type = signal.Kind.ToString().ToLowerInvariant(), + Stage = signal.Phase.ToString().ToLowerInvariant(), + Step = null, + Message = signal.Summary, + At = signal.Timestamp, + Artifact = new FirstSignalArtifactDto + { + Kind = signal.Scope.Type, + Range = null + }, + LastKnownOutcome = signal.LastKnownOutcome is null + ? null + : new FirstSignalLastKnownOutcomeDto + { + SignatureId = signal.LastKnownOutcome.SignatureId, + ErrorCode = signal.LastKnownOutcome.ErrorCode, + Token = signal.LastKnownOutcome.Token, + Excerpt = signal.LastKnownOutcome.Excerpt, + Confidence = signal.LastKnownOutcome.Confidence, + FirstSeenAt = signal.LastKnownOutcome.FirstSeenAt, + HitCount = signal.LastKnownOutcome.HitCount + } + } + }; + } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseControlV2Endpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseControlV2Endpoints.cs new file mode 100644 index 000000000..430b94d5d --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseControlV2Endpoints.cs @@ -0,0 +1,544 @@ +using Microsoft.AspNetCore.Mvc; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.ReleaseOrchestrator.WebApi.Contracts; +using StellaOps.ReleaseOrchestrator.WebApi.Services; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Endpoints; + +/// +/// v2 contract adapters for Pack-driven release control routes. +/// +public static class ReleaseControlV2Endpoints +{ + public static IEndpointRouteBuilder MapReleaseControlV2Endpoints(this IEndpointRouteBuilder app) + { + MapApprovalsV2(app); + MapRunsV2(app); + MapEnvironmentsV2(app); + return app; + } + + private static void MapApprovalsV2(IEndpointRouteBuilder app) + { + var approvals = app.MapGroup("/api/v1/approvals") + .WithTags("Approvals v2") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseRead) + .RequireTenant(); + + approvals.MapGet(string.Empty, ListApprovals) + .WithName("ApprovalsV2_List") + .WithDescription("Return the v2 approval queue for the calling tenant, including per-request digest confidence, reachability-weighted risk score, and ops-data integrity score. Optionally filtered by status and target environment. Designed for the enhanced approval UX."); + + approvals.MapGet("/{id}", GetApprovalDetail) + .WithName("ApprovalsV2_Get") + .WithDescription("Return the v2 decision packet for the specified approval, including the full policy gate evaluation trace, reachability-adjusted finding counts, confidence bands, and all structured evidence references required to make an informed approval decision."); + + approvals.MapGet("/{id}/gates", GetApprovalGates) + .WithName("ApprovalsV2_Gates") + .WithDescription("Return the detailed gate evaluation trace for the specified v2 approval, showing each policy gate's inputs, computed verdict, confidence weight, and any override history. Used by approvers to understand the basis for automated gate results."); + + approvals.MapGet("/{id}/evidence", GetApprovalEvidence) + .WithName("ApprovalsV2_Evidence") + .WithDescription("Return the structured evidence reference set attached to the specified v2 approval decision packet, including SBOM digests, attestation references, scan results, and provenance records. Used to verify the completeness of the evidence chain before approving."); + + approvals.MapGet("/{id}/security-snapshot", GetApprovalSecuritySnapshot) + .WithName("ApprovalsV2_SecuritySnapshot") + .WithDescription("Return the security snapshot computed for the specified approval context, including reachability-adjusted critical and high finding counts (CritR, HighR), SBOM coverage percentage, and the weighted risk score used in the approval decision packet."); + + approvals.MapGet("/{id}/ops-health", GetApprovalOpsHealth) + .WithName("ApprovalsV2_OpsHealth") + .WithDescription("Return the operational data-integrity confidence indicators for the specified approval, including staleness of scan data, missing coverage gaps, and pipeline signal freshness. Low confidence scores reduce the defensibility of approval decisions."); + + approvals.MapPost("/{id}/decision", PostApprovalDecision) + .WithName("ApprovalsV2_Decision") + .WithDescription("Apply a structured decision action (approve, reject, defer, escalate) to the specified v2 approval, attributing the decision to the calling principal with an optional comment. Returns 409 if the approval is not in a state that accepts decisions.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseApprove); + } + + private static void MapRunsV2(IEndpointRouteBuilder app) + { + static void MapRunGroup(RouteGroupBuilder runs) + { + runs.MapGet("/{id}", GetRunDetail) + .WithDescription("Return the promotion run detail timeline for the specified run ID, including each pipeline stage with status, duration, and attached evidence references. Provides the full chronological execution narrative for a release promotion run."); + + runs.MapGet("/{id}/steps", GetRunSteps) + .WithDescription("Return the checkpoint-level step list for the specified promotion run, with per-step status, start/end timestamps, and whether the step produced captured evidence. Used to navigate individual steps in a long-running promotion pipeline."); + + runs.MapGet("/{id}/steps/{stepId}", GetRunStepDetail) + .WithDescription("Return the detailed record for a single promotion run step including its structured log output, captured evidence references, policy gate results, and duration. Used for deep inspection of a specific checkpoint within a promotion run."); + + runs.MapPost("/{id}/rollback", TriggerRollback) + .WithDescription("Initiate a rollback of the specified promotion run, computing a guard-state projection that identifies any post-deployment state that must be unwound before the rollback can proceed. Returns the rollback plan with an estimated blast radius assessment.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseApprove); + } + + var apiRuns = app.MapGroup("/api/v1/runs") + .WithTags("Runs v2") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseRead) + .RequireTenant(); + MapRunGroup(apiRuns); + apiRuns.WithGroupName("runs-v2"); + + var legacyV1Runs = app.MapGroup("/v1/runs") + .WithTags("Runs v2") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseRead) + .RequireTenant(); + MapRunGroup(legacyV1Runs); + legacyV1Runs.WithGroupName("runs-v1-compat"); + } + + private static void MapEnvironmentsV2(IEndpointRouteBuilder app) + { + var environments = app.MapGroup("/api/v1/environments") + .WithTags("Environments v2") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseRead) + .RequireTenant(); + + environments.MapGet("/{id}", GetEnvironmentDetail) + .WithName("EnvironmentsV2_Get") + .WithDescription("Return the standardized environment detail header for the specified environment ID, including its name, tier (dev/stage/prod), current active release, and promotion pipeline position. Used to populate the environment context in release dashboards."); + + environments.MapGet("/{id}/deployments", GetEnvironmentDeployments) + .WithName("EnvironmentsV2_Deployments") + .WithDescription("Return the deployment history for the specified environment ordered by deployment timestamp descending, including each release version, deployment status, and rollback availability. Used for environment-scoped audit and change management views."); + + environments.MapGet("/{id}/security-snapshot", GetEnvironmentSecuritySnapshot) + .WithName("EnvironmentsV2_SecuritySnapshot") + .WithDescription("Return the current security posture snapshot for the specified environment, including reachability-adjusted critical and high finding counts, SBOM coverage, and the top-ranked risks by exploitability. Refreshed on each new deployment or scan cycle."); + + environments.MapGet("/{id}/evidence", GetEnvironmentEvidence) + .WithName("EnvironmentsV2_Evidence") + .WithDescription("Return the evidence snapshot and export references for the specified environment, including the active attestation bundle, SBOM digest, scan result references, and the evidence locker ID for compliance archiving. Used for environment-level attestation workflows."); + + environments.MapGet("/{id}/ops-health", GetEnvironmentOpsHealth) + .WithName("EnvironmentsV2_OpsHealth") + .WithDescription("Return the operational data-confidence and health signals for the specified environment, including scan data staleness, missing SBOM coverage, pipeline signal freshness, and any active incidents affecting the environment's reliability score."); + } + + private static IResult ListApprovals( + [FromQuery] string? status, + [FromQuery] string? targetEnvironment) + { + var rows = ApprovalEndpoints.SeedData.Approvals + .Select(ApprovalEndpoints.WithDerivedSignals) + .Select(ApprovalEndpoints.ToSummary) + .OrderByDescending(row => row.RequestedAt, StringComparer.Ordinal) + .AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(status)) + { + rows = rows.Where(row => string.Equals(row.Status, status, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(targetEnvironment)) + { + rows = rows.Where(row => string.Equals(row.TargetEnvironment, targetEnvironment, StringComparison.OrdinalIgnoreCase)); + } + + return Results.Ok(rows.ToList()); + } + + private static IResult GetApprovalDetail(string id) + { + var approval = FindApproval(id); + return approval is null ? Results.NotFound() : Results.Ok(approval); + } + + private static IResult GetApprovalGates(string id) + { + var approval = FindApproval(id); + return approval is null ? Results.NotFound() : Results.Ok(new + { + approvalId = approval.Id, + decisionDigest = approval.DecisionDigest, + gates = approval.GateResults.OrderBy(g => g.GateName, StringComparer.Ordinal).ToList(), + }); + } + + private static IResult GetApprovalEvidence(string id) + { + var approval = FindApproval(id); + return approval is null ? Results.NotFound() : Results.Ok(new + { + approvalId = approval.Id, + packet = approval.EvidencePacket, + manifestDigest = approval.ManifestDigest, + decisionDigest = approval.DecisionDigest, + }); + } + + private static IResult GetApprovalSecuritySnapshot(string id) + { + var approval = FindApproval(id); + return approval is null ? Results.NotFound() : Results.Ok(new + { + approvalId = approval.Id, + manifestDigest = approval.ManifestDigest, + risk = approval.RiskSnapshot, + reachability = approval.ReachabilityCoverage, + topFindings = BuildTopFindings(approval), + }); + } + + private static IResult GetApprovalOpsHealth(string id) + { + var approval = FindApproval(id); + return approval is null ? Results.NotFound() : Results.Ok(new + { + approvalId = approval.Id, + opsConfidence = approval.OpsConfidence, + impactedJobs = BuildImpactedJobs(approval.TargetEnvironment), + }); + } + + private static IResult PostApprovalDecision(string id, [FromBody] ApprovalDecisionRequest request) + { + var idx = ApprovalEndpoints.SeedData.Approvals.FindIndex(approval => + string.Equals(approval.Id, id, StringComparison.OrdinalIgnoreCase)); + if (idx < 0) + { + return Results.NotFound(); + } + + var approval = ApprovalEndpoints.WithDerivedSignals(ApprovalEndpoints.SeedData.Approvals[idx]); + var normalizedAction = (request.Action ?? string.Empty).Trim().ToLowerInvariant(); + var actor = string.IsNullOrWhiteSpace(request.Actor) ? "release-manager" : request.Actor.Trim(); + var timestamp = DateTimeOffset.Parse("2026-02-19T03:20:00Z").ToString("O"); + + var nextStatus = normalizedAction switch + { + "approve" => approval.CurrentApprovals + 1 >= approval.RequiredApprovals ? "approved" : approval.Status, + "reject" => "rejected", + "defer" => "pending", + "escalate" => "pending", + _ => approval.Status, + }; + + var updated = approval with + { + Status = nextStatus, + CurrentApprovals = normalizedAction == "approve" + ? Math.Min(approval.RequiredApprovals, approval.CurrentApprovals + 1) + : approval.CurrentApprovals, + Actions = approval.Actions + .Concat(new[] + { + new ApprovalEndpoints.ApprovalActionRecordDto + { + Id = $"act-{approval.Actions.Count + 1}", + ApprovalId = approval.Id, + Action = normalizedAction is "approve" or "reject" ? normalizedAction : "comment", + Actor = actor, + Comment = string.IsNullOrWhiteSpace(request.Comment) + ? $"Decision action: {normalizedAction}" + : request.Comment.Trim(), + Timestamp = timestamp, + }, + }) + .ToList(), + }; + + ApprovalEndpoints.SeedData.Approvals[idx] = updated; + return Results.Ok(ApprovalEndpoints.WithDerivedSignals(updated)); + } + + private static IResult GetRunDetail(string id) + { + if (!RunCatalog.TryGetValue(id, out var run)) + { + return Results.NotFound(); + } + + return Results.Ok(run with + { + Steps = run.Steps.OrderBy(step => step.Order).ToList(), + }); + } + + private static IResult GetRunSteps(string id) + { + if (!RunCatalog.TryGetValue(id, out var run)) + { + return Results.NotFound(); + } + + return Results.Ok(run.Steps.OrderBy(step => step.Order).ToList()); + } + + private static IResult GetRunStepDetail(string id, string stepId) + { + if (!RunCatalog.TryGetValue(id, out var run)) + { + return Results.NotFound(); + } + + var step = run.Steps.FirstOrDefault(item => string.Equals(item.StepId, stepId, StringComparison.OrdinalIgnoreCase)); + if (step is null) + { + return Results.NotFound(); + } + + return Results.Ok(step); + } + + private static IResult TriggerRollback(string id, [FromBody] RollbackRequest? request) + { + if (!RunCatalog.TryGetValue(id, out var run)) + { + return Results.NotFound(); + } + + var rollbackAllowed = string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase) + || string.Equals(run.Status, "warning", StringComparison.OrdinalIgnoreCase) + || string.Equals(run.Status, "degraded", StringComparison.OrdinalIgnoreCase); + + if (!rollbackAllowed) + { + return Results.BadRequest(new + { + error = "rollback_guard_blocked", + reason = "Rollback is only allowed when run status is failed/warning/degraded.", + }); + } + + var rollbackRunId = $"rb-{id}"; + return Results.Accepted($"/api/v1/runs/{rollbackRunId}", new + { + rollbackRunId, + sourceRunId = id, + scope = request?.Scope ?? "full", + status = "queued", + requestedAt = "2026-02-19T03:22:00Z", + preview = request?.Preview ?? true, + }); + } + + private static IResult GetEnvironmentDetail(string id) + { + if (!EnvironmentCatalog.TryGetValue(id, out var env)) + { + return Results.NotFound(); + } + + return Results.Ok(env); + } + + private static IResult GetEnvironmentDeployments(string id) + { + if (!EnvironmentCatalog.TryGetValue(id, out var env)) + { + return Results.NotFound(); + } + + return Results.Ok(env.RecentDeployments.OrderByDescending(item => item.DeployedAt).ToList()); + } + + private static IResult GetEnvironmentSecuritySnapshot(string id) + { + if (!EnvironmentCatalog.TryGetValue(id, out var env)) + { + return Results.NotFound(); + } + + return Results.Ok(new + { + environmentId = env.EnvironmentId, + manifestDigest = env.ManifestDigest, + risk = env.RiskSnapshot, + reachability = env.ReachabilityCoverage, + sbomStatus = env.SbomStatus, + topFindings = env.TopFindings, + }); + } + + private static IResult GetEnvironmentEvidence(string id) + { + if (!EnvironmentCatalog.TryGetValue(id, out var env)) + { + return Results.NotFound(); + } + + return Results.Ok(new + { + environmentId = env.EnvironmentId, + evidence = env.Evidence, + }); + } + + private static IResult GetEnvironmentOpsHealth(string id) + { + if (!EnvironmentCatalog.TryGetValue(id, out var env)) + { + return Results.NotFound(); + } + + return Results.Ok(new + { + environmentId = env.EnvironmentId, + opsConfidence = env.OpsConfidence, + impactedJobs = BuildImpactedJobs(env.EnvironmentName), + }); + } + + private static ApprovalEndpoints.ApprovalDto? FindApproval(string id) + { + var approval = ApprovalEndpoints.SeedData.Approvals + .FirstOrDefault(item => string.Equals(item.Id, id, StringComparison.OrdinalIgnoreCase)); + return approval is null ? null : ApprovalEndpoints.WithDerivedSignals(approval); + } + + private static IReadOnlyList BuildTopFindings(ApprovalEndpoints.ApprovalDto approval) + { + return new[] + { + new + { + cve = "CVE-2026-1234", + component = approval.ReleaseComponents.FirstOrDefault()?.Name ?? "unknown-component", + severity = "critical", + reachability = "reachable", + }, + new + { + cve = "CVE-2026-2088", + component = approval.ReleaseComponents.Skip(1).FirstOrDefault()?.Name ?? approval.ReleaseComponents.FirstOrDefault()?.Name ?? "unknown-component", + severity = "high", + reachability = "not_reachable", + }, + }; + } + + private static IReadOnlyList BuildImpactedJobs(string targetEnvironment) + { + var ops = ReleaseControlSignalCatalog.GetOpsConfidence(targetEnvironment); + return ops.Signals + .Select((signal, index) => new + { + job = $"ops-job-{index + 1}", + signal, + status = ops.Status, + }) + .ToList(); + } + + private static readonly IReadOnlyDictionary RunCatalog = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["run-001"] = new( + RunId: "run-001", + ReleaseId: "rel-002", + ManifestDigest: "sha256:beef000000000000000000000000000000000000000000000000000000000002", + Status: "warning", + StartedAt: "2026-02-19T02:10:00Z", + CompletedAt: "2026-02-19T02:19:00Z", + RollbackGuard: "armed", + Steps: + [ + new RunStepDto("step-01", 1, "Materialize Inputs", "passed", "2026-02-19T02:10:00Z", "2026-02-19T02:11:00Z", "/api/v1/evidence/thread/sha256-materialize", "/logs/run-001/step-01.log"), + new RunStepDto("step-02", 2, "Policy Evaluation", "passed", "2026-02-19T02:11:00Z", "2026-02-19T02:13:00Z", "/api/v1/evidence/thread/sha256-policy", "/logs/run-001/step-02.log"), + new RunStepDto("step-03", 3, "Deploy Stage", "warning", "2026-02-19T02:13:00Z", "2026-02-19T02:19:00Z", "/api/v1/evidence/thread/sha256-deploy", "/logs/run-001/step-03.log"), + ]), + }; + + private static readonly IReadOnlyDictionary EnvironmentCatalog = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["env-production"] = new( + EnvironmentId: "env-production", + EnvironmentName: "production", + Region: "us-east", + DeployStatus: "degraded", + SbomStatus: "stale", + ManifestDigest: "sha256:beef000000000000000000000000000000000000000000000000000000000002", + RiskSnapshot: ReleaseControlSignalCatalog.GetRiskSnapshot("rel-002", "production"), + ReachabilityCoverage: ReleaseControlSignalCatalog.GetCoverage("rel-002"), + OpsConfidence: ReleaseControlSignalCatalog.GetOpsConfidence("production"), + TopFindings: + [ + "CVE-2026-1234 reachable in user-service", + "Runtime ingest lag reduces confidence to WARN", + ], + RecentDeployments: + [ + new EnvironmentDeploymentDto("run-001", "rel-002", "1.3.0-rc1", "warning", "2026-02-19T02:19:00Z"), + new EnvironmentDeploymentDto("run-000", "rel-001", "1.2.3", "passed", "2026-02-18T08:30:00Z"), + ], + Evidence: new EnvironmentEvidenceDto( + "env-snapshot-production-20260219", + "sha256:evidence-production-20260219", + "/api/v1/evidence/thread/sha256:evidence-production-20260219")), + ["env-staging"] = new( + EnvironmentId: "env-staging", + EnvironmentName: "staging", + Region: "us-east", + DeployStatus: "healthy", + SbomStatus: "fresh", + ManifestDigest: "sha256:beef000000000000000000000000000000000000000000000000000000000001", + RiskSnapshot: ReleaseControlSignalCatalog.GetRiskSnapshot("rel-001", "staging"), + ReachabilityCoverage: ReleaseControlSignalCatalog.GetCoverage("rel-001"), + OpsConfidence: ReleaseControlSignalCatalog.GetOpsConfidence("staging"), + TopFindings: + [ + "No critical reachable findings.", + ], + RecentDeployments: + [ + new EnvironmentDeploymentDto("run-000", "rel-001", "1.2.3", "passed", "2026-02-18T08:30:00Z"), + ], + Evidence: new EnvironmentEvidenceDto( + "env-snapshot-staging-20260219", + "sha256:evidence-staging-20260219", + "/api/v1/evidence/thread/sha256:evidence-staging-20260219")), + }; +} + +public sealed record ApprovalDecisionRequest(string Action, string? Comment, string? Actor); + +public sealed record RollbackRequest(string? Scope, bool? Preview); + +public sealed record RunDetailDto( + string RunId, + string ReleaseId, + string ManifestDigest, + string Status, + string StartedAt, + string CompletedAt, + string RollbackGuard, + IReadOnlyList Steps); + +public sealed record RunStepDto( + string StepId, + int Order, + string Name, + string Status, + string StartedAt, + string CompletedAt, + string EvidenceThreadLink, + string LogArtifactLink); + +public sealed record EnvironmentDetailDto( + string EnvironmentId, + string EnvironmentName, + string Region, + string DeployStatus, + string SbomStatus, + string ManifestDigest, + PromotionRiskSnapshot RiskSnapshot, + HybridReachabilityCoverage ReachabilityCoverage, + OpsDataConfidence OpsConfidence, + IReadOnlyList TopFindings, + IReadOnlyList RecentDeployments, + EnvironmentEvidenceDto Evidence); + +public sealed record EnvironmentDeploymentDto( + string RunId, + string ReleaseId, + string ReleaseVersion, + string Status, + string DeployedAt); + +public sealed record EnvironmentEvidenceDto( + string SnapshotId, + string DecisionDigest, + string ThreadLink); diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseDashboardEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseDashboardEndpoints.cs new file mode 100644 index 000000000..8431d4c9e --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseDashboardEndpoints.cs @@ -0,0 +1,180 @@ +using Microsoft.AspNetCore.Mvc; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.ReleaseOrchestrator.WebApi.Services; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Endpoints; + +/// +/// Release dashboard endpoints consumed by the Console control plane. +/// +public static class ReleaseDashboardEndpoints +{ + public static IEndpointRouteBuilder MapReleaseDashboardEndpoints(this IEndpointRouteBuilder app) + { + MapForPrefix(app, "/api/v1/release-orchestrator", includeRouteNames: true); + MapForPrefix(app, "/api/release-orchestrator", includeRouteNames: false); + return app; + } + + private static void MapForPrefix(IEndpointRouteBuilder app, string prefix, bool includeRouteNames) + { + var group = app.MapGroup(prefix) + .WithTags("ReleaseDashboard") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseRead) + .RequireTenant(); + + var dashboard = group.MapGet("/dashboard", GetDashboard) + .WithDescription("Return a consolidated release dashboard snapshot for the Console control plane, including pending approvals, active promotions, recent deployments, and environment health indicators. Used by the UI to populate the main release management view."); + if (includeRouteNames) + { + dashboard.WithName("ReleaseDashboard_Get"); + } + + var approve = group.MapPost("/promotions/{id}/approve", ApprovePromotion) + .WithDescription("Record an approval decision on the specified pending promotion request, allowing the associated release to advance to the next environment. The calling principal must hold the release approval scope. Returns 404 when the promotion ID does not exist.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseApprove); + if (includeRouteNames) + { + approve.WithName("ReleaseDashboard_ApprovePromotion"); + } + + var reject = group.MapPost("/promotions/{id}/reject", RejectPromotion) + .WithDescription("Record a rejection decision on the specified pending promotion request with an optional rejection reason, blocking the release from advancing. The calling principal must hold the release approval scope. Returns 404 when the promotion ID does not exist.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseApprove); + if (includeRouteNames) + { + reject.WithName("ReleaseDashboard_RejectPromotion"); + } + } + + private static IResult GetDashboard(ReleasePromotionDecisionStore decisionStore) + { + var approvals = decisionStore.Apply(ApprovalEndpoints.SeedData.Approvals); + var snapshot = ReleaseDashboardSnapshotBuilder.Build(approvals: approvals); + + var releases = ReleaseEndpoints.SeedData.Releases; + + var byStatus = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["draft"] = releases.Count(r => string.Equals(r.Status, "draft", StringComparison.OrdinalIgnoreCase)), + ["ready"] = releases.Count(r => string.Equals(r.Status, "ready", StringComparison.OrdinalIgnoreCase)), + ["deploying"] = releases.Count(r => string.Equals(r.Status, "deploying", StringComparison.OrdinalIgnoreCase)), + ["deployed"] = releases.Count(r => string.Equals(r.Status, "deployed", StringComparison.OrdinalIgnoreCase)), + ["failed"] = releases.Count(r => string.Equals(r.Status, "failed", StringComparison.OrdinalIgnoreCase)), + }; + + var allGates = approvals.SelectMany(a => a.GateResults).ToList(); + var gatesSummary = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["pass"] = allGates.Count(g => string.Equals(g.Status, "passed", StringComparison.OrdinalIgnoreCase)), + ["warn"] = allGates.Count(g => string.Equals(g.Status, "warning", StringComparison.OrdinalIgnoreCase)), + ["block"] = allGates.Count(g => string.Equals(g.Status, "failed", StringComparison.OrdinalIgnoreCase)), + }; + + var recentActivity = snapshot.RecentReleases + .Select(r => new + { + r.Id, + r.Name, + r.Version, + r.Status, + r.CurrentEnvironment, + r.CreatedAt, + r.CreatedBy, + }) + .ToList(); + + return Results.Ok(new + { + totalReleases = releases.Count, + byStatus, + pendingApprovals = snapshot.PendingApprovals.Count, + activeDeployments = snapshot.ActiveDeployments.Count, + gatesSummary, + recentActivity, + pipeline = snapshot.PipelineData, + pendingApprovalDetails = snapshot.PendingApprovals, + activeDeploymentDetails = snapshot.ActiveDeployments, + }); + } + + private static IResult ApprovePromotion( + string id, + HttpContext context, + ReleasePromotionDecisionStore decisionStore) + { + if (!decisionStore.TryApprove( + id, + ResolveActor(context), + comment: null, + out var approval, + out var error)) + { + if (string.Equals(error, "promotion_not_found", StringComparison.Ordinal)) + { + return Results.NotFound(new { message = $"Promotion '{id}' was not found." }); + } + + return Results.Conflict(new { message = $"Promotion '{id}' is not pending." }); + } + + if (approval is null) + { + return Results.NotFound(new { message = $"Promotion '{id}' was not found." }); + } + + return Results.Ok(new + { + success = true, + promotionId = id, + action = "approved", + status = approval.Status, + currentApprovals = approval.CurrentApprovals, + }); + } + + private static IResult RejectPromotion( + string id, + HttpContext context, + ReleasePromotionDecisionStore decisionStore, + [FromBody] RejectPromotionRequest? request) + { + if (!decisionStore.TryReject( + id, + ResolveActor(context), + request?.Reason, + out var approval, + out var error)) + { + if (string.Equals(error, "promotion_not_found", StringComparison.Ordinal)) + { + return Results.NotFound(new { message = $"Promotion '{id}' was not found." }); + } + + return Results.Conflict(new { message = $"Promotion '{id}' is not pending." }); + } + + if (approval is null) + { + return Results.NotFound(new { message = $"Promotion '{id}' was not found." }); + } + + return Results.Ok(new + { + success = true, + promotionId = id, + action = "rejected", + status = approval.Status, + reason = request?.Reason, + }); + } + + private static string ResolveActor(HttpContext context) + { + return context.Request.Headers["X-StellaOps-Actor"].FirstOrDefault() + ?? context.User.Identity?.Name + ?? "system"; + } + + public sealed record RejectPromotionRequest(string? Reason); +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseEndpoints.cs new file mode 100644 index 000000000..60fd23373 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseEndpoints.cs @@ -0,0 +1,756 @@ +using Microsoft.AspNetCore.Mvc; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.ReleaseOrchestrator.WebApi.Services; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Endpoints; + +/// +/// Release management endpoints for the Orchestrator service. +/// Provides CRUD and lifecycle operations for managed releases. +/// Routes: /api/release-orchestrator/releases +/// +public static class ReleaseEndpoints +{ + private static readonly DateTimeOffset PreviewEvaluatedAt = DateTimeOffset.Parse("2026-02-19T03:15:00Z"); + + public static IEndpointRouteBuilder MapReleaseEndpoints(this IEndpointRouteBuilder app) + { + MapReleaseGroup(app, "/api/release-orchestrator/releases", includeRouteNames: true); + MapReleaseGroup(app, "/api/v1/release-orchestrator/releases", includeRouteNames: false); + + return app; + } + + private static void MapReleaseGroup( + IEndpointRouteBuilder app, + string prefix, + bool includeRouteNames) + { + var group = app.MapGroup(prefix) + .WithTags("Releases") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseRead) + .RequireTenant(); + + var list = group.MapGet(string.Empty, ListReleases) + .WithDescription("Return a paginated list of releases for the calling tenant, optionally filtered by status, environment, project, and creation time window. Each release record includes its name, version, current status, component count, and lifecycle timestamps."); + if (includeRouteNames) + { + list.WithName("Release_List"); + } + + var detail = group.MapGet("/{id}", GetRelease) + .WithDescription("Return the full release record for the specified ID including name, version, status, component list, approval gate state, and event history summary. Returns 404 when the release does not exist in the tenant."); + if (includeRouteNames) + { + detail.WithName("Release_Get"); + } + + var create = group.MapPost(string.Empty, CreateRelease) + .WithDescription("Create a new release record in Draft state. The release captures an intent to promote a versioned set of components through defined environments. Returns 409 if a release with the same name and version already exists.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseWrite); + if (includeRouteNames) + { + create.WithName("Release_Create"); + } + + var update = group.MapPatch("/{id}", UpdateRelease) + .WithDescription("Update mutable metadata on the specified release including description, target environment, and custom labels. Status transitions must be performed through the dedicated lifecycle endpoints. Returns 404 when the release does not exist.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseWrite); + if (includeRouteNames) + { + update.WithName("Release_Update"); + } + + var remove = group.MapDelete("/{id}", DeleteRelease) + .WithDescription("Permanently remove the specified release record. Only releases in Draft or Failed status can be deleted; returns 409 for releases in other states. All associated components and events are removed with the release record.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseWrite); + if (includeRouteNames) + { + remove.WithName("Release_Delete"); + } + + var ready = group.MapPost("/{id}/ready", MarkReady) + .WithDescription("Transition the specified release from Draft to Ready state, signalling that all components are assembled and the release is eligible for promotion gate evaluation. Returns 409 if the release is not in Draft state or required components are missing.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseWrite); + if (includeRouteNames) + { + ready.WithName("Release_MarkReady"); + } + + var promote = group.MapPost("/{id}/promote", RequestPromotion) + .WithDescription("Initiate the promotion workflow to advance the specified release to its next target environment, triggering policy gate evaluation. The promotion runs asynchronously; poll the release record or subscribe to events for outcome updates.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseApprove); + if (includeRouteNames) + { + promote.WithName("Release_Promote"); + } + + var deploy = group.MapPost("/{id}/deploy", Deploy) + .WithDescription("Trigger deployment of the specified release to its current target environment. Deployment is orchestrated by the platform and may include pre-deployment checks, artifact staging, and post-deployment validation. Returns 409 if gates have not been satisfied.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseApprove); + if (includeRouteNames) + { + deploy.WithName("Release_Deploy"); + } + + var rollback = group.MapPost("/{id}/rollback", Rollback) + .WithDescription("Initiate a rollback of the specified deployed release to the previous stable version in the current environment. The rollback is audited and creates a new release event. Returns 409 if the release is not in Deployed state or no prior stable version exists.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseApprove); + if (includeRouteNames) + { + rollback.WithName("Release_Rollback"); + } + + var clone = group.MapPost("/{id}/clone", CloneRelease) + .WithDescription("Create a new release by copying the components, labels, and target environment from the specified source release, applying a new name and version. The cloned release starts in Draft state and is independent of the source.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseWrite); + if (includeRouteNames) + { + clone.WithName("Release_Clone"); + } + + var components = group.MapGet("/{releaseId}/components", GetComponents) + .WithDescription("Return the list of components registered in the specified release including their artifact references, versions, content digests, and current deployment status. Returns 404 when the release does not exist."); + if (includeRouteNames) + { + components.WithName("Release_GetComponents"); + } + + var addComponent = group.MapPost("/{releaseId}/components", AddComponent) + .WithDescription("Register a new component in the specified release, supplying the artifact reference and content digest. Components must be added before the release is marked Ready. Returns 409 if a component with the same name is already registered.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseWrite); + if (includeRouteNames) + { + addComponent.WithName("Release_AddComponent"); + } + + var updateComponent = group.MapPatch("/{releaseId}/components/{componentId}", UpdateComponent) + .WithDescription("Update the artifact reference, version, or content digest of the specified release component. Returns 404 when the component does not exist within the release or the release itself does not exist in the tenant.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseWrite); + if (includeRouteNames) + { + updateComponent.WithName("Release_UpdateComponent"); + } + + var removeComponent = group.MapDelete("/{releaseId}/components/{componentId}", RemoveComponent) + .WithDescription("Remove the specified component from the release. Only permitted when the release is in Draft state; returns 409 for releases that are Ready or beyond. Returns 404 when the component or release does not exist in the tenant.") + .RequireAuthorization(ReleaseOrchestratorPolicies.ReleaseWrite); + if (includeRouteNames) + { + removeComponent.WithName("Release_RemoveComponent"); + } + + var events = group.MapGet("/{releaseId}/events", GetEvents) + .WithDescription("Return the chronological event log for the specified release including status transitions, gate evaluations, approval decisions, deployment actions, and rollback events. Useful for audit trails and post-incident analysis."); + if (includeRouteNames) + { + events.WithName("Release_GetEvents"); + } + + var preview = group.MapGet("/{releaseId}/promotion-preview", GetPromotionPreview) + .WithDescription("Evaluate and return the gate check results for the specified release's next promotion without committing any state change. Returns the verdict for each configured policy gate so operators can assess promotion eligibility before triggering it."); + if (includeRouteNames) + { + preview.WithName("Release_PromotionPreview"); + } + + var targets = group.MapGet("/{releaseId}/available-environments", GetAvailableEnvironments) + .WithDescription("Return the list of environment targets that the specified release can be promoted to from its current state, based on the configured promotion pipeline and the caller's access rights. Returns 404 when the release does not exist."); + if (includeRouteNames) + { + targets.WithName("Release_AvailableEnvironments"); + } + + var activity = group.MapGet("/activity", ListActivity) + .WithDescription("Return a paginated feed of release activities across all releases, optionally filtered by environment, outcome, and time window."); + if (includeRouteNames) + { + activity.WithName("Release_Activity"); + } + + var versions = group.MapGet("/versions", ListVersions) + .WithDescription("Return a filtered list of release versions, optionally filtered by gate status."); + if (includeRouteNames) + { + versions.WithName("Release_Versions"); + } + } + + // ---- Handlers ---- + + private static IResult ListReleases( + [FromQuery] string? search, + [FromQuery] string? statuses, + [FromQuery] string? environment, + [FromQuery] string? sortField, + [FromQuery] string? sortOrder, + [FromQuery] int? page, + [FromQuery] int? pageSize) + { + var releases = SeedData.Releases.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.ToLowerInvariant(); + releases = releases.Where(r => + r.Name.Contains(term, StringComparison.OrdinalIgnoreCase) || + r.Version.Contains(term, StringComparison.OrdinalIgnoreCase) || + r.Description.Contains(term, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(statuses)) + { + var statusList = statuses.Split(',', StringSplitOptions.RemoveEmptyEntries); + releases = releases.Where(r => statusList.Contains(r.Status, StringComparer.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(environment)) + { + releases = releases.Where(r => + string.Equals(r.CurrentEnvironment, environment, StringComparison.OrdinalIgnoreCase) || + string.Equals(r.TargetEnvironment, environment, StringComparison.OrdinalIgnoreCase)); + } + + var sorted = (sortField?.ToLowerInvariant(), sortOrder?.ToLowerInvariant()) switch + { + ("name", "asc") => releases.OrderBy(r => r.Name), + ("name", _) => releases.OrderByDescending(r => r.Name), + ("version", "asc") => releases.OrderBy(r => r.Version), + ("version", _) => releases.OrderByDescending(r => r.Version), + ("status", "asc") => releases.OrderBy(r => r.Status), + ("status", _) => releases.OrderByDescending(r => r.Status), + (_, "asc") => releases.OrderBy(r => r.CreatedAt), + _ => releases.OrderByDescending(r => r.CreatedAt), + }; + + var all = sorted.ToList(); + var effectivePage = Math.Max(page ?? 1, 1); + var effectivePageSize = Math.Clamp(pageSize ?? 20, 1, 100); + var items = all.Skip((effectivePage - 1) * effectivePageSize).Take(effectivePageSize).ToList(); + + return Results.Ok(new + { + items, + total = all.Count, + page = effectivePage, + pageSize = effectivePageSize, + }); + } + + private static IResult GetRelease(string id) + { + var release = SeedData.Releases.FirstOrDefault(r => r.Id == id); + return release is not null ? Results.Ok(release) : Results.NotFound(); + } + + private static IResult CreateRelease([FromBody] CreateReleaseDto request, [FromServices] TimeProvider time) + { + var now = time.GetUtcNow(); + + ManagedReleaseDto? sourceVersion = null; + if (!string.IsNullOrEmpty(request.VersionId)) + { + sourceVersion = SeedData.Releases.FirstOrDefault(r => r.Id == request.VersionId); + } + + var release = new ManagedReleaseDto + { + Id = $"rel-{Guid.NewGuid():N}"[..11], + Name = request.Name, + Version = request.Version, + Description = request.Description ?? sourceVersion?.Description ?? "", + Status = "draft", + CurrentEnvironment = null, + TargetEnvironment = request.TargetEnvironment ?? sourceVersion?.TargetEnvironment, + ComponentCount = sourceVersion?.ComponentCount ?? 0, + CreatedAt = now, + CreatedBy = "api", + UpdatedAt = now, + DeployedAt = null, + DeploymentStrategy = request.DeploymentStrategy ?? sourceVersion?.DeploymentStrategy ?? "rolling", + }; + + SeedData.Releases.Add(release); + + return Results.Created($"/api/release-orchestrator/releases/{release.Id}", release); + } + + private static IResult UpdateRelease(string id, [FromBody] UpdateReleaseDto request) + { + var release = SeedData.Releases.FirstOrDefault(r => r.Id == id); + if (release is null) return Results.NotFound(); + + return Results.Ok(release with + { + Name = request.Name ?? release.Name, + Description = request.Description ?? release.Description, + TargetEnvironment = request.TargetEnvironment ?? release.TargetEnvironment, + DeploymentStrategy = request.DeploymentStrategy ?? release.DeploymentStrategy, + UpdatedAt = DateTimeOffset.UtcNow, + }); + } + + private static IResult DeleteRelease(string id) + { + var exists = SeedData.Releases.Any(r => r.Id == id); + return exists ? Results.NoContent() : Results.NotFound(); + } + + private static IResult MarkReady(string id) + { + var release = SeedData.Releases.FirstOrDefault(r => r.Id == id); + if (release is null) return Results.NotFound(); + return Results.Ok(release with { Status = "ready", UpdatedAt = DateTimeOffset.UtcNow }); + } + + private static async Task RequestPromotion( + string id, + [FromBody] PromoteDto request, + [FromServices] TimeProvider time, + [FromServices] WorkflowClient workflowClient) + { + var release = SeedData.Releases.FirstOrDefault(r => r.Id == id); + if (release is null) return Results.NotFound(); + + var targetEnvironment = ResolveTargetEnvironment(request); + var existing = ApprovalEndpoints.SeedData.Approvals + .Select(ApprovalEndpoints.WithDerivedSignals) + .FirstOrDefault(a => + string.Equals(a.ReleaseId, id, StringComparison.OrdinalIgnoreCase) && + string.Equals(a.TargetEnvironment, targetEnvironment, StringComparison.OrdinalIgnoreCase) && + string.Equals(a.Status, "pending", StringComparison.OrdinalIgnoreCase)); + + if (existing is not null) + { + return Results.Ok(ApprovalEndpoints.ToSummary(existing)); + } + + var nextId = $"apr-{ApprovalEndpoints.SeedData.Approvals.Count + 1:000}"; + var now = time.GetUtcNow().ToString("O"); + var approval = ApprovalEndpoints.WithDerivedSignals(new ApprovalEndpoints.ApprovalDto + { + Id = nextId, + ReleaseId = release.Id, + ReleaseName = release.Name, + ReleaseVersion = release.Version, + SourceEnvironment = release.CurrentEnvironment ?? "staging", + TargetEnvironment = targetEnvironment, + RequestedBy = "release-orchestrator", + RequestedAt = now, + Urgency = request.Urgency ?? "normal", + Justification = string.IsNullOrWhiteSpace(request.Justification) + ? $"Promotion requested for {release.Name} {release.Version}." + : request.Justification.Trim(), + Status = "pending", + CurrentApprovals = 0, + RequiredApprovals = 2, + GatesPassed = true, + ScheduledTime = request.ScheduledTime, + ExpiresAt = time.GetUtcNow().AddHours(48).ToString("O"), + GateResults = new List + { + new() + { + GateId = "g-security", + GateName = "Security Snapshot", + Type = "security", + Status = "passed", + Message = "Critical reachable findings within policy threshold.", + Details = new Dictionary(), + EvaluatedAt = now, + }, + new() + { + GateId = "g-ops", + GateName = "Data Integrity", + Type = "quality", + Status = "warning", + Message = "Runtime ingest lag reduces confidence for production decisions.", + Details = new Dictionary(), + EvaluatedAt = now, + }, + }, + ReleaseComponents = BuildReleaseComponents(release.Id), + }); + + ApprovalEndpoints.SeedData.Approvals.Add(approval); + + // Start the release-promotion workflow (fire-and-forget — workflow runs async) + _ = workflowClient.StartWorkflowAsync("release-promotion", new Dictionary + { + ["releaseId"] = release.Id, + ["targetEnvironment"] = targetEnvironment, + ["requestedBy"] = "release-orchestrator", + }); + + return Results.Ok(ApprovalEndpoints.ToSummary(approval)); + } + + private static IResult Deploy(string id) + { + var release = SeedData.Releases.FirstOrDefault(r => r.Id == id); + if (release is null) return Results.NotFound(); + var now = DateTimeOffset.UtcNow; + return Results.Ok(release with + { + Status = "deployed", + CurrentEnvironment = release.TargetEnvironment, + TargetEnvironment = null, + DeployedAt = now, + UpdatedAt = now, + }); + } + + private static IResult Rollback(string id) + { + var release = SeedData.Releases.FirstOrDefault(r => r.Id == id); + if (release is null) return Results.NotFound(); + return Results.Ok(release with + { + Status = "rolled_back", + CurrentEnvironment = null, + UpdatedAt = DateTimeOffset.UtcNow, + }); + } + + private static IResult CloneRelease(string id, [FromBody] CloneReleaseDto request) + { + var release = SeedData.Releases.FirstOrDefault(r => r.Id == id); + if (release is null) return Results.NotFound(); + var now = DateTimeOffset.UtcNow; + return Results.Ok(release with + { + Id = $"rel-{Guid.NewGuid():N}"[..11], + Name = request.Name, + Version = request.Version, + Status = "draft", + CurrentEnvironment = null, + TargetEnvironment = null, + CreatedAt = now, + UpdatedAt = now, + DeployedAt = null, + CreatedBy = "api", + }); + } + + private static IResult GetComponents(string releaseId) + { + if (!SeedData.Components.TryGetValue(releaseId, out var components)) + return Results.Ok(Array.Empty()); + return Results.Ok(components); + } + + private static IResult AddComponent(string releaseId, [FromBody] AddComponentDto request) + { + var component = new ReleaseComponentDto + { + Id = $"comp-{Guid.NewGuid():N}"[..12], + ReleaseId = releaseId, + Name = request.Name, + ImageRef = request.ImageRef, + Digest = request.Digest, + Tag = request.Tag, + Version = request.Version, + Type = request.Type, + ConfigOverrides = request.ConfigOverrides ?? new Dictionary(), + }; + return Results.Created($"/api/release-orchestrator/releases/{releaseId}/components/{component.Id}", component); + } + + private static IResult UpdateComponent(string releaseId, string componentId, [FromBody] UpdateComponentDto request) + { + if (!SeedData.Components.TryGetValue(releaseId, out var components)) + return Results.NotFound(); + var comp = components.FirstOrDefault(c => c.Id == componentId); + if (comp is null) return Results.NotFound(); + return Results.Ok(comp with { ConfigOverrides = request.ConfigOverrides ?? comp.ConfigOverrides }); + } + + private static IResult RemoveComponent(string releaseId, string componentId) + { + return Results.NoContent(); + } + + private static IResult GetEvents(string releaseId) + { + if (!SeedData.Events.TryGetValue(releaseId, out var events)) + return Results.Ok(Array.Empty()); + return Results.Ok(events); + } + + private static IResult GetPromotionPreview(string releaseId, [FromQuery] string? targetEnvironmentId) + { + var targetEnvironment = targetEnvironmentId == "env-production" ? "production" : "staging"; + var risk = ReleaseControlSignalCatalog.GetRiskSnapshot(releaseId, targetEnvironment); + var coverage = ReleaseControlSignalCatalog.GetCoverage(releaseId); + var ops = ReleaseControlSignalCatalog.GetOpsConfidence(targetEnvironment); + var manifestDigest = ResolveManifestDigest(releaseId); + + return Results.Ok(new + { + releaseId, + releaseName = "Platform Release", + sourceEnvironment = "staging", + targetEnvironment, + manifestDigest, + riskSnapshot = risk, + reachabilityCoverage = coverage, + opsConfidence = ops, + gateResults = new[] + { + new { gateId = "g1", gateName = "Security Scan", type = "security", status = "passed", message = "No blocking vulnerabilities found", details = new Dictionary(), evaluatedAt = PreviewEvaluatedAt }, + new { gateId = "g2", gateName = "Policy Compliance", type = "policy", status = "passed", message = "All policies satisfied", details = new Dictionary(), evaluatedAt = PreviewEvaluatedAt }, + new { gateId = "g3", gateName = "Ops Data Integrity", type = "quality", status = ops.Status == "healthy" ? "passed" : "warning", message = ops.Summary, details = new Dictionary(), evaluatedAt = PreviewEvaluatedAt }, + }, + allGatesPassed = true, + requiredApprovers = 2, + estimatedDeployTime = 300, + warnings = ops.Status == "healthy" + ? Array.Empty() + : new[] { "Data-integrity confidence is degraded; decision remains auditable but requires explicit acknowledgment." }, + }); + } + + private static IResult GetAvailableEnvironments(string releaseId) + { + return Results.Ok(new[] + { + new { id = "env-staging", name = "Staging", tier = "staging", opsConfidence = ReleaseControlSignalCatalog.GetOpsConfidence("staging") }, + new { id = "env-production", name = "Production", tier = "production", opsConfidence = ReleaseControlSignalCatalog.GetOpsConfidence("production") }, + new { id = "env-canary", name = "Canary", tier = "production", opsConfidence = ReleaseControlSignalCatalog.GetOpsConfidence("canary") }, + }); + } + + private static string ResolveTargetEnvironment(PromoteDto request) + { + if (!string.IsNullOrWhiteSpace(request.TargetEnvironment)) + { + return request.TargetEnvironment.Trim().ToLowerInvariant(); + } + + return request.TargetEnvironmentId switch + { + "env-production" => "production", + "env-canary" => "canary", + _ => "staging", + }; + } + + private static string ResolveManifestDigest(string releaseId) + { + if (SeedData.Components.TryGetValue(releaseId, out var components) && components.Count > 0) + { + var digestSeed = string.Join('|', components.Select(component => component.Digest)); + return $"sha256:{Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(digestSeed))).ToLowerInvariant()[..64]}"; + } + + return $"sha256:{releaseId.Replace("-", string.Empty, StringComparison.Ordinal).PadRight(64, '0')[..64]}"; + } + + private static List BuildReleaseComponents(string releaseId) + { + if (!SeedData.Components.TryGetValue(releaseId, out var components)) + { + return new List(); + } + + return components + .OrderBy(component => component.Name, StringComparer.Ordinal) + .Select(component => new ApprovalEndpoints.ReleaseComponentSummaryDto + { + Name = component.Name, + Version = component.Version, + Digest = component.Digest, + }) + .ToList(); + } + + // ---- DTOs ---- + + public sealed record ManagedReleaseDto + { + public required string Id { get; init; } + public required string Name { get; init; } + public required string Version { get; init; } + public required string Description { get; init; } + public required string Status { get; init; } + public string? CurrentEnvironment { get; init; } + public string? TargetEnvironment { get; init; } + public int ComponentCount { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public string? CreatedBy { get; init; } + public DateTimeOffset UpdatedAt { get; init; } + public DateTimeOffset? DeployedAt { get; init; } + public string DeploymentStrategy { get; init; } = "rolling"; + } + + public sealed record ReleaseComponentDto + { + public required string Id { get; init; } + public required string ReleaseId { get; init; } + public required string Name { get; init; } + public required string ImageRef { get; init; } + public required string Digest { get; init; } + public string? Tag { get; init; } + public required string Version { get; init; } + public required string Type { get; init; } + public Dictionary ConfigOverrides { get; init; } = new(); + } + + public sealed record ReleaseEventDto + { + public required string Id { get; init; } + public required string ReleaseId { get; init; } + public required string Type { get; init; } + public string? Environment { get; init; } + public required string Actor { get; init; } + public required string Message { get; init; } + public DateTimeOffset Timestamp { get; init; } + public Dictionary Metadata { get; init; } = new(); + } + + public sealed record CreateReleaseDto + { + public required string Name { get; init; } + public required string Version { get; init; } + public string? VersionId { get; init; } + public string? Description { get; init; } + public string? TargetEnvironment { get; init; } + public string? DeploymentStrategy { get; init; } + } + + public sealed record UpdateReleaseDto + { + public string? Name { get; init; } + public string? Description { get; init; } + public string? TargetEnvironment { get; init; } + public string? DeploymentStrategy { get; init; } + } + + public sealed record PromoteDto + { + public string? TargetEnvironment { get; init; } + public string? TargetEnvironmentId { get; init; } + public string? Urgency { get; init; } + public string? Justification { get; init; } + public string? ScheduledTime { get; init; } + } + + public sealed record CloneReleaseDto + { + public required string Name { get; init; } + public required string Version { get; init; } + } + + public sealed record AddComponentDto + { + public required string Name { get; init; } + public required string ImageRef { get; init; } + public required string Digest { get; init; } + public string? Tag { get; init; } + public required string Version { get; init; } + public required string Type { get; init; } + public Dictionary? ConfigOverrides { get; init; } + } + + public sealed record UpdateComponentDto + { + public Dictionary? ConfigOverrides { get; init; } + } + + private static IResult ListActivity( + [FromQuery] string? environment, + [FromQuery] string? outcome, + [FromQuery] int? limit, + [FromQuery] string? releaseId) + { + var events = SeedData.Events.Values.SelectMany(e => e).AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(environment)) + events = events.Where(e => string.Equals(e.Environment, environment, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(outcome)) + events = events.Where(e => string.Equals(e.Type, outcome, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(releaseId)) + events = events.Where(e => string.Equals(e.ReleaseId, releaseId, StringComparison.OrdinalIgnoreCase)); + + var sorted = events.OrderByDescending(e => e.Timestamp).ToList(); + var items = limit > 0 ? sorted.Take(limit.Value).ToList() : sorted; + + return Results.Ok(new { items, total = sorted.Count }); + } + + private static IResult ListVersions( + [FromQuery] string? gateStatus, + [FromQuery] int? limit) + { + var releases = SeedData.Releases.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(gateStatus)) + { + releases = gateStatus.ToLowerInvariant() switch + { + "block" => releases.Where(r => r.Status is "failed" or "rolled_back"), + "pass" => releases.Where(r => r.Status is "ready" or "deployed"), + "warn" => releases.Where(r => r.Status is "deploying"), + _ => releases, + }; + } + + var sorted = releases.OrderByDescending(r => r.CreatedAt).ToList(); + var items = limit > 0 ? sorted.Take(limit.Value).ToList() : sorted; + + return Results.Ok(new { items, total = sorted.Count }); + } + + // ---- Seed Data ---- + + internal static class SeedData + { + public static readonly List Releases = new() + { + new() { Id = "rel-001", Name = "Platform Release", Version = "1.2.3", Description = "Feature release with API improvements and bug fixes", Status = "deployed", CurrentEnvironment = "production", TargetEnvironment = null, ComponentCount = 3, CreatedAt = DateTimeOffset.Parse("2026-01-10T08:00:00Z"), CreatedBy = "deploy-bot", UpdatedAt = DateTimeOffset.Parse("2026-01-11T14:30:00Z"), DeployedAt = DateTimeOffset.Parse("2026-01-11T14:30:00Z"), DeploymentStrategy = "rolling" }, + new() { Id = "rel-002", Name = "Platform Release", Version = "1.3.0-rc1", Description = "Release candidate for next major version", Status = "ready", CurrentEnvironment = "staging", TargetEnvironment = "production", ComponentCount = 4, CreatedAt = DateTimeOffset.Parse("2026-01-11T10:00:00Z"), CreatedBy = "ci-pipeline", UpdatedAt = DateTimeOffset.Parse("2026-01-12T09:00:00Z"), DeploymentStrategy = "blue_green" }, + new() { Id = "rel-003", Name = "Hotfix", Version = "1.2.4", Description = "Critical security patch", Status = "deploying", CurrentEnvironment = "staging", TargetEnvironment = "production", ComponentCount = 1, CreatedAt = DateTimeOffset.Parse("2026-01-12T06:00:00Z"), CreatedBy = "security-team", UpdatedAt = DateTimeOffset.Parse("2026-01-12T10:00:00Z"), DeploymentStrategy = "rolling" }, + new() { Id = "rel-004", Name = "Feature Branch", Version = "2.0.0-alpha", Description = "New architecture preview", Status = "draft", TargetEnvironment = "dev", ComponentCount = 5, CreatedAt = DateTimeOffset.Parse("2026-01-08T15:00:00Z"), CreatedBy = "dev-team", UpdatedAt = DateTimeOffset.Parse("2026-01-10T11:00:00Z"), DeploymentStrategy = "recreate" }, + new() { Id = "rel-005", Name = "Platform Release", Version = "1.2.2", Description = "Previous stable release", Status = "rolled_back", ComponentCount = 3, CreatedAt = DateTimeOffset.Parse("2026-01-05T12:00:00Z"), CreatedBy = "deploy-bot", UpdatedAt = DateTimeOffset.Parse("2026-01-10T08:00:00Z"), DeployedAt = DateTimeOffset.Parse("2026-01-06T10:00:00Z"), DeploymentStrategy = "rolling" }, + }; + + public static readonly Dictionary> Components = new() + { + ["rel-001"] = new() + { + new() { Id = "comp-001", ReleaseId = "rel-001", Name = "api-service", ImageRef = "registry.example.com/api-service", Digest = "sha256:abc123def456", Tag = "v1.2.3", Version = "1.2.3", Type = "container" }, + new() { Id = "comp-002", ReleaseId = "rel-001", Name = "worker-service", ImageRef = "registry.example.com/worker-service", Digest = "sha256:def456abc789", Tag = "v1.2.3", Version = "1.2.3", Type = "container" }, + new() { Id = "comp-003", ReleaseId = "rel-001", Name = "web-app", ImageRef = "registry.example.com/web-app", Digest = "sha256:789abc123def", Tag = "v1.2.3", Version = "1.2.3", Type = "container" }, + }, + ["rel-002"] = new() + { + new() { Id = "comp-004", ReleaseId = "rel-002", Name = "api-service", ImageRef = "registry.example.com/api-service", Digest = "sha256:new123new456", Tag = "v1.3.0-rc1", Version = "1.3.0-rc1", Type = "container" }, + new() { Id = "comp-005", ReleaseId = "rel-002", Name = "worker-service", ImageRef = "registry.example.com/worker-service", Digest = "sha256:new456new789", Tag = "v1.3.0-rc1", Version = "1.3.0-rc1", Type = "container" }, + new() { Id = "comp-006", ReleaseId = "rel-002", Name = "web-app", ImageRef = "registry.example.com/web-app", Digest = "sha256:new789newabc", Tag = "v1.3.0-rc1", Version = "1.3.0-rc1", Type = "container" }, + new() { Id = "comp-007", ReleaseId = "rel-002", Name = "migration", ImageRef = "registry.example.com/migration", Digest = "sha256:mig123mig456", Tag = "v1.3.0-rc1", Version = "1.3.0-rc1", Type = "script" }, + }, + }; + + public static readonly Dictionary> Events = new() + { + ["rel-001"] = new() + { + new() { Id = "evt-001", ReleaseId = "rel-001", Type = "created", Environment = null, Actor = "deploy-bot", Message = "Release created", Timestamp = DateTimeOffset.Parse("2026-01-10T08:00:00Z") }, + new() { Id = "evt-002", ReleaseId = "rel-001", Type = "promoted", Environment = "dev", Actor = "deploy-bot", Message = "Promoted to dev", Timestamp = DateTimeOffset.Parse("2026-01-10T09:00:00Z") }, + new() { Id = "evt-003", ReleaseId = "rel-001", Type = "deployed", Environment = "dev", Actor = "deploy-bot", Message = "Successfully deployed to dev", Timestamp = DateTimeOffset.Parse("2026-01-10T09:30:00Z") }, + new() { Id = "evt-004", ReleaseId = "rel-001", Type = "approved", Environment = "staging", Actor = "qa-team", Message = "Approved for staging", Timestamp = DateTimeOffset.Parse("2026-01-10T14:00:00Z") }, + new() { Id = "evt-005", ReleaseId = "rel-001", Type = "deployed", Environment = "staging", Actor = "deploy-bot", Message = "Successfully deployed to staging", Timestamp = DateTimeOffset.Parse("2026-01-10T14:30:00Z") }, + new() { Id = "evt-006", ReleaseId = "rel-001", Type = "approved", Environment = "production", Actor = "release-manager", Message = "Approved for production", Timestamp = DateTimeOffset.Parse("2026-01-11T10:00:00Z") }, + new() { Id = "evt-007", ReleaseId = "rel-001", Type = "deployed", Environment = "production", Actor = "deploy-bot", Message = "Successfully deployed to production", Timestamp = DateTimeOffset.Parse("2026-01-11T14:30:00Z") }, + }, + ["rel-002"] = new() + { + new() { Id = "evt-008", ReleaseId = "rel-002", Type = "created", Environment = null, Actor = "ci-pipeline", Message = "Release created from CI", Timestamp = DateTimeOffset.Parse("2026-01-11T10:00:00Z") }, + new() { Id = "evt-009", ReleaseId = "rel-002", Type = "deployed", Environment = "staging", Actor = "deploy-bot", Message = "Deployed to staging for testing", Timestamp = DateTimeOffset.Parse("2026-01-11T12:00:00Z") }, + }, + }; + } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs new file mode 100644 index 000000000..90978cf6e --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs @@ -0,0 +1,76 @@ +using StellaOps.Router.AspNet; +using StellaOps.Auth.ServerIntegration; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.JobEngine.Infrastructure; +using StellaOps.ReleaseOrchestrator.WebApi; +using StellaOps.ReleaseOrchestrator.WebApi.Endpoints; +using StellaOps.ReleaseOrchestrator.WebApi.Services; +using System.Reflection; + +var builder = WebApplication.CreateBuilder(args); + +// Tenant services (IStellaOpsTenantAccessor — required by RequireTenant() filters) +builder.Services.AddStellaOpsTenantServices(); + +// Authentication (resource server JWT validation via Authority) +builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration); + +// Authorization policies (scope-based) +builder.Services.AddAuthorization(options => +{ + options.AddReleaseOrchestratorPolicies(); +}); + +// JobEngine infrastructure (Postgres repositories: IAuditRepository, IFirstSignalService, etc.) +builder.Services.AddJobEngineInfrastructure(builder.Configuration); + +// Workflow engine HTTP client (calls workflow service to start workflow instances) +builder.Services.AddHttpClient((sp, client) => +{ + // In Docker compose, workflow service is reachable via internal DNS + client.BaseAddress = new Uri( + builder.Configuration["Workflow:BaseAddress"] ?? "http://workflow.stella-ops.local"); +}); + +// Core services +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => + sp.GetRequiredService()); + +// Router integration +var routerEnabled = builder.Services.AddRouterMicroservice( + builder.Configuration, + serviceName: "release-orchestrator", + version: Assembly.GetExecutingAssembly() + .GetCustomAttribute() + ?.InformationalVersion ?? "1.0.0", + routerOptionsSection: "Router"); + +var app = builder.Build(); + +// Middleware pipeline (order matters) +app.UseIdentityEnvelopeAuthentication(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseStellaOpsTenantMiddleware(); + +app.TryUseStellaRouter(routerEnabled); + +// Map release endpoints +app.MapReleaseEndpoints(); +app.MapApprovalEndpoints(); +app.MapDeploymentEndpoints(); +app.MapReleaseDashboardEndpoints(); +app.MapReleaseControlV2Endpoints(); +app.MapEvidenceEndpoints(); +app.MapAuditEndpoints(); +app.MapFirstSignalEndpoints(); + +app.TryRefreshStellaRouterEndpoints(routerEnabled); + +await app.RunAsync(); + +public partial class Program { } diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/ReleaseOrchestratorPolicies.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/ReleaseOrchestratorPolicies.cs new file mode 100644 index 000000000..6cf284be7 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/ReleaseOrchestratorPolicies.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Authorization; +using StellaOps.Auth.Abstractions; +using StellaOps.Auth.ServerIntegration; + +namespace StellaOps.ReleaseOrchestrator.WebApi; + +/// +/// Named authorization policy constants for the Release Orchestrator service. +/// Each constant is the policy name used with RequireAuthorization(policyName) +/// and corresponds to one or more canonical StellaOps scopes. +/// +public static class ReleaseOrchestratorPolicies +{ + // --- Orchestrator core policies --- + + /// + /// Read-only access to orchestrator run and job state, telemetry, sources, DAG topology, + /// first-signal metrics, SLOs, and the immutable audit log. + /// Requires scope: orch:read. + /// + public const string Read = StellaOpsScopes.OrchRead; + + /// + /// Operational control actions: cancel, retry, replay, force-close circuit breakers, + /// resolve dead-letter entries, and manage workers. + /// Requires scope: orch:operate. + /// + public const string Operate = StellaOpsScopes.OrchOperate; + + // --- Release orchestration policies --- + + /// + /// Read-only access to release records, promotion previews, release events, and dashboards. + /// Requires scope: release:read. + /// + public const string ReleaseRead = StellaOpsScopes.ReleaseRead; + + /// + /// Create, update, and manage release lifecycle state (start, stop, fail, complete). + /// Requires scope: release:write. + /// + public const string ReleaseWrite = StellaOpsScopes.ReleaseWrite; + + /// + /// Approve or reject release promotions and environment-level approval gates. + /// Requires scope: release:publish. + /// + public const string ReleaseApprove = StellaOpsScopes.ReleasePublish; + + /// + /// Registers all Release Orchestrator service authorization policies into the ASP.NET Core + /// authorization options. Call this from Program.cs inside AddAuthorization. + /// + public static void AddReleaseOrchestratorPolicies(this AuthorizationOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + // Orchestrator core + options.AddStellaOpsScopePolicy(Read, StellaOpsScopes.OrchRead); + options.AddStellaOpsScopePolicy(Operate, StellaOpsScopes.OrchOperate); + + // Release orchestration + options.AddStellaOpsScopePolicy(ReleaseRead, StellaOpsScopes.ReleaseRead); + options.AddStellaOpsScopePolicy(ReleaseWrite, StellaOpsScopes.ReleaseWrite); + options.AddStellaOpsScopePolicy(ReleaseApprove, StellaOpsScopes.ReleasePublish); + } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/DeploymentCompatibilityModels.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/DeploymentCompatibilityModels.cs new file mode 100644 index 000000000..a9a5cb57b --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/DeploymentCompatibilityModels.cs @@ -0,0 +1,120 @@ +using System.Text.Json; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Services; + +public sealed record CreateDeploymentRequest +{ + public string ReleaseId { get; init; } = string.Empty; + public string EnvironmentId { get; init; } = string.Empty; + public string? EnvironmentName { get; init; } + public string Strategy { get; init; } = "rolling"; + public JsonElement? StrategyConfig { get; init; } + public string? PackageType { get; init; } + public string? PackageRefId { get; init; } + public string? PackageRefName { get; init; } + public IReadOnlyList PromotionStages { get; init; } = Array.Empty(); +} + +public sealed record PromotionStageDto +{ + public string Name { get; init; } = string.Empty; + public string EnvironmentId { get; init; } = string.Empty; +} + +public record class DeploymentSummaryDto +{ + public required string Id { get; init; } + public required string ReleaseId { get; init; } + public required string ReleaseName { get; init; } + public required string ReleaseVersion { get; init; } + public required string EnvironmentId { get; init; } + public required string EnvironmentName { get; init; } + public required string Status { get; init; } + public required string Strategy { get; init; } + public int Progress { get; init; } + public DateTimeOffset StartedAt { get; init; } + public DateTimeOffset? CompletedAt { get; init; } + public string InitiatedBy { get; init; } = string.Empty; + public int TargetCount { get; init; } + public int CompletedTargets { get; init; } + public int FailedTargets { get; init; } +} + +public sealed record DeploymentDto : DeploymentSummaryDto +{ + public List Targets { get; init; } = []; + public string? CurrentStep { get; init; } + public bool CanPause { get; init; } + public bool CanResume { get; init; } + public bool CanCancel { get; init; } + public bool CanRollback { get; init; } + public JsonElement? StrategyConfig { get; init; } + public IReadOnlyList PromotionStages { get; init; } = Array.Empty(); + public string? PackageType { get; init; } + public string? PackageRefId { get; init; } + public string? PackageRefName { get; init; } +} + +public sealed record DeploymentTargetDto +{ + public required string Id { get; init; } + public required string Name { get; init; } + public required string Type { get; init; } + public required string Status { get; init; } + public int Progress { get; init; } + public DateTimeOffset? StartedAt { get; init; } + public DateTimeOffset? CompletedAt { get; init; } + public int? Duration { get; init; } + public string AgentId { get; init; } = string.Empty; + public string? Error { get; init; } + public string? PreviousVersion { get; init; } +} + +public sealed record DeploymentEventDto +{ + public required string Id { get; init; } + public required string Type { get; init; } + public string? TargetId { get; init; } + public string? TargetName { get; init; } + public required string Message { get; init; } + public DateTimeOffset Timestamp { get; init; } +} + +public sealed record DeploymentLogEntryDto +{ + public DateTimeOffset Timestamp { get; init; } + public required string Level { get; init; } + public required string Source { get; init; } + public string? TargetId { get; init; } + public required string Message { get; init; } +} + +public sealed record DeploymentMetricsDto +{ + public int TotalDuration { get; init; } + public int AverageTargetDuration { get; init; } + public double SuccessRate { get; init; } + public int RollbackCount { get; init; } + public int ImagesPulled { get; init; } + public int ContainersStarted { get; init; } + public int ContainersRemoved { get; init; } + public int HealthChecksPerformed { get; init; } +} + +public sealed record DeploymentCompatibilityState( + DeploymentDto Deployment, + List Logs, + List Events, + DeploymentMetricsDto Metrics); + +public enum DeploymentMutationStatus +{ + Success, + NotFound, + Conflict, +} + +public sealed record DeploymentMutationResult( + DeploymentMutationStatus Status, + string Message, + DeploymentDto? Deployment); diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/DeploymentCompatibilityStateFactory.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/DeploymentCompatibilityStateFactory.cs new file mode 100644 index 000000000..13ba6ee57 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/DeploymentCompatibilityStateFactory.cs @@ -0,0 +1,358 @@ +namespace StellaOps.ReleaseOrchestrator.WebApi.Services; + +internal static class DeploymentCompatibilityStateFactory +{ + public static IReadOnlyList CreateSeedStates() + => [ + CreateSeedState("dep-001", "rel-001", "platform-release", "2026.04.01", "env-prod", "Production", "completed", "rolling", DateTimeOffset.Parse("2026-04-01T09:00:00Z"), 3, null, 1), + CreateSeedState("dep-002", "rel-002", "checkout-api", "2026.04.02", "env-staging", "Staging", "running", "canary", DateTimeOffset.Parse("2026-04-02T12:15:00Z"), 3, null, 4), + CreateSeedState("dep-003", "rel-003", "worker-service", "2026.04.03", "env-dev", "Development", "failed", "all_at_once", DateTimeOffset.Parse("2026-04-03T08:30:00Z"), 4, 2, 7), + CreateSeedState("dep-004", "rel-004", "gateway-hotfix", "hf-2026.04.04", "env-stage-eu", "EU Stage", "paused", "blue_green", DateTimeOffset.Parse("2026-04-04T06:00:00Z"), 4, 0, 10), + ]; + + public static DeploymentCompatibilityState CreateState( + CreateDeploymentRequest request, + string actor, + TimeProvider timeProvider) + { + var now = timeProvider.GetUtcNow(); + var id = $"dep-{Guid.NewGuid():N}"[..16]; + var envName = string.IsNullOrWhiteSpace(request.EnvironmentName) + ? Pretty(request.EnvironmentId) + : request.EnvironmentName!; + var targets = CreateTargets( + request.EnvironmentId, + request.Strategy == "all_at_once" ? 4 : 3, + failedIndex: null, + offset: 20, + baseTime: now.AddMinutes(-4)); + + var deployment = Recalculate(new DeploymentDto + { + Id = id, + ReleaseId = request.ReleaseId, + ReleaseName = request.PackageRefName ?? request.ReleaseId, + ReleaseVersion = request.PackageRefName ?? request.PackageRefId ?? "version-1", + EnvironmentId = request.EnvironmentId, + EnvironmentName = envName, + Status = "pending", + Strategy = request.Strategy, + StartedAt = now, + InitiatedBy = actor, + Targets = targets, + CurrentStep = "Queued for rollout", + CanCancel = true, + StrategyConfig = request.StrategyConfig, + PromotionStages = request.PromotionStages, + PackageType = request.PackageType, + PackageRefId = request.PackageRefId, + PackageRefName = request.PackageRefName, + }); + + return new DeploymentCompatibilityState( + deployment, + [ + new DeploymentLogEntryDto + { + Timestamp = now, + Level = "info", + Source = "release-orchestrator", + Message = $"Deployment {id} created for {request.EnvironmentId}.", + }, + ], + [ + new DeploymentEventDto + { + Id = $"evt-{Guid.NewGuid():N}"[..16], + Type = "started", + Message = $"Deployment {id} queued.", + Timestamp = now, + }, + ], + new DeploymentMetricsDto()); + } + + public static DeploymentCompatibilityState Transition( + DeploymentCompatibilityState current, + string nextStatus, + string eventType, + string message, + bool complete, + TimeProvider timeProvider) + { + var now = timeProvider.GetUtcNow(); + var nextDeployment = Recalculate(current.Deployment with + { + Status = nextStatus, + CompletedAt = complete ? now : current.Deployment.CompletedAt, + CurrentStep = nextStatus switch + { + "paused" => "Deployment paused", + "running" => "Deployment resumed", + "cancelled" => "Deployment cancelled", + "rolling_back" => "Rollback started", + _ => current.Deployment.CurrentStep, + }, + }); + + var nextMetrics = nextStatus == "rolling_back" + ? current.Metrics with { RollbackCount = current.Metrics.RollbackCount + 1 } + : current.Metrics; + + var logs = current.Logs + .Append(new DeploymentLogEntryDto + { + Timestamp = now, + Level = "info", + Source = "release-orchestrator", + Message = message, + }) + .ToList(); + + var events = current.Events + .Append(new DeploymentEventDto + { + Id = $"evt-{Guid.NewGuid():N}"[..16], + Type = eventType, + Message = message, + Timestamp = now, + }) + .ToList(); + + return current with + { + Deployment = nextDeployment, + Logs = logs, + Events = events, + Metrics = nextMetrics, + }; + } + + public static DeploymentCompatibilityState Retry( + DeploymentCompatibilityState current, + string targetId, + TimeProvider timeProvider) + { + var target = current.Deployment.Targets.First(t => string.Equals(t.Id, targetId, StringComparison.OrdinalIgnoreCase)); + var now = timeProvider.GetUtcNow(); + var targets = current.Deployment.Targets + .Select(item => item.Id == targetId + ? item with + { + Status = "pending", + Progress = 0, + StartedAt = null, + CompletedAt = null, + Duration = null, + Error = null, + } + : item) + .ToList(); + + var nextDeployment = Recalculate(current.Deployment with + { + Status = "running", + CompletedAt = null, + CurrentStep = $"Retrying {target.Name}", + Targets = targets, + }); + + var logs = current.Logs + .Append(new DeploymentLogEntryDto + { + Timestamp = now, + Level = "warn", + Source = "release-orchestrator", + TargetId = targetId, + Message = $"Retry requested for {target.Name}.", + }) + .ToList(); + + var events = current.Events + .Append(new DeploymentEventDto + { + Id = $"evt-{Guid.NewGuid():N}"[..16], + Type = "target_started", + TargetId = targetId, + TargetName = target.Name, + Message = $"Retry started for {target.Name}.", + Timestamp = now, + }) + .ToList(); + + return current with + { + Deployment = nextDeployment, + Logs = logs, + Events = events, + }; + } + + private static DeploymentCompatibilityState CreateSeedState( + string id, + string releaseId, + string releaseName, + string releaseVersion, + string environmentId, + string environmentName, + string status, + string strategy, + DateTimeOffset startedAt, + int targetCount, + int? failedIndex, + int offset) + { + var targets = CreateTargets(environmentId, targetCount, failedIndex, offset, startedAt.AddMinutes(-targetCount * 4)); + DateTimeOffset? completedAt = status is "completed" or "failed" ? startedAt.AddMinutes(18) : null; + var deployment = Recalculate(new DeploymentDto + { + Id = id, + ReleaseId = releaseId, + ReleaseName = releaseName, + ReleaseVersion = releaseVersion, + EnvironmentId = environmentId, + EnvironmentName = environmentName, + Status = status, + Strategy = strategy, + StartedAt = startedAt, + CompletedAt = completedAt, + InitiatedBy = "deploy-bot", + Targets = targets, + CurrentStep = status switch + { + "running" => $"Deploying {targets.First(t => t.Status == "running").Name}", + "paused" => "Awaiting operator resume", + "failed" => $"Target {targets.First(t => t.Status == "failed").Name} failed", + _ => null, + }, + }); + + var logs = new List + { + new() + { + Timestamp = startedAt, + Level = "info", + Source = "release-orchestrator", + Message = $"Deployment {id} started.", + }, + }; + logs.AddRange(targets.Select(target => new DeploymentLogEntryDto + { + Timestamp = target.StartedAt ?? startedAt, + Level = target.Status == "failed" ? "error" : "info", + Source = target.AgentId, + TargetId = target.Id, + Message = target.Status == "failed" + ? $"{target.Name} failed health checks." + : $"{target.Name} progressed to {target.Status}.", + })); + + var events = new List + { + new() + { + Id = $"evt-{id}-start", + Type = "started", + Message = $"Deployment {id} started.", + Timestamp = startedAt, + }, + }; + events.AddRange(targets.Select(target => new DeploymentEventDto + { + Id = $"evt-{id}-{target.Id}", + Type = target.Status == "failed" + ? "target_failed" + : target.Status == "running" + ? "target_started" + : "target_completed", + TargetId = target.Id, + TargetName = target.Name, + Message = target.Status == "failed" + ? $"{target.Name} failed." + : target.Status == "running" + ? $"{target.Name} is running." + : $"{target.Name} completed.", + Timestamp = target.StartedAt ?? startedAt, + })); + + var completedDurations = targets.Where(target => target.Duration.HasValue).Select(target => target.Duration!.Value).ToArray(); + var metrics = new DeploymentMetricsDto + { + TotalDuration = completedAt.HasValue ? (int)(completedAt.Value - startedAt).TotalMilliseconds : 0, + AverageTargetDuration = completedDurations.Length == 0 ? 0 : (int)completedDurations.Average(), + SuccessRate = Math.Round(targets.Count(target => target.Status == "completed") / (double)targetCount * 100, 2), + ImagesPulled = targetCount, + ContainersStarted = targets.Count(target => target.Status is "completed" or "running"), + ContainersRemoved = targets.Count(target => target.Status == "completed"), + HealthChecksPerformed = targetCount * 2, + }; + + return new DeploymentCompatibilityState(deployment, logs, events, metrics); + } + + private static List CreateTargets( + string environmentId, + int count, + int? failedIndex, + int offset, + DateTimeOffset baseTime) + { + var items = new List(count); + var prefix = environmentId.Contains("prod", StringComparison.OrdinalIgnoreCase) ? "prod" : "node"; + for (var index = 0; index < count; index++) + { + var failed = failedIndex.HasValue && index == failedIndex.Value; + var running = !failedIndex.HasValue && index == count - 1; + var status = failed ? "failed" : running ? "running" : "completed"; + var startedAt = baseTime.AddMinutes(index * 3); + DateTimeOffset? completedAt = status == "completed" ? startedAt.AddMinutes(2) : null; + items.Add(new DeploymentTargetDto + { + Id = $"tgt-{offset + index:000}", + Name = $"{prefix}-{offset + index:00}", + Type = index % 2 == 0 ? "docker_host" : "compose_host", + Status = status, + Progress = status == "completed" ? 100 : status == "running" ? 65 : 45, + StartedAt = startedAt, + CompletedAt = completedAt, + Duration = completedAt.HasValue ? (int)(completedAt.Value - startedAt).TotalMilliseconds : null, + AgentId = $"agent-{offset + index:000}", + Error = status == "failed" ? "Health check failed" : null, + PreviousVersion = "2026.03.31", + }); + } + + return items; + } + + internal static DeploymentDto Recalculate(DeploymentDto deployment) + { + var totalTargets = deployment.Targets.Count; + var completedTargets = deployment.Targets.Count(target => target.Status == "completed"); + var failedTargets = deployment.Targets.Count(target => target.Status == "failed"); + var progress = totalTargets == 0 + ? 0 + : (int)Math.Round(deployment.Targets.Sum(target => target.Progress) / (double)totalTargets); + + return deployment with + { + TargetCount = totalTargets, + CompletedTargets = completedTargets, + FailedTargets = failedTargets, + Progress = progress, + CanPause = deployment.Status == "running", + CanResume = deployment.Status == "paused", + CanCancel = deployment.Status is "pending" or "running" or "paused", + CanRollback = deployment.Status is "completed" or "failed" or "running" or "paused", + }; + } + + private static string Pretty(string value) + { + return string.Join( + ' ', + value.Split(['-', '_'], StringSplitOptions.RemoveEmptyEntries) + .Select(part => char.ToUpperInvariant(part[0]) + part[1..])); + } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/EndpointHelpers.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/EndpointHelpers.cs new file mode 100644 index 000000000..726416bb0 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/EndpointHelpers.cs @@ -0,0 +1,59 @@ +using StellaOps.JobEngine.Core.Domain; +using System.Text; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Services; + +/// +/// Helper methods for endpoint operations. +/// +public static class EndpointHelpers +{ + private const int DefaultLimit = 50; + private const int MaxLimit = 100; + + /// + /// Gets limit value, clamped to valid range. + /// + public static int GetLimit(int? requestedLimit) => + Math.Clamp(requestedLimit ?? DefaultLimit, 1, MaxLimit); + + /// + /// Parses offset from cursor string. + /// + public static int ParseCursorOffset(string? cursor, int defaultOffset = 0) + { + if (string.IsNullOrWhiteSpace(cursor)) + { + return defaultOffset; + } + + try + { + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(cursor)); + if (int.TryParse(decoded, out var offset)) + { + return offset; + } + } + catch + { + // Invalid cursor, return default + } + + return defaultOffset; + } + + /// + /// Creates a cursor for the next page. + /// + public static string? CreateNextCursor(int currentOffset, int limit, int returnedCount) + { + if (returnedCount < limit) + { + return null; // No more results + } + + var nextOffset = currentOffset + limit; + return Convert.ToBase64String(Encoding.UTF8.GetBytes(nextOffset.ToString())); + } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/IDeploymentCompatibilityStore.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/IDeploymentCompatibilityStore.cs new file mode 100644 index 000000000..853653b5f --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/IDeploymentCompatibilityStore.cs @@ -0,0 +1,48 @@ +namespace StellaOps.ReleaseOrchestrator.WebApi.Services; + +public interface IDeploymentCompatibilityStore +{ + Task> ListAsync(string tenantId, CancellationToken cancellationToken); + + Task GetAsync(string tenantId, string deploymentId, CancellationToken cancellationToken); + + Task?> GetLogsAsync( + string tenantId, + string deploymentId, + string? targetId, + string? level, + int? limit, + CancellationToken cancellationToken); + + Task?> GetEventsAsync( + string tenantId, + string deploymentId, + CancellationToken cancellationToken); + + Task GetMetricsAsync( + string tenantId, + string deploymentId, + CancellationToken cancellationToken); + + Task CreateAsync( + string tenantId, + CreateDeploymentRequest request, + string actor, + CancellationToken cancellationToken); + + Task TransitionAsync( + string tenantId, + string deploymentId, + IReadOnlyCollection allowedStatuses, + string nextStatus, + string eventType, + string message, + bool complete, + CancellationToken cancellationToken); + + Task RetryAsync( + string tenantId, + string deploymentId, + string targetId, + CancellationToken cancellationToken); +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/InMemoryDeploymentCompatibilityStore.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/InMemoryDeploymentCompatibilityStore.cs new file mode 100644 index 000000000..1d6c7eb6b --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/InMemoryDeploymentCompatibilityStore.cs @@ -0,0 +1,169 @@ +using System.Collections.Concurrent; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Services; + +public sealed class InMemoryDeploymentCompatibilityStore : IDeploymentCompatibilityStore +{ + private readonly ConcurrentDictionary> _tenants = new(StringComparer.Ordinal); + private readonly TimeProvider _timeProvider; + + public InMemoryDeploymentCompatibilityStore(TimeProvider timeProvider) + { + _timeProvider = timeProvider; + } + + public Task> ListAsync(string tenantId, CancellationToken cancellationToken) + { + var states = GetOrSeedTenantState(tenantId); + return Task.FromResult>(states.Values + .Select(state => state.Deployment) + .OrderByDescending(item => item.StartedAt) + .ThenBy(item => item.Id, StringComparer.Ordinal) + .ToList()); + } + + public Task GetAsync(string tenantId, string deploymentId, CancellationToken cancellationToken) + { + var states = GetOrSeedTenantState(tenantId); + return Task.FromResult(states.TryGetValue(deploymentId, out var state) ? state.Deployment : null); + } + + public Task?> GetLogsAsync( + string tenantId, + string deploymentId, + string? targetId, + string? level, + int? limit, + CancellationToken cancellationToken) + { + var states = GetOrSeedTenantState(tenantId); + if (!states.TryGetValue(deploymentId, out var state)) + { + return Task.FromResult?>(null); + } + + IEnumerable logs = state.Logs; + if (!string.IsNullOrWhiteSpace(targetId)) + { + logs = logs.Where(item => string.Equals(item.TargetId, targetId, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(level)) + { + logs = logs.Where(item => string.Equals(item.Level, level, StringComparison.OrdinalIgnoreCase)); + } + + return Task.FromResult?>(logs + .TakeLast(Math.Clamp(limit ?? 500, 1, 5000)) + .ToList()); + } + + public Task?> GetEventsAsync( + string tenantId, + string deploymentId, + CancellationToken cancellationToken) + { + var states = GetOrSeedTenantState(tenantId); + return Task.FromResult?>(states.TryGetValue(deploymentId, out var state) + ? state.Events.OrderBy(item => item.Timestamp).ToList() + : null); + } + + public Task GetMetricsAsync( + string tenantId, + string deploymentId, + CancellationToken cancellationToken) + { + var states = GetOrSeedTenantState(tenantId); + return Task.FromResult(states.TryGetValue(deploymentId, out var state) ? state.Metrics : null); + } + + public Task CreateAsync( + string tenantId, + CreateDeploymentRequest request, + string actor, + CancellationToken cancellationToken) + { + var states = GetOrSeedTenantState(tenantId); + var state = DeploymentCompatibilityStateFactory.CreateState(request, actor, _timeProvider); + states[state.Deployment.Id] = state; + return Task.FromResult(state.Deployment); + } + + public Task TransitionAsync( + string tenantId, + string deploymentId, + IReadOnlyCollection allowedStatuses, + string nextStatus, + string eventType, + string message, + bool complete, + CancellationToken cancellationToken) + { + var states = GetOrSeedTenantState(tenantId); + if (!states.TryGetValue(deploymentId, out var current)) + { + return Task.FromResult(new DeploymentMutationResult(DeploymentMutationStatus.NotFound, string.Empty, null)); + } + + if (!allowedStatuses.Contains(current.Deployment.Status, StringComparer.OrdinalIgnoreCase)) + { + return Task.FromResult(new DeploymentMutationResult( + DeploymentMutationStatus.Conflict, + $"Deployment {deploymentId} cannot transition from '{current.Deployment.Status}' to '{nextStatus}'.", + null)); + } + + var next = DeploymentCompatibilityStateFactory.Transition(current, nextStatus, eventType, message, complete, _timeProvider); + states[deploymentId] = next; + return Task.FromResult(new DeploymentMutationResult(DeploymentMutationStatus.Success, message, next.Deployment)); + } + + public Task RetryAsync( + string tenantId, + string deploymentId, + string targetId, + CancellationToken cancellationToken) + { + var states = GetOrSeedTenantState(tenantId); + if (!states.TryGetValue(deploymentId, out var current)) + { + return Task.FromResult(new DeploymentMutationResult(DeploymentMutationStatus.NotFound, string.Empty, null)); + } + + var target = current.Deployment.Targets.FirstOrDefault(item => string.Equals(item.Id, targetId, StringComparison.OrdinalIgnoreCase)); + if (target is null) + { + return Task.FromResult(new DeploymentMutationResult(DeploymentMutationStatus.NotFound, string.Empty, null)); + } + + if (target.Status is not ("failed" or "skipped")) + { + return Task.FromResult(new DeploymentMutationResult( + DeploymentMutationStatus.Conflict, + $"Target {targetId} is not in a retryable state.", + null)); + } + + var next = DeploymentCompatibilityStateFactory.Retry(current, targetId, _timeProvider); + states[deploymentId] = next; + return Task.FromResult(new DeploymentMutationResult( + DeploymentMutationStatus.Success, + $"Retry initiated for {target.Name}.", + next.Deployment)); + } + + private ConcurrentDictionary GetOrSeedTenantState(string tenantId) + { + return _tenants.GetOrAdd(tenantId, _ => + { + var states = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + foreach (var seed in DeploymentCompatibilityStateFactory.CreateSeedStates()) + { + states[seed.Deployment.Id] = seed; + } + + return states; + }); + } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ReleaseControlSignalCatalog.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ReleaseControlSignalCatalog.cs new file mode 100644 index 000000000..f900a41c7 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ReleaseControlSignalCatalog.cs @@ -0,0 +1,121 @@ +using StellaOps.ReleaseOrchestrator.WebApi.Contracts; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Services; + +/// +/// Deterministic signal projections used by release-control contract adapters. +/// +public static class ReleaseControlSignalCatalog +{ + private static readonly IReadOnlyDictionary RiskByRelease = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["rel-001"] = new("production", 0, 0, 1, 96.5m, "clean"), + ["rel-002"] = new("production", 1, 1, 3, 62.0m, "warning"), + ["rel-003"] = new("production", 2, 1, 2, 58.0m, "blocked"), + ["rel-004"] = new("dev", 0, 1, 1, 88.0m, "warning"), + ["rel-005"] = new("production", 0, 0, 0, 97.0m, "clean"), + }; + + private static readonly IReadOnlyDictionary CoverageByRelease = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["rel-001"] = new(100, 100, 92, 2), + ["rel-002"] = new(100, 86, 41, 26), + ["rel-003"] = new(100, 80, 35, 31), + ["rel-004"] = new(100, 72, 0, 48), + ["rel-005"] = new(100, 100, 100, 1), + }; + + private static readonly IReadOnlyDictionary OpsByEnvironment = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["production"] = new( + "warning", + "NVD freshness and runtime ingest lag reduce decision confidence.", + 71, + DateTimeOffset.Parse("2026-02-19T03:15:00Z"), + new[] + { + "feeds:nvd=warn(3h stale)", + "sbom-rescan=fail(12 digests stale)", + "reach-runtime=warn(agent degraded)", + }), + ["staging"] = new( + "healthy", + "All freshness and ingest checks are within policy threshold.", + 94, + DateTimeOffset.Parse("2026-02-19T03:15:00Z"), + new[] + { + "feeds=ok", + "sbom-rescan=ok", + "reach-runtime=ok", + }), + ["dev"] = new( + "warning", + "Runtime evidence coverage is limited for non-prod workloads.", + 78, + DateTimeOffset.Parse("2026-02-19T03:15:00Z"), + new[] + { + "feeds=ok", + "sbom-rescan=ok", + "reach-runtime=warn(low coverage)", + }), + ["canary"] = new( + "healthy", + "Canary telemetry and feed freshness are green.", + 90, + DateTimeOffset.Parse("2026-02-19T03:15:00Z"), + new[] + { + "feeds=ok", + "sbom-rescan=ok", + "reach-runtime=ok", + }), + }; + + public static PromotionRiskSnapshot GetRiskSnapshot(string releaseId, string targetEnvironment) + { + if (RiskByRelease.TryGetValue(releaseId, out var risk)) + { + return string.Equals(risk.EnvironmentId, targetEnvironment, StringComparison.OrdinalIgnoreCase) + ? risk + : risk with { EnvironmentId = targetEnvironment }; + } + + return new PromotionRiskSnapshot(targetEnvironment, 0, 0, 0, 100m, "clean"); + } + + public static HybridReachabilityCoverage GetCoverage(string releaseId) + { + return CoverageByRelease.TryGetValue(releaseId, out var coverage) + ? coverage + : new HybridReachabilityCoverage(100, 100, 100, 1); + } + + public static OpsDataConfidence GetOpsConfidence(string targetEnvironment) + { + return OpsByEnvironment.TryGetValue(targetEnvironment, out var confidence) + ? confidence + : new OpsDataConfidence( + "unknown", + "No platform data-integrity signal is available for this environment.", + 0, + DateTimeOffset.Parse("2026-02-19T03:15:00Z"), + new[] { "platform-signal=missing" }); + } + + public static ApprovalEvidencePacket BuildEvidencePacket(string approvalId, string releaseId) + { + var suffix = $"{releaseId}-{approvalId}".Replace(":", string.Empty, StringComparison.Ordinal); + + return new ApprovalEvidencePacket( + DecisionDigest: $"sha256:decision-{suffix}", + PolicyDecisionDsse: $"policy-decision-{approvalId}.dsse", + SbomSnapshotId: $"sbom-snapshot-{releaseId}", + ReachabilitySnapshotId: $"reachability-snapshot-{releaseId}", + DataIntegritySnapshotId: $"ops-snapshot-{releaseId}"); + } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ReleaseDashboardSnapshotBuilder.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ReleaseDashboardSnapshotBuilder.cs new file mode 100644 index 000000000..9dd531df1 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ReleaseDashboardSnapshotBuilder.cs @@ -0,0 +1,250 @@ +using System.Globalization; +using StellaOps.ReleaseOrchestrator.WebApi.Endpoints; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Services; + +/// +/// Builds deterministic release dashboard snapshots from in-memory seed data. +/// +public static class ReleaseDashboardSnapshotBuilder +{ + private static readonly PipelineDefinition[] PipelineDefinitions = + { + new("dev", "development", "Development", 1), + new("staging", "staging", "Staging", 2), + new("uat", "uat", "UAT", 3), + new("production", "production", "Production", 4), + }; + + private static readonly HashSet AllowedReleaseStatuses = new(StringComparer.OrdinalIgnoreCase) + { + "draft", + "ready", + "promoting", + "deployed", + "failed", + "deprecated", + "rolled_back", + }; + + public static ReleaseDashboardSnapshot Build( + IReadOnlyList? approvals = null, + IReadOnlyList? releases = null) + { + var releaseItems = (releases ?? ReleaseEndpoints.SeedData.Releases) + .OrderByDescending(release => release.CreatedAt) + .ThenBy(release => release.Id, StringComparer.Ordinal) + .ToArray(); + + var approvalItems = (approvals ?? ApprovalEndpoints.SeedData.Approvals) + .OrderBy(approval => ParseTimestamp(approval.RequestedAt)) + .ThenBy(approval => approval.Id, StringComparer.Ordinal) + .ToArray(); + + var pendingApprovals = approvalItems + .Where(approval => string.Equals(approval.Status, "pending", StringComparison.OrdinalIgnoreCase)) + .Select(approval => new PendingApprovalItem( + approval.Id, + approval.ReleaseId, + approval.ReleaseName, + approval.ReleaseVersion, + ToDisplayEnvironment(approval.SourceEnvironment), + ToDisplayEnvironment(approval.TargetEnvironment), + approval.RequestedBy, + approval.RequestedAt, + NormalizeUrgency(approval.Urgency))) + .ToArray(); + + var activeDeployments = releaseItems + .Where(release => string.Equals(release.Status, "deploying", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(release => release.UpdatedAt) + .ThenBy(release => release.Id, StringComparer.Ordinal) + .Select((release, index) => + { + var progress = Math.Min(90, 45 + (index * 15)); + var totalTargets = Math.Max(1, release.ComponentCount); + var completedTargets = Math.Clamp( + (int)Math.Round(totalTargets * (progress / 100d), MidpointRounding.AwayFromZero), + 1, + totalTargets); + + return new ActiveDeploymentItem( + Id: $"dep-{release.Id}", + ReleaseId: release.Id, + ReleaseName: release.Name, + ReleaseVersion: release.Version, + Environment: ToDisplayEnvironment(release.TargetEnvironment ?? release.CurrentEnvironment ?? "staging"), + Progress: progress, + Status: "running", + StartedAt: release.UpdatedAt.ToString("O"), + CompletedTargets: completedTargets, + TotalTargets: totalTargets); + }) + .ToArray(); + + var pipelineEnvironments = PipelineDefinitions + .Select(definition => + { + var releaseCount = releaseItems.Count(release => + string.Equals(NormalizeEnvironment(release.CurrentEnvironment), definition.NormalizedName, StringComparison.OrdinalIgnoreCase)); + var pendingCount = pendingApprovals.Count(approval => + string.Equals(NormalizeEnvironment(approval.TargetEnvironment), definition.NormalizedName, StringComparison.OrdinalIgnoreCase)); + var hasActiveDeployment = activeDeployments.Any(deployment => + string.Equals(NormalizeEnvironment(deployment.Environment), definition.NormalizedName, StringComparison.OrdinalIgnoreCase)); + + var healthStatus = hasActiveDeployment || pendingCount > 0 + ? "degraded" + : releaseCount > 0 + ? "healthy" + : "unknown"; + + return new PipelineEnvironmentItem( + definition.Id, + definition.NormalizedName, + definition.DisplayName, + definition.Order, + releaseCount, + pendingCount, + healthStatus); + }) + .ToArray(); + + var pipelineConnections = PipelineDefinitions + .Skip(1) + .Select((definition, index) => new PipelineConnectionItem( + PipelineDefinitions[index].Id, + definition.Id)) + .ToArray(); + + var recentReleases = releaseItems + .Take(10) + .Select(release => new RecentReleaseItem( + release.Id, + release.Name, + release.Version, + NormalizeReleaseStatus(release.Status), + release.CurrentEnvironment is null ? null : ToDisplayEnvironment(release.CurrentEnvironment), + release.CreatedAt.ToString("O"), + string.IsNullOrWhiteSpace(release.CreatedBy) ? "system" : release.CreatedBy, + release.ComponentCount)) + .ToArray(); + + return new ReleaseDashboardSnapshot( + new PipelineData(pipelineEnvironments, pipelineConnections), + pendingApprovals, + activeDeployments, + recentReleases); + } + + private static DateTimeOffset ParseTimestamp(string value) + { + if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) + { + return parsed; + } + + return DateTimeOffset.MinValue; + } + + private static string NormalizeEnvironment(string? value) + { + var normalized = value?.Trim().ToLowerInvariant() ?? string.Empty; + return normalized switch + { + "dev" => "development", + "stage" => "staging", + "prod" => "production", + _ => normalized, + }; + } + + private static string ToDisplayEnvironment(string? value) + { + return NormalizeEnvironment(value) switch + { + "development" => "Development", + "staging" => "Staging", + "uat" => "UAT", + "production" => "Production", + var other when string.IsNullOrWhiteSpace(other) => "Unknown", + var other => CultureInfo.InvariantCulture.TextInfo.ToTitleCase(other), + }; + } + + private static string NormalizeReleaseStatus(string value) + { + var normalized = value.Trim().ToLowerInvariant(); + if (string.Equals(normalized, "deploying", StringComparison.OrdinalIgnoreCase)) + { + return "promoting"; + } + + return AllowedReleaseStatuses.Contains(normalized) ? normalized : "draft"; + } + + private static string NormalizeUrgency(string value) + { + var normalized = value.Trim().ToLowerInvariant(); + return normalized switch + { + "low" or "normal" or "high" or "critical" => normalized, + _ => "normal", + }; + } + + private sealed record PipelineDefinition(string Id, string NormalizedName, string DisplayName, int Order); +} + +public sealed record ReleaseDashboardSnapshot( + PipelineData PipelineData, + IReadOnlyList PendingApprovals, + IReadOnlyList ActiveDeployments, + IReadOnlyList RecentReleases); + +public sealed record PipelineData( + IReadOnlyList Environments, + IReadOnlyList Connections); + +public sealed record PipelineEnvironmentItem( + string Id, + string Name, + string DisplayName, + int Order, + int ReleaseCount, + int PendingCount, + string HealthStatus); + +public sealed record PipelineConnectionItem(string From, string To); + +public sealed record PendingApprovalItem( + string Id, + string ReleaseId, + string ReleaseName, + string ReleaseVersion, + string SourceEnvironment, + string TargetEnvironment, + string RequestedBy, + string RequestedAt, + string Urgency); + +public sealed record ActiveDeploymentItem( + string Id, + string ReleaseId, + string ReleaseName, + string ReleaseVersion, + string Environment, + int Progress, + string Status, + string StartedAt, + int CompletedTargets, + int TotalTargets); + +public sealed record RecentReleaseItem( + string Id, + string Name, + string Version, + string Status, + string? CurrentEnvironment, + string CreatedAt, + string CreatedBy, + int ComponentCount); diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ReleasePromotionDecisionStore.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ReleasePromotionDecisionStore.cs new file mode 100644 index 000000000..d6430b21a --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ReleasePromotionDecisionStore.cs @@ -0,0 +1,214 @@ +using System.Collections.Concurrent; +using System.Globalization; +using StellaOps.ReleaseOrchestrator.WebApi.Endpoints; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Services; + +/// +/// Tracks in-memory promotion decisions for the dashboard compatibility endpoints +/// without mutating the shared seed catalog used by deterministic tests. +/// +public sealed class ReleasePromotionDecisionStore +{ + private readonly ConcurrentDictionary overrides = + new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList Apply(IEnumerable approvals) + { + return approvals + .Select(Apply) + .ToArray(); + } + + public ApprovalEndpoints.ApprovalDto Apply(ApprovalEndpoints.ApprovalDto approval) + { + return overrides.TryGetValue(approval.Id, out var updated) + ? updated + : approval; + } + + public bool TryApprove( + string approvalId, + string actor, + string? comment, + out ApprovalEndpoints.ApprovalDto? approval, + out string? error) + { + lock (overrides) + { + var current = ResolveCurrentApproval(approvalId); + if (current is null) + { + approval = null; + error = "promotion_not_found"; + return false; + } + + if (string.Equals(current.Status, "rejected", StringComparison.OrdinalIgnoreCase)) + { + approval = null; + error = "promotion_not_pending"; + return false; + } + + if (string.Equals(current.Status, "approved", StringComparison.OrdinalIgnoreCase)) + { + approval = current; + error = null; + return true; + } + + var approvedAt = NextTimestamp(current); + var currentApprovals = Math.Min(current.RequiredApprovals, current.CurrentApprovals + 1); + var status = currentApprovals >= current.RequiredApprovals ? "approved" : current.Status; + + approval = current with + { + CurrentApprovals = currentApprovals, + Status = status, + Actions = AppendAction(current.Actions, new ApprovalEndpoints.ApprovalActionRecordDto + { + Id = BuildActionId(current.Id, current.Actions.Count + 1), + ApprovalId = current.Id, + Action = "approved", + Actor = actor, + Comment = comment ?? string.Empty, + Timestamp = approvedAt, + }), + Approvers = ApplyApprovalToApprovers(current.Approvers, actor, approvedAt), + }; + + overrides[approval.Id] = approval; + error = null; + return true; + } + } + + public bool TryReject( + string approvalId, + string actor, + string? comment, + out ApprovalEndpoints.ApprovalDto? approval, + out string? error) + { + lock (overrides) + { + var current = ResolveCurrentApproval(approvalId); + if (current is null) + { + approval = null; + error = "promotion_not_found"; + return false; + } + + if (string.Equals(current.Status, "approved", StringComparison.OrdinalIgnoreCase)) + { + approval = null; + error = "promotion_not_pending"; + return false; + } + + if (string.Equals(current.Status, "rejected", StringComparison.OrdinalIgnoreCase)) + { + approval = current; + error = null; + return true; + } + + var rejectedAt = NextTimestamp(current); + approval = current with + { + Status = "rejected", + Actions = AppendAction(current.Actions, new ApprovalEndpoints.ApprovalActionRecordDto + { + Id = BuildActionId(current.Id, current.Actions.Count + 1), + ApprovalId = current.Id, + Action = "rejected", + Actor = actor, + Comment = comment ?? string.Empty, + Timestamp = rejectedAt, + }), + }; + + overrides[approval.Id] = approval; + error = null; + return true; + } + } + + private ApprovalEndpoints.ApprovalDto? ResolveCurrentApproval(string approvalId) + { + if (overrides.TryGetValue(approvalId, out var updated)) + { + return updated; + } + + return ApprovalEndpoints.SeedData.Approvals + .FirstOrDefault(item => string.Equals(item.Id, approvalId, StringComparison.OrdinalIgnoreCase)); + } + + private static List ApplyApprovalToApprovers( + List approvers, + string actor, + string approvedAt) + { + var updated = approvers + .Select(item => + { + var matchesActor = + string.Equals(item.Id, actor, StringComparison.OrdinalIgnoreCase) + || string.Equals(item.Email, actor, StringComparison.OrdinalIgnoreCase) + || string.Equals(item.Name, actor, StringComparison.OrdinalIgnoreCase); + + return matchesActor + ? item with { HasApproved = true, ApprovedAt = approvedAt } + : item; + }) + .ToList(); + + if (updated.Any(item => item.HasApproved && string.Equals(item.ApprovedAt, approvedAt, StringComparison.Ordinal))) + { + return updated; + } + + updated.Add(new ApprovalEndpoints.ApproverDto + { + Id = actor, + Name = actor, + Email = actor.Contains('@', StringComparison.Ordinal) ? actor : $"{actor}@local", + HasApproved = true, + ApprovedAt = approvedAt, + }); + + return updated; + } + + private static List AppendAction( + List actions, + ApprovalEndpoints.ApprovalActionRecordDto action) + { + var updated = actions.ToList(); + updated.Add(action); + return updated; + } + + private static string BuildActionId(string approvalId, int index) + => $"{approvalId}-action-{index:D2}"; + + private static string NextTimestamp(ApprovalEndpoints.ApprovalDto approval) + { + var latestTimestamp = approval.Actions + .Select(action => ParseTimestamp(action.Timestamp)) + .Append(ParseTimestamp(approval.RequestedAt)) + .Max(); + + return latestTimestamp.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture); + } + + private static DateTimeOffset ParseTimestamp(string value) + { + return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) + ? parsed + : DateTimeOffset.UnixEpoch; + } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/TenantResolver.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/TenantResolver.cs new file mode 100644 index 000000000..d160e6715 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/TenantResolver.cs @@ -0,0 +1,35 @@ +namespace StellaOps.ReleaseOrchestrator.WebApi.Services; + +/// +/// Resolves tenant context from HTTP request headers. +/// +public sealed class TenantResolver +{ + private const string DefaultTenantHeader = "X-Tenant-Id"; + + /// + /// Resolves the tenant ID from the request headers. + /// + /// HTTP context. + /// Tenant ID. + /// Thrown when tenant header is missing or empty. + public string Resolve(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (!context.Request.Headers.TryGetValue(DefaultTenantHeader, out var values)) + { + throw new InvalidOperationException( + $"Tenant header '{DefaultTenantHeader}' is required for release orchestrator operations."); + } + + var tenantId = values.ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + throw new InvalidOperationException( + $"Tenant header '{DefaultTenantHeader}' must contain a value."); + } + + return tenantId.Trim(); + } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/WorkflowClient.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/WorkflowClient.cs new file mode 100644 index 000000000..ebc405e92 --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/WorkflowClient.cs @@ -0,0 +1,70 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StellaOps.ReleaseOrchestrator.WebApi.Services; + +/// +/// HTTP client for starting workflow instances via the workflow engine API. +/// +public sealed class WorkflowClient( + HttpClient httpClient, + ILogger logger) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// + /// Starts a workflow instance with the given name and payload. + /// Returns the workflow instance ID, or null if the call fails. + /// + public async Task StartWorkflowAsync( + string workflowName, + IDictionary payload, + CancellationToken cancellationToken = default) + { + var request = new + { + workflowName, + payload, + }; + + try + { + var response = await httpClient.PostAsJsonAsync( + "/api/workflow/start", request, JsonOptions, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync( + JsonOptions, cancellationToken); + + logger.LogInformation( + "Started workflow {WorkflowName} instance {InstanceId}", + workflowName, result?.WorkflowInstanceId); + + return result; + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogWarning( + "Workflow start failed for {WorkflowName}: {StatusCode} {Body}", + workflowName, response.StatusCode, body); + return null; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to start workflow {WorkflowName}", workflowName); + return null; + } + } +} + +public sealed record WorkflowStartResult +{ + public string? WorkflowInstanceId { get; init; } + public string? WorkflowName { get; init; } + public string? WorkflowVersion { get; init; } +} diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/StellaOps.ReleaseOrchestrator.WebApi.csproj b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/StellaOps.ReleaseOrchestrator.WebApi.csproj new file mode 100644 index 000000000..3302e739e --- /dev/null +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/StellaOps.ReleaseOrchestrator.WebApi.csproj @@ -0,0 +1,29 @@ + + + + + net10.0 + Exe + enable + enable + preview + true + + + + + + + + + + + + + + + + 1.0.0-alpha1 + 1.0.0-alpha1 + + diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/Models/ScriptModels.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/Models/ScriptModels.cs index e26864ae8..2ac9c003e 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/Models/ScriptModels.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/Models/ScriptModels.cs @@ -31,7 +31,10 @@ public enum ScriptLanguage Bash, /// TypeScript script (.ts) on Node.js 22. - TypeScript + TypeScript, + + /// PowerShell script (.ps1). + PowerShell } /// @@ -97,6 +100,9 @@ public sealed record Script /// Searchable tags. public ImmutableArray Tags { get; init; } = []; + /// Declared variables for this script. + public ImmutableArray Variables { get; init; } = []; + /// Visibility/access level. public required ScriptVisibility Visibility { get; init; } @@ -132,6 +138,7 @@ public sealed record Script ScriptLanguage.Go => ".go", ScriptLanguage.Bash => ".sh", ScriptLanguage.TypeScript => ".ts", + ScriptLanguage.PowerShell => ".ps1", _ => ".txt" }; } @@ -169,6 +176,27 @@ public sealed record ScriptDependency public bool IsDevelopment { get; init; } } +/// +/// Script variable declaration. +/// +public sealed record ScriptVariable +{ + /// Variable name. + public required string Name { get; init; } + + /// Variable description. + public string? Description { get; init; } + + /// Whether the variable is required. + public bool IsRequired { get; init; } + + /// Default value. + public string? DefaultValue { get; init; } + + /// Whether the variable is a secret. + public bool IsSecret { get; init; } +} + /// /// Resolved dependency with full metadata. /// diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/Persistence/PostgresScriptStore.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/Persistence/PostgresScriptStore.cs new file mode 100644 index 000000000..0dca8ef30 --- /dev/null +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/Persistence/PostgresScriptStore.cs @@ -0,0 +1,311 @@ +using System.Collections.Immutable; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.ReleaseOrchestrator.Scripts.Persistence; + +/// +/// PostgreSQL implementation of . +/// Uses raw SQL via RepositoryBase following the ScheduleRepository pattern. +/// +public sealed class PostgresScriptStore : RepositoryBase, IScriptStore +{ + private static readonly JsonSerializerOptions s_json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + // Scripts are not tenant-scoped in the traditional sense; use a synthetic tenant for RepositoryBase. + private const string DefaultTenant = "__scripts__"; + + public PostgresScriptStore(ScriptsDataSource dataSource, ILogger logger) + : base(dataSource, logger) + { + } + + public async Task SaveAsync(Script script, CancellationToken ct = default) + { + const string sql = """ + INSERT INTO scripts.scripts ( + id, name, description, language, content, entry_point, version, + dependencies, tags, variables, visibility, owner_id, team_id, + content_hash, is_sample, sample_category, created_at, updated_at) + VALUES ( + @id, @name, @description, @language, @content, @entry_point, @version, + @dependencies, @tags, @variables, @visibility, @owner_id, @team_id, + @content_hash, @is_sample, @sample_category, @created_at, @updated_at) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + content = EXCLUDED.content, + entry_point = EXCLUDED.entry_point, + version = EXCLUDED.version, + dependencies = EXCLUDED.dependencies, + tags = EXCLUDED.tags, + variables = EXCLUDED.variables, + visibility = EXCLUDED.visibility, + team_id = EXCLUDED.team_id, + content_hash = EXCLUDED.content_hash, + is_sample = EXCLUDED.is_sample, + sample_category = EXCLUDED.sample_category, + updated_at = EXCLUDED.updated_at + """; + + await using var conn = await DataSource.OpenConnectionAsync(DefaultTenant, "writer", ct).ConfigureAwait(false); + await using var cmd = CreateCommand(sql, conn); + + AddParameter(cmd, "id", script.Id); + AddParameter(cmd, "name", script.Name); + AddParameter(cmd, "description", (object?)script.Description ?? DBNull.Value); + AddParameter(cmd, "language", script.Language.ToString().ToLowerInvariant()); + AddParameter(cmd, "content", script.Content); + AddParameter(cmd, "entry_point", (object?)script.EntryPoint ?? DBNull.Value); + AddParameter(cmd, "version", script.Version); + AddJsonbParameter(cmd, "dependencies", JsonSerializer.Serialize(script.Dependencies, s_json)); + AddTextArrayParameter(cmd, "tags", script.Tags.ToArray()); + AddJsonbParameter(cmd, "variables", JsonSerializer.Serialize(script.Variables, s_json)); + AddParameter(cmd, "visibility", script.Visibility.ToString().ToLowerInvariant()); + AddParameter(cmd, "owner_id", script.OwnerId); + AddParameter(cmd, "team_id", (object?)script.TeamId ?? DBNull.Value); + AddParameter(cmd, "content_hash", script.ContentHash); + AddParameter(cmd, "is_sample", script.IsSample); + AddParameter(cmd, "sample_category", (object?)script.SampleCategory ?? DBNull.Value); + AddParameter(cmd, "created_at", script.CreatedAt); + AddParameter(cmd, "updated_at", (object?)script.UpdatedAt ?? DBNull.Value); + + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + public async Task GetAsync(string scriptId, CancellationToken ct = default) + { + const string sql = """ + SELECT id, name, description, language, content, entry_point, version, + dependencies, tags, variables, visibility, owner_id, team_id, + content_hash, is_sample, sample_category, created_at, updated_at + FROM scripts.scripts + WHERE id = @id + LIMIT 1 + """; + + return await QuerySingleOrDefaultAsync