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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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" },
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -336,12 +336,7 @@ internal static class ConsoleBrandingEndpointExtensions
|
||||
"StellaOps",
|
||||
null, // No custom logo
|
||||
null, // No custom favicon
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["--theme-bg-primary"] = "#ffffff",
|
||||
["--theme-text-primary"] = "#0f172a",
|
||||
["--theme-brand-primary"] = "#4328b7"
|
||||
}
|
||||
new Dictionary<string, string>()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Creates system-managed schedules on startup for each tenant.
|
||||
/// Missing schedules are inserted; existing ones are left untouched.
|
||||
/// </summary>
|
||||
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<SystemScheduleBootstrap> _logger;
|
||||
|
||||
public SystemScheduleBootstrap(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<SystemScheduleBootstrap> 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<IScheduleRepository>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ISchedulerAuditService, InMemorySchedulerAuditService>();
|
||||
builder.Services.AddSingleton<IPolicyRunService, InMemoryPolicyRunService>();
|
||||
}
|
||||
// Scripts registry (shares the same Postgres options as Scheduler)
|
||||
builder.Services.AddSingleton<ScriptsDataSource>();
|
||||
builder.Services.AddSingleton<IScriptStore, PostgresScriptStore>();
|
||||
builder.Services.AddSingleton<ISearchIndexer, InMemorySearchIndexer>();
|
||||
builder.Services.AddSingleton<IScriptValidator, ScriptValidator>();
|
||||
builder.Services.AddSingleton<ILanguageValidator, CSharpScriptValidator>();
|
||||
builder.Services.AddSingleton<ILanguageValidator, PythonScriptValidator>();
|
||||
builder.Services.AddSingleton<ILanguageValidator, TypeScriptScriptValidator>();
|
||||
builder.Services.AddSingleton<IScriptRegistry, ScriptRegistry>();
|
||||
|
||||
// Workflow engine HTTP client (starts workflow instances for system schedules)
|
||||
builder.Services.AddHttpClient<StellaOps.Scheduler.WebService.Workflow.WorkflowTriggerClient>((sp, client) =>
|
||||
{
|
||||
client.BaseAddress = new Uri(
|
||||
builder.Configuration["Workflow:BaseAddress"] ?? "http://workflow.stella-ops.local");
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IGraphJobCompletionPublisher, GraphJobEventPublisher>();
|
||||
builder.Services.AddSingleton<IResolverJobService, InMemoryResolverJobService>();
|
||||
if (cartographerOptions.Webhook.Enabled)
|
||||
@@ -147,6 +169,7 @@ builder.Services.AddSingleton<IExpiringDigestService>(NullExpiringDigestService.
|
||||
builder.Services.AddSingleton<IExpiringAlertService>(NullExpiringAlertService.Instance);
|
||||
builder.Services.AddHostedService<ExceptionLifecycleWorker>();
|
||||
builder.Services.AddHostedService<ExpiringNotificationWorker>();
|
||||
builder.Services.AddHostedService<SystemScheduleBootstrap>();
|
||||
|
||||
var schedulerOptions = builder.Configuration.GetSection("Scheduler").Get<SchedulerOptions>() ?? 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);
|
||||
|
||||
@@ -17,7 +17,8 @@ internal sealed record ScheduleCreateRequest(
|
||||
[property: JsonPropertyName("notify")] ScheduleNotify? Notify = null,
|
||||
[property: JsonPropertyName("limits")] ScheduleLimits? Limits = null,
|
||||
[property: JsonPropertyName("subscribers")] ImmutableArray<string>? 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,
|
||||
|
||||
@@ -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<IResult> 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<string, string>
|
||||
{
|
||||
["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<IResult> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
|
||||
<ProjectReference Include="../../ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/StellaOps.ReleaseOrchestrator.Scripts.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Translations\*.json" />
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Workflow;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for triggering workflow instances from the scheduler.
|
||||
/// Maps system schedule names to workflow definitions.
|
||||
/// </summary>
|
||||
public sealed class WorkflowTriggerClient(
|
||||
HttpClient httpClient,
|
||||
ILogger<WorkflowTriggerClient> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps system schedule names to workflow definition names.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> 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",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Tries to resolve a workflow name for the given schedule name.
|
||||
/// </summary>
|
||||
public static string? ResolveWorkflowName(string scheduleName)
|
||||
{
|
||||
return ScheduleToWorkflow.TryGetValue(scheduleName, out var wf) ? wf : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a workflow instance for the given schedule.
|
||||
/// </summary>
|
||||
public async Task<WorkflowStartResult?> 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<string, object?>
|
||||
{
|
||||
["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<WorkflowStartResult>(
|
||||
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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for triggering workflow instances from system schedules.
|
||||
/// </summary>
|
||||
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<IResult> 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ public sealed record Schedule
|
||||
DateTimeOffset updatedAt,
|
||||
string updatedBy,
|
||||
ImmutableArray<string>? 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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
@@ -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)';
|
||||
@@ -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,
|
||||
|
||||
@@ -30,11 +30,11 @@ public sealed class ScheduleRepository : RepositoryBase<SchedulerDataSource>, 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<SchedulerDataSource>, 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<SchedulerDataSource>, 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Eventing\StellaOps.Eventing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\Findings\StellaOps.Findings.Ledger\StellaOps.Findings.Ledger.csproj" />
|
||||
<ProjectReference Include="..\..\..\JobEngine\StellaOps.JobEngine\StellaOps.JobEngine.Infrastructure\StellaOps.JobEngine.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using StellaOps.JobEngine.Core.Domain;
|
||||
using StellaOps.JobEngine.Infrastructure.Repositories;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Contracts;
|
||||
|
||||
// ===== Audit Contracts =====
|
||||
|
||||
/// <summary>
|
||||
/// Response for an audit entry.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List response for audit entries.
|
||||
/// </summary>
|
||||
public sealed record AuditEntryListResponse(
|
||||
IReadOnlyList<AuditEntryResponse> Entries,
|
||||
string? NextCursor);
|
||||
|
||||
/// <summary>
|
||||
/// Response for audit summary.
|
||||
/// </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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for chain verification.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// API response for first signal endpoint.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Risk snapshot surfaced in promotion/approval contracts (Pack 13/17).
|
||||
/// </summary>
|
||||
public sealed record PromotionRiskSnapshot(
|
||||
string EnvironmentId,
|
||||
int CriticalReachable,
|
||||
int HighReachable,
|
||||
int HighNotReachable,
|
||||
decimal VexCoveragePercent,
|
||||
string Severity);
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid reachability coverage (build/image/runtime) surfaced as confidence.
|
||||
/// </summary>
|
||||
public sealed record HybridReachabilityCoverage(
|
||||
int BuildCoveragePercent,
|
||||
int ImageCoveragePercent,
|
||||
int RuntimeCoveragePercent,
|
||||
int EvidenceAgeHours);
|
||||
|
||||
/// <summary>
|
||||
/// Operations/data confidence summary consumed by approvals and promotions.
|
||||
/// </summary>
|
||||
public sealed record OpsDataConfidence(
|
||||
string Status,
|
||||
string Summary,
|
||||
int TrustScore,
|
||||
DateTimeOffset DataAsOf,
|
||||
IReadOnlyList<string> Signals);
|
||||
|
||||
/// <summary>
|
||||
/// Evidence packet summary for approval decision packets.
|
||||
/// </summary>
|
||||
public sealed record ApprovalEvidencePacket(
|
||||
string DecisionDigest,
|
||||
string PolicyDecisionDsse,
|
||||
string SbomSnapshotId,
|
||||
string ReachabilitySnapshotId,
|
||||
string DataIntegritySnapshotId);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Approval endpoints for the release orchestrator.
|
||||
/// Routes: /api/release-orchestrator/approvals
|
||||
/// </summary>
|
||||
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<GateResultDto> GateResults { get; init; } = new();
|
||||
public List<ApprovalActionRecordDto> Actions { get; init; } = new();
|
||||
public List<ApproverDto> Approvers { get; init; } = new();
|
||||
public List<ReleaseComponentSummaryDto> 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<string, object> 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<ApprovalDto> 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" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for audit log operations.
|
||||
/// </summary>
|
||||
public static class AuditEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps audit endpoints to the route builder.
|
||||
/// </summary>
|
||||
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<IResult> 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<AuditEventType>(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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IResult> 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<IResult> 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<DeploymentSummaryDto> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> TransitionAsync(
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
string deploymentId,
|
||||
IReadOnlyCollection<string> 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<string> Csv(params string?[] values)
|
||||
{
|
||||
var set = new HashSet<string>(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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence management endpoints for the release orchestrator.
|
||||
/// Provides listing, inspection, verification, export, and timeline
|
||||
/// operations for release evidence packets.
|
||||
/// Routes: /api/release-orchestrator/evidence
|
||||
/// </summary>
|
||||
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<object>() });
|
||||
}
|
||||
|
||||
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<EvidencePacketDto> 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<string, List<EvidenceTimelineEventDto>> 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoint for first signal (TTFS).
|
||||
/// </summary>
|
||||
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<IResult> 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
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// v2 contract adapters for Pack-driven release control routes.
|
||||
/// </summary>
|
||||
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<object> 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<object> 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<string, RunDetailDto> RunCatalog =
|
||||
new Dictionary<string, RunDetailDto>(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<string, EnvironmentDetailDto> EnvironmentCatalog =
|
||||
new Dictionary<string, EnvironmentDetailDto>(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<RunStepDto> 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<string> TopFindings,
|
||||
IReadOnlyList<EnvironmentDeploymentDto> 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);
|
||||
@@ -0,0 +1,180 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.ReleaseOrchestrator.WebApi.Services;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Release dashboard endpoints consumed by the Console control plane.
|
||||
/// </summary>
|
||||
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<string, int>(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<string, int>(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);
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.ReleaseOrchestrator.WebApi.Services;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Release management endpoints for the Orchestrator service.
|
||||
/// Provides CRUD and lifecycle operations for managed releases.
|
||||
/// Routes: /api/release-orchestrator/releases
|
||||
/// </summary>
|
||||
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<IResult> 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<ApprovalEndpoints.GateResultDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GateId = "g-security",
|
||||
GateName = "Security Snapshot",
|
||||
Type = "security",
|
||||
Status = "passed",
|
||||
Message = "Critical reachable findings within policy threshold.",
|
||||
Details = new Dictionary<string, object>(),
|
||||
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<string, object>(),
|
||||
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<string, object?>
|
||||
{
|
||||
["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<object>());
|
||||
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<string, string>(),
|
||||
};
|
||||
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<object>());
|
||||
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<string, object>(), evaluatedAt = PreviewEvaluatedAt },
|
||||
new { gateId = "g2", gateName = "Policy Compliance", type = "policy", status = "passed", message = "All policies satisfied", details = new Dictionary<string, object>(), evaluatedAt = PreviewEvaluatedAt },
|
||||
new { gateId = "g3", gateName = "Ops Data Integrity", type = "quality", status = ops.Status == "healthy" ? "passed" : "warning", message = ops.Summary, details = new Dictionary<string, object>(), evaluatedAt = PreviewEvaluatedAt },
|
||||
},
|
||||
allGatesPassed = true,
|
||||
requiredApprovers = 2,
|
||||
estimatedDeployTime = 300,
|
||||
warnings = ops.Status == "healthy"
|
||||
? Array.Empty<string>()
|
||||
: 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<ApprovalEndpoints.ReleaseComponentSummaryDto> BuildReleaseComponents(string releaseId)
|
||||
{
|
||||
if (!SeedData.Components.TryGetValue(releaseId, out var components))
|
||||
{
|
||||
return new List<ApprovalEndpoints.ReleaseComponentSummaryDto>();
|
||||
}
|
||||
|
||||
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<string, string> 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<string, object> 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<string, string>? ConfigOverrides { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UpdateComponentDto
|
||||
{
|
||||
public Dictionary<string, string>? 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<ManagedReleaseDto> 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<string, List<ReleaseComponentDto>> 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<string, List<ReleaseEventDto>> 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") },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<WorkflowClient>((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<ReleasePromotionDecisionStore>();
|
||||
builder.Services.AddSingleton<TenantResolver>();
|
||||
builder.Services.AddSingleton<InMemoryDeploymentCompatibilityStore>();
|
||||
builder.Services.AddSingleton<IDeploymentCompatibilityStore>(sp =>
|
||||
sp.GetRequiredService<InMemoryDeploymentCompatibilityStore>());
|
||||
|
||||
// Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "release-orchestrator",
|
||||
version: Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
?.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 { }
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi;
|
||||
|
||||
/// <summary>
|
||||
/// Named authorization policy constants for the Release Orchestrator service.
|
||||
/// Each constant is the policy name used with <c>RequireAuthorization(policyName)</c>
|
||||
/// and corresponds to one or more canonical StellaOps scopes.
|
||||
/// </summary>
|
||||
public static class ReleaseOrchestratorPolicies
|
||||
{
|
||||
// --- Orchestrator core policies ---
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access to orchestrator run and job state, telemetry, sources, DAG topology,
|
||||
/// first-signal metrics, SLOs, and the immutable audit log.
|
||||
/// Requires scope: <c>orch:read</c>.
|
||||
/// </summary>
|
||||
public const string Read = StellaOpsScopes.OrchRead;
|
||||
|
||||
/// <summary>
|
||||
/// Operational control actions: cancel, retry, replay, force-close circuit breakers,
|
||||
/// resolve dead-letter entries, and manage workers.
|
||||
/// Requires scope: <c>orch:operate</c>.
|
||||
/// </summary>
|
||||
public const string Operate = StellaOpsScopes.OrchOperate;
|
||||
|
||||
// --- Release orchestration policies ---
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access to release records, promotion previews, release events, and dashboards.
|
||||
/// Requires scope: <c>release:read</c>.
|
||||
/// </summary>
|
||||
public const string ReleaseRead = StellaOpsScopes.ReleaseRead;
|
||||
|
||||
/// <summary>
|
||||
/// Create, update, and manage release lifecycle state (start, stop, fail, complete).
|
||||
/// Requires scope: <c>release:write</c>.
|
||||
/// </summary>
|
||||
public const string ReleaseWrite = StellaOpsScopes.ReleaseWrite;
|
||||
|
||||
/// <summary>
|
||||
/// Approve or reject release promotions and environment-level approval gates.
|
||||
/// Requires scope: <c>release:publish</c>.
|
||||
/// </summary>
|
||||
public const string ReleaseApprove = StellaOpsScopes.ReleasePublish;
|
||||
|
||||
/// <summary>
|
||||
/// Registers all Release Orchestrator service authorization policies into the ASP.NET Core
|
||||
/// authorization options. Call this from <c>Program.cs</c> inside <c>AddAuthorization</c>.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<PromotionStageDto> PromotionStages { get; init; } = Array.Empty<PromotionStageDto>();
|
||||
}
|
||||
|
||||
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<DeploymentTargetDto> 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<PromotionStageDto> PromotionStages { get; init; } = Array.Empty<PromotionStageDto>();
|
||||
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<DeploymentLogEntryDto> Logs,
|
||||
List<DeploymentEventDto> Events,
|
||||
DeploymentMetricsDto Metrics);
|
||||
|
||||
public enum DeploymentMutationStatus
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
Conflict,
|
||||
}
|
||||
|
||||
public sealed record DeploymentMutationResult(
|
||||
DeploymentMutationStatus Status,
|
||||
string Message,
|
||||
DeploymentDto? Deployment);
|
||||
@@ -0,0 +1,358 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Services;
|
||||
|
||||
internal static class DeploymentCompatibilityStateFactory
|
||||
{
|
||||
public static IReadOnlyList<DeploymentCompatibilityState> 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<DeploymentLogEntryDto>
|
||||
{
|
||||
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<DeploymentEventDto>
|
||||
{
|
||||
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<DeploymentTargetDto> CreateTargets(
|
||||
string environmentId,
|
||||
int count,
|
||||
int? failedIndex,
|
||||
int offset,
|
||||
DateTimeOffset baseTime)
|
||||
{
|
||||
var items = new List<DeploymentTargetDto>(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..]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using StellaOps.JobEngine.Core.Domain;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for endpoint operations.
|
||||
/// </summary>
|
||||
public static class EndpointHelpers
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets limit value, clamped to valid range.
|
||||
/// </summary>
|
||||
public static int GetLimit(int? requestedLimit) =>
|
||||
Math.Clamp(requestedLimit ?? DefaultLimit, 1, MaxLimit);
|
||||
|
||||
/// <summary>
|
||||
/// Parses offset from cursor string.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cursor for the next page.
|
||||
/// </summary>
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Services;
|
||||
|
||||
public interface IDeploymentCompatibilityStore
|
||||
{
|
||||
Task<IReadOnlyList<DeploymentDto>> ListAsync(string tenantId, CancellationToken cancellationToken);
|
||||
|
||||
Task<DeploymentDto?> GetAsync(string tenantId, string deploymentId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<DeploymentLogEntryDto>?> GetLogsAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
string? targetId,
|
||||
string? level,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<DeploymentEventDto>?> GetEventsAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DeploymentMetricsDto?> GetMetricsAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DeploymentDto> CreateAsync(
|
||||
string tenantId,
|
||||
CreateDeploymentRequest request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DeploymentMutationResult> TransitionAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
IReadOnlyCollection<string> allowedStatuses,
|
||||
string nextStatus,
|
||||
string eventType,
|
||||
string message,
|
||||
bool complete,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DeploymentMutationResult> RetryAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
string targetId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Services;
|
||||
|
||||
public sealed class InMemoryDeploymentCompatibilityStore : IDeploymentCompatibilityStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, DeploymentCompatibilityState>> _tenants = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryDeploymentCompatibilityStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DeploymentDto>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var states = GetOrSeedTenantState(tenantId);
|
||||
return Task.FromResult<IReadOnlyList<DeploymentDto>>(states.Values
|
||||
.Select(state => state.Deployment)
|
||||
.OrderByDescending(item => item.StartedAt)
|
||||
.ThenBy(item => item.Id, StringComparer.Ordinal)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public Task<DeploymentDto?> 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<IReadOnlyList<DeploymentLogEntryDto>?> 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<IReadOnlyList<DeploymentLogEntryDto>?>(null);
|
||||
}
|
||||
|
||||
IEnumerable<DeploymentLogEntryDto> 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<IReadOnlyList<DeploymentLogEntryDto>?>(logs
|
||||
.TakeLast(Math.Clamp(limit ?? 500, 1, 5000))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DeploymentEventDto>?> GetEventsAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var states = GetOrSeedTenantState(tenantId);
|
||||
return Task.FromResult<IReadOnlyList<DeploymentEventDto>?>(states.TryGetValue(deploymentId, out var state)
|
||||
? state.Events.OrderBy(item => item.Timestamp).ToList()
|
||||
: null);
|
||||
}
|
||||
|
||||
public Task<DeploymentMetricsDto?> 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<DeploymentDto> 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<DeploymentMutationResult> TransitionAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
IReadOnlyCollection<string> 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<DeploymentMutationResult> 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<string, DeploymentCompatibilityState> GetOrSeedTenantState(string tenantId)
|
||||
{
|
||||
return _tenants.GetOrAdd(tenantId, _ =>
|
||||
{
|
||||
var states = new ConcurrentDictionary<string, DeploymentCompatibilityState>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var seed in DeploymentCompatibilityStateFactory.CreateSeedStates())
|
||||
{
|
||||
states[seed.Deployment.Id] = seed;
|
||||
}
|
||||
|
||||
return states;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using StellaOps.ReleaseOrchestrator.WebApi.Contracts;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic signal projections used by release-control contract adapters.
|
||||
/// </summary>
|
||||
public static class ReleaseControlSignalCatalog
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, PromotionRiskSnapshot> RiskByRelease =
|
||||
new Dictionary<string, PromotionRiskSnapshot>(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<string, HybridReachabilityCoverage> CoverageByRelease =
|
||||
new Dictionary<string, HybridReachabilityCoverage>(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<string, OpsDataConfidence> OpsByEnvironment =
|
||||
new Dictionary<string, OpsDataConfidence>(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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using System.Globalization;
|
||||
using StellaOps.ReleaseOrchestrator.WebApi.Endpoints;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic release dashboard snapshots from in-memory seed data.
|
||||
/// </summary>
|
||||
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<string> AllowedReleaseStatuses = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"draft",
|
||||
"ready",
|
||||
"promoting",
|
||||
"deployed",
|
||||
"failed",
|
||||
"deprecated",
|
||||
"rolled_back",
|
||||
};
|
||||
|
||||
public static ReleaseDashboardSnapshot Build(
|
||||
IReadOnlyList<ApprovalEndpoints.ApprovalDto>? approvals = null,
|
||||
IReadOnlyList<ReleaseEndpoints.ManagedReleaseDto>? 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<PendingApprovalItem> PendingApprovals,
|
||||
IReadOnlyList<ActiveDeploymentItem> ActiveDeployments,
|
||||
IReadOnlyList<RecentReleaseItem> RecentReleases);
|
||||
|
||||
public sealed record PipelineData(
|
||||
IReadOnlyList<PipelineEnvironmentItem> Environments,
|
||||
IReadOnlyList<PipelineConnectionItem> 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);
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using StellaOps.ReleaseOrchestrator.WebApi.Endpoints;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks in-memory promotion decisions for the dashboard compatibility endpoints
|
||||
/// without mutating the shared seed catalog used by deterministic tests.
|
||||
/// </summary>
|
||||
public sealed class ReleasePromotionDecisionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ApprovalEndpoints.ApprovalDto> overrides =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IReadOnlyList<ApprovalEndpoints.ApprovalDto> Apply(IEnumerable<ApprovalEndpoints.ApprovalDto> 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<ApprovalEndpoints.ApproverDto> ApplyApprovalToApprovers(
|
||||
List<ApprovalEndpoints.ApproverDto> 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<ApprovalEndpoints.ApprovalActionRecordDto> AppendAction(
|
||||
List<ApprovalEndpoints.ApprovalActionRecordDto> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves tenant context from HTTP request headers.
|
||||
/// </summary>
|
||||
public sealed class TenantResolver
|
||||
{
|
||||
private const string DefaultTenantHeader = "X-Tenant-Id";
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the tenant ID from the request headers.
|
||||
/// </summary>
|
||||
/// <param name="context">HTTP context.</param>
|
||||
/// <returns>Tenant ID.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when tenant header is missing or empty.</exception>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.WebApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for starting workflow instances via the workflow engine API.
|
||||
/// </summary>
|
||||
public sealed class WorkflowClient(
|
||||
HttpClient httpClient,
|
||||
ILogger<WorkflowClient> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Starts a workflow instance with the given name and payload.
|
||||
/// Returns the workflow instance ID, or null if the call fails.
|
||||
/// </summary>
|
||||
public async Task<WorkflowStartResult?> StartWorkflowAsync(
|
||||
string workflowName,
|
||||
IDictionary<string, object?> 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<WorkflowStartResult>(
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\..\..\JobEngine\StellaOps.JobEngine\StellaOps.JobEngine.Core\StellaOps.JobEngine.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\JobEngine\StellaOps.JobEngine\StellaOps.JobEngine.Infrastructure\StellaOps.JobEngine.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -31,7 +31,10 @@ public enum ScriptLanguage
|
||||
Bash,
|
||||
|
||||
/// <summary>TypeScript script (.ts) on Node.js 22.</summary>
|
||||
TypeScript
|
||||
TypeScript,
|
||||
|
||||
/// <summary>PowerShell script (.ps1).</summary>
|
||||
PowerShell
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -97,6 +100,9 @@ public sealed record Script
|
||||
/// <summary>Searchable tags.</summary>
|
||||
public ImmutableArray<string> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>Declared variables for this script.</summary>
|
||||
public ImmutableArray<ScriptVariable> Variables { get; init; } = [];
|
||||
|
||||
/// <summary>Visibility/access level.</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Script variable declaration.
|
||||
/// </summary>
|
||||
public sealed record ScriptVariable
|
||||
{
|
||||
/// <summary>Variable name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Variable description.</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Whether the variable is required.</summary>
|
||||
public bool IsRequired { get; init; }
|
||||
|
||||
/// <summary>Default value.</summary>
|
||||
public string? DefaultValue { get; init; }
|
||||
|
||||
/// <summary>Whether the variable is a secret.</summary>
|
||||
public bool IsSecret { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolved dependency with full metadata.
|
||||
/// </summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IScriptStore"/>.
|
||||
/// Uses raw SQL via RepositoryBase following the ScheduleRepository pattern.
|
||||
/// </summary>
|
||||
public sealed class PostgresScriptStore : RepositoryBase<ScriptsDataSource>, 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<PostgresScriptStore> 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<Script?> 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<Script>(
|
||||
DefaultTenant, sql,
|
||||
cmd => AddParameter(cmd, "id", scriptId),
|
||||
MapScript, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string scriptId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = "DELETE FROM scripts.scripts WHERE id = @id";
|
||||
var rows = await ExecuteAsync(
|
||||
DefaultTenant, sql,
|
||||
cmd => AddParameter(cmd, "id", scriptId),
|
||||
ct).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public async Task SaveVersionAsync(ScriptVersion version, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO scripts.script_versions (
|
||||
script_id, version, content, content_hash, dependencies, created_at, created_by, change_note)
|
||||
VALUES (
|
||||
@script_id, @version, @content, @content_hash, @dependencies, @created_at, @created_by, @change_note)
|
||||
ON CONFLICT (script_id, version) DO NOTHING
|
||||
""";
|
||||
|
||||
await using var conn = await DataSource.OpenConnectionAsync(DefaultTenant, "writer", ct).ConfigureAwait(false);
|
||||
await using var cmd = CreateCommand(sql, conn);
|
||||
|
||||
AddParameter(cmd, "script_id", version.ScriptId);
|
||||
AddParameter(cmd, "version", version.Version);
|
||||
AddParameter(cmd, "content", version.Content);
|
||||
AddParameter(cmd, "content_hash", version.ContentHash);
|
||||
AddJsonbParameter(cmd, "dependencies", JsonSerializer.Serialize(version.Dependencies, s_json));
|
||||
AddParameter(cmd, "created_at", version.CreatedAt);
|
||||
AddParameter(cmd, "created_by", version.CreatedBy);
|
||||
AddParameter(cmd, "change_note", (object?)version.ChangeNote ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ScriptVersion?> GetVersionAsync(string scriptId, int version, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT script_id, version, content, content_hash, dependencies, created_at, created_by, change_note
|
||||
FROM scripts.script_versions
|
||||
WHERE script_id = @script_id AND version = @version
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync<ScriptVersion>(
|
||||
DefaultTenant, sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "script_id", scriptId);
|
||||
AddParameter(cmd, "version", version);
|
||||
},
|
||||
MapVersion, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<ScriptVersion>> GetVersionsAsync(string scriptId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT script_id, version, content, content_hash, dependencies, created_at, created_by, change_note
|
||||
FROM scripts.script_versions
|
||||
WHERE script_id = @script_id
|
||||
ORDER BY version DESC
|
||||
""";
|
||||
|
||||
var results = await QueryAsync<ScriptVersion>(
|
||||
DefaultTenant, sql,
|
||||
cmd => AddParameter(cmd, "script_id", scriptId),
|
||||
MapVersion, ct).ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
public async Task<ScriptSearchResult> SearchAsync(ScriptSearchCriteria criteria, CancellationToken ct = default)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("""
|
||||
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 1=1
|
||||
""");
|
||||
|
||||
var countSb = new StringBuilder("SELECT COUNT(*) FROM scripts.scripts WHERE 1=1");
|
||||
var parameters = new List<(string name, object value)>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(criteria.SearchText))
|
||||
{
|
||||
var clause = " AND (name ILIKE @search OR description ILIKE @search OR @search_raw = ANY(tags))";
|
||||
sb.Append(clause);
|
||||
countSb.Append(clause);
|
||||
parameters.Add(("search", $"%{criteria.SearchText}%"));
|
||||
parameters.Add(("search_raw", criteria.SearchText));
|
||||
}
|
||||
|
||||
if (criteria.Language.HasValue)
|
||||
{
|
||||
var clause = " AND language = @language";
|
||||
sb.Append(clause);
|
||||
countSb.Append(clause);
|
||||
parameters.Add(("language", criteria.Language.Value.ToString().ToLowerInvariant()));
|
||||
}
|
||||
|
||||
if (criteria.Visibility.HasValue)
|
||||
{
|
||||
var clause = " AND visibility = @visibility";
|
||||
sb.Append(clause);
|
||||
countSb.Append(clause);
|
||||
parameters.Add(("visibility", criteria.Visibility.Value.ToString().ToLowerInvariant()));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(criteria.OwnerId))
|
||||
{
|
||||
var clause = " AND owner_id = @owner_id";
|
||||
sb.Append(clause);
|
||||
countSb.Append(clause);
|
||||
parameters.Add(("owner_id", criteria.OwnerId));
|
||||
}
|
||||
|
||||
if (criteria.IsSample.HasValue)
|
||||
{
|
||||
var clause = " AND is_sample = @is_sample";
|
||||
sb.Append(clause);
|
||||
countSb.Append(clause);
|
||||
parameters.Add(("is_sample", criteria.IsSample.Value));
|
||||
}
|
||||
|
||||
sb.Append(" ORDER BY updated_at DESC NULLS LAST, created_at DESC");
|
||||
sb.Append($" LIMIT {criteria.Limit} OFFSET {criteria.Offset}");
|
||||
|
||||
void Configure(NpgsqlCommand cmd)
|
||||
{
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
AddParameter(cmd, name, value);
|
||||
}
|
||||
}
|
||||
|
||||
var scripts = await QueryAsync<Script>(DefaultTenant, sb.ToString(), Configure, MapScript, ct).ConfigureAwait(false);
|
||||
|
||||
// Count query
|
||||
var total = await ExecuteScalarAsync<long>(DefaultTenant, countSb.ToString(), Configure, ct).ConfigureAwait(false);
|
||||
|
||||
return new ScriptSearchResult
|
||||
{
|
||||
Scripts = scripts.ToImmutableArray(),
|
||||
TotalCount = (int)total,
|
||||
Offset = criteria.Offset,
|
||||
Limit = criteria.Limit,
|
||||
};
|
||||
}
|
||||
|
||||
private static Script MapScript(NpgsqlDataReader r) => new()
|
||||
{
|
||||
Id = r.GetString(r.GetOrdinal("id")),
|
||||
Name = r.GetString(r.GetOrdinal("name")),
|
||||
Description = GetNullableString(r, r.GetOrdinal("description")),
|
||||
Language = ParseLanguage(r.GetString(r.GetOrdinal("language"))),
|
||||
Content = r.GetString(r.GetOrdinal("content")),
|
||||
EntryPoint = GetNullableString(r, r.GetOrdinal("entry_point")),
|
||||
Version = r.GetInt32(r.GetOrdinal("version")),
|
||||
Dependencies = DeserializeJson<ImmutableArray<ScriptDependency>>(GetNullableString(r, r.GetOrdinal("dependencies")) ?? "[]"),
|
||||
Tags = r.GetFieldValue<string[]>(r.GetOrdinal("tags")).ToImmutableArray(),
|
||||
Variables = DeserializeJson<ImmutableArray<ScriptVariable>>(GetNullableString(r, r.GetOrdinal("variables")) ?? "[]"),
|
||||
Visibility = ParseVisibility(r.GetString(r.GetOrdinal("visibility"))),
|
||||
OwnerId = r.GetString(r.GetOrdinal("owner_id")),
|
||||
TeamId = GetNullableString(r, r.GetOrdinal("team_id")),
|
||||
ContentHash = r.GetString(r.GetOrdinal("content_hash")),
|
||||
IsSample = r.GetBoolean(r.GetOrdinal("is_sample")),
|
||||
SampleCategory = GetNullableString(r, r.GetOrdinal("sample_category")),
|
||||
CreatedAt = r.GetFieldValue<DateTimeOffset>(r.GetOrdinal("created_at")),
|
||||
UpdatedAt = GetNullableDateTimeOffset(r, r.GetOrdinal("updated_at")),
|
||||
};
|
||||
|
||||
private static ScriptVersion MapVersion(NpgsqlDataReader r) => new()
|
||||
{
|
||||
ScriptId = r.GetString(r.GetOrdinal("script_id")),
|
||||
Version = r.GetInt32(r.GetOrdinal("version")),
|
||||
Content = r.GetString(r.GetOrdinal("content")),
|
||||
ContentHash = r.GetString(r.GetOrdinal("content_hash")),
|
||||
Dependencies = DeserializeJson<ImmutableArray<ScriptDependency>>(GetNullableString(r, r.GetOrdinal("dependencies")) ?? "[]"),
|
||||
CreatedAt = r.GetFieldValue<DateTimeOffset>(r.GetOrdinal("created_at")),
|
||||
CreatedBy = r.GetString(r.GetOrdinal("created_by")),
|
||||
ChangeNote = GetNullableString(r, r.GetOrdinal("change_note")),
|
||||
};
|
||||
|
||||
private static ScriptLanguage ParseLanguage(string value) => value.ToLowerInvariant() switch
|
||||
{
|
||||
"csharp" => ScriptLanguage.CSharp,
|
||||
"python" => ScriptLanguage.Python,
|
||||
"java" => ScriptLanguage.Java,
|
||||
"go" => ScriptLanguage.Go,
|
||||
"bash" => ScriptLanguage.Bash,
|
||||
"typescript" => ScriptLanguage.TypeScript,
|
||||
"powershell" => ScriptLanguage.PowerShell,
|
||||
_ => ScriptLanguage.Bash,
|
||||
};
|
||||
|
||||
private static ScriptVisibility ParseVisibility(string value) => value.ToLowerInvariant() switch
|
||||
{
|
||||
"private" => ScriptVisibility.Private,
|
||||
"team" => ScriptVisibility.Team,
|
||||
"organization" => ScriptVisibility.Organization,
|
||||
"public" => ScriptVisibility.Public,
|
||||
_ => ScriptVisibility.Private,
|
||||
};
|
||||
|
||||
private static T DeserializeJson<T>(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json, s_json) ?? default!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Scripts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for the Scripts module.
|
||||
/// </summary>
|
||||
public sealed class ScriptsDataSource : DataSourceBase
|
||||
{
|
||||
public const string DefaultSchemaName = "scripts";
|
||||
|
||||
public ScriptsDataSource(IOptions<PostgresOptions> options, ILogger<ScriptsDataSource> logger)
|
||||
: base(CreateOptions(options.Value), logger)
|
||||
{
|
||||
}
|
||||
|
||||
protected override string ModuleName => "Scripts";
|
||||
|
||||
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
|
||||
{
|
||||
baseOptions.SchemaName = DefaultSchemaName;
|
||||
}
|
||||
return baseOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Scripts.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Simple search indexer that delegates to <see cref="IScriptStore"/>.
|
||||
/// Full-text search via PostgreSQL tsvector can be added later.
|
||||
/// </summary>
|
||||
public sealed class InMemorySearchIndexer : ISearchIndexer
|
||||
{
|
||||
private readonly IScriptStore _store;
|
||||
|
||||
public InMemorySearchIndexer(IScriptStore store)
|
||||
{
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public Task IndexScriptAsync(Script script, CancellationToken ct = default)
|
||||
{
|
||||
// No-op: PostgresScriptStore already persists all searchable fields.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveScriptAsync(string scriptId, CancellationToken ct = default)
|
||||
{
|
||||
// No-op: CASCADE delete on script_versions handles cleanup.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<ScriptSearchResult> SearchAsync(ScriptSearchCriteria criteria, CancellationToken ct = default)
|
||||
{
|
||||
// Delegate to store which uses ILIKE for text matching.
|
||||
return _store.SearchAsync(criteria, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.ReleaseOrchestrator.Scripts</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Exclude incomplete subdirectories that have unresolved external dependencies (Docker, LSP, etc.) -->
|
||||
<ItemGroup>
|
||||
<Compile Remove="Access\**" />
|
||||
<Compile Remove="Audit\**" />
|
||||
<Compile Remove="Debug\**" />
|
||||
<Compile Remove="Dependencies\**" />
|
||||
<Compile Remove="Documentation\**" />
|
||||
<Compile Remove="Editor\**" />
|
||||
<Compile Remove="Execution\**" />
|
||||
<Compile Remove="LanguageServers\**" />
|
||||
<Compile Remove="Library\**" />
|
||||
<Compile Remove="Policies\**" />
|
||||
<Compile Remove="Runtime\**" />
|
||||
<Compile Remove="Sandbox\**" />
|
||||
<Compile Remove="Telemetry\**" />
|
||||
<Compile Remove="Validation\**" />
|
||||
<Compile Remove="Versioning\**" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,477 +1,109 @@
|
||||
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { catchError, forkJoin, of } from 'rxjs';
|
||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
|
||||
import { AUTH_SERVICE, AuthService } from '../../core/auth';
|
||||
import { ORCHESTRATOR_CONTROL_API, type OrchestratorControlApi } from '../../core/api/jobengine-control.client';
|
||||
import type {
|
||||
JobEngineDeadLetterStatsResponse,
|
||||
JobEngineJobSummary,
|
||||
JobEngineQuotaSummary,
|
||||
} from '../../core/api/jobengine-control.models';
|
||||
import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
|
||||
import { SchedulerRunsComponent } from '../scheduler-ops/scheduler-runs.component';
|
||||
import { SchedulerSchedulesPanelComponent } from './scheduler-schedules-panel.component';
|
||||
import { SchedulerWorkersPanelComponent } from './scheduler-workers-panel.component';
|
||||
|
||||
type SchedulerView = 'runs' | 'schedules' | 'workers';
|
||||
|
||||
@Component({
|
||||
selector: 'app-jobengine-dashboard',
|
||||
imports: [RouterLink],
|
||||
imports: [
|
||||
SchedulerRunsComponent,
|
||||
SchedulerSchedulesPanelComponent,
|
||||
SchedulerWorkersPanelComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="jobengine-dashboard">
|
||||
<header class="jobengine-dashboard__header">
|
||||
<div>
|
||||
<h1>Scheduled Jobs</h1>
|
||||
<p>Execution queues, quotas, dead-letter recovery, and scheduler handoffs.</p>
|
||||
</div>
|
||||
<div class="jobengine-dashboard__actions">
|
||||
<a class="btn btn--secondary" [routerLink]="OPERATIONS_PATHS.schedulerRuns">Scheduler Runs</a>
|
||||
<a class="btn btn--secondary" [routerLink]="OPERATIONS_PATHS.deadLetter">Dead-Letter</a>
|
||||
<button class="btn btn--primary" type="button" (click)="refresh()" [disabled]="loading()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loadError()) {
|
||||
<div class="jobengine-dashboard__banner jobengine-dashboard__banner--error" role="alert">
|
||||
{{ loadError() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<section class="jobengine-dashboard__kpis">
|
||||
<article class="kpi"><div class="shimmer shimmer--lg"></div><div class="shimmer shimmer--sm"></div></article>
|
||||
<article class="kpi"><div class="shimmer shimmer--lg"></div><div class="shimmer shimmer--sm"></div></article>
|
||||
<article class="kpi"><div class="shimmer shimmer--lg"></div><div class="shimmer shimmer--sm"></div></article>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="jobengine-dashboard__kpis">
|
||||
<article class="kpi">
|
||||
<span class="kpi__label">Total Jobs</span>
|
||||
<strong class="kpi__value">{{ jobSummary()?.totalJobs ?? 0 }}</strong>
|
||||
<span class="kpi__hint">{{ jobSummary()?.leasedJobs ?? 0 }} running</span>
|
||||
</article>
|
||||
<article class="kpi">
|
||||
<span class="kpi__label">Failed Jobs</span>
|
||||
<strong class="kpi__value">{{ jobSummary()?.failedJobs ?? 0 }}</strong>
|
||||
<span class="kpi__hint">{{ deadLetterStats()?.totalEntries ?? 0 }} dead-letter entries</span>
|
||||
</article>
|
||||
<article class="kpi">
|
||||
<span class="kpi__label">Quota Policies</span>
|
||||
<strong class="kpi__value">{{ quotaSummary()?.totalQuotas ?? 0 }}</strong>
|
||||
<span class="kpi__hint">{{ quotaSummary()?.pausedQuotas ?? 0 }} paused</span>
|
||||
</article>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (allCountsZero()) {
|
||||
<p class="jobengine-dashboard__empty-hint">
|
||||
No jobs have been submitted yet. Jobs are created automatically when releases are promoted, scans are triggered, or scheduled tasks run.
|
||||
</p>
|
||||
}
|
||||
|
||||
<section class="jobengine-dashboard__grid">
|
||||
<a class="surface" [routerLink]="OPERATIONS_PATHS.jobEngineJobs">
|
||||
<h2>Jobs</h2>
|
||||
<p>Browse execution records, inspect payload digests, and follow DAG relationships.</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Pending</dt>
|
||||
<dd>{{ jobSummary()?.pendingJobs ?? 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Scheduled</dt>
|
||||
<dd>{{ jobSummary()?.scheduledJobs ?? 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Completed</dt>
|
||||
<dd>{{ jobSummary()?.succeededJobs ?? 0 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</a>
|
||||
|
||||
@if (authService.canManageJobEngineQuotas()) {
|
||||
<a class="surface" data-testid="jobengine-quotas-card" [routerLink]="OPERATIONS_PATHS.jobEngineQuotas">
|
||||
<h2>Execution Quotas</h2>
|
||||
<p>Manage per-job-type concurrency, refill rate, and pause state.</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Average Token Usage</dt>
|
||||
<dd>{{ quotaPercent(quotaSummary()?.averageTokenUtilization) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Average Concurrency Usage</dt>
|
||||
<dd>{{ quotaPercent(quotaSummary()?.averageConcurrencyUtilization) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Paused Quotas</dt>
|
||||
<dd>{{ quotaSummary()?.pausedQuotas ?? 0 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</a>
|
||||
} @else {
|
||||
<article class="surface surface--restricted" data-testid="jobengine-quotas-card" aria-disabled="true">
|
||||
<h2>Execution Quotas</h2>
|
||||
<p>Quota metrics are visible, but management stays locked until the session has quota-admin scope.</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Average Token Usage</dt>
|
||||
<dd>{{ quotaPercent(quotaSummary()?.averageTokenUtilization) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Average Concurrency Usage</dt>
|
||||
<dd>{{ quotaPercent(quotaSummary()?.averageConcurrencyUtilization) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Paused Quotas</dt>
|
||||
<dd>{{ quotaSummary()?.pausedQuotas ?? 0 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<span class="surface__notice">Access required to manage quotas.</span>
|
||||
</article>
|
||||
<div class="schedules">
|
||||
<div class="schedules__segmented" role="radiogroup" aria-label="Scheduler view">
|
||||
@for (opt of schedulerViews; track opt.id) {
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
class="schedules__seg-btn"
|
||||
[class.schedules__seg-btn--active]="activeView() === opt.id"
|
||||
[attr.aria-checked]="activeView() === opt.id"
|
||||
(click)="activeView.set(opt.id)"
|
||||
>{{ opt.label }}</button>
|
||||
}
|
||||
|
||||
<a class="surface" [routerLink]="OPERATIONS_PATHS.deadLetter">
|
||||
<h2>Dead-Letter Recovery</h2>
|
||||
<p>Retry or resolve failed execution records and inspect replay outcomes.</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Retryable</dt>
|
||||
<dd>{{ deadLetterStats()?.retryableEntries ?? 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Replayed</dt>
|
||||
<dd>{{ deadLetterStats()?.replayedEntries ?? 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Resolved</dt>
|
||||
<dd>{{ deadLetterStats()?.resolvedEntries ?? 0 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="jobengine-dashboard__access surface">
|
||||
<h2>Your Access</h2>
|
||||
<ul>
|
||||
<li>View Jobs: {{ authService.canViewOrchestrator() ? 'Granted' : 'Denied' }}</li>
|
||||
<li>Operate Jobs: {{ authService.canOperateOrchestrator() ? 'Granted' : 'Denied' }}</li>
|
||||
<li>Manage Quotas: {{ authService.canManageJobEngineQuotas() ? 'Granted' : 'Denied' }}</li>
|
||||
<li>Initiate Backfill: {{ authService.canInitiateBackfill() ? 'Granted' : 'Denied' }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
@switch (activeView()) {
|
||||
@case ('runs') { <app-scheduler-runs /> }
|
||||
@case ('schedules') { <app-scheduler-schedules-panel /> }
|
||||
@case ('workers') { <app-scheduler-workers-panel /> }
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.jobengine-dashboard {
|
||||
.schedules {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.jobengine-dashboard__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.jobengine-dashboard__header h1 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.8rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.jobengine-dashboard__header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.jobengine-dashboard__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.jobengine-dashboard__banner {
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.jobengine-dashboard__banner--error {
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error-text);
|
||||
border: 1px solid var(--color-status-error-border);
|
||||
}
|
||||
|
||||
.jobengine-dashboard__kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.kpi,
|
||||
.surface {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 1.1rem;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.kpi:hover,
|
||||
a.surface:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.kpi {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.kpi__label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.kpi__value {
|
||||
font-size: 2rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.kpi__hint {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.jobengine-dashboard__empty-hint {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
border-left: 3px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.jobengine-dashboard__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.surface {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
a.surface:hover {
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.surface h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.surface--restricted {
|
||||
cursor: default;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.surface--restricted:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.surface p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.surface dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.surface dl div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.88rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.surface dl div:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.surface dt {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.surface dd {
|
||||
margin: 0;
|
||||
color: var(--color-text-heading);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.surface__notice {
|
||||
.schedules__segmented {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-border);
|
||||
font-size: 0.78rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.jobengine-dashboard__access ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
color: var(--color-text-secondary);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
align-self: start;
|
||||
}
|
||||
.schedules__seg-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
transition: background 150ms ease, border-color 150ms ease, transform 150ms ease;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
.schedules__seg-btn:hover:not(.schedules__seg-btn--active) {
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.schedules__seg-btn--active {
|
||||
background: var(--color-surface-primary);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
|
||||
}
|
||||
.schedules__seg-btn:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
/* Hide redundant headers from embedded sub-components */
|
||||
.schedules ::ng-deep .page-header,
|
||||
.schedules ::ng-deep .back-link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
.schedules ::ng-deep .stats-row,
|
||||
.schedules ::ng-deep .stat-card,
|
||||
.schedules ::ng-deep .connection-banner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--color-btn-primary-bg);
|
||||
color: var(--color-btn-primary-text);
|
||||
border-color: var(--color-btn-primary-bg);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
background: var(--color-btn-primary-bg);
|
||||
border-color: var(--color-btn-primary-bg);
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(90deg, var(--color-surface-secondary) 25%, var(--color-surface-tertiary, rgba(255,255,255,0.08)) 50%, var(--color-surface-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer-move 1.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.shimmer--lg {
|
||||
height: 2rem;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.shimmer--sm {
|
||||
height: 0.85rem;
|
||||
width: 80%;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes shimmer-move {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.jobengine-dashboard__header,
|
||||
.jobengine-dashboard__kpis,
|
||||
.jobengine-dashboard__grid {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
.schedules ::ng-deep .filter-bar__select,
|
||||
.schedules ::ng-deep .filter-bar__btn {
|
||||
flex: 0 0 auto !important;
|
||||
width: auto !important;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class JobEngineDashboardComponent implements OnInit {
|
||||
protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
|
||||
protected readonly authService = inject(AUTH_SERVICE) as AuthService;
|
||||
private readonly controlApi = inject(ORCHESTRATOR_CONTROL_API) as OrchestratorControlApi;
|
||||
export class JobEngineDashboardComponent {
|
||||
protected readonly activeView = signal<SchedulerView>('runs');
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly loadError = signal<string | null>(null);
|
||||
protected readonly jobSummary = signal<JobEngineJobSummary | null>(null);
|
||||
protected readonly quotaSummary = signal<JobEngineQuotaSummary | null>(null);
|
||||
protected readonly deadLetterStats = signal<JobEngineDeadLetterStatsResponse | null>(null);
|
||||
protected readonly allCountsZero = computed(() => {
|
||||
const jobs = this.jobSummary();
|
||||
const quotas = this.quotaSummary();
|
||||
const dl = this.deadLetterStats();
|
||||
return !this.loading()
|
||||
&& (jobs?.totalJobs ?? 0) === 0
|
||||
&& (quotas?.totalQuotas ?? 0) === 0
|
||||
&& (dl?.totalEntries ?? 0) === 0;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
protected refresh(): void {
|
||||
this.loading.set(true);
|
||||
this.loadError.set(null);
|
||||
|
||||
forkJoin({
|
||||
jobSummary: this.controlApi.getJobSummary().pipe(catchError(() => of(null))),
|
||||
quotaSummary: this.controlApi.getQuotaSummary().pipe(catchError(() => of(null))),
|
||||
deadLetterStats: this.controlApi.getDeadLetterStats().pipe(catchError(() => of(null))),
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
this.jobSummary.set(result.jobSummary);
|
||||
this.quotaSummary.set(result.quotaSummary);
|
||||
this.deadLetterStats.set(result.deadLetterStats);
|
||||
if (!result.jobSummary && !result.quotaSummary && !result.deadLetterStats) {
|
||||
this.loadError.set('Execution metrics are currently unavailable. Links remain usable.');
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loadError.set('Execution metrics are currently unavailable. Links remain usable.');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected quotaPercent(value: number | undefined): string {
|
||||
return value === undefined ? '-' : `${Math.round(value * 100)}%`;
|
||||
}
|
||||
protected readonly schedulerViews: { id: SchedulerView; label: string }[] = [
|
||||
{ id: 'runs', label: 'Runs' },
|
||||
{ id: 'schedules', label: 'Schedules' },
|
||||
{ id: 'workers', label: 'Workers' },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,422 +6,202 @@ import { catchError, of } from 'rxjs';
|
||||
import { JobEngineJobsClient, type JobEngineJobRecord } from '../../core/api/jobengine-jobs.client';
|
||||
import { ORCHESTRATOR_CONTROL_API, type OrchestratorControlApi } from '../../core/api/jobengine-control.client';
|
||||
import { OPERATIONS_PATHS, deadLetterQueuePath, jobEngineDagPath, jobEngineJobPath } from '../platform/ops/operations-paths';
|
||||
import { StellaFilterMultiComponent, type FilterMultiOption } from '../../shared/components/stella-filter-multi/stella-filter-multi.component';
|
||||
|
||||
import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
@Component({
|
||||
selector: 'app-jobengine-jobs',
|
||||
imports: [FormsModule, RouterLink],
|
||||
imports: [FormsModule, RouterLink, StellaFilterMultiComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="jobengine-jobs">
|
||||
<header class="jobengine-jobs__header">
|
||||
<div>
|
||||
<a [routerLink]="OPERATIONS_PATHS.jobEngine" class="jobengine-jobs__back">← Back to JobEngine</a>
|
||||
<h1>JobEngine Jobs</h1>
|
||||
<p>Browse execution records, inspect payload lineage, and follow dependency edges.</p>
|
||||
<div class="je-jobs">
|
||||
<div class="filter-bar">
|
||||
<div class="filter-bar__search">
|
||||
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input type="search" class="filter-bar__input" [(ngModel)]="searchQuery" placeholder="Job ID, type, correlation ID..." />
|
||||
</div>
|
||||
<div class="jobengine-jobs__actions">
|
||||
<a class="btn btn--secondary" [routerLink]="OPERATIONS_PATHS.jobsQueues">Jobs & Queues</a>
|
||||
<button class="btn btn--secondary" type="button" (click)="refresh()" [disabled]="loading()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loadError()) {
|
||||
<div class="jobengine-jobs__banner jobengine-jobs__banner--error" role="alert">
|
||||
{{ loadError() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (actionNotice()) {
|
||||
<div class="jobengine-jobs__banner jobengine-jobs__banner--info" role="status">
|
||||
{{ actionNotice() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="jobengine-jobs__stats">
|
||||
<article class="stat-card">
|
||||
<strong>{{ stats().total }}</strong>
|
||||
<span>Total</span>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<strong>{{ stats().running }}</strong>
|
||||
<span>Running</span>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<strong>{{ stats().completed }}</strong>
|
||||
<span>Completed</span>
|
||||
</article>
|
||||
<article class="stat-card stat-card--warning">
|
||||
<strong>{{ stats().failed }}</strong>
|
||||
<span>Failed</span>
|
||||
</article>
|
||||
<article class="stat-card stat-card--danger">
|
||||
<strong>{{ deadLetterCount() }}</strong>
|
||||
<span>Dead-Letter</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="jobengine-jobs__filters">
|
||||
<label>
|
||||
Search
|
||||
<input type="search" [(ngModel)]="searchQuery" placeholder="Job ID, type, correlation ID" />
|
||||
</label>
|
||||
<label>
|
||||
Status
|
||||
<select [(ngModel)]="statusFilter">
|
||||
<option value="">All statuses</option>
|
||||
@for (status of statusOptions; track status) {
|
||||
<option [value]="status">{{ status }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Type
|
||||
<select [(ngModel)]="typeFilter">
|
||||
<option value="">All types</option>
|
||||
@for (jobType of typeOptions(); track jobType) {
|
||||
<option [value]="jobType">{{ jobType }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="jobengine-jobs__list">
|
||||
@if (loading()) {
|
||||
<div class="empty-state">Loading jobs...</div>
|
||||
} @else if (!filteredJobs().length) {
|
||||
<div class="empty-state">No jobs match the current filters.</div>
|
||||
} @else {
|
||||
@for (job of filteredJobs(); track job.jobId) {
|
||||
<article class="job-card">
|
||||
<div class="job-card__summary">
|
||||
<div>
|
||||
<div class="job-card__title-row">
|
||||
<span class="job-card__type">{{ job.jobType }}</span>
|
||||
<span class="job-card__status" [class]="'job-card__status--' + statusTone(job.status)">
|
||||
{{ job.status }}
|
||||
</span>
|
||||
</div>
|
||||
<h2>{{ job.jobId }}</h2>
|
||||
<p>{{ jobDescription(job) }}</p>
|
||||
</div>
|
||||
<button class="btn btn--ghost" type="button" (click)="toggleExpand(job.jobId)">
|
||||
{{ expandedJobId() === job.jobId ? 'Collapse' : 'Expand' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (expandedJobId() === job.jobId) {
|
||||
<div class="job-card__details">
|
||||
<dl>
|
||||
<div><dt>Run</dt><dd>{{ job.runId || '-' }}</dd></div>
|
||||
<div><dt>Priority</dt><dd>{{ job.priority }}</dd></div>
|
||||
<div><dt>Attempts</dt><dd>{{ job.attempt }} / {{ job.maxAttempts }}</dd></div>
|
||||
<div><dt>Created</dt><dd>{{ formatDateTime(job.createdAt) }}</dd></div>
|
||||
<div><dt>Scheduled</dt><dd>{{ formatDateTime(job.scheduledAt) }}</dd></div>
|
||||
<div><dt>Completed</dt><dd>{{ formatDateTime(job.completedAt) }}</dd></div>
|
||||
<div><dt>Worker</dt><dd>{{ job.workerId || '-' }}</dd></div>
|
||||
<div><dt>Correlation</dt><dd>{{ job.correlationId || '-' }}</dd></div>
|
||||
</dl>
|
||||
|
||||
@if (job.reason) {
|
||||
<div class="job-card__reason">
|
||||
<strong>Reason</strong>
|
||||
<p>{{ job.reason }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="job-card__actions">
|
||||
<a class="btn btn--secondary" [routerLink]="jobEngineJobPath(job.jobId)">View Details</a>
|
||||
@if (job.runId) {
|
||||
<a class="btn btn--secondary" [routerLink]="jobEngineDagPath(job.jobId)">View DAG</a>
|
||||
}
|
||||
@if (job.status === 'failed') {
|
||||
<a class="btn btn--secondary" [routerLink]="deadLetterQueuePath()">Open Dead-Letter</a>
|
||||
}
|
||||
@if (job.correlationId) {
|
||||
<button class="btn btn--secondary" type="button" (click)="copyText(job.correlationId!)">
|
||||
Copy CorrID
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
<stella-filter-multi
|
||||
label="Status"
|
||||
[options]="statusMultiOptions()"
|
||||
(optionsChange)="onStatusFilterChange($event)"
|
||||
/>
|
||||
@if (typeMultiOptions().length > 1) {
|
||||
<stella-filter-multi
|
||||
label="Type"
|
||||
[options]="typeMultiOptions()"
|
||||
(optionsChange)="onTypeFilterChange($event)"
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
<button class="filter-bar__btn" type="button" (click)="refresh()" [disabled]="loading()">Refresh</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="empty-state">Loading jobs...</div>
|
||||
} @else if (!filteredJobs().length) {
|
||||
<div class="empty-state">No jobs match the current filters.</div>
|
||||
} @else {
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" (click)="toggleSort('jobType')">Type {{ sortIcon('jobType') }}</th>
|
||||
<th class="sortable" (click)="toggleSort('jobId')">Job ID {{ sortIcon('jobId') }}</th>
|
||||
<th class="sortable" (click)="toggleSort('status')">Status {{ sortIcon('status') }}</th>
|
||||
<th class="sortable" (click)="toggleSort('priority')">Priority {{ sortIcon('priority') }}</th>
|
||||
<th>Attempts</th>
|
||||
<th class="sortable" (click)="toggleSort('createdAt')">Created {{ sortIcon('createdAt') }}</th>
|
||||
<th>Worker</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (job of paginatedJobs(); track job.jobId) {
|
||||
<tr>
|
||||
<td><span class="mono">{{ job.jobType }}</span></td>
|
||||
<td><a class="id-link" [routerLink]="jobEngineJobPath(job.jobId)">{{ shortId(job.jobId) }}</a></td>
|
||||
<td><span class="status-pill" [class]="'status-pill--' + statusTone(job.status)">{{ toUiStatus(job.status) }}</span></td>
|
||||
<td>{{ job.priority }}</td>
|
||||
<td>{{ job.attempt }}/{{ job.maxAttempts }}</td>
|
||||
<td>{{ formatDateTime(job.createdAt) }}</td>
|
||||
<td class="mono">{{ job.workerId || '-' }}</td>
|
||||
<td class="actions-cell">
|
||||
<a class="stella-table-action" [routerLink]="jobEngineJobPath(job.jobId)">Detail</a>
|
||||
@if (job.runId) {
|
||||
<a class="stella-table-action" [routerLink]="jobEngineDagPath(job.jobId)">DAG</a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pager -->
|
||||
@if (totalPages() > 1) {
|
||||
<div class="pager">
|
||||
<span class="pager__info">{{ filteredJobs().length }} jobs</span>
|
||||
<div class="pager__controls">
|
||||
<button class="pager__btn" [disabled]="page() <= 1" (click)="page.set(page() - 1)">‹ Prev</button>
|
||||
<span class="pager__current">{{ page() }} / {{ totalPages() }}</span>
|
||||
<button class="pager__btn" [disabled]="page() >= totalPages()" (click)="page.set(page() + 1)">Next ›</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.jobengine-jobs {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.je-jobs { display: grid; gap: 0.75rem; }
|
||||
|
||||
.jobengine-jobs__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.jobengine-jobs__header h1 {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.jobengine-jobs__header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.jobengine-jobs__back {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.65rem;
|
||||
color: var(--color-status-info);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.jobengine-jobs__actions,
|
||||
.jobengine-jobs__filters,
|
||||
.job-card__actions,
|
||||
.job-card__summary {
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.jobengine-jobs__banner {
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.jobengine-jobs__banner--error {
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error-text);
|
||||
border: 1px solid var(--color-status-error-border);
|
||||
}
|
||||
|
||||
.jobengine-jobs__banner--info {
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
border: 1px solid var(--color-status-info-border);
|
||||
}
|
||||
|
||||
.jobengine-jobs__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.jobengine-jobs__filters,
|
||||
.job-card {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
.filter-bar__search { position: relative; flex: 1; min-width: 180px; }
|
||||
.filter-bar__search-icon {
|
||||
position: absolute; left: 0.75rem; top: 50%;
|
||||
transform: translateY(-50%); color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
font-size: 1.7rem;
|
||||
color: var(--color-text-heading);
|
||||
.filter-bar__input { width: 100%; padding: 0.5rem 0.75rem 0.5rem 2.25rem; }
|
||||
.filter-bar__select {
|
||||
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.stat-card span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
.filter-bar__select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.stat-card--warning strong {
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.stat-card--danger strong {
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.jobengine-jobs__filters {
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.jobengine-jobs__filters label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.jobengine-jobs__filters input,
|
||||
.jobengine-jobs__filters select {
|
||||
.filter-bar__btn {
|
||||
padding: 0.5rem 0.85rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
padding: 0.5rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter-bar__btn:hover:not(:disabled) { border-color: var(--color-brand-primary); }
|
||||
.filter-bar__btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.jobengine-jobs__list {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
.table-container {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.job-card {
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th, .data-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.job-card__summary {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.job-card__title-row {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.job-card__type,
|
||||
.job-card__status {
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.74rem;
|
||||
.data-table th {
|
||||
background: var(--color-surface-secondary);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
position: sticky; top: 0; z-index: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
|
||||
.data-table tbody tr:hover { background: var(--color-nav-hover); }
|
||||
|
||||
.job-card__type {
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.sortable { cursor: pointer; user-select: none; }
|
||||
.sortable:hover { color: var(--color-text-heading); }
|
||||
|
||||
.job-card__status--running {
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
}
|
||||
.mono { font-family: ui-monospace, monospace; font-size: 0.78rem; }
|
||||
.id-link { color: var(--color-brand-primary); text-decoration: none; font-family: ui-monospace, monospace; font-size: 0.78rem; }
|
||||
.id-link:hover { text-decoration: underline; }
|
||||
|
||||
.job-card__status--completed {
|
||||
background: var(--color-status-success-bg);
|
||||
color: var(--color-status-success-text);
|
||||
.status-pill {
|
||||
display: inline-flex; padding: 0.15rem 0.5rem; border-radius: 9999px;
|
||||
font-size: 0.72rem; font-weight: 600; text-transform: capitalize;
|
||||
}
|
||||
.status-pill--running { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
.status-pill--completed { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.status-pill--failed, .status-pill--cancelled { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
|
||||
.status-pill--pending, .status-pill--queued { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
|
||||
|
||||
.job-card__status--failed,
|
||||
.job-card__status--cancelled {
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error-text);
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.stella-table-action {
|
||||
display: inline-flex; padding: 0.2rem 0.5rem; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-primary); background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary); text-decoration: none; font-size: 0.75rem; cursor: pointer;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
.stella-table-action:hover { border-color: var(--color-brand-primary); }
|
||||
|
||||
.job-card__status--pending,
|
||||
.job-card__status--queued {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
.pager {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.5rem 0.75rem; font-size: 0.82rem; color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.job-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-heading);
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.job-card p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.job-card__details {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
padding-top: 0.9rem;
|
||||
}
|
||||
|
||||
.job-card__details dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.job-card__details div {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.job-card__details dt {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.job-card__details dd {
|
||||
margin: 0;
|
||||
color: var(--color-text-heading);
|
||||
font-family: ui-monospace, monospace;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.job-card__reason {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-secondary);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.job-card__reason strong {
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
.pager__controls { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.pager__btn {
|
||||
padding: 0.3rem 0.6rem; border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md); background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary); cursor: pointer; font-size: 0.78rem;
|
||||
}
|
||||
.pager__btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.pager__current { font-weight: 600; }
|
||||
|
||||
.empty-state {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.jobengine-jobs__header,
|
||||
.jobengine-jobs__stats {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.job-card__details dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
padding: 2.5rem; text-align: center; color: var(--color-text-muted); font-size: 0.88rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
@@ -432,52 +212,76 @@ export class JobEngineJobsComponent implements OnInit {
|
||||
protected readonly jobEngineJobPath = jobEngineJobPath;
|
||||
protected readonly jobEngineDagPath = jobEngineDagPath;
|
||||
protected readonly deadLetterQueuePath = deadLetterQueuePath;
|
||||
protected readonly statusOptions = ['pending', 'queued', 'running', 'completed', 'failed', 'cancelled'];
|
||||
private readonly statusList = ['pending', 'queued', 'running', 'completed', 'failed', 'cancelled'];
|
||||
|
||||
protected searchQuery = '';
|
||||
protected statusFilter = '';
|
||||
protected typeFilter = '';
|
||||
protected readonly selectedStatuses = signal<Set<string>>(new Set(this.statusList));
|
||||
protected readonly selectedTypes = signal<Set<string>>(new Set());
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly loadError = signal<string | null>(null);
|
||||
protected readonly actionNotice = signal<string | null>(null);
|
||||
protected readonly expandedJobId = signal<string | null>(null);
|
||||
protected readonly jobs = signal<readonly JobEngineJobRecord[]>([]);
|
||||
protected readonly deadLetterCount = signal(0);
|
||||
protected readonly page = signal(1);
|
||||
protected readonly pageSize = 25;
|
||||
protected readonly sortField = signal<string>('createdAt');
|
||||
protected readonly sortDir = signal<'asc' | 'desc'>('desc');
|
||||
|
||||
private readonly jobsClient = inject(JobEngineJobsClient);
|
||||
private readonly controlApi = inject(ORCHESTRATOR_CONTROL_API) as OrchestratorControlApi;
|
||||
|
||||
protected readonly typeOptions = computed(() =>
|
||||
Array.from(new Set(this.jobs().map((job) => job.jobType))).sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
);
|
||||
protected readonly statusMultiOptions = computed<FilterMultiOption[]>(() => {
|
||||
const sel = this.selectedStatuses();
|
||||
return this.statusList.map(s => ({ id: s, label: s, checked: sel.has(s) }));
|
||||
});
|
||||
|
||||
protected readonly typeMultiOptions = computed<FilterMultiOption[]>(() => {
|
||||
const types = Array.from(new Set(this.jobs().map(j => j.jobType))).sort((a, b) => a.localeCompare(b));
|
||||
const sel = this.selectedTypes();
|
||||
return types.map(t => ({ id: t, label: t, checked: sel.size === 0 || sel.has(t) }));
|
||||
});
|
||||
|
||||
protected onStatusFilterChange(opts: FilterMultiOption[]): void {
|
||||
this.selectedStatuses.set(new Set(opts.filter(o => o.checked).map(o => o.id)));
|
||||
this.page.set(1);
|
||||
}
|
||||
|
||||
protected onTypeFilterChange(opts: FilterMultiOption[]): void {
|
||||
const checked = opts.filter(o => o.checked);
|
||||
this.selectedTypes.set(checked.length === opts.length ? new Set() : new Set(checked.map(o => o.id)));
|
||||
this.page.set(1);
|
||||
}
|
||||
|
||||
protected readonly filteredJobs = computed(() => {
|
||||
const query = this.searchQuery.trim().toLowerCase();
|
||||
const field = this.sortField();
|
||||
const dir = this.sortDir() === 'asc' ? 1 : -1;
|
||||
const statuses = this.selectedStatuses();
|
||||
const types = this.selectedTypes();
|
||||
|
||||
return this.jobs()
|
||||
.filter((job) => !this.statusFilter || this.toUiStatus(job.status) === this.statusFilter)
|
||||
.filter((job) => !this.typeFilter || job.jobType === this.typeFilter)
|
||||
.filter((job) =>
|
||||
.filter(job => statuses.size === 0 || statuses.has(this.toUiStatus(job.status)))
|
||||
.filter(job => types.size === 0 || types.has(job.jobType))
|
||||
.filter(job =>
|
||||
!query ||
|
||||
job.jobId.toLowerCase().includes(query) ||
|
||||
job.jobType.toLowerCase().includes(query) ||
|
||||
(job.correlationId ?? '').toLowerCase().includes(query),
|
||||
)
|
||||
.slice()
|
||||
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt));
|
||||
.sort((a, b) => {
|
||||
const av = (a as unknown as Record<string, unknown>)[field] ?? '';
|
||||
const bv = (b as unknown as Record<string, unknown>)[field] ?? '';
|
||||
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
||||
return String(av).localeCompare(String(bv)) * dir;
|
||||
});
|
||||
});
|
||||
|
||||
protected readonly stats = computed(() => {
|
||||
const jobs = this.jobs();
|
||||
return {
|
||||
total: jobs.length,
|
||||
running: jobs.filter((job) => this.toUiStatus(job.status) === 'running').length,
|
||||
completed: jobs.filter((job) => this.toUiStatus(job.status) === 'completed').length,
|
||||
failed: jobs.filter((job) => this.toUiStatus(job.status) === 'failed').length,
|
||||
};
|
||||
protected readonly totalPages = computed(() => Math.max(1, Math.ceil(this.filteredJobs().length / this.pageSize)));
|
||||
|
||||
protected readonly paginatedJobs = computed(() => {
|
||||
const start = (this.page() - 1) * this.pageSize;
|
||||
return this.filteredJobs().slice(start, start + this.pageSize);
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -489,49 +293,48 @@ export class JobEngineJobsComponent implements OnInit {
|
||||
this.loadError.set(null);
|
||||
|
||||
this.jobsClient
|
||||
.listJobs({ limit: 100 })
|
||||
.listJobs({ limit: 200 })
|
||||
.pipe(catchError(() => of({ jobs: [], nextCursor: null })))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.jobs.set(result.jobs);
|
||||
this.loading.set(false);
|
||||
if (!result.jobs.length) {
|
||||
this.loadError.set('No jobs were returned from JobEngine. Filters and links remain available.');
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.jobs.set([]);
|
||||
this.loadError.set('Failed to load JobEngine jobs.');
|
||||
this.loadError.set('Failed to load jobs.');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
|
||||
this.controlApi
|
||||
.getDeadLetterStats()
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((stats) => this.deadLetterCount.set(stats?.totalEntries ?? 0));
|
||||
}
|
||||
|
||||
protected toggleExpand(jobId: string): void {
|
||||
this.expandedJobId.set(this.expandedJobId() === jobId ? null : jobId);
|
||||
protected toggleSort(field: string): void {
|
||||
if (this.sortField() === field) {
|
||||
this.sortDir.set(this.sortDir() === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
this.sortField.set(field);
|
||||
this.sortDir.set('asc');
|
||||
}
|
||||
this.page.set(1);
|
||||
}
|
||||
|
||||
protected copyText(value: string): void {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(value);
|
||||
this.actionNotice.set(`Copied ${value} to the clipboard.`);
|
||||
}
|
||||
protected sortIcon(field: string): string {
|
||||
if (this.sortField() !== field) return '';
|
||||
return this.sortDir() === 'asc' ? '\u2191' : '\u2193';
|
||||
}
|
||||
|
||||
protected jobDescription(job: JobEngineJobRecord): string {
|
||||
const status = this.toUiStatus(job.status);
|
||||
if (status === 'running') {
|
||||
return `Leased to ${job.workerId ?? 'an active worker'}${job.runId ? ` in run ${job.runId}` : ''}.`;
|
||||
protected shortId(id: string): string {
|
||||
return id.length > 20 ? id.slice(0, 8) + '\u2026' + id.slice(-6) : id;
|
||||
}
|
||||
|
||||
protected toUiStatus(status: string): string {
|
||||
switch (status) {
|
||||
case 'scheduled': return 'queued';
|
||||
case 'leased': return 'running';
|
||||
case 'succeeded': return 'completed';
|
||||
case 'canceled': return 'cancelled';
|
||||
default: return status;
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return job.reason ?? 'This execution failed and may require dead-letter recovery.';
|
||||
}
|
||||
return `Created by ${job.createdBy}${job.projectId ? ` for ${job.projectId}` : ''}.`;
|
||||
}
|
||||
|
||||
protected statusTone(status: string): string {
|
||||
@@ -539,30 +342,16 @@ export class JobEngineJobsComponent implements OnInit {
|
||||
}
|
||||
|
||||
protected formatDateTime(value?: string | null): string {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (!value) return '-';
|
||||
return new Date(value).toLocaleString(this.dateFmt.locale(), {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
private toUiStatus(status: string): string {
|
||||
switch (status) {
|
||||
case 'scheduled':
|
||||
return 'queued';
|
||||
case 'leased':
|
||||
return 'running';
|
||||
case 'succeeded':
|
||||
return 'completed';
|
||||
case 'canceled':
|
||||
return 'cancelled';
|
||||
default:
|
||||
return status;
|
||||
protected copyText(value: string): void {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(value);
|
||||
this.actionNotice.set(`Copied ${value} to clipboard.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,12 +56,12 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="jobengine-quotas__filters">
|
||||
<label>
|
||||
Job Type
|
||||
<input type="search" [(ngModel)]="jobTypeFilter" placeholder="scan, export, advisory-sync" />
|
||||
</label>
|
||||
</section>
|
||||
<div class="filter-bar">
|
||||
<div class="filter-bar__search">
|
||||
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input type="search" class="filter-bar__input" [(ngModel)]="jobTypeFilter" placeholder="Filter by job type..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="jobengine-quotas__table-wrap">
|
||||
@if (!filteredQuotas().length) {
|
||||
@@ -189,24 +189,23 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.jobengine-quotas__filters {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.jobengine-quotas__filters label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.jobengine-quotas__filters input {
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
padding: 0.5rem 0.65rem;
|
||||
min-width: 260px;
|
||||
border-radius: var(--radius-lg);
|
||||
align-items: center;
|
||||
}
|
||||
.filter-bar__search { position: relative; flex: 1; }
|
||||
.filter-bar__search-icon {
|
||||
position: absolute; left: 0.75rem; top: 50%;
|
||||
transform: translateY(-50%); color: var(--color-text-secondary);
|
||||
}
|
||||
.filter-bar__input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
|
||||
}
|
||||
|
||||
.jobengine-quotas__table-wrap {
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { SCHEDULER_API } from '../../core/api/scheduler.client';
|
||||
import type { Schedule } from '../../features/scheduler-ops/scheduler-ops.models';
|
||||
import { StellaFilterMultiComponent, type FilterMultiOption } from '../../shared/components/stella-filter-multi/stella-filter-multi.component';
|
||||
import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
import type { SchedulerApi, UpdateScheduleDto } from '../../core/api/scheduler.client';
|
||||
|
||||
interface CadencePreset { id: string; label: string; cron: string; }
|
||||
|
||||
const CADENCE_PRESETS: CadencePreset[] = [
|
||||
{ id: 'hourly', label: 'Every hour', cron: '0 * * * *' },
|
||||
{ id: '4h', label: 'Every 4 hours', cron: '0 */4 * * *' },
|
||||
{ id: '2h', label: 'Every 2 hours', cron: '0 */2 * * *' },
|
||||
{ id: 'nightly', label: 'Nightly (02:00)', cron: '0 2 * * *' },
|
||||
{ id: '6am', label: 'Nightly (06:00)', cron: '0 6 * * *' },
|
||||
{ id: 'weekday', label: 'Weekdays (05:00)', cron: '0 5 * * 1-5' },
|
||||
{ id: 'weekly', label: 'Weekly (Sun 03:00)', cron: '0 3 * * 0' },
|
||||
];
|
||||
|
||||
interface EditState {
|
||||
id: string;
|
||||
cronExpression: string;
|
||||
presetId: string;
|
||||
parallelism: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-scheduler-schedules-panel',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [FormsModule, StellaFilterMultiComponent],
|
||||
template: `
|
||||
<!-- Notice banner -->
|
||||
@if (notice(); as msg) {
|
||||
<div class="notice">{{ msg }}</div>
|
||||
}
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-bar__search">
|
||||
<svg class="filter-bar__search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input
|
||||
class="filter-bar__input"
|
||||
type="text"
|
||||
placeholder="Search schedules..."
|
||||
[(ngModel)]="searchQuery"
|
||||
/>
|
||||
<button
|
||||
class="filter-bar__clear"
|
||||
[class.filter-bar__clear--visible]="searchQuery"
|
||||
(click)="searchQuery = ''"
|
||||
type="button"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M18 6 6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<stella-filter-multi
|
||||
label="Status"
|
||||
[options]="statusMultiOptions()"
|
||||
(optionsChange)="onStatusFilterChange($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Data table -->
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@for (col of columns; track col.field) {
|
||||
<th
|
||||
class="sortable"
|
||||
(click)="toggleSort(col.field)"
|
||||
>{{ col.label }}
|
||||
@if (sortField() === col.field) {
|
||||
<span>{{ sortDir() === 'asc' ? ' \u25B2' : ' \u25BC' }}</span>
|
||||
}
|
||||
</th>
|
||||
}
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (s of filteredSchedules(); track s.id) {
|
||||
<tr [class.row--editing]="editing()?.id === s.id">
|
||||
<td>
|
||||
{{ s.name }}
|
||||
@if (s.source === 'system') {
|
||||
<span class="source-badge">System</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono">{{ s.cronExpression }}</td>
|
||||
<td>{{ s.mode === 'analysis-only' ? 'Scan' : 'Refresh' }}</td>
|
||||
<td>{{ s.selection.scope }}</td>
|
||||
<td>
|
||||
<span class="status-pill" [class.status-pill--enabled]="s.enabled" [class.status-pill--disabled]="!s.enabled">
|
||||
{{ s.enabled ? 'enabled' : 'disabled' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ s.lastRunAt ? formatDateTime(s.lastRunAt) : '\u2014' }}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="stella-table-action" (click)="triggerNow(s)">Run Now</button>
|
||||
<button class="stella-table-action" (click)="editing()?.id === s.id ? cancelEdit() : startEdit(s)">
|
||||
{{ editing()?.id === s.id ? 'Cancel' : 'Edit' }}
|
||||
</button>
|
||||
<button class="stella-table-action" (click)="toggleEnabled(s)">
|
||||
{{ s.enabled ? 'Disable' : 'Enable' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Inline edit row -->
|
||||
@if (editing()?.id === s.id) {
|
||||
<tr class="edit-row">
|
||||
<td [attr.colspan]="columns.length + 1">
|
||||
<div class="edit-panel">
|
||||
<div class="edit-section">
|
||||
<label class="edit-label">Cadence</label>
|
||||
<div class="preset-chips">
|
||||
@for (p of cadencePresets; track p.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="preset-chip"
|
||||
[class.preset-chip--active]="editing()?.presetId === p.id"
|
||||
(click)="selectPreset(p)"
|
||||
>{{ p.label }}</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="preset-chip"
|
||||
[class.preset-chip--active]="editing()?.presetId === 'custom'"
|
||||
(click)="selectPreset({ id: 'custom', label: 'Custom', cron: editing()?.cronExpression ?? '' })"
|
||||
>Custom</button>
|
||||
</div>
|
||||
@if (editing()?.presetId === 'custom') {
|
||||
<input class="edit-input mono" type="text" [ngModel]="editing()?.cronExpression" (ngModelChange)="patchEdit('cronExpression', $event)" placeholder="0 2 * * *" />
|
||||
} @else {
|
||||
<span class="edit-hint">Cron: <code>{{ editing()?.cronExpression }}</code></span>
|
||||
}
|
||||
</div>
|
||||
<div class="edit-row-fields">
|
||||
<div class="edit-section">
|
||||
<label class="edit-label">Parallelism</label>
|
||||
<input type="number" min="1" max="16" [ngModel]="editing()?.parallelism" (ngModelChange)="patchEdit('parallelism', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button class="stella-table-action" (click)="cancelEdit()">Cancel</button>
|
||||
<button class="save-btn" (click)="saveEdit()">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td [attr.colspan]="columns.length + 1" class="empty-state">No schedules configured.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
|
||||
.filter-bar {
|
||||
display: flex; gap: 0.75rem; padding: 0.75rem 1rem;
|
||||
background: var(--color-surface-primary); border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg); align-items: center; margin-bottom: 0.75rem;
|
||||
}
|
||||
.filter-bar__search { position: relative; flex: 1; min-width: 180px; }
|
||||
.filter-bar__search-icon { position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--color-text-secondary); }
|
||||
.filter-bar__input { width: 100%; padding: 0.5rem 2rem 0.5rem 2.25rem; }
|
||||
.filter-bar__clear { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); border: none; background: none; cursor: pointer; color: var(--color-text-muted); padding: 0.15rem; border-radius: 50%; display: flex; align-items: center; opacity: 0; pointer-events: none; transition: opacity 0.15s; }
|
||||
.filter-bar__clear--visible { opacity: 1; pointer-events: auto; }
|
||||
.filter-bar__clear:hover { color: var(--color-text-primary); background: var(--color-surface-tertiary); }
|
||||
|
||||
.table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); font-size: 0.8125rem; }
|
||||
.data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
|
||||
.data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
|
||||
.data-table tbody tr:hover { background: var(--color-nav-hover); }
|
||||
.sortable { cursor: pointer; user-select: none; }
|
||||
.mono { font-family: ui-monospace, monospace; font-size: 0.78rem; }
|
||||
|
||||
.status-pill { display: inline-flex; padding: 0.15rem 0.5rem; border-radius: 9999px; font-size: 0.72rem; font-weight: 600; text-transform: capitalize; }
|
||||
.status-pill--enabled { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.status-pill--disabled { background: var(--color-surface-tertiary); color: var(--color-text-muted); }
|
||||
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.stella-table-action { display: inline-flex; padding: 0.2rem 0.5rem; border-radius: var(--radius-md); border: 1px solid var(--color-border-primary); background: var(--color-surface-primary); color: var(--color-text-primary); text-decoration: none; font-size: 0.75rem; cursor: pointer; margin-right: 0.25rem; }
|
||||
.stella-table-action:hover { border-color: var(--color-brand-primary); }
|
||||
|
||||
.source-badge {
|
||||
display: inline-flex;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--color-brand-soft);
|
||||
color: var(--color-brand-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.empty-state { padding: 2.5rem; text-align: center; color: var(--color-text-muted); font-size: 0.88rem; }
|
||||
.notice { padding: 0.55rem 0.85rem; border-radius: 8px; background: var(--color-status-info-bg); color: var(--color-status-info-text); border: 1px solid var(--color-status-info-border); font-size: 0.82rem; margin-bottom: 0.75rem; }
|
||||
|
||||
/* Inline edit */
|
||||
.row--editing { background: var(--color-brand-soft) !important; }
|
||||
.edit-row td { padding: 0 !important; border-bottom: 1px solid var(--color-border-primary); }
|
||||
.edit-panel { padding: 0.85rem 1rem; display: grid; gap: 0.75rem; background: var(--color-surface-secondary); border-top: 1px dashed var(--color-border-primary); }
|
||||
.edit-section { display: grid; gap: 0.25rem; }
|
||||
.edit-label { font-size: 0.72rem; color: var(--color-text-secondary); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.edit-row-fields { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.edit-row-fields .edit-section { flex: 1; min-width: 140px; }
|
||||
.edit-row-fields select, .edit-row-fields input { padding: 0.4rem 0.65rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); color: var(--color-text-primary); font-size: 0.82rem; }
|
||||
.edit-input { padding: 0.4rem 0.65rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); color: var(--color-text-primary); font-size: 0.82rem; margin-top: 0.25rem; width: 200px; }
|
||||
.edit-hint { font-size: 0.78rem; color: var(--color-text-muted); margin-top: 0.15rem; }
|
||||
.edit-hint code { font-family: ui-monospace, monospace; background: var(--color-surface-tertiary); padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); }
|
||||
.edit-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
|
||||
.save-btn { padding: 0.4rem 0.85rem; border: 1px solid var(--color-btn-primary-border, var(--color-border-primary)); border-radius: var(--radius-md); background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); cursor: pointer; font-size: 0.82rem; font-weight: 600; }
|
||||
.save-btn:hover { filter: brightness(1.05); }
|
||||
|
||||
/* Cadence preset chips */
|
||||
.preset-chips { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
.preset-chip { padding: 0.3rem 0.6rem; border: 1px solid var(--color-border-primary); border-radius: 9999px; background: var(--color-surface-primary); color: var(--color-text-secondary); font-size: 0.75rem; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
|
||||
.preset-chip:hover { border-color: var(--color-brand-primary); }
|
||||
.preset-chip--active { border-color: var(--color-brand-primary); background: var(--color-brand-soft); color: var(--color-text-heading); font-weight: 600; }
|
||||
`],
|
||||
})
|
||||
export class SchedulerSchedulesPanelComponent implements OnInit {
|
||||
private readonly api = inject<SchedulerApi>(SCHEDULER_API);
|
||||
private readonly dateFmt = inject(DateFormatService);
|
||||
|
||||
// --- State signals ---
|
||||
readonly schedules = signal<Schedule[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly notice = signal<string | null>(null);
|
||||
readonly selectedStatuses = signal<Set<string>>(new Set(['enabled', 'disabled']));
|
||||
readonly sortField = signal<string>('name');
|
||||
readonly sortDir = signal<'asc' | 'desc'>('asc');
|
||||
readonly editing = signal<EditState | null>(null);
|
||||
readonly cadencePresets = CADENCE_PRESETS;
|
||||
|
||||
searchQuery = '';
|
||||
|
||||
readonly columns = [
|
||||
{ field: 'name', label: 'Name' },
|
||||
{ field: 'cronExpression', label: 'Cadence' },
|
||||
{ field: 'mode', label: 'Mode' },
|
||||
{ field: 'scope', label: 'Scope' },
|
||||
{ field: 'enabled', label: 'Status' },
|
||||
{ field: 'lastRunAt', label: 'Last Run' },
|
||||
];
|
||||
|
||||
// --- Computed ---
|
||||
|
||||
readonly statusMultiOptions = computed<FilterMultiOption[]>(() => {
|
||||
const sel = this.selectedStatuses();
|
||||
return [
|
||||
{ id: 'enabled', label: 'Enabled', checked: sel.has('enabled') },
|
||||
{ id: 'disabled', label: 'Disabled', checked: sel.has('disabled') },
|
||||
];
|
||||
});
|
||||
|
||||
readonly filteredSchedules = computed(() => {
|
||||
let list = this.schedules();
|
||||
const query = this.searchQuery.toLowerCase().trim();
|
||||
const statuses = this.selectedStatuses();
|
||||
|
||||
// Filter by status
|
||||
if (statuses.size < 2) {
|
||||
list = list.filter((s) => {
|
||||
const key = s.enabled ? 'enabled' : 'disabled';
|
||||
return statuses.has(key);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (query) {
|
||||
list = list.filter((s) =>
|
||||
s.name.toLowerCase().includes(query) ||
|
||||
s.cronExpression.toLowerCase().includes(query) ||
|
||||
s.mode.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
const field = this.sortField();
|
||||
const dir = this.sortDir() === 'asc' ? 1 : -1;
|
||||
list = [...list].sort((a, b) => {
|
||||
const av = this.sortValue(a, field);
|
||||
const bv = this.sortValue(b, field);
|
||||
if (av < bv) return -1 * dir;
|
||||
if (av > bv) return 1 * dir;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSchedules();
|
||||
}
|
||||
|
||||
// --- Data loading ---
|
||||
|
||||
loadSchedules(): void {
|
||||
this.loading.set(true);
|
||||
this.api.listSchedules().pipe(
|
||||
catchError((err) => {
|
||||
this.notice.set('Failed to load schedules.');
|
||||
console.error('loadSchedules error', err);
|
||||
return of([] as Schedule[]);
|
||||
}),
|
||||
).subscribe((data) => {
|
||||
this.schedules.set(data);
|
||||
this.loading.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
toggleEnabled(schedule: Schedule): void {
|
||||
const action$ = schedule.enabled
|
||||
? this.api.pauseSchedule(schedule.id)
|
||||
: this.api.resumeSchedule(schedule.id);
|
||||
action$.pipe(
|
||||
catchError((err) => {
|
||||
this.notice.set(`Failed to ${schedule.enabled ? 'disable' : 'enable'} schedule.`);
|
||||
console.error('toggleEnabled error', err);
|
||||
return of(null);
|
||||
}),
|
||||
).subscribe(() => {
|
||||
this.notice.set(`Schedule "${schedule.name}" ${schedule.enabled ? 'disabled' : 'enabled'}.`);
|
||||
this.loadSchedules();
|
||||
});
|
||||
}
|
||||
|
||||
triggerNow(schedule: Schedule): void {
|
||||
this.api.triggerSchedule(schedule.id).pipe(
|
||||
catchError((err) => {
|
||||
this.notice.set('Failed to trigger schedule.');
|
||||
console.error('triggerNow error', err);
|
||||
return of(null);
|
||||
}),
|
||||
).subscribe(() => {
|
||||
this.notice.set(`Schedule "${schedule.name}" triggered.`);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Inline edit ---
|
||||
|
||||
startEdit(schedule: Schedule): void {
|
||||
const matchingPreset = CADENCE_PRESETS.find(p => p.cron === schedule.cronExpression);
|
||||
this.editing.set({
|
||||
id: schedule.id,
|
||||
cronExpression: schedule.cronExpression,
|
||||
presetId: matchingPreset?.id ?? 'custom',
|
||||
parallelism: schedule.limits.parallelism ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editing.set(null);
|
||||
}
|
||||
|
||||
selectPreset(preset: CadencePreset): void {
|
||||
const current = this.editing();
|
||||
if (!current) return;
|
||||
this.editing.set({ ...current, presetId: preset.id, cronExpression: preset.cron || current.cronExpression });
|
||||
}
|
||||
|
||||
patchEdit(field: keyof EditState, value: unknown): void {
|
||||
const current = this.editing();
|
||||
if (!current) return;
|
||||
this.editing.set({ ...current, [field]: value });
|
||||
}
|
||||
|
||||
saveEdit(): void {
|
||||
const edit = this.editing();
|
||||
if (!edit) return;
|
||||
|
||||
const dto: UpdateScheduleDto = {
|
||||
cronExpression: edit.cronExpression,
|
||||
limits: { parallelism: edit.parallelism },
|
||||
};
|
||||
|
||||
this.api.updateSchedule(edit.id, dto).pipe(
|
||||
catchError((err) => {
|
||||
this.notice.set('Failed to save changes.');
|
||||
console.error('saveEdit error', err);
|
||||
return of(null);
|
||||
}),
|
||||
).subscribe((result) => {
|
||||
if (result) {
|
||||
this.notice.set(`Schedule "${result.name}" updated.`);
|
||||
this.editing.set(null);
|
||||
this.loadSchedules();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Filter callback ---
|
||||
|
||||
onStatusFilterChange(options: FilterMultiOption[]): void {
|
||||
const next = new Set<string>();
|
||||
for (const opt of options) {
|
||||
if (opt.checked) next.add(opt.id);
|
||||
}
|
||||
this.selectedStatuses.set(next);
|
||||
}
|
||||
|
||||
// --- Sort ---
|
||||
|
||||
toggleSort(field: string): void {
|
||||
if (this.sortField() === field) {
|
||||
this.sortDir.set(this.sortDir() === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
this.sortField.set(field);
|
||||
this.sortDir.set('asc');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Formatting ---
|
||||
|
||||
formatDateTime(iso: string): string {
|
||||
return this.dateFmt.format(iso, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private sortValue(schedule: Schedule, field: string): string {
|
||||
switch (field) {
|
||||
case 'name': return schedule.name.toLowerCase();
|
||||
case 'cronExpression': return schedule.cronExpression;
|
||||
case 'mode': return schedule.mode;
|
||||
case 'scope': return schedule.selection.scope;
|
||||
case 'enabled': return schedule.enabled ? 'a' : 'z';
|
||||
case 'lastRunAt': return schedule.lastRunAt ?? '';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import type { Worker, WorkerStatus, BackpressureStatus } from '../../features/scheduler-ops/scheduler-ops.models';
|
||||
import { StellaFilterMultiComponent, type FilterMultiOption } from '../../shared/components/stella-filter-multi/stella-filter-multi.component';
|
||||
import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
|
||||
type SortField = 'hostname' | 'version' | 'status' | 'currentLoad' | 'completedJobs' | 'failedJobs' | 'lastHeartbeat';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
@Component({
|
||||
selector: 'app-scheduler-workers-panel',
|
||||
standalone: true,
|
||||
imports: [FormsModule, StellaFilterMultiComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@if (backpressure().isActive) {
|
||||
<div class="backpressure">
|
||||
<strong>Backpressure Detected</strong>
|
||||
<span>— Queue depth: {{ backpressure().queueDepth }}/{{ backpressure().queueThreshold }}
|
||||
| Worker utilization: {{ backpressure().workerUtilization }}%</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (notice()) {
|
||||
<div class="notice">{{ notice() }}</div>
|
||||
}
|
||||
|
||||
<div class="filter-bar">
|
||||
<div class="filter-bar__search">
|
||||
<svg class="filter-bar__search-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input class="filter-bar__input" type="text" placeholder="Search workers..."
|
||||
[ngModel]="searchQuery()" (ngModelChange)="searchQuery.set($event)" />
|
||||
<button class="filter-bar__clear" [class.filter-bar__clear--visible]="searchQuery()"
|
||||
(click)="searchQuery.set('')" type="button">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<stella-filter-multi label="Status"
|
||||
[options]="statusMultiOptions()"
|
||||
(optionsChange)="onStatusFilterChange($event)" />
|
||||
|
||||
<button class="filter-bar__btn" (click)="refreshFleet()" type="button">Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
@if (filteredWorkers().length === 0 && !loading()) {
|
||||
<div class="empty-state">No workers registered.</div>
|
||||
} @else {
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" (click)="toggleSort('hostname')">Hostname {{ sortIndicator('hostname') }}</th>
|
||||
<th class="sortable" (click)="toggleSort('version')">Version {{ sortIndicator('version') }}</th>
|
||||
<th class="sortable" (click)="toggleSort('status')">Status {{ sortIndicator('status') }}</th>
|
||||
<th class="sortable" (click)="toggleSort('currentLoad')">Load {{ sortIndicator('currentLoad') }}</th>
|
||||
<th class="sortable" (click)="toggleSort('completedJobs')">Completed {{ sortIndicator('completedJobs') }}</th>
|
||||
<th class="sortable" (click)="toggleSort('failedJobs')">Failed {{ sortIndicator('failedJobs') }}</th>
|
||||
<th class="sortable" (click)="toggleSort('lastHeartbeat')">Last Heartbeat {{ sortIndicator('lastHeartbeat') }}</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (w of filteredWorkers(); track w.id) {
|
||||
<tr>
|
||||
<td class="mono">{{ w.hostname }}</td>
|
||||
<td class="mono">{{ w.version }}</td>
|
||||
<td><span class="status-pill" [class]="'status-pill--' + w.status">{{ w.status }}</span></td>
|
||||
<td class="mono">{{ w.currentLoad }}/{{ w.maxLoad }}</td>
|
||||
<td>{{ w.completedJobs }}</td>
|
||||
<td>{{ w.failedJobs }}</td>
|
||||
<td>{{ formatRelative(w.lastHeartbeat) }}</td>
|
||||
<td class="actions-cell">
|
||||
@if (w.status === 'active') {
|
||||
<button class="stella-table-action" (click)="drainWorker(w)" type="button">Drain</button>
|
||||
}
|
||||
@if (w.status === 'draining') {
|
||||
<button class="stella-table-action" (click)="cancelDrain(w)" type="button">Cancel Drain</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
|
||||
.backpressure { display: flex; align-items: center; gap: 1rem; padding: 0.65rem 1rem; border-radius: var(--radius-lg); margin-bottom: 0.75rem; background: var(--color-status-warning-bg); color: var(--color-status-warning-text); border: 1px solid var(--color-status-warning-border); font-size: 0.85rem; }
|
||||
.backpressure strong { font-weight: 600; }
|
||||
|
||||
.filter-bar { display: flex; gap: 0.75rem; padding: 0.75rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); align-items: center; margin-bottom: 0.75rem; }
|
||||
.filter-bar__search { position: relative; flex: 1; min-width: 180px; }
|
||||
.filter-bar__search-icon { position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--color-text-secondary); }
|
||||
.filter-bar__input { width: 100%; padding: 0.5rem 2rem 0.5rem 2.25rem; }
|
||||
.filter-bar__clear { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); border: none; background: none; cursor: pointer; color: var(--color-text-muted); padding: 0.15rem; border-radius: 50%; display: flex; align-items: center; opacity: 0; pointer-events: none; transition: opacity 0.15s; }
|
||||
.filter-bar__clear--visible { opacity: 1; pointer-events: auto; }
|
||||
.filter-bar__clear:hover { color: var(--color-text-primary); background: var(--color-surface-tertiary); }
|
||||
.filter-bar__btn { padding: 0.5rem 0.85rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-secondary); color: var(--color-text-primary); cursor: pointer; font-size: 0.82rem; font-weight: 500; white-space: nowrap; }
|
||||
.filter-bar__btn:hover { border-color: var(--color-brand-primary); }
|
||||
|
||||
.table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); font-size: 0.8125rem; }
|
||||
.data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
|
||||
.data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
|
||||
.data-table tbody tr:hover { background: var(--color-nav-hover); }
|
||||
.sortable { cursor: pointer; user-select: none; }
|
||||
.mono { font-family: ui-monospace, monospace; font-size: 0.78rem; }
|
||||
|
||||
.status-pill { display: inline-flex; padding: 0.15rem 0.5rem; border-radius: 9999px; font-size: 0.72rem; font-weight: 600; text-transform: capitalize; }
|
||||
.status-pill--active { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.status-pill--draining { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
|
||||
.status-pill--unhealthy { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
|
||||
.status-pill--offline { background: var(--color-surface-tertiary); color: var(--color-text-muted); }
|
||||
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.stella-table-action { display: inline-flex; padding: 0.2rem 0.5rem; border-radius: var(--radius-md); border: 1px solid var(--color-border-primary); background: var(--color-surface-primary); color: var(--color-text-primary); text-decoration: none; font-size: 0.75rem; cursor: pointer; margin-right: 0.25rem; }
|
||||
.stella-table-action:hover { border-color: var(--color-brand-primary); }
|
||||
|
||||
.empty-state { padding: 2.5rem; text-align: center; color: var(--color-text-muted); font-size: 0.88rem; }
|
||||
.notice { padding: 0.55rem 0.85rem; border-radius: 8px; background: var(--color-status-info-bg); color: var(--color-status-info-text); border: 1px solid var(--color-status-info-border); font-size: 0.82rem; margin-bottom: 0.75rem; }
|
||||
`],
|
||||
})
|
||||
export class SchedulerWorkersPanelComponent implements OnInit {
|
||||
private readonly dateFmt = inject(DateFormatService);
|
||||
|
||||
readonly workers = signal<Worker[]>([]);
|
||||
readonly backpressure = signal<BackpressureStatus>({
|
||||
isActive: false, severity: 'none', queueDepth: 0, queueThreshold: 500,
|
||||
workerUtilization: 0, workerThreshold: 90, recommendations: [],
|
||||
});
|
||||
readonly loading = signal(false);
|
||||
readonly notice = signal('');
|
||||
readonly searchQuery = signal('');
|
||||
readonly selectedStatuses = signal(new Set<string>(['active', 'draining', 'unhealthy', 'offline']));
|
||||
|
||||
sortField = signal<SortField>('hostname');
|
||||
sortDir = signal<SortDir>('asc');
|
||||
|
||||
readonly statusMultiOptions = computed<FilterMultiOption[]>(() => {
|
||||
const sel = this.selectedStatuses();
|
||||
return (['active', 'draining', 'unhealthy', 'offline'] as WorkerStatus[]).map(s => ({
|
||||
id: s, label: s.charAt(0).toUpperCase() + s.slice(1), checked: sel.has(s),
|
||||
}));
|
||||
});
|
||||
|
||||
readonly filteredWorkers = computed(() => {
|
||||
const q = this.searchQuery().toLowerCase();
|
||||
const statuses = this.selectedStatuses();
|
||||
const field = this.sortField();
|
||||
const dir = this.sortDir();
|
||||
|
||||
let list = this.workers().filter(w => {
|
||||
if (!statuses.has(w.status)) return false;
|
||||
if (q && !w.hostname.toLowerCase().includes(q) && !w.version.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
list = [...list].sort((a, b) => {
|
||||
const av = a[field], bv = b[field];
|
||||
const cmp = typeof av === 'number' && typeof bv === 'number'
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv));
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadWorkers();
|
||||
}
|
||||
|
||||
loadWorkers(): void {
|
||||
this.loading.set(true);
|
||||
const now = Date.now();
|
||||
const mk = (id: string, host: string, ver: string, st: WorkerStatus, hbAgo: number,
|
||||
load: number, max: number, done: number, fail: number, startAgo: number): Worker => ({
|
||||
id, hostname: host, version: ver, status: st,
|
||||
startedAt: new Date(now - startAgo).toISOString(),
|
||||
lastHeartbeat: new Date(now - hbAgo).toISOString(),
|
||||
currentLoad: load, maxLoad: max, completedJobs: done, failedJobs: fail,
|
||||
activeJobs: [], capabilities: ['scan', 'export'], labels: {},
|
||||
});
|
||||
this.workers.set([
|
||||
mk('w-001', 'worker-01.stella-ops.local', '1.0.0-alpha', 'active', 5_000, 3, 8, 1247, 12, 86_400_000),
|
||||
mk('w-002', 'worker-02.stella-ops.local', '1.0.0-alpha', 'draining', 120_000, 1, 8, 3891, 27, 172_800_000),
|
||||
mk('w-003', 'worker-03.stella-ops.local', '0.9.8', 'unhealthy', 300_000, 0, 4, 562, 89, 604_800_000),
|
||||
mk('w-004', 'worker-04.stella-ops.local', '1.0.0-alpha', 'active', 2_000, 6, 8, 410, 3, 43_200_000),
|
||||
]);
|
||||
this.backpressure.set({
|
||||
isActive: true, severity: 'medium', queueDepth: 342, queueThreshold: 500,
|
||||
workerUtilization: 72, workerThreshold: 90, recommendations: ['Consider adding workers.'],
|
||||
});
|
||||
this.loading.set(false);
|
||||
}
|
||||
|
||||
onStatusFilterChange(options: FilterMultiOption[]): void {
|
||||
this.selectedStatuses.set(new Set(options.filter(o => o.checked).map(o => o.id)));
|
||||
}
|
||||
|
||||
toggleSort(field: SortField): void {
|
||||
if (this.sortField() === field) {
|
||||
this.sortDir.set(this.sortDir() === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
this.sortField.set(field);
|
||||
this.sortDir.set('asc');
|
||||
}
|
||||
}
|
||||
|
||||
sortIndicator(field: SortField): string {
|
||||
if (this.sortField() !== field) return '';
|
||||
return this.sortDir() === 'asc' ? '\u25B2' : '\u25BC';
|
||||
}
|
||||
|
||||
drainWorker(w: Worker): void {
|
||||
this.workers.update(list => list.map(x => x.id === w.id ? { ...x, status: 'draining' as WorkerStatus } : x));
|
||||
this.notice.set(`Draining ${w.hostname}...`);
|
||||
}
|
||||
|
||||
cancelDrain(w: Worker): void {
|
||||
this.workers.update(list => list.map(x => x.id === w.id ? { ...x, status: 'active' as WorkerStatus } : x));
|
||||
this.notice.set(`Cancelled drain for ${w.hostname}.`);
|
||||
}
|
||||
|
||||
refreshFleet(): void {
|
||||
this.notice.set('');
|
||||
this.loadWorkers();
|
||||
}
|
||||
|
||||
formatRelative(dateStr: string): string {
|
||||
const diff = Math.max(0, Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000));
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
}
|
||||
@@ -933,7 +933,7 @@ export class PlatformJobsQueuesPageComponent {
|
||||
{ label: 'Manage Schedules', route: OPERATIONS_PATHS.schedulerSchedules },
|
||||
{
|
||||
label: row.lastStatus === 'FAIL' ? 'Review Dead-Letter Queue' : 'Open Worker Fleet',
|
||||
route: row.lastStatus === 'FAIL' ? deadLetterQueuePath() : OPERATIONS_PATHS.schedulerWorkers,
|
||||
route: row.lastStatus === 'FAIL' ? deadLetterQueuePath() : OPERATIONS_PATHS.jobEngine,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -950,7 +950,7 @@ export class PlatformJobsQueuesPageComponent {
|
||||
|
||||
workerActions(row: WorkerRow): readonly TabAction[] {
|
||||
return [
|
||||
{ label: 'Open Worker Fleet', route: OPERATIONS_PATHS.schedulerWorkers },
|
||||
{ label: 'Open Worker Fleet', route: OPERATIONS_PATHS.jobEngine },
|
||||
{
|
||||
label: row.state === 'DEGRADED' ? 'Inspect Scheduler Runs' : 'Open Scheduler Runs',
|
||||
route: OPERATIONS_PATHS.schedulerRuns,
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { ScheduleManagementComponent } from './schedule-management.component';
|
||||
import { Schedule } from './scheduler-ops.models';
|
||||
import { SCHEDULER_API, SchedulerApi } from '../../core/api/scheduler.client';
|
||||
|
||||
describe('ScheduleManagementComponent', () => {
|
||||
let fixture: ComponentFixture<ScheduleManagementComponent>;
|
||||
let component: ScheduleManagementComponent;
|
||||
let mockApi: jasmine.SpyObj<SchedulerApi>;
|
||||
|
||||
const mockSchedule: Schedule = {
|
||||
id: 'sch-test-001',
|
||||
name: 'Test Schedule',
|
||||
cronExpression: '0 6 * * *',
|
||||
timezone: 'UTC',
|
||||
enabled: true,
|
||||
mode: 'analysis-only',
|
||||
selection: { scope: 'all-images' },
|
||||
limits: { parallelism: 1 },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdBy: 'test@example.com',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockApi = jasmine.createSpyObj<SchedulerApi>('SchedulerApi', [
|
||||
'listSchedules', 'getSchedule', 'createSchedule', 'updateSchedule',
|
||||
'deleteSchedule', 'pauseSchedule', 'resumeSchedule', 'triggerSchedule',
|
||||
'previewImpact', 'listRuns', 'cancelRun', 'retryRun',
|
||||
]);
|
||||
mockApi.listSchedules.and.returnValue(of([mockSchedule]));
|
||||
mockApi.createSchedule.and.returnValue(of(mockSchedule));
|
||||
mockApi.updateSchedule.and.returnValue(of(mockSchedule));
|
||||
mockApi.deleteSchedule.and.returnValue(of(void 0));
|
||||
mockApi.pauseSchedule.and.returnValue(of(void 0));
|
||||
mockApi.resumeSchedule.and.returnValue(of(void 0));
|
||||
mockApi.triggerSchedule.and.returnValue(of(void 0));
|
||||
mockApi.previewImpact.and.returnValue(of({
|
||||
total: 42,
|
||||
usageOnly: true,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sample: [],
|
||||
warnings: [],
|
||||
}));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FormsModule, ScheduleManagementComponent],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([]),
|
||||
{ provide: SCHEDULER_API, useValue: mockApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ScheduleManagementComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display page header', () => {
|
||||
fixture.detectChanges();
|
||||
const header = fixture.nativeElement.querySelector('.page-header h1');
|
||||
expect(header.textContent).toBe('Schedule Management');
|
||||
});
|
||||
|
||||
describe('Schedule Cards', () => {
|
||||
beforeEach(() => {
|
||||
component.schedules.set([mockSchedule]);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display schedule cards', () => {
|
||||
const cards = fixture.nativeElement.querySelectorAll('.schedule-card');
|
||||
expect(cards.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should display schedule name', () => {
|
||||
const card = fixture.nativeElement.querySelector('.schedule-card');
|
||||
expect(card.textContent).toContain('Test Schedule');
|
||||
});
|
||||
|
||||
it('should display enabled indicator', () => {
|
||||
const indicator = fixture.nativeElement.querySelector('.status-indicator.enabled');
|
||||
expect(indicator).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display cron expression', () => {
|
||||
const cron = fixture.nativeElement.querySelector('.schedule-cron code');
|
||||
expect(cron.textContent).toContain('0 6 * * *');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Actions Menu', () => {
|
||||
beforeEach(() => {
|
||||
component.schedules.set([mockSchedule]);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should toggle actions menu', () => {
|
||||
expect(component.activeMenu()).toBeNull();
|
||||
|
||||
component.toggleActions(mockSchedule.id);
|
||||
expect(component.activeMenu()).toBe(mockSchedule.id);
|
||||
|
||||
component.toggleActions(mockSchedule.id);
|
||||
expect(component.activeMenu()).toBeNull();
|
||||
});
|
||||
|
||||
it('should toggle schedule enabled status via API', () => {
|
||||
component.toggleEnabled(mockSchedule);
|
||||
expect(mockApi.pauseSchedule).toHaveBeenCalledWith(mockSchedule.id);
|
||||
});
|
||||
|
||||
it('should duplicate schedule via API', () => {
|
||||
component.duplicateSchedule(mockSchedule);
|
||||
expect(mockApi.createSchedule).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete schedule after confirmation', () => {
|
||||
spyOn(window, 'confirm').and.returnValue(true);
|
||||
component.deleteSchedule(mockSchedule);
|
||||
expect(mockApi.deleteSchedule).toHaveBeenCalledWith(mockSchedule.id);
|
||||
});
|
||||
|
||||
it('should not delete schedule if cancelled', () => {
|
||||
spyOn(window, 'confirm').and.returnValue(false);
|
||||
component.deleteSchedule(mockSchedule);
|
||||
expect(mockApi.deleteSchedule).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Create Modal', () => {
|
||||
it('should open create modal', () => {
|
||||
component.showCreateModal();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.showModal()).toBe(true);
|
||||
expect(component.editingSchedule()).toBeNull();
|
||||
|
||||
const modal = fixture.nativeElement.querySelector('.modal');
|
||||
expect(modal).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should close modal', () => {
|
||||
component.showCreateModal();
|
||||
fixture.detectChanges();
|
||||
|
||||
component.closeModal();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.showModal()).toBe(false);
|
||||
});
|
||||
|
||||
it('should create new schedule via API', () => {
|
||||
component.showCreateModal();
|
||||
component.scheduleForm.name = 'New Schedule';
|
||||
component.scheduleForm.cronExpression = '0 12 * * *';
|
||||
component.scheduleForm.mode = 'analysis-only';
|
||||
|
||||
component.saveSchedule();
|
||||
|
||||
expect(mockApi.createSchedule).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Modal', () => {
|
||||
beforeEach(() => {
|
||||
component.schedules.set([mockSchedule]);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should open edit modal with schedule data', () => {
|
||||
component.editSchedule(mockSchedule);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.showModal()).toBe(true);
|
||||
expect(component.editingSchedule()).toBe(mockSchedule);
|
||||
expect(component.scheduleForm.name).toBe(mockSchedule.name);
|
||||
});
|
||||
|
||||
it('should update existing schedule via API', () => {
|
||||
component.editSchedule(mockSchedule);
|
||||
component.scheduleForm.name = 'Updated Name';
|
||||
|
||||
component.saveSchedule();
|
||||
|
||||
expect(mockApi.updateSchedule).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('should validate form correctly', () => {
|
||||
component.showCreateModal();
|
||||
|
||||
component.scheduleForm.name = '';
|
||||
expect(component.isFormValid()).toBe(false);
|
||||
|
||||
component.scheduleForm.name = 'Test';
|
||||
component.scheduleForm.cronExpression = '0 6 * * *';
|
||||
component.scheduleForm.mode = 'analysis-only';
|
||||
expect(component.isFormValid()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Impact Preview', () => {
|
||||
it('should generate impact preview via API', () => {
|
||||
component.showCreateModal();
|
||||
component.scheduleForm.name = 'Test';
|
||||
component.scheduleForm.cronExpression = '0 6 * * *';
|
||||
|
||||
component.previewImpact();
|
||||
|
||||
expect(mockApi.previewImpact).toHaveBeenCalled();
|
||||
expect(component.impactPreview()).not.toBeNull();
|
||||
expect(component.impactPreview()?.total).toBe(42);
|
||||
});
|
||||
|
||||
it('should clear impact preview on modal close', () => {
|
||||
component.showCreateModal();
|
||||
component.previewImpact();
|
||||
expect(component.impactPreview()).not.toBeNull();
|
||||
|
||||
component.closeModal();
|
||||
expect(component.impactPreview()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mode Labels', () => {
|
||||
it('should return correct labels for modes', () => {
|
||||
expect(component.getModeLabel('analysis-only')).toBe('Analysis Only');
|
||||
expect(component.getModeLabel('content-refresh')).toBe('Content Refresh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cron Descriptions', () => {
|
||||
it('should return cron descriptions', () => {
|
||||
expect(component.getCronDescription('0 * * * *')).toBe('Every hour');
|
||||
expect(component.getCronDescription('0 6 * * *')).toBe('Daily at 6:00 AM');
|
||||
expect(component.getCronDescription('0 0 * * *')).toBe('Daily at midnight');
|
||||
expect(component.getCronDescription('0 0 * * 0')).toBe('Weekly on Sunday at midnight');
|
||||
expect(component.getCronDescription('0 0 1 * *')).toBe('Monthly on the 1st at midnight');
|
||||
expect(component.getCronDescription('*/5 * * * *')).toBe('Custom schedule');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utility Methods', () => {
|
||||
it('should format datetime correctly', () => {
|
||||
const datetime = '2024-12-29T10:30:00Z';
|
||||
const formatted = component.formatDateTime(datetime);
|
||||
expect(formatted).toContain('Dec');
|
||||
expect(formatted).toContain('29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should show empty state when no schedules', () => {
|
||||
mockApi.listSchedules.and.returnValue(of([]));
|
||||
component.schedules.set([]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const emptyState = fixture.nativeElement.querySelector('.empty-state');
|
||||
expect(emptyState).toBeTruthy();
|
||||
expect(emptyState.textContent).toContain('No schedules configured');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,7 @@ export interface Schedule {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
source?: 'system' | 'user' | 'integration';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,18 +33,12 @@ export const schedulerOpsRoutes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'schedules',
|
||||
loadComponent: () =>
|
||||
import('./schedule-management.component').then(
|
||||
(m) => m.ScheduleManagementComponent
|
||||
),
|
||||
data: { title: 'Schedule Management' },
|
||||
redirectTo: '/ops/operations/jobengine?tab=schedules',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'workers',
|
||||
loadComponent: () =>
|
||||
import('./worker-fleet.component').then(
|
||||
(m) => m.WorkerFleetComponent
|
||||
),
|
||||
data: { title: 'Worker Fleet' },
|
||||
redirectTo: '/ops/operations/jobengine?tab=schedules',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -16,13 +16,15 @@ import { SCHEDULER_API } from '../../core/api/scheduler.client';
|
||||
import { OPERATIONS_PATHS, schedulerRunStreamPath } from '../platform/ops/operations-paths';
|
||||
|
||||
import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
import { StellaFilterMultiComponent, type FilterMultiOption } from '../../shared/components/stella-filter-multi/stella-filter-multi.component';
|
||||
import { StellaFilterChipComponent, type FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
|
||||
/**
|
||||
* Scheduler Runs Component (Sprint: SPRINT_20251229_017)
|
||||
* Lists scheduler runs with real-time updates and cancel/retry actions.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-scheduler-runs',
|
||||
imports: [FormsModule, RouterLink],
|
||||
imports: [FormsModule, RouterLink, StellaFilterMultiComponent, StellaFilterChipComponent],
|
||||
template: `
|
||||
<div class="scheduler-runs">
|
||||
<header class="page-header">
|
||||
@@ -31,12 +33,6 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
<p>Monitor and manage scheduled task executions.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-secondary" [routerLink]="OPERATIONS_PATHS.schedulerSchedules">
|
||||
Manage Schedules
|
||||
</button>
|
||||
<button class="btn btn-secondary" [routerLink]="OPERATIONS_PATHS.schedulerWorkers">
|
||||
Worker Fleet
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -56,28 +52,22 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by schedule name or run ID..."
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
<div class="filter-bar">
|
||||
<div class="filter-bar__search">
|
||||
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input
|
||||
type="text"
|
||||
class="filter-bar__input"
|
||||
placeholder="Search by schedule name or run ID..."
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
/>
|
||||
</div>
|
||||
<stella-filter-multi
|
||||
label="Status"
|
||||
[options]="statusMultiOptions()"
|
||||
(optionsChange)="onStatusMultiChange($event)"
|
||||
/>
|
||||
<select [ngModel]="statusFilter()" (ngModelChange)="onStatusFilterChange($event)">
|
||||
<option value="">All statuses</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="queued">Queued</option>
|
||||
</select>
|
||||
<select [ngModel]="timeFilter()" (ngModelChange)="onTimeFilterChange($event)">
|
||||
<option value="1h">Last 1 hour</option>
|
||||
<option value="24h">Last 24 hours</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
@@ -255,31 +245,38 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
input, select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
font-size: 0.88rem;
|
||||
color: inherit;
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-bar__search { position: relative; flex: 1; }
|
||||
.filter-bar__search-icon {
|
||||
position: absolute; left: 0.75rem; top: 50%;
|
||||
transform: translateY(-50%); color: var(--color-text-secondary);
|
||||
}
|
||||
.filter-bar__input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
|
||||
}
|
||||
.filter-bar__select {
|
||||
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.filter-bar__select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.connection-banner {
|
||||
@@ -685,6 +682,34 @@ export class SchedulerRunsComponent implements OnInit, OnDestroy {
|
||||
readonly statusFilter = signal<SchedulerRunStatus | ''>('');
|
||||
readonly timeFilter = signal<'1h' | '24h' | '7d' | '30d'>('24h');
|
||||
|
||||
private readonly statusList: SchedulerRunStatus[] = ['running', 'completed', 'failed', 'cancelled', 'pending', 'queued'];
|
||||
readonly selectedStatuses = signal<Set<string>>(new Set(this.statusList));
|
||||
|
||||
readonly statusMultiOptions = computed<FilterMultiOption[]>(() => {
|
||||
const sel = this.selectedStatuses();
|
||||
return this.statusList.map(s => ({ id: s, label: s, checked: sel.has(s) }));
|
||||
});
|
||||
|
||||
readonly timeChipOptions: FilterChipOption[] = [
|
||||
{ id: '1h', label: '1h' },
|
||||
{ id: '24h', label: '24h' },
|
||||
{ id: '7d', label: '7d' },
|
||||
{ id: '30d', label: '30d' },
|
||||
];
|
||||
|
||||
onStatusMultiChange(opts: FilterMultiOption[]): void {
|
||||
const checked = opts.filter(o => o.checked).map(o => o.id);
|
||||
this.selectedStatuses.set(new Set(checked));
|
||||
// Also update the single statusFilter for the existing filteredRuns logic
|
||||
if (checked.length === this.statusList.length || checked.length === 0) {
|
||||
this.statusFilter.set('');
|
||||
} else if (checked.length === 1) {
|
||||
this.statusFilter.set(checked[0] as SchedulerRunStatus);
|
||||
} else {
|
||||
this.statusFilter.set('');
|
||||
}
|
||||
}
|
||||
|
||||
readonly expandedRun = signal<string | null>(null);
|
||||
readonly isConnected = signal(true);
|
||||
readonly actionNotice = signal<string | null>(null);
|
||||
@@ -703,9 +728,9 @@ export class SchedulerRunsComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
const status = this.statusFilter();
|
||||
if (status) {
|
||||
result = result.filter(r => r.status === status);
|
||||
const statuses = this.selectedStatuses();
|
||||
if (statuses.size > 0 && statuses.size < this.statusList.length) {
|
||||
result = result.filter(r => statuses.has(r.status));
|
||||
}
|
||||
|
||||
const cutoff = this.getTimeCutoffMs(this.timeFilter());
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { WorkerFleetComponent } from './worker-fleet.component';
|
||||
import { Worker, BackpressureStatus } from './scheduler-ops.models';
|
||||
|
||||
describe('WorkerFleetComponent', () => {
|
||||
let fixture: ComponentFixture<WorkerFleetComponent>;
|
||||
let component: WorkerFleetComponent;
|
||||
|
||||
const mockWorker: Worker = {
|
||||
id: 'worker-test-001',
|
||||
hostname: 'worker-1.example.com',
|
||||
version: '1.5.0',
|
||||
status: 'active',
|
||||
startedAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
currentLoad: 75,
|
||||
maxLoad: 100,
|
||||
completedJobs: 1500,
|
||||
failedJobs: 25,
|
||||
activeJobs: [
|
||||
{ jobId: 'job-001', type: 'scan', startedAt: new Date().toISOString(), progress: 50 },
|
||||
],
|
||||
capabilities: ['scan', 'sbom', 'export'],
|
||||
labels: { env: 'production' },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkerFleetComponent],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WorkerFleetComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
component.ngOnDestroy();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display page header', () => {
|
||||
fixture.detectChanges();
|
||||
const header = fixture.nativeElement.querySelector('.page-header h1');
|
||||
expect(header.textContent).toBe('Worker Fleet');
|
||||
});
|
||||
|
||||
describe('Fleet Summary', () => {
|
||||
beforeEach(() => {
|
||||
component.workers.set([mockWorker]);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should calculate fleet summary', () => {
|
||||
const summary = component.fleetSummary();
|
||||
|
||||
expect(summary.totalWorkers).toBe(1);
|
||||
expect(summary.activeWorkers).toBe(1);
|
||||
expect(summary.usedCapacity).toBe(75);
|
||||
expect(summary.totalCapacity).toBe(100);
|
||||
});
|
||||
|
||||
it('should display summary cards', () => {
|
||||
const summaryCards = fixture.nativeElement.querySelectorAll('.summary-card');
|
||||
expect(summaryCards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should calculate version distribution', () => {
|
||||
const workers = [
|
||||
{ ...mockWorker, id: 'w1', version: '1.5.0' },
|
||||
{ ...mockWorker, id: 'w2', version: '1.5.0' },
|
||||
{ ...mockWorker, id: 'w3', version: '1.4.0' },
|
||||
];
|
||||
component.workers.set(workers);
|
||||
|
||||
const summary = component.fleetSummary();
|
||||
|
||||
expect(summary.versionDistribution['1.5.0']).toBe(2);
|
||||
expect(summary.versionDistribution['1.4.0']).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backpressure Status', () => {
|
||||
it('should display backpressure warning when active', () => {
|
||||
component.backpressure.set({
|
||||
isActive: true,
|
||||
severity: 'high',
|
||||
queueDepth: 500,
|
||||
queueThreshold: 200,
|
||||
workerUtilization: 95,
|
||||
workerThreshold: 80,
|
||||
estimatedClearTime: 3600000,
|
||||
recommendations: ['Scale up workers', 'Pause new jobs'],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const warning = fixture.nativeElement.querySelector('.backpressure-warning');
|
||||
expect(warning).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should hide backpressure warning when not active', () => {
|
||||
component.backpressure.set({
|
||||
isActive: false,
|
||||
severity: 'none',
|
||||
queueDepth: 50,
|
||||
queueThreshold: 200,
|
||||
workerUtilization: 30,
|
||||
workerThreshold: 80,
|
||||
recommendations: [],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const warning = fixture.nativeElement.querySelector('.backpressure-warning');
|
||||
expect(warning).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Worker Cards', () => {
|
||||
beforeEach(() => {
|
||||
component.workers.set([mockWorker]);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display worker cards', () => {
|
||||
const cards = fixture.nativeElement.querySelectorAll('.worker-card');
|
||||
expect(cards.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should display worker hostname', () => {
|
||||
const card = fixture.nativeElement.querySelector('.worker-card');
|
||||
expect(card.textContent).toContain('worker-1.example.com');
|
||||
});
|
||||
|
||||
it('should display worker version', () => {
|
||||
const card = fixture.nativeElement.querySelector('.worker-card');
|
||||
expect(card.textContent).toContain('1.5.0');
|
||||
});
|
||||
|
||||
it('should display load bar', () => {
|
||||
const loadBar = fixture.nativeElement.querySelector('.load-fill');
|
||||
expect(loadBar).toBeTruthy();
|
||||
expect(loadBar.style.width).toBe('75%');
|
||||
});
|
||||
|
||||
it('should display active jobs count', () => {
|
||||
const card = fixture.nativeElement.querySelector('.worker-card');
|
||||
expect(card.textContent).toContain('1'); // active jobs
|
||||
});
|
||||
});
|
||||
|
||||
describe('Worker Actions', () => {
|
||||
beforeEach(() => {
|
||||
component.workers.set([mockWorker]);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should drain worker after confirmation', () => {
|
||||
spyOn(window, 'confirm').and.returnValue(true);
|
||||
|
||||
component.drainWorker(mockWorker);
|
||||
|
||||
const updated = component.workers().find(w => w.id === mockWorker.id);
|
||||
expect(updated?.status).toBe('draining');
|
||||
});
|
||||
|
||||
it('should not drain worker if cancelled', () => {
|
||||
spyOn(window, 'confirm').and.returnValue(false);
|
||||
|
||||
component.drainWorker(mockWorker);
|
||||
|
||||
const updated = component.workers().find(w => w.id === mockWorker.id);
|
||||
expect(updated?.status).toBe('active');
|
||||
});
|
||||
|
||||
it('should restart worker after confirmation', () => {
|
||||
spyOn(window, 'confirm').and.returnValue(true);
|
||||
const consoleSpy = spyOn(console, 'log');
|
||||
|
||||
component.restartWorker(mockWorker);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Restarting worker:', mockWorker.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Worker Status Classes', () => {
|
||||
it('should return correct status class', () => {
|
||||
expect(component.getStatusClass('active')).toBe('status-active');
|
||||
expect(component.getStatusClass('draining')).toBe('status-draining');
|
||||
expect(component.getStatusClass('offline')).toBe('status-offline');
|
||||
expect(component.getStatusClass('unhealthy')).toBe('status-unhealthy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utility Methods', () => {
|
||||
it('should format datetime correctly', () => {
|
||||
const datetime = '2024-12-29T10:30:00Z';
|
||||
const formatted = component.formatDateTime(datetime);
|
||||
expect(formatted).toContain('Dec');
|
||||
expect(formatted).toContain('29');
|
||||
});
|
||||
|
||||
it('should format uptime correctly', () => {
|
||||
const startedAt = new Date(Date.now() - 3600000).toISOString(); // 1 hour ago
|
||||
const uptime = component.formatUptime(startedAt);
|
||||
expect(uptime).toContain('h');
|
||||
});
|
||||
|
||||
it('should calculate load percentage', () => {
|
||||
expect(component.getLoadPercentage(75, 100)).toBe(75);
|
||||
expect(component.getLoadPercentage(50, 200)).toBe(25);
|
||||
expect(component.getLoadPercentage(0, 100)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle zero max load', () => {
|
||||
expect(component.getLoadPercentage(50, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Version Distribution', () => {
|
||||
it('should display version bars', () => {
|
||||
component.workers.set([
|
||||
{ ...mockWorker, id: 'w1', version: '1.5.0' },
|
||||
{ ...mockWorker, id: 'w2', version: '1.4.0' },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const versionBars = fixture.nativeElement.querySelectorAll('.version-bar');
|
||||
expect(versionBars.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lifecycle', () => {
|
||||
it('should connect SSE on init', () => {
|
||||
component.ngOnInit();
|
||||
expect(component.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should disconnect SSE on destroy', () => {
|
||||
component.ngOnInit();
|
||||
component.ngOnDestroy();
|
||||
expect(component.isConnected()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should show empty state when no workers', () => {
|
||||
component.workers.set([]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const emptyState = fixture.nativeElement.querySelector('.empty-state');
|
||||
expect(emptyState).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Worker Detail Modal', () => {
|
||||
beforeEach(() => {
|
||||
component.workers.set([mockWorker]);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should open worker detail modal', () => {
|
||||
component.viewWorkerDetails(mockWorker);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedWorker()).toBe(mockWorker);
|
||||
});
|
||||
|
||||
it('should close worker detail modal', () => {
|
||||
component.viewWorkerDetails(mockWorker);
|
||||
component.closeWorkerDetails();
|
||||
|
||||
expect(component.selectedWorker()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,798 +0,0 @@
|
||||
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
signal,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
Worker,
|
||||
WorkerFleetSummary,
|
||||
WorkerStatus,
|
||||
BackpressureStatus,
|
||||
} from './scheduler-ops.models';
|
||||
import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
|
||||
|
||||
import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
/**
|
||||
* Worker Fleet Dashboard Component (Sprint: SPRINT_20251229_017)
|
||||
* Displays worker status, load, and health with drain/restart controls.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-worker-fleet',
|
||||
imports: [RouterLink],
|
||||
template: `
|
||||
<div class="worker-fleet">
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<a [routerLink]="OPERATIONS_PATHS.schedulerRuns" class="back-link"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:inline;vertical-align:middle"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Back to Runs</a>
|
||||
<h1>Worker Fleet</h1>
|
||||
<p>Monitor worker status, load distribution, and health.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (actionNotice()) {
|
||||
<div class="fleet-notice" role="status">{{ actionNotice() }}</div>
|
||||
}
|
||||
|
||||
<!-- Fleet Summary -->
|
||||
<div class="fleet-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-value">{{ fleetSummary().totalWorkers }}</span>
|
||||
<span class="summary-label">Total Workers</span>
|
||||
</div>
|
||||
<div class="summary-card success">
|
||||
<span class="summary-value">{{ fleetSummary().activeWorkers }}</span>
|
||||
<span class="summary-label">Active</span>
|
||||
</div>
|
||||
<div class="summary-card warning">
|
||||
<span class="summary-value">{{ fleetSummary().drainingWorkers }}</span>
|
||||
<span class="summary-label">Draining</span>
|
||||
</div>
|
||||
<div class="summary-card error">
|
||||
<span class="summary-value">{{ fleetSummary().unhealthyWorkers }}</span>
|
||||
<span class="summary-label">Unhealthy</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="capacity-bar">
|
||||
<div
|
||||
class="capacity-fill"
|
||||
[style.width.%]="capacityPercentage()"
|
||||
></div>
|
||||
</div>
|
||||
<span class="summary-label">
|
||||
{{ fleetSummary().usedCapacity }} / {{ fleetSummary().totalCapacity }} capacity
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backpressure Warning -->
|
||||
@if (backpressure().isActive) {
|
||||
<div class="backpressure-warning" [class]="'severity-' + backpressure().severity">
|
||||
<div class="warning-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
|
||||
<div class="warning-content">
|
||||
<h3>Backpressure Detected</h3>
|
||||
<p>
|
||||
Queue depth: {{ backpressure().queueDepth }} / {{ backpressure().queueThreshold }}
|
||||
| Worker utilization: {{ backpressure().workerUtilization }}%
|
||||
</p>
|
||||
@if (backpressure().estimatedClearTime) {
|
||||
<p class="clear-time">
|
||||
Estimated clear time: {{ formatDuration(backpressure().estimatedClearTime) }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="warning-actions">
|
||||
<button class="btn btn-secondary" (click)="scaleWorkers()">
|
||||
Scale Workers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Version Distribution -->
|
||||
<section class="version-section">
|
||||
<h2>Version Distribution</h2>
|
||||
<div class="version-bars">
|
||||
@for (entry of getVersionEntries(); track entry.version) {
|
||||
<div class="version-bar">
|
||||
<span class="version-label">{{ entry.version }}</span>
|
||||
<div class="bar-container">
|
||||
<div
|
||||
class="bar-fill"
|
||||
[style.width.%]="entry.percentage"
|
||||
></div>
|
||||
</div>
|
||||
<span class="version-count">{{ entry.count }} workers</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Workers Grid -->
|
||||
<section class="workers-section">
|
||||
<div class="section-header">
|
||||
<h2>Workers</h2>
|
||||
<div class="section-actions">
|
||||
<button class="btn btn-secondary" (click)="drainAll()">
|
||||
Drain All
|
||||
</button>
|
||||
<button class="btn btn-secondary" (click)="refreshFleet()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workers-grid">
|
||||
@for (worker of workers(); track worker.id) {
|
||||
<div class="worker-card" [class]="'status-' + worker.status">
|
||||
<div class="worker-header">
|
||||
<div class="worker-status">
|
||||
<span class="status-dot" [class]="worker.status"></span>
|
||||
<span class="status-text">{{ worker.status }}</span>
|
||||
</div>
|
||||
<span class="worker-version">v{{ worker.version }}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="worker-hostname">{{ worker.hostname }}</h3>
|
||||
<code class="worker-id">{{ worker.id }}</code>
|
||||
|
||||
<div class="worker-load">
|
||||
<div class="load-bar">
|
||||
<div
|
||||
class="load-fill"
|
||||
[style.width.%]="(worker.currentLoad / worker.maxLoad) * 100"
|
||||
[class.high]="worker.currentLoad / worker.maxLoad > 0.8"
|
||||
></div>
|
||||
</div>
|
||||
<span class="load-text">
|
||||
{{ worker.currentLoad }} / {{ worker.maxLoad }} jobs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="worker-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ worker.completedJobs }}</span>
|
||||
<span class="stat-label">Completed</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ worker.failedJobs }}</span>
|
||||
<span class="stat-label">Failed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="worker-timing">
|
||||
<span>Started: {{ formatDateTime(worker.startedAt) }}</span>
|
||||
<span>Last heartbeat: {{ formatRelative(worker.lastHeartbeat) }}</span>
|
||||
</div>
|
||||
|
||||
@if (worker.activeJobs.length > 0) {
|
||||
<div class="active-jobs">
|
||||
<h4>Active Jobs ({{ worker.activeJobs.length }})</h4>
|
||||
@for (job of worker.activeJobs; track job.jobId) {
|
||||
<div class="job-item">
|
||||
<span class="job-type">{{ job.type }}</span>
|
||||
<span class="job-progress">{{ job.progress }}%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="worker-actions">
|
||||
@if (worker.status === 'active') {
|
||||
<button class="btn btn-sm btn-secondary" (click)="drainWorker(worker)">
|
||||
Drain
|
||||
</button>
|
||||
}
|
||||
@if (worker.status === 'draining') {
|
||||
<button class="btn btn-sm btn-secondary" (click)="cancelDrain(worker)">
|
||||
Cancel Drain
|
||||
</button>
|
||||
}
|
||||
@if (worker.status === 'unhealthy') {
|
||||
<button class="btn btn-sm btn-danger" (click)="restartWorker(worker)">
|
||||
Restart
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-secondary" (click)="viewWorkerLogs(worker)">
|
||||
Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="empty-state">
|
||||
<p>No workers registered.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.worker-fleet {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-link);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.fleet-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.fleet-notice {
|
||||
background: var(--color-status-info-bg);
|
||||
border: 1px solid rgba(56, 189, 248, 0.45);
|
||||
color: var(--color-status-info);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
|
||||
&.success { background: var(--color-status-success-bg); }
|
||||
&.warning { background: var(--color-status-warning-bg); }
|
||||
&.error { background: var(--color-status-error-bg); }
|
||||
|
||||
.summary-value {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.capacity-bar {
|
||||
height: 8px;
|
||||
background: var(--color-surface-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.capacity-fill {
|
||||
height: 100%;
|
||||
background: var(--color-btn-primary-bg);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.backpressure-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--color-status-warning-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 2rem;
|
||||
|
||||
&.severity-high, &.severity-critical {
|
||||
background: var(--color-status-error-bg);
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.warning-content {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.clear-time {
|
||||
margin-top: 0.25rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version-section {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.version-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.version-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr 100px;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.version-label {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.bar-container {
|
||||
height: 20px;
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: var(--color-btn-primary-bg);
|
||||
}
|
||||
|
||||
.version-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.workers-section {
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.workers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.worker-card {
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
border-left: 4px solid var(--color-status-success);
|
||||
|
||||
&.status-draining { border-left-color: var(--color-status-warning); }
|
||||
&.status-offline { border-left-color: var(--color-text-secondary); }
|
||||
&.status-unhealthy { border-left-color: var(--color-status-error); }
|
||||
|
||||
.worker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.worker-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
|
||||
&.active { background: var(--color-status-success); }
|
||||
&.draining { background: var(--color-status-warning); }
|
||||
&.offline { background: var(--color-text-secondary); }
|
||||
&.unhealthy { background: var(--color-status-error); }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.worker-version {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--color-surface-tertiary);
|
||||
border-radius: 0.125rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.worker-hostname {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.worker-id {
|
||||
display: block;
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.worker-load {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.load-bar {
|
||||
height: 6px;
|
||||
background: var(--color-surface-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.load-fill {
|
||||
height: 100%;
|
||||
background: var(--color-status-success);
|
||||
transition: width 0.3s ease;
|
||||
|
||||
&.high {
|
||||
background: var(--color-status-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.load-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.worker-stats {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.worker-timing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.active-jobs {
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.job-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--color-surface-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.worker-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
|
||||
&.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
&.btn-secondary {
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
&.btn-danger {
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class WorkerFleetComponent {
|
||||
private readonly dateFmt = inject(DateFormatService);
|
||||
|
||||
protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
|
||||
readonly actionNotice = signal<string | null>(null);
|
||||
|
||||
readonly workers = signal<Worker[]>([
|
||||
{
|
||||
id: 'worker-001',
|
||||
hostname: 'worker-prod-1.example.com',
|
||||
version: '2.1.0',
|
||||
status: 'active',
|
||||
startedAt: new Date(Date.now() - 86400000 * 3).toISOString(),
|
||||
lastHeartbeat: new Date(Date.now() - 5000).toISOString(),
|
||||
currentLoad: 8,
|
||||
maxLoad: 10,
|
||||
completedJobs: 1250,
|
||||
failedJobs: 12,
|
||||
activeJobs: [
|
||||
{ jobId: 'job-001', type: 'scan', startedAt: new Date().toISOString(), progress: 65 },
|
||||
{ jobId: 'job-002', type: 'sbom', startedAt: new Date().toISOString(), progress: 30 },
|
||||
],
|
||||
capabilities: ['scan', 'sbom', 'export'],
|
||||
labels: { 'tier': 'production', 'region': 'us-east-1' },
|
||||
},
|
||||
{
|
||||
id: 'worker-002',
|
||||
hostname: 'worker-prod-2.example.com',
|
||||
version: '2.1.0',
|
||||
status: 'active',
|
||||
startedAt: new Date(Date.now() - 86400000 * 3).toISOString(),
|
||||
lastHeartbeat: new Date(Date.now() - 3000).toISOString(),
|
||||
currentLoad: 5,
|
||||
maxLoad: 10,
|
||||
completedJobs: 980,
|
||||
failedJobs: 8,
|
||||
activeJobs: [
|
||||
{ jobId: 'job-003', type: 'export', startedAt: new Date().toISOString(), progress: 80 },
|
||||
],
|
||||
capabilities: ['scan', 'sbom', 'export'],
|
||||
labels: { 'tier': 'production', 'region': 'us-east-1' },
|
||||
},
|
||||
{
|
||||
id: 'worker-003',
|
||||
hostname: 'worker-prod-3.example.com',
|
||||
version: '2.0.5',
|
||||
status: 'draining',
|
||||
startedAt: new Date(Date.now() - 86400000 * 7).toISOString(),
|
||||
lastHeartbeat: new Date(Date.now() - 2000).toISOString(),
|
||||
currentLoad: 2,
|
||||
maxLoad: 10,
|
||||
completedJobs: 2150,
|
||||
failedJobs: 25,
|
||||
activeJobs: [
|
||||
{ jobId: 'job-004', type: 'scan', startedAt: new Date().toISOString(), progress: 95 },
|
||||
],
|
||||
capabilities: ['scan', 'sbom'],
|
||||
labels: { 'tier': 'production', 'region': 'us-west-2' },
|
||||
},
|
||||
{
|
||||
id: 'worker-004',
|
||||
hostname: 'worker-prod-4.example.com',
|
||||
version: '2.1.0',
|
||||
status: 'unhealthy',
|
||||
startedAt: new Date(Date.now() - 86400000 * 1).toISOString(),
|
||||
lastHeartbeat: new Date(Date.now() - 120000).toISOString(),
|
||||
currentLoad: 0,
|
||||
maxLoad: 10,
|
||||
completedJobs: 150,
|
||||
failedJobs: 45,
|
||||
activeJobs: [],
|
||||
capabilities: ['scan', 'sbom', 'export'],
|
||||
labels: { 'tier': 'production', 'region': 'us-west-2' },
|
||||
},
|
||||
]);
|
||||
|
||||
readonly backpressure = signal<BackpressureStatus>({
|
||||
isActive: true,
|
||||
severity: 'medium',
|
||||
queueDepth: 450,
|
||||
queueThreshold: 500,
|
||||
workerUtilization: 85,
|
||||
workerThreshold: 80,
|
||||
estimatedClearTime: 300000,
|
||||
recommendations: [
|
||||
'Consider scaling up workers to handle increased load.',
|
||||
'Review long-running jobs for potential optimization.',
|
||||
],
|
||||
});
|
||||
|
||||
readonly fleetSummary = computed<WorkerFleetSummary>(() => {
|
||||
const allWorkers = this.workers();
|
||||
const active = allWorkers.filter(w => w.status === 'active');
|
||||
const draining = allWorkers.filter(w => w.status === 'draining');
|
||||
const unhealthy = allWorkers.filter(w => w.status === 'unhealthy');
|
||||
const offline = allWorkers.filter(w => w.status === 'offline');
|
||||
|
||||
const totalCapacity = allWorkers.reduce((sum, w) => sum + w.maxLoad, 0);
|
||||
const usedCapacity = allWorkers.reduce((sum, w) => sum + w.currentLoad, 0);
|
||||
|
||||
const versionDistribution: Record<string, number> = {};
|
||||
allWorkers.forEach(w => {
|
||||
versionDistribution[w.version] = (versionDistribution[w.version] || 0) + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
totalWorkers: allWorkers.length,
|
||||
activeWorkers: active.length,
|
||||
drainingWorkers: draining.length,
|
||||
offlineWorkers: offline.length,
|
||||
unhealthyWorkers: unhealthy.length,
|
||||
totalCapacity,
|
||||
usedCapacity,
|
||||
versionDistribution,
|
||||
};
|
||||
});
|
||||
|
||||
readonly capacityPercentage = computed(() => {
|
||||
const summary = this.fleetSummary();
|
||||
return summary.totalCapacity > 0
|
||||
? Math.round((summary.usedCapacity / summary.totalCapacity) * 100)
|
||||
: 0;
|
||||
});
|
||||
|
||||
getVersionEntries(): Array<{ version: string; count: number; percentage: number }> {
|
||||
const distribution = this.fleetSummary().versionDistribution;
|
||||
const total = Object.values(distribution).reduce((sum, c) => sum + c, 0);
|
||||
return Object.entries(distribution)
|
||||
.map(([version, count]) => ({
|
||||
version,
|
||||
count,
|
||||
percentage: total > 0 ? Math.round((count / total) * 100) : 0,
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (right.count !== left.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
return left.version.localeCompare(right.version);
|
||||
});
|
||||
}
|
||||
|
||||
drainWorker(worker: Worker): void {
|
||||
if (confirm(`Drain worker "${worker.hostname}"? It will stop accepting new jobs.`)) {
|
||||
this.workers.update(workers =>
|
||||
workers.map(w =>
|
||||
w.id === worker.id ? { ...w, status: 'draining' as WorkerStatus } : w
|
||||
)
|
||||
);
|
||||
this.actionNotice.set(`Worker ${worker.hostname} is now draining.`);
|
||||
}
|
||||
}
|
||||
|
||||
cancelDrain(worker: Worker): void {
|
||||
this.workers.update(workers =>
|
||||
workers.map(w =>
|
||||
w.id === worker.id ? { ...w, status: 'active' as WorkerStatus } : w
|
||||
)
|
||||
);
|
||||
this.actionNotice.set(`Drain cancelled for ${worker.hostname}.`);
|
||||
}
|
||||
|
||||
restartWorker(worker: Worker): void {
|
||||
if (confirm(`Restart worker "${worker.hostname}"?`)) {
|
||||
this.actionNotice.set(`Restart requested for ${worker.hostname}.`);
|
||||
}
|
||||
}
|
||||
|
||||
viewWorkerLogs(worker: Worker): void {
|
||||
this.actionNotice.set(`Opened logs for ${worker.hostname}.`);
|
||||
}
|
||||
|
||||
drainAll(): void {
|
||||
if (confirm('Drain all active workers? This will prevent new jobs from being scheduled.')) {
|
||||
this.workers.update(workers =>
|
||||
workers.map(w =>
|
||||
w.status === 'active' ? { ...w, status: 'draining' as WorkerStatus } : w
|
||||
)
|
||||
);
|
||||
this.actionNotice.set('All active workers moved to draining.');
|
||||
}
|
||||
}
|
||||
|
||||
refreshFleet(): void {
|
||||
this.actionNotice.set('Fleet status refreshed.');
|
||||
}
|
||||
|
||||
scaleWorkers(): void {
|
||||
this.actionNotice.set('Scale workers action queued for operator review.');
|
||||
}
|
||||
|
||||
formatDateTime(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString(this.dateFmt.locale(), {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
formatRelative(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
return `${Math.floor(diff / 3600000)}h ago`;
|
||||
}
|
||||
|
||||
formatDuration(ms: number | undefined): string {
|
||||
if (ms === undefined) return '-';
|
||||
if (ms < 60000) return `${Math.floor(ms / 1000)}s`;
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
return `${minutes}m`;
|
||||
}
|
||||
}
|
||||
@@ -417,7 +417,7 @@ export interface NavItem {
|
||||
}
|
||||
<span class="nav-item__label">{{ label }}</span>
|
||||
@if (tooltip && !isChild) {
|
||||
<span class="nav-item__context">{{ tooltip }}</span>
|
||||
<span class="nav-item__context" [attr.title]="tooltip">{{ tooltip }}</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
@@ -624,7 +624,10 @@ export interface NavItem {
|
||||
color: var(--color-sidebar-text-muted);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.35;
|
||||
white-space: normal;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-item__badge {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Workflow.Contracts;
|
||||
using StellaOps.Workflow.Engine.Services;
|
||||
|
||||
namespace StellaOps.Workflow.WebService.Bootstrap;
|
||||
|
||||
/// <summary>
|
||||
/// Deploys built-in workflow definitions on startup.
|
||||
/// Definitions are embedded as JSON resources in the Definitions/ folder.
|
||||
/// Import is idempotent — existing definitions with matching content hash are skipped.
|
||||
/// </summary>
|
||||
public sealed class WorkflowDefinitionBootstrap : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<WorkflowDefinitionBootstrap> _logger;
|
||||
|
||||
private static readonly string[] DefinitionNames =
|
||||
[
|
||||
"release-promotion",
|
||||
"scan-execution",
|
||||
"advisory-refresh",
|
||||
"compliance-sweep",
|
||||
];
|
||||
|
||||
public WorkflowDefinitionBootstrap(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<WorkflowDefinitionBootstrap> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Wait briefly for the database and other services to initialize
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var deploymentApi = scope.ServiceProvider.GetRequiredService<WorkflowDefinitionDeploymentService>();
|
||||
|
||||
foreach (var name in DefinitionNames)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
var json = await LoadDefinitionJsonAsync(name);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
_logger.LogWarning("Workflow definition resource not found: {Name}", name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var response = await deploymentApi.ImportAsync(new WorkflowDefinitionImportRequest
|
||||
{
|
||||
CanonicalDefinitionJson = json,
|
||||
ImportedBy = "system-bootstrap",
|
||||
}, stoppingToken);
|
||||
|
||||
if (response.WasImported)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Deployed workflow definition {Name} v{Version} (hash: {Hash})",
|
||||
response.WorkflowName, response.Version, response.ContentHash);
|
||||
}
|
||||
else if (response.ValidationIssues is { Count: > 0 })
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Workflow definition {Name} failed validation: {Issues}",
|
||||
response.WorkflowName,
|
||||
string.Join("; ", response.ValidationIssues));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Workflow definition {Name} already up-to-date (hash: {Hash})",
|
||||
response.WorkflowName, response.ContentHash);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deploy workflow definition: {Name}", name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Workflow definition bootstrap complete ({Count} definitions processed)", DefinitionNames.Length);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Workflow definition bootstrap failed");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> LoadDefinitionJsonAsync(string name)
|
||||
{
|
||||
// Try embedded resource first
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceName = assembly.GetManifestResourceNames()
|
||||
.FirstOrDefault(n => n.EndsWith($"{name}.json", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (resourceName is not null)
|
||||
{
|
||||
await using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is not null)
|
||||
{
|
||||
using var reader = new StreamReader(stream);
|
||||
return await reader.ReadToEndAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: load from file system (development)
|
||||
var filePath = Path.Combine(AppContext.BaseDirectory, "Definitions", $"{name}.json");
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
filePath = Path.Combine("Definitions", $"{name}.json");
|
||||
}
|
||||
|
||||
return File.Exists(filePath) ? await File.ReadAllTextAsync(filePath) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"schemaVersion": "stellaops.workflow.definition/v1",
|
||||
"workflowName": "advisory-refresh",
|
||||
"workflowVersion": "1.0.0",
|
||||
"displayName": "Advisory Refresh",
|
||||
"startRequest": {
|
||||
"contractName": "StellaOps.Concelier.Contracts.AdvisoryRefreshRequest",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"feedIds": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"allowAdditionalProperties": true
|
||||
},
|
||||
"workflowRoles": [],
|
||||
"start": {
|
||||
"initializeStateExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "feedIds", "expression": { "$type": "path", "path": "start.feedIds" } },
|
||||
{ "name": "status", "expression": { "$type": "string", "value": "refreshing" } }
|
||||
]
|
||||
},
|
||||
"initialSequence": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "refresh-advisory-feeds",
|
||||
"invocation": {
|
||||
"address": {
|
||||
"$type": "microservice",
|
||||
"microserviceName": "concelier",
|
||||
"command": "refresh-feeds"
|
||||
},
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "feedIds", "expression": { "$type": "path", "path": "state.feedIds" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"resultKey": "refreshResult"
|
||||
},
|
||||
{
|
||||
"$type": "set-state",
|
||||
"stateKey": "status",
|
||||
"valueExpression": { "$type": "string", "value": "completed" }
|
||||
},
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"tasks": [],
|
||||
"requiredModules": []
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"schemaVersion": "stellaops.workflow.definition/v1",
|
||||
"workflowName": "compliance-sweep",
|
||||
"workflowVersion": "1.0.0",
|
||||
"displayName": "Compliance Sweep",
|
||||
"startRequest": {
|
||||
"contractName": "StellaOps.Policy.Contracts.ComplianceSweepRequest",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"policySetId": { "type": "string" },
|
||||
"scope": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"allowAdditionalProperties": true
|
||||
},
|
||||
"workflowRoles": [],
|
||||
"start": {
|
||||
"initializeStateExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policySetId", "expression": { "$type": "path", "path": "start.policySetId" } },
|
||||
{ "name": "scope", "expression": { "$type": "path", "path": "start.scope" } },
|
||||
{ "name": "status", "expression": { "$type": "string", "value": "sweeping" } }
|
||||
]
|
||||
},
|
||||
"initialSequence": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "evaluate-compliance",
|
||||
"invocation": {
|
||||
"address": {
|
||||
"$type": "microservice",
|
||||
"microserviceName": "policy-engine",
|
||||
"command": "evaluate-compliance-sweep"
|
||||
},
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policySetId", "expression": { "$type": "path", "path": "state.policySetId" } },
|
||||
{ "name": "scope", "expression": { "$type": "path", "path": "state.scope" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"resultKey": "complianceResult"
|
||||
},
|
||||
{
|
||||
"$type": "set-state",
|
||||
"stateKey": "status",
|
||||
"valueExpression": { "$type": "string", "value": "completed" }
|
||||
},
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"tasks": [],
|
||||
"requiredModules": []
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"schemaVersion": "stellaops.workflow.definition/v1",
|
||||
"workflowName": "release-promotion",
|
||||
"workflowVersion": "1.0.0",
|
||||
"displayName": "Release Promotion",
|
||||
"startRequest": {
|
||||
"contractName": "StellaOps.ReleaseOrchestrator.Contracts.ReleasePromotionRequest",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"releaseId": { "type": "string" },
|
||||
"targetEnvironment": { "type": "string" },
|
||||
"requestedBy": { "type": "string" }
|
||||
},
|
||||
"required": ["releaseId", "targetEnvironment", "requestedBy"]
|
||||
},
|
||||
"allowAdditionalProperties": true
|
||||
},
|
||||
"workflowRoles": ["release-approver", "release-operator"],
|
||||
"businessReference": {
|
||||
"keyExpression": { "$type": "path", "path": "start.releaseId" },
|
||||
"parts": [
|
||||
{ "name": "releaseId", "expression": { "$type": "path", "path": "start.releaseId" } },
|
||||
{ "name": "environment", "expression": { "$type": "path", "path": "start.targetEnvironment" } }
|
||||
]
|
||||
},
|
||||
"start": {
|
||||
"initializeStateExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "releaseId", "expression": { "$type": "path", "path": "start.releaseId" } },
|
||||
{ "name": "targetEnvironment", "expression": { "$type": "path", "path": "start.targetEnvironment" } },
|
||||
{ "name": "requestedBy", "expression": { "$type": "path", "path": "start.requestedBy" } },
|
||||
{ "name": "outcome", "expression": { "$type": "string", "value": "pending" } }
|
||||
]
|
||||
},
|
||||
"initialSequence": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "set-state",
|
||||
"stateKey": "outcome",
|
||||
"valueExpression": { "$type": "string", "value": "evaluating-gates" }
|
||||
},
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "evaluate-security-gates",
|
||||
"invocation": {
|
||||
"address": {
|
||||
"$type": "microservice",
|
||||
"microserviceName": "policy-engine",
|
||||
"command": "evaluate-release-gates"
|
||||
},
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "releaseId", "expression": { "$type": "path", "path": "state.releaseId" } },
|
||||
{ "name": "environment", "expression": { "$type": "path", "path": "state.targetEnvironment" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"resultKey": "gateResult"
|
||||
},
|
||||
{
|
||||
"$type": "decision",
|
||||
"decisionName": "gates-passed-check",
|
||||
"conditionExpression": {
|
||||
"$type": "binary",
|
||||
"operator": "==",
|
||||
"left": { "$type": "path", "path": "state.gateResult.passed" },
|
||||
"right": { "$type": "boolean", "value": true }
|
||||
},
|
||||
"whenTrue": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "set-state",
|
||||
"stateKey": "outcome",
|
||||
"valueExpression": { "$type": "string", "value": "awaiting-approval" }
|
||||
},
|
||||
{
|
||||
"$type": "activate-task",
|
||||
"taskName": "Approve Release Promotion"
|
||||
}
|
||||
]
|
||||
},
|
||||
"whenElse": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "set-state",
|
||||
"stateKey": "outcome",
|
||||
"valueExpression": { "$type": "string", "value": "gates-failed" }
|
||||
},
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"taskName": "Approve Release Promotion",
|
||||
"taskType": "approval",
|
||||
"routeExpression": { "$type": "string", "value": "release-approval" },
|
||||
"payloadExpression": { "$type": "path", "path": "state" },
|
||||
"taskRoles": ["release-approver"],
|
||||
"onComplete": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "decision",
|
||||
"decisionName": "approval-decision",
|
||||
"conditionExpression": {
|
||||
"$type": "binary",
|
||||
"operator": "==",
|
||||
"left": { "$type": "path", "path": "task.result.decision" },
|
||||
"right": { "$type": "string", "value": "approved" }
|
||||
},
|
||||
"whenTrue": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "set-state",
|
||||
"stateKey": "outcome",
|
||||
"valueExpression": { "$type": "string", "value": "deploying" }
|
||||
},
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "execute-deployment",
|
||||
"invocation": {
|
||||
"address": {
|
||||
"$type": "microservice",
|
||||
"microserviceName": "release-orchestrator",
|
||||
"command": "execute-deployment"
|
||||
},
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "releaseId", "expression": { "$type": "path", "path": "state.releaseId" } },
|
||||
{ "name": "environment", "expression": { "$type": "path", "path": "state.targetEnvironment" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"resultKey": "deploymentResult"
|
||||
},
|
||||
{
|
||||
"$type": "set-state",
|
||||
"stateKey": "outcome",
|
||||
"valueExpression": { "$type": "string", "value": "completed" }
|
||||
},
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
},
|
||||
"whenElse": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "set-state",
|
||||
"stateKey": "outcome",
|
||||
"valueExpression": { "$type": "string", "value": "rejected" }
|
||||
},
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"requiredModules": []
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"schemaVersion": "stellaops.workflow.definition/v1",
|
||||
"workflowName": "scan-execution",
|
||||
"workflowVersion": "1.0.0",
|
||||
"displayName": "Scan Execution",
|
||||
"startRequest": {
|
||||
"contractName": "StellaOps.Scanner.Contracts.ScanExecutionRequest",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scanType": { "type": "string" },
|
||||
"targets": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"required": ["scanType"]
|
||||
},
|
||||
"allowAdditionalProperties": true
|
||||
},
|
||||
"workflowRoles": ["scanner-operator"],
|
||||
"businessReference": {
|
||||
"keyExpression": { "$type": "path", "path": "instance.id" },
|
||||
"parts": [
|
||||
{ "name": "scanType", "expression": { "$type": "path", "path": "start.scanType" } }
|
||||
]
|
||||
},
|
||||
"start": {
|
||||
"initializeStateExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "scanType", "expression": { "$type": "path", "path": "start.scanType" } },
|
||||
{ "name": "targets", "expression": { "$type": "path", "path": "start.targets" } },
|
||||
{ "name": "status", "expression": { "$type": "string", "value": "queued" } }
|
||||
]
|
||||
},
|
||||
"initialSequence": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "set-state",
|
||||
"stateKey": "status",
|
||||
"valueExpression": { "$type": "string", "value": "scanning" }
|
||||
},
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "run-scan",
|
||||
"invocation": {
|
||||
"address": {
|
||||
"$type": "microservice",
|
||||
"microserviceName": "scanner",
|
||||
"command": "execute-scan"
|
||||
},
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "scanType", "expression": { "$type": "path", "path": "state.scanType" } },
|
||||
{ "name": "targets", "expression": { "$type": "path", "path": "state.targets" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"resultKey": "scanResult"
|
||||
},
|
||||
{
|
||||
"$type": "set-state",
|
||||
"stateKey": "status",
|
||||
"valueExpression": { "$type": "string", "value": "completed" }
|
||||
},
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"tasks": [],
|
||||
"requiredModules": []
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using StellaOps.Workflow.DataStore.MongoDB;
|
||||
using StellaOps.Workflow.DataStore.PostgreSQL;
|
||||
using StellaOps.Workflow.Engine.Authorization;
|
||||
using StellaOps.Workflow.Engine.Services;
|
||||
using StellaOps.Workflow.Signaling.Redis;
|
||||
using StellaOps.Workflow.WebService.Endpoints;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.Workflow.WebService.Bootstrap;
|
||||
using System.Reflection;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -15,21 +17,30 @@ builder.Services.AddWorkflowEngineHostedServices();
|
||||
// Authorization service (required by WorkflowRuntimeService)
|
||||
builder.Services.AddScoped<WorkflowTaskAuthorizationService>();
|
||||
|
||||
// MongoDB data store (projection store, runtime state, signals, dead letters, etc.)
|
||||
builder.Services.AddWorkflowMongoDataStore(builder.Configuration);
|
||||
// PostgreSQL data store (projection store, runtime state, signals, dead letters, etc.)
|
||||
builder.Services.AddWorkflowPostgresDataStore(builder.Configuration);
|
||||
|
||||
// Redis signaling driver (wake notifications across instances)
|
||||
builder.Services.AddWorkflowRedisSignaling(builder.Configuration);
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "workflow",
|
||||
version: Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
// Rendering layout engines can be registered here when available:
|
||||
// builder.Services.AddWorkflowElkSharpRenderer();
|
||||
// builder.Services.AddWorkflowSvgRenderer();
|
||||
// Deploy built-in workflow definitions on startup
|
||||
builder.Services.AddHostedService<WorkflowDefinitionBootstrap>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
// Map all workflow API endpoints under /api/workflow
|
||||
app.MapWorkflowEndpoints();
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
public partial class Program { }
|
||||
|
||||
@@ -9,15 +9,21 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Definitions\*.json" />
|
||||
<!-- SDK auto-includes Content items; rely on EmbeddedResource for definitions -->
|
||||
<Content Update="Definitions\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Workflow.Engine/StellaOps.Workflow.Engine.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Workflow.Abstractions/StellaOps.Workflow.Abstractions.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Workflow.Contracts/StellaOps.Workflow.Contracts.csproj" />
|
||||
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Workflow.DataStore.Oracle/StellaOps.Workflow.DataStore.Oracle.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Workflow.DataStore.MongoDB/StellaOps.Workflow.DataStore.MongoDB.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/StellaOps.Workflow.DataStore.PostgreSQL.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Workflow.Signaling.Redis/StellaOps.Workflow.Signaling.Redis.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Workflow.Renderer.Svg/StellaOps.Workflow.Renderer.Svg.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Workflow.Renderer.ElkSharp/StellaOps.Workflow.Renderer.ElkSharp.csproj" />
|
||||
|
||||
@@ -18,16 +18,16 @@
|
||||
]
|
||||
},
|
||||
"WorkflowBackend": {
|
||||
"Provider": "Mongo",
|
||||
"Mongo": {
|
||||
"ConnectionStringName": "WorkflowMongo",
|
||||
"DatabaseName": "stellaops_workflow"
|
||||
"Provider": "Postgres",
|
||||
"Postgres": {
|
||||
"ConnectionStringName": "WorkflowPostgres",
|
||||
"SchemaName": "workflow"
|
||||
}
|
||||
},
|
||||
"WorkflowSignalDriver": {
|
||||
"Provider": "Native"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"WorkflowMongo": "mongodb://localhost:27017"
|
||||
"WorkflowPostgres": "Host=db.stella-ops.local;Port=5432;Database=stellaops_platform;Username=stellaops;Password=stellaops;Maximum Pool Size=20;Minimum Pool Size=2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ public static class PostgresWorkflowDataStoreExtensions
|
||||
services.Replace(ServiceDescriptor.Scoped<IWorkflowProjectionRetentionStore, PostgresWorkflowProjectionRetentionStore>());
|
||||
services.Replace(ServiceDescriptor.Scoped<IWorkflowMutationCoordinator, PostgresWorkflowMutationCoordinator>());
|
||||
services.Replace(ServiceDescriptor.Scoped<IWorkflowProjectionStore, PostgresWorkflowProjectionStore>());
|
||||
services.Replace(ServiceDescriptor.Scoped<IWorkflowDefinitionStore, PostgresWorkflowDefinitionStore>());
|
||||
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalStore>(sp => sp.GetRequiredService<PostgresWorkflowSignalStore>()));
|
||||
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalClaimStore>(sp => sp.GetRequiredService<PostgresWorkflowSignalStore>()));
|
||||
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalScheduler>(sp => sp.GetRequiredService<PostgresWorkflowScheduleBus>()));
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
|
||||
namespace StellaOps.Workflow.DataStore.PostgreSQL;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed implementation of <see cref="IWorkflowDefinitionStore"/>.
|
||||
/// Stores versioned canonical workflow definitions in the <c>wf_definitions</c> table.
|
||||
/// </summary>
|
||||
public sealed class PostgresWorkflowDefinitionStore(
|
||||
PostgresWorkflowDatabase db) : IWorkflowDefinitionStore
|
||||
{
|
||||
public async Task<WorkflowDefinitionRecord?> GetActiveAsync(
|
||||
string workflowName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var scope = await db.OpenScopeAsync(requireTransaction: false, cancellationToken);
|
||||
var table = db.Qualify("wf_definitions");
|
||||
|
||||
await using var cmd = db.CreateCommand(scope.Connection,
|
||||
$"SELECT * FROM {table} WHERE workflow_name = @name AND is_active = true LIMIT 1");
|
||||
cmd.Parameters.AddWithValue("name", workflowName);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
return await reader.ReadAsync(cancellationToken) ? ReadRecord(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<WorkflowDefinitionRecord?> GetAsync(
|
||||
string workflowName, string version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var scope = await db.OpenScopeAsync(requireTransaction: false, cancellationToken);
|
||||
var table = db.Qualify("wf_definitions");
|
||||
|
||||
await using var cmd = db.CreateCommand(scope.Connection,
|
||||
$"SELECT * FROM {table} WHERE workflow_name = @name AND workflow_version = @ver LIMIT 1");
|
||||
cmd.Parameters.AddWithValue("name", workflowName);
|
||||
cmd.Parameters.AddWithValue("ver", version);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
return await reader.ReadAsync(cancellationToken) ? ReadRecord(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<WorkflowDefinitionRecord>> GetVersionsAsync(
|
||||
string workflowName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var scope = await db.OpenScopeAsync(requireTransaction: false, cancellationToken);
|
||||
var table = db.Qualify("wf_definitions");
|
||||
|
||||
await using var cmd = db.CreateCommand(scope.Connection,
|
||||
$"SELECT * FROM {table} WHERE workflow_name = @name ORDER BY created_on_utc DESC");
|
||||
cmd.Parameters.AddWithValue("name", workflowName);
|
||||
|
||||
var results = new List<WorkflowDefinitionRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
results.Add(ReadRecord(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<WorkflowDefinitionRecord>> GetAllActiveAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var scope = await db.OpenScopeAsync(requireTransaction: false, cancellationToken);
|
||||
var table = db.Qualify("wf_definitions");
|
||||
|
||||
await using var cmd = db.CreateCommand(scope.Connection,
|
||||
$"SELECT * FROM {table} WHERE is_active = true ORDER BY workflow_name");
|
||||
|
||||
var results = new List<WorkflowDefinitionRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
results.Add(ReadRecord(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(
|
||||
WorkflowDefinitionRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var scope = await db.OpenScopeAsync(requireTransaction: true, cancellationToken);
|
||||
var table = db.Qualify("wf_definitions");
|
||||
|
||||
await using var cmd = db.CreateCommand(scope.Connection,
|
||||
$@"INSERT INTO {table}
|
||||
(workflow_name, workflow_version, base_version, build_iteration,
|
||||
content_hash, canonical_definition_json, display_name, is_active,
|
||||
created_on_utc, activated_on_utc, imported_by)
|
||||
VALUES
|
||||
(@name, @ver, @base_ver, @build_iter,
|
||||
@hash, @json, @display, @active,
|
||||
@created, @activated, @imported_by)
|
||||
ON CONFLICT (workflow_name, workflow_version) DO UPDATE SET
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
canonical_definition_json = EXCLUDED.canonical_definition_json,
|
||||
display_name = EXCLUDED.display_name,
|
||||
is_active = EXCLUDED.is_active,
|
||||
activated_on_utc = EXCLUDED.activated_on_utc,
|
||||
imported_by = EXCLUDED.imported_by",
|
||||
scope.Transaction);
|
||||
|
||||
cmd.Parameters.AddWithValue("name", record.WorkflowName);
|
||||
cmd.Parameters.AddWithValue("ver", record.WorkflowVersion);
|
||||
cmd.Parameters.AddWithValue("base_ver", record.BaseVersion);
|
||||
cmd.Parameters.AddWithValue("build_iter", record.BuildIteration);
|
||||
cmd.Parameters.AddWithValue("hash", record.ContentHash);
|
||||
cmd.Parameters.AddWithValue("json", record.CanonicalDefinitionJson);
|
||||
cmd.Parameters.AddWithValue("display", (object?)record.DisplayName ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("active", record.IsActive);
|
||||
cmd.Parameters.AddWithValue("created", record.CreatedOnUtc == default ? DateTime.UtcNow : record.CreatedOnUtc);
|
||||
cmd.Parameters.AddWithValue("activated", (object?)record.ActivatedOnUtc ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("imported_by", (object?)record.ImportedBy ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
await scope.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ActivateAsync(
|
||||
string workflowName, string version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var scope = await db.OpenScopeAsync(requireTransaction: true, cancellationToken);
|
||||
var table = db.Qualify("wf_definitions");
|
||||
|
||||
// Deactivate all versions
|
||||
await using var deactivate = db.CreateCommand(scope.Connection,
|
||||
$"UPDATE {table} SET is_active = false WHERE workflow_name = @name",
|
||||
scope.Transaction);
|
||||
deactivate.Parameters.AddWithValue("name", workflowName);
|
||||
await deactivate.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
// Activate requested version
|
||||
await using var activate = db.CreateCommand(scope.Connection,
|
||||
$"UPDATE {table} SET is_active = true, activated_on_utc = @now WHERE workflow_name = @name AND workflow_version = @ver",
|
||||
scope.Transaction);
|
||||
activate.Parameters.AddWithValue("name", workflowName);
|
||||
activate.Parameters.AddWithValue("ver", version);
|
||||
activate.Parameters.AddWithValue("now", DateTime.UtcNow);
|
||||
await activate.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
await scope.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<WorkflowDefinitionRecord?> FindByHashAsync(
|
||||
string workflowName, string contentHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var scope = await db.OpenScopeAsync(requireTransaction: false, cancellationToken);
|
||||
var table = db.Qualify("wf_definitions");
|
||||
|
||||
await using var cmd = db.CreateCommand(scope.Connection,
|
||||
$"SELECT * FROM {table} WHERE workflow_name = @name AND content_hash = @hash LIMIT 1");
|
||||
cmd.Parameters.AddWithValue("name", workflowName);
|
||||
cmd.Parameters.AddWithValue("hash", contentHash);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
return await reader.ReadAsync(cancellationToken) ? ReadRecord(reader) : null;
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionRecord ReadRecord(Npgsql.NpgsqlDataReader reader)
|
||||
{
|
||||
return new WorkflowDefinitionRecord
|
||||
{
|
||||
WorkflowName = reader.GetString(reader.GetOrdinal("workflow_name")),
|
||||
WorkflowVersion = reader.GetString(reader.GetOrdinal("workflow_version")),
|
||||
BaseVersion = reader.GetString(reader.GetOrdinal("base_version")),
|
||||
BuildIteration = reader.GetInt32(reader.GetOrdinal("build_iteration")),
|
||||
ContentHash = reader.GetString(reader.GetOrdinal("content_hash")),
|
||||
CanonicalDefinitionJson = reader.GetString(reader.GetOrdinal("canonical_definition_json")),
|
||||
DisplayName = reader.IsDBNull(reader.GetOrdinal("display_name")) ? null : reader.GetString(reader.GetOrdinal("display_name")),
|
||||
IsActive = reader.GetBoolean(reader.GetOrdinal("is_active")),
|
||||
CreatedOnUtc = reader.GetDateTime(reader.GetOrdinal("created_on_utc")),
|
||||
ActivatedOnUtc = reader.IsDBNull(reader.GetOrdinal("activated_on_utc")) ? null : reader.GetDateTime(reader.GetOrdinal("activated_on_utc")),
|
||||
ImportedBy = reader.IsDBNull(reader.GetOrdinal("imported_by")) ? null : reader.GetString(reader.GetOrdinal("imported_by")),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user