diff --git a/AGENTS.md b/AGENTS.md index 9cab0c048..aef8d918d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ Authoritative module design lives under: - Authority (OAuth/OIDC): `src/Authority/` (includes IssuerDirectory) - Policy: `src/Policy/` - Evidence: `src/EvidenceLocker/`, `src/Attestor/` (includes Signer, Provenance) -- Scheduling/execution: `src/JobEngine/` (includes Scheduler, TaskRunner, PacksRegistry) +- Scheduling/execution: `src/JobEngine/` (includes Scheduler, PacksRegistry) - Integrations: `src/Integrations/` (includes Extensions) - UI: `src/Web/` - Feeds/VEX: `src/Concelier/` (includes Feedser, Excititor), `src/VexLens/`, `src/VexHub/` diff --git a/devops/compose/docker-compose.stella-ops.yml b/devops/compose/docker-compose.stella-ops.yml index f04593c45..0b4354a19 100644 --- a/devops/compose/docker-compose.stella-ops.yml +++ b/devops/compose/docker-compose.stella-ops.yml @@ -162,7 +162,7 @@ volumes: advisory-ai-plans: advisory-ai-outputs: evidence-data: - taskrunner-artifacts-data: + services: # =========================================================================== @@ -435,7 +435,7 @@ services: STELLAOPS_RISKENGINE_URL: "http://riskengine.stella-ops.local" # STELLAOPS_JOBENGINE_URL removed: WebService retired; audit/first-signal now served by release-orchestrator STELLAOPS_RELEASE_ORCHESTRATOR_URL: "http://release-orchestrator.stella-ops.local" - STELLAOPS_TASKRUNNER_URL: "http://taskrunner.stella-ops.local" + # STELLAOPS_TASKRUNNER_URL removed: TaskRunner service deleted STELLAOPS_SCHEDULER_URL: "http://scheduler.stella-ops.local" STELLAOPS_GRAPH_URL: "http://graph.stella-ops.local" STELLAOPS_CARTOGRAPHER_URL: "http://cartographer.stella-ops.local" @@ -1194,70 +1194,8 @@ services: # Workflow orchestration → workflow service (Slot 46) # Scheduler remains in Slot 14 (scheduler-web / scheduler-worker) - # --- Slot 18: TaskRunner --------------------------------------------------- - taskrunner-web: - <<: *resources-light - image: stellaops/taskrunner-web:dev - container_name: stellaops-taskrunner-web - restart: unless-stopped - depends_on: *depends-infra - environment: - ASPNETCORE_URLS: "http://+:8080" - <<: [*kestrel-cert, *router-microservice-defaults, *gc-light] - ConnectionStrings__Default: *postgres-connection - ConnectionStrings__Redis: "cache.stella-ops.local:6379" - TASKRUNNER__STORAGE__DRIVER: "postgres" - TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING: *postgres-connection - TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER: "seed-fs" - TASKRUNNER__STORAGE__OBJECTSTORE__SEEDFS__ROOTPATH: "/app/artifacts" - Router__Enabled: "${TASKRUNNER_ROUTER_ENABLED:-true}" - Router__Messaging__ConsumerGroup: "taskrunner" - volumes: - - *cert-volume - - taskrunner-artifacts-data:/app/artifacts - ports: - - "127.1.0.18:80:80" - networks: - stellaops: - aliases: - - taskrunner.stella-ops.local - frontdoor: {} - healthcheck: - test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/80'"] - <<: *healthcheck-tcp - labels: *release-labels - - taskrunner-worker: - <<: *resources-light - image: stellaops/taskrunner-worker:dev - container_name: stellaops-taskrunner-worker - restart: unless-stopped - depends_on: *depends-infra - environment: - <<: [*kestrel-cert, *gc-light] - ConnectionStrings__Default: *postgres-connection - ConnectionStrings__Redis: "cache.stella-ops.local:6379" - TASKRUNNER__STORAGE__DRIVER: "postgres" - TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING: *postgres-connection - TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER: "seed-fs" - TASKRUNNER__STORAGE__OBJECTSTORE__SEEDFS__ROOTPATH: "/app/artifacts" - # AirGap egress policy (disable for dev) - AirGap__Egress__Enabled: "false" - volumes: - - *cert-volume - tmpfs: - - /app/artifacts:mode=1777 - - /app/queue:mode=1777 - - /app/state:mode=1777 - - /app/approvals:mode=1777 - - /app/logs:mode=1777 - networks: - stellaops: - aliases: - - taskrunner-worker.stella-ops.local - healthcheck: - <<: *healthcheck-worker - labels: *release-labels + # --- Slot 18: TaskRunner (REMOVED) ------------------------------------------ + # taskrunner-web and taskrunner-worker deleted; task_runner_id DB columns left as nullable legacy # --- Slot 19: Scheduler ---------------------------------------------------- scheduler-web: diff --git a/devops/compose/docker-compose.stella-services.yml b/devops/compose/docker-compose.stella-services.yml index 7dddec15c..a54114e8f 100644 --- a/devops/compose/docker-compose.stella-services.yml +++ b/devops/compose/docker-compose.stella-services.yml @@ -141,7 +141,7 @@ volumes: advisory-ai-plans: advisory-ai-outputs: evidence-data: - taskrunner-artifacts-data: + services: # =========================================================================== @@ -269,7 +269,7 @@ services: STELLAOPS_RISKENGINE_URL: "http://riskengine.stella-ops.local" # STELLAOPS_JOBENGINE_URL removed: WebService retired; audit/first-signal now served by release-orchestrator STELLAOPS_RELEASE_ORCHESTRATOR_URL: "http://release-orchestrator.stella-ops.local" - STELLAOPS_TASKRUNNER_URL: "http://taskrunner.stella-ops.local" + # STELLAOPS_TASKRUNNER_URL removed: TaskRunner service deleted STELLAOPS_SCHEDULER_URL: "http://scheduler.stella-ops.local" STELLAOPS_GRAPH_URL: "http://graph.stella-ops.local" STELLAOPS_CARTOGRAPHER_URL: "http://cartographer.stella-ops.local" @@ -985,68 +985,8 @@ services: # Workflow orchestration -> workflow service (Slot 46) # Scheduler remains in Slot 14 (scheduler-web / scheduler-worker) - # --- Slot 18: TaskRunner --------------------------------------------------- - taskrunner-web: - <<: *resources-light - image: stellaops/taskrunner-web:dev - container_name: stellaops-taskrunner-web - restart: unless-stopped - environment: - ASPNETCORE_URLS: "http://+:8080" - <<: [*kestrel-cert, *router-microservice-defaults, *gc-light] - ConnectionStrings__Default: "${STELLAOPS_POSTGRES_CONNECTION}" - ConnectionStrings__Redis: "cache.stella-ops.local:6379" - TASKRUNNER__STORAGE__DRIVER: "postgres" - TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING: "${STELLAOPS_POSTGRES_CONNECTION}" - TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER: "seed-fs" - TASKRUNNER__STORAGE__OBJECTSTORE__SEEDFS__ROOTPATH: "/app/artifacts" - Router__Enabled: "${TASKRUNNER_ROUTER_ENABLED:-true}" - Router__Messaging__ConsumerGroup: "taskrunner" - volumes: - - ${STELLAOPS_CERT_VOLUME} - - taskrunner-artifacts-data:/app/artifacts - ports: - - "127.1.0.18:80:80" - networks: - stellaops: - aliases: - - taskrunner.stella-ops.local - frontdoor: {} - healthcheck: - test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/80'"] - <<: *healthcheck-tcp - labels: *release-labels - - taskrunner-worker: - <<: *resources-light - image: stellaops/taskrunner-worker:dev - container_name: stellaops-taskrunner-worker - restart: unless-stopped - environment: - <<: [*kestrel-cert, *gc-light] - ConnectionStrings__Default: "${STELLAOPS_POSTGRES_CONNECTION}" - ConnectionStrings__Redis: "cache.stella-ops.local:6379" - TASKRUNNER__STORAGE__DRIVER: "postgres" - TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING: "${STELLAOPS_POSTGRES_CONNECTION}" - TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER: "seed-fs" - TASKRUNNER__STORAGE__OBJECTSTORE__SEEDFS__ROOTPATH: "/app/artifacts" - # AirGap egress policy (disable for dev) - AirGap__Egress__Enabled: "false" - volumes: - - ${STELLAOPS_CERT_VOLUME} - tmpfs: - - /app/artifacts:mode=1777 - - /app/queue:mode=1777 - - /app/state:mode=1777 - - /app/approvals:mode=1777 - - /app/logs:mode=1777 - networks: - stellaops: - aliases: - - taskrunner-worker.stella-ops.local - healthcheck: - <<: *healthcheck-worker - labels: *release-labels + # --- Slot 18: TaskRunner (REMOVED) ------------------------------------------ + # taskrunner-web and taskrunner-worker deleted; task_runner_id DB columns left as nullable legacy # --- Slot 19: Scheduler ---------------------------------------------------- scheduler-web: diff --git a/devops/compose/envsettings-override.json b/devops/compose/envsettings-override.json index 80dee5e10..4d7e6b0e8 100644 --- a/devops/compose/envsettings-override.json +++ b/devops/compose/envsettings-override.json @@ -21,7 +21,7 @@ "airgapController": "https://stella-ops.local", "gateway": "https://stella-ops.local", "doctor": "https://stella-ops.local", - "taskrunner": "https://stella-ops.local", + "timelineindexer": "https://stella-ops.local", "timeline": "https://stella-ops.local", "packsregistry": "https://stella-ops.local", diff --git a/devops/compose/hosts.stellaops.local b/devops/compose/hosts.stellaops.local index af935230f..22f649fe8 100644 --- a/devops/compose/hosts.stellaops.local +++ b/devops/compose/hosts.stellaops.local @@ -24,7 +24,7 @@ 127.1.0.14 policy-gateway.stella-ops.local # backwards-compat alias (merged into policy-engine) 127.1.0.16 riskengine.stella-ops.local 127.1.0.17 orchestrator.stella-ops.local -127.1.0.18 taskrunner.stella-ops.local +# 127.1.0.18 taskrunner.stella-ops.local # REMOVED: TaskRunner service deleted 127.1.0.19 scheduler.stella-ops.local 127.1.0.20 graph.stella-ops.local 127.1.0.21 cartographer.stella-ops.local diff --git a/devops/compose/openapi_routeprefix_smoke_microservice.csv b/devops/compose/openapi_routeprefix_smoke_microservice.csv index b7e1a336c..a70bbcc96 100644 --- a/devops/compose/openapi_routeprefix_smoke_microservice.csv +++ b/devops/compose/openapi_routeprefix_smoke_microservice.csv @@ -89,7 +89,7 @@ "Microservice","/vexhub","https://vexhub.stella-ops.local","/vexhub/api/v1/vex/index","200" "Microservice","/vexlens","http://vexlens.stella-ops.local","/vexlens/api/v1/vexlens/stats","200" "Microservice","/orchestrator","http://orchestrator.stella-ops.local","/orchestrator/scale/load","200" -"Microservice","/taskrunner","http://taskrunner.stella-ops.local","/taskrunner","302" + "Microservice","/cartographer","http://cartographer.stella-ops.local",, "Microservice","/reachgraph","http://reachgraph.stella-ops.local","/reachgraph/v1/cve-mappings/stats","400" "Microservice","/doctor","http://doctor.stella-ops.local","/doctor/api/v1/doctor/checks","401" diff --git a/devops/compose/openapi_routeprefix_smoke_reverseproxy.csv b/devops/compose/openapi_routeprefix_smoke_reverseproxy.csv index a7d1a84f5..fdcc7aae8 100644 --- a/devops/compose/openapi_routeprefix_smoke_reverseproxy.csv +++ b/devops/compose/openapi_routeprefix_smoke_reverseproxy.csv @@ -92,7 +92,7 @@ "ReverseProxy","/vexhub","https://vexhub.stella-ops.local",, "ReverseProxy","/vexlens","http://vexlens.stella-ops.local",, "ReverseProxy","/orchestrator","http://orchestrator.stella-ops.local",, -"ReverseProxy","/taskrunner","http://taskrunner.stella-ops.local",, + "ReverseProxy","/cartographer","http://cartographer.stella-ops.local",, "ReverseProxy","/reachgraph","http://reachgraph.stella-ops.local",, "ReverseProxy","/doctor","http://doctor.stella-ops.local",, diff --git a/devops/docker/Dockerfile.console b/devops/docker/Dockerfile.console index 31bdd1913..7a532e046 100644 --- a/devops/docker/Dockerfile.console +++ b/devops/docker/Dockerfile.console @@ -332,7 +332,7 @@ server { sub_filter '"http://airgap-controller.stella-ops.local"' '"/airgap"'; sub_filter '"http://integrations.stella-ops.local"' '"/integrations"'; sub_filter '"http://smremote.stella-ops.local"' '"/smremote"'; - sub_filter '"http://taskrunner.stella-ops.local"' '"/taskrunner"'; + sub_filter '"http://sbomservice.stella-ops.local"' '"/sbomservice"'; sub_filter '"http://timelineindexer.stella-ops.local"' '"/timelineindexer"'; sub_filter '"http://issuerdirectory.stella-ops.local"' '"/issuerdirectory"'; diff --git a/devops/docker/console-nginx-override.conf b/devops/docker/console-nginx-override.conf index 602ffc590..ce0796830 100644 --- a/devops/docker/console-nginx-override.conf +++ b/devops/docker/console-nginx-override.conf @@ -55,7 +55,7 @@ server { sub_filter '"http://airgap-controller.stella-ops.local"' '"/airgap"'; sub_filter '"http://integrations.stella-ops.local"' '"/integrations"'; sub_filter '"http://smremote.stella-ops.local"' '"/smremote"'; - sub_filter '"http://taskrunner.stella-ops.local"' '"/taskrunner"'; + sub_filter '"http://sbomservice.stella-ops.local"' '"/sbomservice"'; sub_filter '"http://timelineindexer.stella-ops.local"' '"/timelineindexer"'; sub_filter '"http://issuerdirectory.stella-ops.local"' '"/issuerdirectory"'; @@ -261,12 +261,6 @@ server { proxy_pass $upstream; } - location /taskrunner/ { - set $upstream http://taskrunner.stella-ops.local; - rewrite ^/taskrunner/(.*)$ /$1 break; - proxy_pass $upstream; - } - location /sbomservice/ { set $upstream http://sbomservice.stella-ops.local; rewrite ^/sbomservice/(.*)$ /$1 break; diff --git a/devops/docker/services-matrix.env b/devops/docker/services-matrix.env index 77cdd5069..7b9086733 100644 --- a/devops/docker/services-matrix.env +++ b/devops/docker/services-matrix.env @@ -40,9 +40,7 @@ riskengine-web|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps riskengine-worker|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps.RiskEngine.Worker/StellaOps.RiskEngine.Worker.csproj|StellaOps.RiskEngine.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 +# ── Slot 18: TaskRunner (REMOVED) ─────────────────────────────────────────────── # ── Slot 19: Scheduler ────────────────────────────────────────────────────────── scheduler-web|devops/docker/Dockerfile.hardened.template|src/JobEngine/StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.csproj|StellaOps.Scheduler.WebService|8080 scheduler-worker|devops/docker/Dockerfile.hardened.template|src/JobEngine/StellaOps.Scheduler.Worker.Host/StellaOps.Scheduler.Worker.Host.csproj|StellaOps.Scheduler.Worker.Host|8080 diff --git a/docs/api/taskrunner-openapi.yaml b/docs/api/taskrunner-openapi.yaml deleted file mode 100644 index 5f8432a36..000000000 --- a/docs/api/taskrunner-openapi.yaml +++ /dev/null @@ -1,886 +0,0 @@ -# OpenAPI 3.1 specification for StellaOps TaskRunner WebService -openapi: 3.1.0 -info: - title: StellaOps TaskRunner API - version: 0.1.0-draft - description: | - Contract for TaskRunner service covering pack runs, simulations, logs, artifacts, and approvals. - Uses the platform error envelope and tenant header `X-StellaOps-Tenant`. - - ## Streaming Endpoints - The `/runs/{runId}/logs` endpoint returns logs in NDJSON (Newline Delimited JSON) format - for efficient streaming. Each line is a complete JSON object. - - ## Control Flow Steps - TaskPacks support the following step kinds: - - **run**: Execute an action using a builtin or custom executor - - **parallel**: Execute child steps concurrently with optional maxParallel limit - - **map**: Iterate over items and execute a template step for each - - **loop**: Iterate with items expression, range, or static list - - **conditional**: Branch based on condition expressions - - **gate.approval**: Require manual approval before proceeding - - **gate.policy**: Evaluate policy and optionally require override approval -servers: - - url: https://taskrunner.stellaops.example.com - description: Production - - url: https://taskrunner.dev.stellaops.example.com - description: Development -security: - - oauth2: [taskrunner.viewer] - - oauth2: [taskrunner.operator] - - oauth2: [taskrunner.admin] - -paths: - /v1/task-runner/simulations: - post: - summary: Simulate a task pack - description: | - Validates a task pack manifest, creates an execution plan, and simulates the run - without actually executing any steps. Returns the simulation result showing which - steps would execute, which are skipped, and which require approvals. - operationId: simulateTaskPack - tags: [Simulations] - parameters: - - $ref: '#/components/parameters/Tenant' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SimulationRequest' - examples: - basic-simulation: - summary: Basic simulation request - value: - manifest: | - apiVersion: stellaops.io/pack.v1 - kind: TaskPack - metadata: - name: scan-deploy - version: 1.0.0 - spec: - inputs: - - name: target - type: string - required: true - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: scan - run: - uses: builtin:scanner - with: - target: "{{ inputs.target }}" - inputs: - target: "registry.example.com/app:v1.2.3" - responses: - '200': - description: Simulation completed - content: - application/json: - schema: - $ref: '#/components/schemas/SimulationResponse' - examples: - simulation-result: - value: - planHash: "sha256:a1b2c3d4e5f6..." - failurePolicy: - maxAttempts: 1 - backoffSeconds: 0 - continueOnError: false - steps: - - id: scan - templateId: scan - kind: Run - enabled: true - status: Pending - uses: "builtin:scanner" - children: [] - outputs: [] - hasPendingApprovals: false - '400': - description: Invalid manifest or inputs - content: - application/json: - schema: - $ref: '#/components/schemas/PlanErrorResponse' - default: - $ref: '#/components/responses/Error' - - /v1/task-runner/runs: - post: - summary: Create a pack run - description: | - Creates a new pack run from a task pack manifest. The run is scheduled for execution - and will proceed through its steps. If approval gates are present, the run will pause - at those gates until approvals are granted. - operationId: createPackRun - tags: [Runs] - parameters: - - $ref: '#/components/parameters/Tenant' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateRunRequest' - examples: - create-run: - summary: Create a new run - value: - runId: "run-20251206-001" - manifest: | - apiVersion: stellaops.io/pack.v1 - kind: TaskPack - metadata: - name: deploy-app - version: 2.0.0 - spec: - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 200 - memoryLimitMiB: 256 - quotaSeconds: 120 - slo: - runP95Seconds: 600 - approvalP95Seconds: 1800 - maxQueueDepth: 50 - approvals: - - id: security-review - grants: [packs.approve] - steps: - - id: build - run: - uses: builtin:build - - id: approval - gate: - approval: - id: security-review - message: "Security review required before deploy" - - id: deploy - run: - uses: builtin:deploy - tenantId: "tenant-prod" - responses: - '201': - description: Run created - headers: - Location: - description: URL of the created run - schema: - type: string - content: - application/json: - schema: - $ref: '#/components/schemas/RunStateResponse' - '400': - description: Invalid manifest or inputs - content: - application/json: - schema: - $ref: '#/components/schemas/PlanErrorResponse' - '409': - description: Run ID already exists - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorEnvelope' - default: - $ref: '#/components/responses/Error' - - /v1/task-runner/runs/{runId}: - get: - summary: Get run state - description: | - Returns the current state of a pack run, including status of all steps, - failure policy, and timing information. - operationId: getRunState - tags: [Runs] - parameters: - - $ref: '#/components/parameters/Tenant' - - $ref: '#/components/parameters/RunId' - responses: - '200': - description: Run state - content: - application/json: - schema: - $ref: '#/components/schemas/RunStateResponse' - examples: - running: - summary: Run in progress - value: - runId: "run-20251206-001" - planHash: "sha256:a1b2c3d4..." - failurePolicy: - maxAttempts: 2 - backoffSeconds: 30 - continueOnError: false - createdAt: "2025-12-06T10:00:00Z" - updatedAt: "2025-12-06T10:05:00Z" - steps: - - stepId: build - kind: Run - enabled: true - continueOnError: false - status: Succeeded - attempts: 1 - lastTransitionAt: "2025-12-06T10:02:00Z" - - stepId: approval - kind: GateApproval - enabled: true - continueOnError: false - approvalId: security-review - gateMessage: "Security review required before deploy" - status: Pending - attempts: 0 - statusReason: "awaiting-approval" - - stepId: deploy - kind: Run - enabled: true - continueOnError: false - status: Pending - attempts: 0 - '404': - description: Run not found - default: - $ref: '#/components/responses/Error' - - /v1/task-runner/runs/{runId}/logs: - get: - summary: Stream run logs - description: | - Returns run logs as a stream of NDJSON (Newline Delimited JSON) entries. - Each line is a complete JSON object representing a log entry with timestamp, - level, event type, message, and optional metadata. - - **Content-Type**: `application/x-ndjson` - operationId: streamRunLogs - tags: [Logs] - parameters: - - $ref: '#/components/parameters/Tenant' - - $ref: '#/components/parameters/RunId' - responses: - '200': - description: Log stream - content: - application/x-ndjson: - schema: - $ref: '#/components/schemas/RunLogEntry' - examples: - log-stream: - summary: Sample NDJSON log stream - value: | - {"timestamp":"2025-12-06T10:00:00Z","level":"info","eventType":"run.created","message":"Run created via API.","metadata":{"planHash":"sha256:a1b2c3d4...","requestedAt":"2025-12-06T10:00:00Z"}} - {"timestamp":"2025-12-06T10:00:01Z","level":"info","eventType":"step.started","message":"Starting step: build","stepId":"build"} - {"timestamp":"2025-12-06T10:02:00Z","level":"info","eventType":"step.completed","message":"Step completed: build","stepId":"build","metadata":{"duration":"119s"}} - {"timestamp":"2025-12-06T10:02:01Z","level":"warn","eventType":"gate.awaiting","message":"Awaiting approval: security-review","stepId":"approval"} - '404': - description: Run not found - default: - $ref: '#/components/responses/Error' - - /v1/task-runner/runs/{runId}/artifacts: - get: - summary: List run artifacts - description: | - Returns a list of artifacts captured during the run, including file outputs, - evidence bundles, and expression-evaluated results. - operationId: listRunArtifacts - tags: [Artifacts] - parameters: - - $ref: '#/components/parameters/Tenant' - - $ref: '#/components/parameters/RunId' - responses: - '200': - description: Artifact list - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/RunArtifact' - examples: - artifacts: - value: - - name: scan-report - type: file - sourcePath: "/output/scan-report.json" - storedPath: "runs/run-20251206-001/artifacts/scan-report.json" - status: captured - capturedAt: "2025-12-06T10:02:00Z" - - name: evidence-bundle - type: object - status: captured - capturedAt: "2025-12-06T10:02:00Z" - expressionJson: '{"sha256":"abc123...","attestations":[...]}' - '404': - description: Run not found - default: - $ref: '#/components/responses/Error' - - /v1/task-runner/runs/{runId}/approvals/{approvalId}: - post: - summary: Apply approval decision - description: | - Applies an approval decision (approved, rejected, or expired) to a pending - approval gate. The planHash must match to prevent approving a stale plan. - - If approved, the run will resume execution. If rejected, the run will fail - at the gate step. - operationId: applyApprovalDecision - tags: [Approvals] - parameters: - - $ref: '#/components/parameters/Tenant' - - $ref: '#/components/parameters/RunId' - - $ref: '#/components/parameters/ApprovalId' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ApprovalDecisionRequest' - examples: - approve: - summary: Approve the gate - value: - decision: approved - planHash: "sha256:a1b2c3d4e5f678901234567890abcdef1234567890abcdef1234567890abcdef" - actorId: "user:alice@example.com" - summary: "Reviewed and approved for production deployment" - reject: - summary: Reject the gate - value: - decision: rejected - planHash: "sha256:a1b2c3d4e5f678901234567890abcdef1234567890abcdef1234567890abcdef" - actorId: "user:bob@example.com" - summary: "Security scan found critical vulnerabilities" - responses: - '200': - description: Decision applied - content: - application/json: - schema: - $ref: '#/components/schemas/ApprovalDecisionResponse' - examples: - approved: - value: - status: approved - resumed: true - '400': - description: Invalid decision or planHash format - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorEnvelope' - '404': - description: Run or approval not found - '409': - description: Plan hash mismatch - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorEnvelope' - default: - $ref: '#/components/responses/Error' - - /v1/task-runner/runs/{runId}/cancel: - post: - summary: Cancel a run - description: | - Requests cancellation of a run. Remaining pending steps will be marked as - skipped. Steps that have already succeeded or been skipped are not affected. - operationId: cancelRun - tags: [Runs] - parameters: - - $ref: '#/components/parameters/Tenant' - - $ref: '#/components/parameters/RunId' - responses: - '202': - description: Cancellation accepted - headers: - Location: - description: URL of the run - schema: - type: string - content: - application/json: - schema: - type: object - properties: - status: - type: string - enum: [cancelled] - '404': - description: Run not found - default: - $ref: '#/components/responses/Error' - - /.well-known/openapi: - get: - summary: Get OpenAPI metadata - description: | - Returns metadata about the OpenAPI specification including the spec URL, - ETag for caching, and a signature for verification. - operationId: getOpenApiMetadata - tags: [Metadata] - responses: - '200': - description: OpenAPI metadata - headers: - ETag: - description: Spec version ETag - schema: - type: string - X-Signature: - description: Spec signature for verification - schema: - type: string - content: - application/json: - schema: - $ref: '#/components/schemas/OpenApiMetadata' - examples: - metadata: - value: - specUrl: "/openapi" - version: "0.1.0-draft" - buildVersion: "20251206.1" - etag: '"abc123"' - signature: "sha256:def456..." - -components: - securitySchemes: - oauth2: - type: oauth2 - flows: - clientCredentials: - tokenUrl: https://auth.stellaops.example.com/oauth/token - scopes: - taskrunner.viewer: Read-only access to runs and logs - taskrunner.operator: Create runs and apply approvals - taskrunner.admin: Full administrative access - - parameters: - Tenant: - name: X-StellaOps-Tenant - in: header - required: false - description: Tenant slug (optional for single-tenant deployments) - schema: - type: string - RunId: - name: runId - in: path - required: true - description: Unique run identifier - schema: - type: string - pattern: '^[a-zA-Z0-9_-]+$' - ApprovalId: - name: approvalId - in: path - required: true - description: Approval gate identifier (from task pack approvals section) - schema: - type: string - - responses: - Error: - description: Standard error envelope - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorEnvelope' - examples: - internal-error: - value: - error: - code: internal_error - message: "An unexpected error occurred" - traceId: "f62f3c2b9c8e4c53" - - schemas: - ErrorEnvelope: - type: object - required: [error] - properties: - error: - type: object - required: [code, message] - properties: - code: - type: string - description: Machine-readable error code - message: - type: string - description: Human-readable error message - traceId: - type: string - description: Trace ID for debugging - - SimulationRequest: - type: object - required: [manifest] - properties: - manifest: - type: string - description: Task pack manifest in YAML format - inputs: - type: object - additionalProperties: true - description: Input values to provide to the task pack - - SimulationResponse: - type: object - required: [planHash, failurePolicy, steps, outputs, hasPendingApprovals] - properties: - planHash: - type: string - description: SHA-256 hash of the execution plan - pattern: '^sha256:[a-f0-9]{64}$' - failurePolicy: - $ref: '#/components/schemas/FailurePolicy' - steps: - type: array - items: - $ref: '#/components/schemas/SimulationStep' - outputs: - type: array - items: - $ref: '#/components/schemas/SimulationOutput' - hasPendingApprovals: - type: boolean - description: Whether the plan contains approval gates - - SimulationStep: - type: object - required: [id, templateId, kind, enabled, status, children] - properties: - id: - type: string - templateId: - type: string - kind: - type: string - enum: [Run, GateApproval, GatePolicy, Parallel, Map, Loop, Conditional, Unknown] - enabled: - type: boolean - status: - type: string - enum: [Pending, Skipped, RequiresApproval, RequiresPolicy, WillIterate, WillBranch] - statusReason: - type: string - uses: - type: string - description: Executor reference for run steps - approvalId: - type: string - gateMessage: - type: string - maxParallel: - type: integer - continueOnError: - type: boolean - children: - type: array - items: - $ref: '#/components/schemas/SimulationStep' - loopInfo: - $ref: '#/components/schemas/LoopInfo' - conditionalInfo: - $ref: '#/components/schemas/ConditionalInfo' - policyInfo: - $ref: '#/components/schemas/PolicyInfo' - - LoopInfo: - type: object - description: Loop step simulation details - properties: - itemsExpression: - type: string - iterator: - type: string - index: - type: string - maxIterations: - type: integer - aggregationMode: - type: string - enum: [collect, merge, last, first, none] - - ConditionalInfo: - type: object - description: Conditional step simulation details - properties: - branches: - type: array - items: - type: object - properties: - condition: - type: string - stepCount: - type: integer - elseStepCount: - type: integer - outputUnion: - type: boolean - - PolicyInfo: - type: object - description: Policy gate simulation details - properties: - policyId: - type: string - policyVersion: - type: string - failureAction: - type: string - enum: [abort, warn, requestOverride, branch] - retryCount: - type: integer - - SimulationOutput: - type: object - required: [name, type, requiresRuntimeValue] - properties: - name: - type: string - type: - type: string - requiresRuntimeValue: - type: boolean - pathExpression: - type: string - valueExpression: - type: string - - CreateRunRequest: - type: object - required: [manifest] - properties: - runId: - type: string - description: Optional custom run ID (auto-generated if not provided) - manifest: - type: string - description: Task pack manifest in YAML format - inputs: - type: object - additionalProperties: true - description: Input values to provide to the task pack - tenantId: - type: string - description: Tenant identifier - - RunStateResponse: - type: object - required: [runId, planHash, failurePolicy, createdAt, updatedAt, steps] - properties: - runId: - type: string - planHash: - type: string - pattern: '^sha256:[a-f0-9]{64}$' - failurePolicy: - $ref: '#/components/schemas/FailurePolicy' - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - steps: - type: array - items: - $ref: '#/components/schemas/RunStateStep' - - RunStateStep: - type: object - required: [stepId, kind, enabled, continueOnError, status, attempts] - properties: - stepId: - type: string - kind: - type: string - enum: [Run, GateApproval, GatePolicy, Parallel, Map, Loop, Conditional, Unknown] - enabled: - type: boolean - continueOnError: - type: boolean - maxParallel: - type: integer - approvalId: - type: string - gateMessage: - type: string - status: - type: string - enum: [Pending, Running, Succeeded, Failed, Skipped] - attempts: - type: integer - lastTransitionAt: - type: string - format: date-time - nextAttemptAt: - type: string - format: date-time - statusReason: - type: string - - FailurePolicy: - type: object - required: [maxAttempts, backoffSeconds, continueOnError] - properties: - maxAttempts: - type: integer - minimum: 1 - backoffSeconds: - type: integer - minimum: 0 - continueOnError: - type: boolean - - RunLogEntry: - type: object - required: [timestamp, level, eventType, message] - description: | - Log entry returned in NDJSON stream. Each entry is a single JSON object - followed by a newline character. - properties: - timestamp: - type: string - format: date-time - level: - type: string - enum: [debug, info, warn, error] - eventType: - type: string - description: | - Event type identifier, e.g.: - - run.created, run.started, run.completed, run.failed, run.cancelled - - step.started, step.completed, step.failed, step.skipped - - gate.awaiting, gate.approved, gate.rejected - - run.schedule-failed, run.cancel-requested - message: - type: string - stepId: - type: string - metadata: - type: object - additionalProperties: - type: string - - RunArtifact: - type: object - required: [name, type, status] - properties: - name: - type: string - type: - type: string - enum: [file, object] - sourcePath: - type: string - storedPath: - type: string - status: - type: string - enum: [pending, captured, failed] - notes: - type: string - capturedAt: - type: string - format: date-time - expressionJson: - type: string - description: JSON string of evaluated expression result for object outputs - - ApprovalDecisionRequest: - type: object - required: [decision, planHash] - properties: - decision: - type: string - enum: [approved, rejected, expired] - planHash: - type: string - pattern: '^sha256:[a-f0-9]{64}$' - description: Plan hash to verify against (must match current run plan) - actorId: - type: string - description: Identifier of the approver (e.g., user:alice@example.com) - summary: - type: string - description: Optional comment explaining the decision - - ApprovalDecisionResponse: - type: object - required: [status, resumed] - properties: - status: - type: string - enum: [approved, rejected, expired] - resumed: - type: boolean - description: Whether the run was resumed (true for approved decisions) - - PlanErrorResponse: - type: object - required: [errors] - properties: - errors: - type: array - items: - type: object - required: [path, message] - properties: - path: - type: string - description: JSON path to the error location - message: - type: string - - OpenApiMetadata: - type: object - required: [specUrl, version, etag] - properties: - specUrl: - type: string - description: URL to fetch the full OpenAPI spec - version: - type: string - description: API version - buildVersion: - type: string - description: Build version identifier - etag: - type: string - description: ETag for caching - signature: - type: string - description: Signature for spec verification - -tags: - - name: Simulations - description: Task pack simulation without execution - - name: Runs - description: Pack run lifecycle management - - name: Logs - description: Run log streaming - - name: Artifacts - description: Run artifact management - - name: Approvals - description: Approval gate decisions - - name: Metadata - description: Service metadata and discovery diff --git a/docs/features/checked/taskrunner/pack-run-approval-gates.md b/docs/features/checked/taskrunner/pack-run-approval-gates.md deleted file mode 100644 index 32fad3091..000000000 --- a/docs/features/checked/taskrunner/pack-run-approval-gates.md +++ /dev/null @@ -1,29 +0,0 @@ -# Pack Run Approval Gates - -## Module -TaskRunner - -## Status -IMPLEMENTED - -## Description -Approval gate system for task packs with coordinator, decision service, state tracking, and gate state updating. - -## Implementation Details -- **Approval coordinator**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunApprovalCoordinator.cs` -- orchestrates approval gate flow -- **Approval state**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunApprovalState.cs` -- approval state tracking model -- **Approval status**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunApprovalStatus.cs` -- approval status enum -- **Approval store interface**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunApprovalStore.cs` -- approval persistence contract -- **Gate state updater**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunGateStateUpdater.cs` -- updates gate states during execution -- **Decision service**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/PackRunApprovalDecisionService.cs` -- processes approval decisions -- **File-based store**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunApprovalStore.cs` -- file-backed approval persistence -- **Postgres store**: `src/TaskRunner/__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunApprovalStore.cs` -- PostgreSQL approval persistence -- **Tests**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunApprovalCoordinatorTests.cs`, `PackRunApprovalDecisionServiceTests.cs`, `PackRunGateStateUpdaterTests.cs` -- **Source**: Feature matrix scan - -## E2E Test Plan -- [ ] Verify approval gates block execution until approved -- [ ] Test approval coordinator handles multi-approver gates -- [ ] Verify gate state transitions (pending -> approved/rejected) -- [ ] Test approval persistence survives service restart -- [ ] Verify rejected gates prevent pack run continuation diff --git a/docs/features/checked/taskrunner/pack-run-evidence-and-provenance.md b/docs/features/checked/taskrunner/pack-run-evidence-and-provenance.md deleted file mode 100644 index c0443dd29..000000000 --- a/docs/features/checked/taskrunner/pack-run-evidence-and-provenance.md +++ /dev/null @@ -1,32 +0,0 @@ -# Pack Run Evidence and Provenance - -## Module -TaskRunner - -## Status -IMPLEMENTED - -## Description -Evidence capture and provenance writing for pack runs, including attestation service for DSSE-signed provenance records. - -## Implementation Details -- **Attestation service**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Attestation/IPackRunAttestationService.cs` -- DSSE-signed attestation contract -- **Attestation model**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Attestation/PackRunAttestation.cs` -- attestation record for pack runs -- **Evidence snapshot service**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IPackRunEvidenceSnapshotService.cs` -- evidence snapshot capture -- **Evidence snapshot model**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/PackRunEvidenceSnapshot.cs` -- snapshot data model -- **Evidence store**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IPackRunEvidenceStore.cs` -- evidence persistence contract -- **Redaction guard**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IPackRunRedactionGuard.cs` -- sensitive data redaction -- **Bundle import evidence**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/BundleImportEvidence.cs`, `IBundleImportEvidenceService.cs` -- air-gap bundle import evidence -- **Provenance writer interface**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunProvenanceWriter.cs` -- provenance writing contract -- **Provenance manifest factory**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/ProvenanceManifestFactory.cs` -- creates SLSA-compatible provenance manifests -- **Filesystem provenance writer**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunProvenanceWriter.cs` -- **Postgres evidence store**: `src/TaskRunner/__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunEvidenceStore.cs` -- **Tests**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunAttestationTests.cs`, `PackRunEvidenceSnapshotTests.cs`, `PackRunProvenanceWriterTests.cs`, `BundleImportEvidenceTests.cs` -- **Source**: Feature matrix scan - -## E2E Test Plan -- [ ] Verify DSSE-signed attestations are generated per pack run -- [ ] Test evidence snapshot captures all execution artifacts -- [ ] Verify provenance manifest includes SLSA-compatible metadata -- [ ] Test redaction guard strips sensitive data from evidence -- [ ] Verify bundle import evidence records air-gap import provenance diff --git a/docs/features/checked/taskrunner/pack-run-execution-engine.md b/docs/features/checked/taskrunner/pack-run-execution-engine.md deleted file mode 100644 index eb9c069cf..000000000 --- a/docs/features/checked/taskrunner/pack-run-execution-engine.md +++ /dev/null @@ -1,35 +0,0 @@ -# Pack Run Execution Engine - -## Module -TaskRunner - -## Status -IMPLEMENTED - -## Description -Full execution engine with graph-based execution planning, step state machine, and processor for running task packs. - -## Implementation Details -- **Processor**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunProcessor.cs` -- main execution engine processor -- **Processor result**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunProcessorResult.cs` -- execution result model -- **Execution graph**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraph.cs` -- DAG-based execution planning -- **Graph builder**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraphBuilder.cs` -- builds execution graphs from manifests -- **Step state machine**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunStepStateMachine.cs` -- state transitions for individual steps -- **Step executor interface**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunStepExecutor.cs` -- step execution contract -- **Execution context**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionContext.cs` -- runtime context for execution -- **State management**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunState.cs`, `PackRunStateFactory.cs` -- execution state tracking -- **Job dispatcher**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunJobDispatcher.cs`, `IPackRunJobScheduler.cs` -- job scheduling and dispatch -- **Simulation engine**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationEngine.cs`, `PackRunSimulationModels.cs` -- dry-run simulation -- **Telemetry**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/TaskRunnerTelemetry.cs` -- execution metrics -- **Worker service**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Services/PackRunWorkerService.cs` -- background worker -- **Infrastructure**: file-based and no-op step executors, dispatchers, artifact uploaders under `StellaOps.TaskRunner.Infrastructure/Execution/` -- **Postgres state store**: `src/TaskRunner/__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunStateStore.cs`, `PostgresPackRunLogStore.cs` -- **Tests**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunProcessorTests.cs`, `PackRunExecutionGraphBuilderTests.cs`, `PackRunStepStateMachineTests.cs`, `PackRunStateFactoryTests.cs`, `PackRunSimulationEngineTests.cs` -- **Source**: Feature matrix scan - -## E2E Test Plan -- [ ] Verify execution graph correctly orders steps based on dependencies -- [ ] Test step state machine transitions (pending -> running -> completed/failed) -- [ ] Verify processor handles step failures with configured retry/abort behavior -- [ ] Test simulation engine produces accurate dry-run results -- [ ] Verify execution state persists across service restarts diff --git a/docs/features/checked/taskrunner/sealed-mode-install-enforcer.md b/docs/features/checked/taskrunner/sealed-mode-install-enforcer.md deleted file mode 100644 index 51e342c51..000000000 --- a/docs/features/checked/taskrunner/sealed-mode-install-enforcer.md +++ /dev/null @@ -1,30 +0,0 @@ -# Sealed-Mode Install Enforcer (Air-Gap Support) - -## Module -TaskRunner - -## Status -IMPLEMENTED - -## Description -Enforcer for sealed/air-gap mode that ensures task pack installations comply with offline constraints and logs all install actions for audit. - -## Implementation Details -- **Enforcer interface**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/ISealedInstallEnforcer.cs` -- sealed mode enforcement contract -- **Enforcer implementation**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedInstallEnforcer.cs` -- validates installations comply with offline constraints -- **Enforcement result**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedInstallEnforcementResult.cs` -- result model for enforcement checks -- **Sealed mode status**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedModeStatus.cs` -- current sealed mode state -- **Sealed requirements**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedRequirements.cs` -- requirements for sealed mode compliance -- **Audit logger**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/ISealedInstallAuditLogger.cs` -- audit logging for install actions -- **Air-gap status provider**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/IAirGapStatusProvider.cs` -- checks if system is in air-gap mode -- **HTTP status provider**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/AirGap/HttpAirGapStatusProvider.cs` -- HTTP-based air-gap status check -- **Bundle ingestion executor**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/BundleIngestionStepExecutor.cs` -- air-gap bundle ingestion step -- **Tests**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/SealedInstallEnforcerTests.cs`, `BundleIngestionStepExecutorTests.cs` -- **Source**: Feature matrix scan - -## E2E Test Plan -- [ ] Verify enforcer blocks network-dependent installations in sealed mode -- [ ] Test sealed mode status detection via HTTP provider -- [ ] Verify audit logger records all install actions in sealed mode -- [ ] Test bundle ingestion step works in offline environment -- [ ] Verify enforcement result reports compliance violations diff --git a/docs/features/checked/taskrunner/taskpack-manifest-and-planning.md b/docs/features/checked/taskrunner/taskpack-manifest-and-planning.md deleted file mode 100644 index 194b58445..000000000 --- a/docs/features/checked/taskrunner/taskpack-manifest-and-planning.md +++ /dev/null @@ -1,30 +0,0 @@ -# TaskPack Manifest and Planning - -## Module -TaskRunner - -## Status -IMPLEMENTED - -## Description -Full task pack manifest system with loading, validation, planning, and plan hashing for deterministic execution verification. - -## Implementation Details -- **Manifest model**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TaskPacks/TaskPackManifest.cs` -- task pack manifest schema -- **Manifest loader**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TaskPacks/TaskPackManifestLoader.cs` -- loads manifests from filesystem/storage -- **Manifest validator**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TaskPacks/TaskPackManifestValidator.cs` -- validates manifest structure and constraints -- **Planner**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlanner.cs` -- creates execution plans from manifests -- **Plan model**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlan.cs` -- execution plan data model -- **Plan hasher**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlanHasher.cs` -- deterministic plan hashing for verification -- **Plan insights**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlanInsights.cs` -- planning insights and analysis -- **Expressions**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Expressions/TaskPackExpressions.cs` -- expression evaluation for manifest conditions -- **Canonical JSON**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Serialization/CanonicalJson.cs` -- deterministic JSON serialization for plan hashing -- **Tests**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskPackPlannerTests.cs`, `TestManifests.cs`, `TestManifests.Egress.cs` -- **Source**: Feature matrix scan - -## E2E Test Plan -- [ ] Verify manifest loading from filesystem -- [ ] Test manifest validation catches invalid structures -- [ ] Verify planner creates correct execution plans from manifests -- [ ] Test deterministic plan hashing produces consistent hashes -- [ ] Verify expression evaluation in manifest conditions diff --git a/docs/features/checked/taskrunner/taskrunner-loop-and-conditional-step-kinds.md b/docs/features/checked/taskrunner/taskrunner-loop-and-conditional-step-kinds.md deleted file mode 100644 index fc26020ae..000000000 --- a/docs/features/checked/taskrunner/taskrunner-loop-and-conditional-step-kinds.md +++ /dev/null @@ -1,27 +0,0 @@ -# TaskRunner Loop and Conditional Step Kinds - -## Module -TaskRunner - -## Status -IMPLEMENTED - -## Description -Extended TaskRunner execution engine with loop and conditional step types, enabling iterative and branching task execution patterns beyond simple sequential flows. - -## Implementation Details -- **Step state machine**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunStepStateMachine.cs` -- manages step state transitions including loop and conditional steps -- **Execution graph**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraph.cs` -- DAG supports loop and conditional edges -- **Graph builder**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraphBuilder.cs` -- builds graphs with loop/conditional nodes -- **Expressions**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Expressions/TaskPackExpressions.cs` -- expression evaluation for conditional branching -- **Manifest model**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TaskPacks/TaskPackManifest.cs` -- manifest supports loop and conditional step kind definitions -- **Processor**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunProcessor.cs` -- processes loop iterations and conditional branches -- **Tests**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunStepStateMachineTests.cs`, `PackRunExecutionGraphBuilderTests.cs`, `PackRunProcessorTests.cs` -- **Source**: SPRINT_0157_0001_0001_taskrunner_i.md - -## E2E Test Plan -- [ ] Verify loop steps iterate the configured number of times -- [ ] Test conditional steps branch based on expression evaluation -- [ ] Verify loop step supports early exit on condition -- [ ] Test nested loops and conditionals execute correctly -- [ ] Verify execution graph handles loop back-edges without cycles diff --git a/docs/features/checked/taskrunner/taskrunner-sdk-client-with-openapi.md b/docs/features/checked/taskrunner/taskrunner-sdk-client-with-openapi.md deleted file mode 100644 index 495dd7e9c..000000000 --- a/docs/features/checked/taskrunner/taskrunner-sdk-client-with-openapi.md +++ /dev/null @@ -1,32 +0,0 @@ -# TaskRunner SDK Client with OpenAPI - -## Module -TaskRunner - -## Status -IMPLEMENTED - -## Description -Auto-generated SDK client for TaskRunner APIs with OpenAPI spec, deprecation middleware, and versioned endpoint support for external integrators. - -## Implementation Details -- **Client interface**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/ITaskRunnerClient.cs` -- SDK client contract -- **Client implementation**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/TaskRunnerClient.cs` -- HTTP client for TaskRunner APIs -- **Client options**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/TaskRunnerClientOptions.cs` -- configurable client options -- **DI extensions**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Extensions/TaskRunnerClientServiceCollectionExtensions.cs` -- DI registration -- **Pack run models**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Models/PackRunModels.cs` -- client-side pack run models -- **Lifecycle helper**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Lifecycle/PackRunLifecycleHelper.cs` -- pack run lifecycle management -- **Pagination**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Pagination/Paginator.cs` -- paginated API result handling -- **Streaming log reader**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Streaming/StreamingLogReader.cs` -- real-time log streaming -- **OpenAPI metadata**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/OpenApiMetadataFactory.cs` -- OpenAPI spec generation -- **Deprecation middleware**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Deprecation/ApiDeprecationMiddleware.cs`, `ApiDeprecationOptions.cs`, `IDeprecationNotificationService.cs` -- API versioning and deprecation support -- **WebService program**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs` -- API host with OpenAPI endpoints -- **Tests**: `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerClientTests.cs`, `OpenApiMetadataFactoryTests.cs`, `ApiDeprecationTests.cs` -- **Source**: SPRINT_0157_0001_0001_taskrunner_i.md - -## E2E Test Plan -- [ ] Verify SDK client can list, create, and manage pack runs -- [ ] Test streaming log reader receives real-time execution logs -- [ ] Verify OpenAPI spec is generated and accessible at /swagger endpoint -- [ ] Test deprecation middleware returns correct headers for deprecated endpoints -- [ ] Verify pagination handles large result sets correctly diff --git a/docs/modules/jobengine/architecture.md b/docs/modules/jobengine/architecture.md index 13190befe..a9c70ce88 100644 --- a/docs/modules/jobengine/architecture.md +++ b/docs/modules/jobengine/architecture.md @@ -157,7 +157,7 @@ All responses include deterministic timestamps, job digests, and DSSE signature ## 8) Orchestration domain subdomains (Sprint 208) -Sprint 208 consolidated Scheduler, TaskRunner, and PacksRegistry source trees under `src/JobEngine/` as subdomains of the orchestration domain. Each subdomain retains its own project names, namespaces, and runtime identities. No namespace renames were performed. +Sprint 208 consolidated Scheduler, TaskRunner, and PacksRegistry source trees under `src/JobEngine/` as subdomains of the orchestration domain. Each subdomain retains its own project names, namespaces, and runtime identities. No namespace renames were performed. **TaskRunner was subsequently removed (2026-04-08); its `task_runner_id` DB columns remain as nullable legacy fields.** ### 8.1) Scheduler subdomain @@ -169,21 +169,9 @@ The Scheduler service re-evaluates already-cataloged images when intelligence ch **Database:** `SchedulerDbContext` (schema `scheduler`, 11 entities). Owns `schedules`, `runs`, `impact_cursors`, `locks`, `audit` tables. See archived docs: `docs-archived/modules/scheduler/architecture.md`. -### 8.2) TaskRunner subdomain +### 8.2) TaskRunner subdomain (REMOVED) -**Source location:** `src/JobEngine/StellaOps.TaskRunner/`, `src/JobEngine/StellaOps.TaskRunner.__Libraries/` - -The TaskRunner provides the execution substrate for Orchestrator jobs. Workers poll lease endpoints, execute tasks, report outcomes, and stream logs/artifacts for pack-runs. - -**Deployables:** `StellaOps.TaskRunner.WebService`, `StellaOps.TaskRunner.Worker`. - -**Database and storage contract (Sprint 312):** -- `Storage:Driver=postgres` is the production default for run state, logs, and approvals. -- Postgres-backed stores: `PostgresPackRunStateStore`, `PostgresPackRunLogStore`, `PostgresPackRunApprovalStore` via `TaskRunnerDataSource`. -- Artifact payload channel uses object storage path (`seed-fs` driver) configured with `TaskRunner:Storage:ObjectStore:SeedFs:RootPath`. -- Startup fails fast when `Storage:ObjectStore:Driver` is set to `rustfs` (not implemented) or any unsupported driver value. -- Non-development startup fails fast when `Storage:Driver=postgres` and no connection string is configured. -- Explicit non-production overrides remain available (`filesystem`, `inmemory`) but are no longer implicit defaults. +> TaskRunner was deleted on 2026-04-08. Source directories, Docker services, CLI commands, and docs have been removed. The `task_runner_id` columns in the database remain as nullable legacy fields. No new migrations were created. ### 8.3) PacksRegistry subdomain @@ -221,7 +209,7 @@ Merging would require renaming one set of entities (e.g., `SchedulerJobs`, `Sche 3. Schemas provide clean separation at zero cost. 4. Future domain rename (Sprint 221) is a better venue for any schema consolidation. -**Consequences:** TaskRunner and PacksRegistry remain independent subdomains and now implement explicit storage contracts (Postgres state/metadata plus object-store payload channels) without cross-schema DB merge. +**Consequences:** PacksRegistry remains an independent subdomain implementing explicit storage contracts (Postgres state/metadata plus object-store payload channels) without cross-schema DB merge. TaskRunner was subsequently removed. --- diff --git a/docs/operations/devops/task-runner-simulation.md b/docs/operations/devops/task-runner-simulation.md deleted file mode 100644 index 5af1a8e7b..000000000 --- a/docs/operations/devops/task-runner-simulation.md +++ /dev/null @@ -1,48 +0,0 @@ -# Task Runner — Simulation & Failure Policy Notes - -> **Status:** Draft (2025-11-04) — execution wiring + CLI simulate command landed; docs pending final polish - -The Task Runner planning layer now materialises additional runtime metadata to unblock execution and simulation flows: - -- **Execution graph builder** – converts `TaskPackPlan` steps (including `map` and `parallel`) into a deterministic graph with preserved enablement flags and per-step metadata (`maxParallel`, `continueOnError`, parameters, approval IDs). -- **Simulation engine** – walks the execution graph and classifies steps as `pending`, `skipped`, `requires-approval`, or `requires-policy`, producing a deterministic preview for CLI/UI consumers while surfacing declared outputs. -- **Failure policy** – pack-level `spec.failure.retries` is normalised into a `TaskPackPlanFailurePolicy` (default: `maxAttempts = 1`, `backoffSeconds = 0`). The new step state machine uses this policy to schedule retries and to determine when a run must abort. -- **Simulation API + Worker** – `POST /v1/task-runner/simulations` returns the deterministic preview; `GET /v1/task-runner/runs/{id}` exposes persisted retry windows now written by the worker as it honours `maxParallel`, `continueOnError`, and retry windows during execution. - -## Current behaviour - -- Map steps expand into child iterations (`stepId[index]::templateId`) with per-item parameters preserved for runtime reference. -- Parallel blocks honour `maxParallel` (defaults to unlimited) and the worker executes children accordingly, short-circuiting when `continueOnError` is false. -- Simulation output mirrors approvals/policy gates, allowing the WebService/CLI to show which actions must occur before execution resumes. -- File-backed state store persists `PackRunState` snapshots (`nextAttemptAt`, attempts, reasons) so orchestration clients and CLI can resume runs deterministically even in air-gapped environments. -- Step state machine transitions: - - `pending → running → succeeded` - - `running → failed` (abort) once attempts ≥ `maxAttempts` - - `running → pending` with scheduled `nextAttemptAt` when retries remain - - `pending → skipped` for disabled steps (e.g., `when` expressions). - -## CLI usage - -Run the simulation without mutating state: - -```bash -stella task-runner simulate \ - --manifest ./packs/sample-pack.yaml \ - --inputs ./inputs.json \ - --format table -``` - -Use `--format json` (or `--output path.json`) to emit the raw payload produced by `POST /api/task-runner/simulations`. - -## Follow-up gaps - -- Fold the CLI command into the official reference/quickstart guides and capture exit-code conventions. - -References: - -- `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraphBuilder.cs` -- `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationEngine.cs` -- `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunStepStateMachine.cs` -- `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunStateStore.cs` -- `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Services/PackRunWorkerService.cs` -- `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs` diff --git a/docs/qa/feature-checks/state/taskrunner.json b/docs/qa/feature-checks/state/taskrunner.json deleted file mode 100644 index 57e5d92b0..000000000 --- a/docs/qa/feature-checks/state/taskrunner.json +++ /dev/null @@ -1,135 +0,0 @@ -{ - "module": "taskrunner", - "featureCount": 7, - "lastUpdatedUtc": "2026-02-13T08:00:00Z", - "summary": { - "passed": 7, - "failed": 0, - "blocked": 0, - "skipped": 0, - "done": 7, - "queued": 0 - }, - "buildNote": "All 7 taskrunner features verified via Tier 0/1/2 pipeline on 2026-02-13. Baseline: 227/227 tests pass in StellaOps.TaskRunner.Tests.csproj (net10.0, 1.6s). All source files verified on disk. All features moved to docs/features/checked/taskrunner/.", - "features": { - "pack-run-approval-gates": { - "status": "done", - "tier": 2, - "retryCount": 0, - "sourceVerified": true, - "buildVerified": true, - "e2eVerified": true, - "skipReason": null, - "lastRunId": "run-001", - "lastUpdatedUtc": "2026-02-13T08:00:00Z", - "featureFile": "docs/features/checked/taskrunner/pack-run-approval-gates.md", - "notes": [ - "[2026-02-13T08:00:00Z] checking: Tier 0 source verification - 8 source files confirmed on disk (coordinator, state, status, store interface, gate updater, decision service, file store, postgres store).", - "[2026-02-13T08:00:00Z] checking: Tier 1 build passed 227/227 tests. Code review confirms approval coordinator with ConcurrentDictionary, approve/reject/expire transitions, plan hash verification.", - "[2026-02-13T08:00:00Z] done: Tier 2 behavioral verification passed. 12 tests across PackRunApprovalCoordinatorTests, PackRunApprovalDecisionServiceTests, PackRunGateStateUpdaterTests. All approval state transitions, plan hash mismatch detection, and scheduler resume logic verified. Moved to checked/." - ] - }, - "pack-run-evidence-and-provenance": { - "status": "done", - "tier": 2, - "retryCount": 0, - "sourceVerified": true, - "buildVerified": true, - "e2eVerified": true, - "skipReason": null, - "lastRunId": "run-001", - "lastUpdatedUtc": "2026-02-13T08:00:00Z", - "featureFile": "docs/features/checked/taskrunner/pack-run-evidence-and-provenance.md", - "notes": [ - "[2026-02-13T08:00:00Z] checking: Tier 0 source verification - 12 source files confirmed (attestation service, evidence snapshot, evidence store, redaction guard, bundle import evidence, provenance writer, provenance manifest factory, filesystem provenance writer, postgres evidence store).", - "[2026-02-13T08:00:00Z] checking: Tier 1 build passed 227/227 tests. Code review confirms DSSE attestation with signing/verification, Merkle root evidence snapshots, deterministic hashing, sensitive data redaction.", - "[2026-02-13T08:00:00Z] done: Tier 2 behavioral verification passed. 48 tests across PackRunAttestationTests (13), PackRunEvidenceSnapshotTests (24), PackRunProvenanceWriterTests (1), BundleImportEvidenceTests (10). Full attestation lifecycle (generate/verify/revoke), deterministic hashing, redaction, evidence export verified. Moved to checked/." - ] - }, - "pack-run-execution-engine": { - "status": "done", - "tier": 2, - "retryCount": 0, - "sourceVerified": true, - "buildVerified": true, - "e2eVerified": true, - "skipReason": null, - "lastRunId": "run-001", - "lastUpdatedUtc": "2026-02-13T08:00:00Z", - "featureFile": "docs/features/checked/taskrunner/pack-run-execution-engine.md", - "notes": [ - "[2026-02-13T08:00:00Z] checking: Tier 0 source verification - 14 source files confirmed (processor, execution graph, graph builder, step state machine, step executor, execution context, state management, job dispatcher, simulation engine, telemetry, worker service, infrastructure step executors, postgres stores).", - "[2026-02-13T08:00:00Z] checking: Tier 1 build passed 227/227 tests. Code review confirms DAG-based execution graph, step state machine with retry/backoff, processor with approval integration, simulation engine for dry-run.", - "[2026-02-13T08:00:00Z] done: Tier 2 behavioral verification passed. 18 tests across PackRunProcessorTests (2), PackRunExecutionGraphBuilderTests (2), PackRunStepStateMachineTests (4), PackRunStateFactoryTests, PackRunSimulationEngineTests (7). State transitions, retry policy, parallel/map steps, simulation accuracy verified. Moved to checked/." - ] - }, - "sealed-mode-install-enforcer": { - "status": "done", - "tier": 2, - "retryCount": 0, - "sourceVerified": true, - "buildVerified": true, - "e2eVerified": true, - "skipReason": null, - "lastRunId": "run-001", - "lastUpdatedUtc": "2026-02-13T08:00:00Z", - "featureFile": "docs/features/checked/taskrunner/sealed-mode-install-enforcer.md", - "notes": [ - "[2026-02-13T08:00:00Z] checking: Tier 0 source verification - 9 source files confirmed (enforcer interface/impl, enforcement result, sealed mode status, sealed requirements, audit logger, air-gap status provider, HTTP status provider, bundle ingestion executor).", - "[2026-02-13T08:00:00Z] checking: Tier 1 build passed 227/227 tests. Code review confirms sealed-mode enforcement with configurable options, 5 requirement types (bundle version, staleness, time anchor, offline duration, signature verification), bundle ingestion with checksum validation.", - "[2026-02-13T08:00:00Z] done: Tier 2 behavioral verification passed. 18 tests across SealedInstallEnforcerTests (13) and BundleIngestionStepExecutorTests (4). All enforcement scenarios verified: pack not requiring sealed, enforcement disabled, sealed required but not sealed, sealed and satisfied, bundle version below minimum, advisory too stale, time anchor missing/invalid, status provider failure. Bundle ingestion with checksum verified. Moved to checked/." - ] - }, - "taskpack-manifest-and-planning": { - "status": "done", - "tier": 2, - "retryCount": 0, - "sourceVerified": true, - "buildVerified": true, - "e2eVerified": true, - "skipReason": null, - "lastRunId": "run-001", - "lastUpdatedUtc": "2026-02-13T08:00:00Z", - "featureFile": "docs/features/checked/taskrunner/taskpack-manifest-and-planning.md", - "notes": [ - "[2026-02-13T08:00:00Z] checking: Tier 0 source verification - 9 source files confirmed (manifest model/loader/validator, planner, plan model/hasher/insights, expressions, canonical JSON).", - "[2026-02-13T08:00:00Z] checking: Tier 1 build passed 227/227 tests. Code review confirms manifest loading, validation, planning with expression evaluation, deterministic plan hashing via canonical JSON, egress policy enforcement.", - "[2026-02-13T08:00:00Z] done: Tier 2 behavioral verification passed. 13 tests in TaskPackPlannerTests. Deterministic sha256 hash format, condition evaluation, step references, map expansion, approval requirements, secrets, outputs, failure policies, sealed-mode egress validation verified. 8+ manifest variants used. Moved to checked/." - ] - }, - "taskrunner-loop-and-conditional-step-kinds": { - "status": "done", - "tier": 2, - "retryCount": 0, - "sourceVerified": true, - "buildVerified": true, - "e2eVerified": true, - "skipReason": null, - "lastRunId": "run-001", - "lastUpdatedUtc": "2026-02-13T08:00:00Z", - "featureFile": "docs/features/checked/taskrunner/taskrunner-loop-and-conditional-step-kinds.md", - "notes": [ - "[2026-02-13T08:00:00Z] checking: Tier 0 source verification - 6 source files confirmed (step state machine, execution graph, graph builder, expressions, manifest model, processor). Shared implementation with execution engine.", - "[2026-02-13T08:00:00Z] checking: Tier 1 build passed 227/227 tests. Code review confirms PackRunStepKind.Loop and PackRunStepKind.Conditional in enum, simulation WillIterate and WillBranch statuses, loop info with iterator/index/maxIterations/aggregationMode, conditional info with branches and outputUnion.", - "[2026-02-13T08:00:00Z] done: Tier 2 behavioral verification passed. 15 tests across PackRunStepStateMachineTests, PackRunExecutionGraphBuilderTests, PackRunProcessorTests, PackRunSimulationEngineTests. Loop step with WillIterate status, conditional step with WillBranch status, map step expansion, disabled conditional step skipping verified. Moved to checked/." - ] - }, - "taskrunner-sdk-client-with-openapi": { - "status": "done", - "tier": 2, - "retryCount": 0, - "sourceVerified": true, - "buildVerified": true, - "e2eVerified": true, - "skipReason": null, - "lastRunId": "run-001", - "lastUpdatedUtc": "2026-02-13T08:00:00Z", - "featureFile": "docs/features/checked/taskrunner/taskrunner-sdk-client-with-openapi.md", - "notes": [ - "[2026-02-13T08:00:00Z] checking: Tier 0 source verification - 13 source files confirmed (client interface/impl/options, DI extensions, pack run models, lifecycle helper, paginator, streaming log reader, OpenAPI metadata factory, deprecation middleware/options/notification service, WebService program).", - "[2026-02-13T08:00:00Z] checking: Tier 1 build passed 227/227 tests. Code review confirms SDK client with HTTP implementation, NDJSON streaming log reader, paginator with async enumeration, OpenAPI metadata with deterministic signatures/ETags, deprecation middleware with wildcard path matching and sunset headers.", - "[2026-02-13T08:00:00Z] done: Tier 2 behavioral verification passed. 25 tests across TaskRunnerClientTests (13), OpenApiMetadataFactoryTests (4), ApiDeprecationTests (8). Streaming log parsing with level filtering and step grouping, pagination with multi-page collection, OpenAPI deterministic signatures, deprecation sunset scheduling with path pattern matching verified. Moved to checked/." - ] - } - } -} diff --git a/docs/technical/architecture/component-map.md b/docs/technical/architecture/component-map.md index 105091946..34ea685dd 100644 --- a/docs/technical/architecture/component-map.md +++ b/docs/technical/architecture/component-map.md @@ -22,7 +22,7 @@ Concise descriptions of every top-level component under `src/`, summarising the ## Policy & Governance - **Policy** — Policy Engine core libraries and services executing lattice logic across SBOM, advisory, and VEX evidence. Emits explain traces, drives Findings, Notifier, and Export Center (`docs/modules/policy/architecture.md`). -- **Policy Studio / TaskRunner / PacksRegistry** - Authoring, automation, and reusable template services that orchestrate policy and operational workflows (`docs/modules/packs-registry/guides/`, `docs/modules/cli/`, `docs/modules/ui/`). +- **Policy Studio / PacksRegistry** - Authoring and reusable template services that orchestrate policy and operational workflows (`docs/modules/packs-registry/guides/`, `docs/modules/cli/`, `docs/modules/ui/`). - **Governance components** (Authority scopes, Policy governance, Console policy UI) are covered in `docs/security/policy-governance.md` and `docs/modules/ui/policies.md`. ## Identity, Signing & Provenance @@ -35,7 +35,7 @@ Concise descriptions of every top-level component under `src/`, summarising the ## Scheduling, Orchestration & Automation - **Scheduler** — Detects advisory/VEX deltas and orchestrates deterministic rescan runs toward Scanner and Policy Engine (`docs/modules/scheduler/architecture.md`). - **Orchestrator** — Central coordination service dispatching jobs (scans, exports, policy runs) to modules, working closely with Scheduler, CLI, and UI (`docs/modules/jobengine/architecture.md`). -- **TaskRunner** - Executes automation packs sourced from PacksRegistry, integrating with Orchestrator, CLI, Notify, and Authority (`docs/modules/packs-registry/guides/runbook.md`). + - **Signals** — Ingests runtime posture signals and feeds Policy/Notifier workflows (`docs/modules/zastava/architecture.md`, signals sections). - **TimelineIndexer** — Builds timelines of evidence/events for forensics and audit tooling (`docs/modules/timeline-indexer/guides/timeline.md`). diff --git a/docs/technical/architecture/module-matrix.md b/docs/technical/architecture/module-matrix.md index 74da1b84e..e24db0fcb 100644 --- a/docs/technical/architecture/module-matrix.md +++ b/docs/technical/architecture/module-matrix.md @@ -23,7 +23,7 @@ The solution contains **46 top-level modules** in `src/`. The architecture docum | Scanning & Analysis | 5 | Scanner, BinaryIndex, AdvisoryAI, Symbols, ReachGraph | | Artifacts & Evidence | 7 | Attestor, Signer, SbomService, EvidenceLocker, ExportCenter, Provenance, Provcache | | Policy & Risk | 3 | Policy, RiskEngine, Unknowns (VulnExplorer merged into Findings Ledger) | -| Operations | 8 | Scheduler, Orchestrator, TaskRunner, Notify, Notifier, PacksRegistry, TimelineIndexer, Replay | +| Operations | 7 | Scheduler, Orchestrator, Notify, Notifier, PacksRegistry, TimelineIndexer, Replay | | Integration | 5 | CLI, Zastava, Web, API, Registry | | Infrastructure | 6 | Cryptography, Telemetry, Graph, Signals, AirGap, AOC | | Testing & Benchmarks | 2 | Benchmark, Bench | @@ -81,7 +81,7 @@ The solution contains **46 top-level modules** in `src/`. The architecture docum | Module | Path | Purpose | WebService | Worker | Storage | |--------|------|---------|------------|--------|---------| -| **JobEngine** | `src/JobEngine/` | Workflow orchestration, scheduling, task execution, pack registry. Includes Scheduler, TaskRunner, PacksRegistry (Sprint 208); renamed from Orchestrator (Sprint 221). | Yes | Yes | PostgreSQL (`orchestrator`, `scheduler`) | +| **JobEngine** | `src/JobEngine/` | Workflow orchestration, scheduling, pack registry. Includes Scheduler, PacksRegistry (Sprint 208); renamed from Orchestrator (Sprint 221). TaskRunner removed. | Yes | Yes | PostgreSQL (`orchestrator`, `scheduler`) | | **Notify** | `src/Notify/` | Unified notification service (shared libraries + merged WebService). Notifier WebService merged into Notify WebService (2026-04-08). | Yes | N/A | PostgreSQL (`notify`) | | **Notifier** | `src/Notifier/` | Notifier Worker (delivery engine). WebService merged into Notify (2026-04-08). | N/A | Yes | PostgreSQL (`notify`) | | **Timeline** | `src/Timeline/` | Timeline query, event indexing, and replay. Includes TimelineIndexer (Sprint 210). | Yes | No | PostgreSQL | @@ -132,7 +132,7 @@ The solution contains **46 top-level modules** in `src/`. The architecture docum | Type | Modules | |------|---------| -| **WebService + Worker** | Scanner, Concelier, Excititor, Policy, Notifier, TaskRunner, AirGap, Mirror | +| **WebService + Worker** | Scanner, Concelier, Excititor, Policy, Notifier, AirGap, Mirror | | **WebService Only** | Authority, Gateway, Router, Platform, VexLens, VexHub, IssuerDirectory, BinaryIndex, AdvisoryAI, Symbols, ReachGraph, Attestor, Signer, SbomService, EvidenceLocker, ExportCenter, RiskEngine, VulnExplorer, Unknowns, Scheduler, Orchestrator, PacksRegistry, TimelineIndexer, Replay, Zastava, Registry | | **Library** | Feedser, Provenance, Provcache, Notify, API, Cryptography, Telemetry, Graph, Signals, AOC | | **CLI/Tool** | CLI, Benchmark, Bench, Tools | @@ -142,7 +142,7 @@ The solution contains **46 top-level modules** in `src/`. The architecture docum | Store | Modules | |-------|---------| -| **PostgreSQL** | Authority, Concelier, Excititor, VexLens, VexHub, IssuerDirectory, Scanner, BinaryIndex, AdvisoryAI, Symbols, ReachGraph, Attestor, Signer, SbomService, Policy, RiskEngine, VulnExplorer, Unknowns, Scheduler, Orchestrator, TaskRunner, Notifier, PacksRegistry, TimelineIndexer, Replay, Zastava, Registry | +| **PostgreSQL** | Authority, Concelier, Excititor, VexLens, VexHub, IssuerDirectory, Scanner, BinaryIndex, AdvisoryAI, Symbols, ReachGraph, Attestor, Signer, SbomService, Policy, RiskEngine, VulnExplorer, Unknowns, Scheduler, Orchestrator, Notifier, PacksRegistry, TimelineIndexer, Replay, Zastava, Registry | | **RustFS (S3)** | Scanner, Attestor, SbomService, EvidenceLocker, ExportCenter, AirGap, Mirror | | **Valkey** | Gateway, Router, Scanner, Policy, Scheduler, Notifier (for queues/cache) | | **Stateless** | Gateway, Platform, CLI, Web | diff --git a/docs/technical/architecture/platform-topology.md b/docs/technical/architecture/platform-topology.md index 94e67dab1..73a6cfc54 100644 --- a/docs/technical/architecture/platform-topology.md +++ b/docs/technical/architecture/platform-topology.md @@ -97,10 +97,10 @@ SUPPORTING ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ ORCHESTRATION & WORKFLOW │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Scheduler │ │ Orchestrator │ │ TaskRunner │ │ -│ │(Job Sched) │ │(Coordinator) │ │(Executor) │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ Scheduler │ │ Orchestrator │ │ +│ │(Job Sched) │ │(Coordinator) │ │ +│ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ diff --git a/docs/technical/architecture/port-registry.md b/docs/technical/architecture/port-registry.md index cc6204d3a..84aba03fe 100644 --- a/docs/technical/architecture/port-registry.md +++ b/docs/technical/architecture/port-registry.md @@ -33,7 +33,7 @@ This page focuses on deterministic slot/port allocation and may include legacy o | 15 | 10150 | 10151 | ~~Policy Gateway~~ (merged into Policy Engine, Slot 14) | `policy-gateway.stella-ops.local` -> `policy-engine.stella-ops.local` | _removed_ | _removed_ | | 16 | 10160 | 10161 | RiskEngine | `riskengine.stella-ops.local` | `src/Findings/StellaOps.RiskEngine.WebService` | `STELLAOPS_RISKENGINE_URL` | | 17 | 10170 | 10171 | ~~Orchestrator~~ (retired; audit/first-signal moved to Release Orchestrator, Slot 48) | `jobengine.stella-ops.local` | _removed_ | _removed_ | -| 18 | 10180 | 10181 | TaskRunner | `taskrunner.stella-ops.local` | `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService` | `STELLAOPS_TASKRUNNER_URL` | +| 18 | 10180 | 10181 | ~~TaskRunner~~ (removed) | `taskrunner.stella-ops.local` | _removed_ | _removed_ | | 19 | 10190 | 10191 | Scheduler | `scheduler.stella-ops.local` | `src/JobEngine/StellaOps.Scheduler.WebService` | `STELLAOPS_SCHEDULER_URL` | | 20 | 10200 | 10201 | Graph API | `graph.stella-ops.local` | `src/Graph/StellaOps.Graph.Api` | `STELLAOPS_GRAPH_URL` | | 21 | 10210 | 10211 | Cartographer | `cartographer.stella-ops.local` | `src/Scanner/StellaOps.Scanner.Cartographer` | `STELLAOPS_CARTOGRAPHER_URL` | @@ -76,7 +76,7 @@ Worker services associated with a web service use ports offset by +2/+3 from the | 10062 | 10063 | EvidenceLocker Worker | `src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Worker` | | 10162 | 10163 | RiskEngine Worker | `src/Findings/StellaOps.RiskEngine.Worker` | | 10172 | 10173 | Orchestrator Worker | `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.Worker` | -| 10182 | 10183 | TaskRunner Worker | `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker` | +| 10182 | 10183 | ~~TaskRunner Worker~~ (removed) | _removed_ | | 10232 | 10233 | TimelineIndexer Worker | `src/Timeline/StellaOps.TimelineIndexer.Worker` | | 10282 | 10283 | Notifier Worker | `src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker` | | 10342 | 10343 | PacksRegistry Worker | `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Worker` | @@ -128,7 +128,7 @@ Add the following to your hosts file (`C:\Windows\System32\drivers\etc\hosts` on 127.1.0.14 policy-gateway.stella-ops.local # alias -> policy-engine (merged) 127.1.0.16 riskengine.stella-ops.local 127.1.0.17 jobengine.stella-ops.local -127.1.0.18 taskrunner.stella-ops.local +# 127.1.0.18 taskrunner.stella-ops.local # REMOVED 127.1.0.19 scheduler.stella-ops.local 127.1.0.20 graph.stella-ops.local 127.1.0.21 cartographer.stella-ops.local diff --git a/docs/technical/architecture/webservice-catalog.md b/docs/technical/architecture/webservice-catalog.md index 1098fcad8..35be8455f 100644 --- a/docs/technical/architecture/webservice-catalog.md +++ b/docs/technical/architecture/webservice-catalog.md @@ -35,7 +35,7 @@ This page is the source-of-truth inventory for Stella Ops `*.WebService` runtime | JobEngine | JobEngine | `jobengine.stella-ops.local` | Release orchestration, approvals, DAG/workflow APIs. | postgres | `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService` | `src/JobEngine` | | JobEngine | PacksRegistry | `packsregistry.stella-ops.local` | Pack/provenance/attestation registry APIs. | postgres + seed-fs object payloads | `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService` | `src/JobEngine` | | JobEngine | Scheduler | `scheduler.stella-ops.local` | Schedule/run planning and event APIs. | postgres | `src/JobEngine/StellaOps.Scheduler.WebService` | `src/JobEngine` | -| JobEngine | TaskRunner | `taskrunner.stella-ops.local` | Task execution, run state/log, approval, and artifact APIs. | postgres + seed-fs object payloads | `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService` | `src/JobEngine` | + | Notify | Notify | `notify.stella-ops.local` | Notification rule/channel/template, delivery, escalation, incident, and simulation APIs (merged from Notifier). | postgres | `src/Notify/StellaOps.Notify.WebService` | `src/Notify` | | Platform | Platform | `platform.stella-ops.local` | Console aggregation, setup, admin, and read-model APIs. | postgres | `src/Platform/StellaOps.Platform.WebService` | `src/Platform` | | ReachGraph | ReachGraph | `reachgraph.stella-ops.local` | Reachability graph and CVE mapping APIs. | postgres | `src/ReachGraph/StellaOps.ReachGraph.WebService` | `src/ReachGraph` | diff --git a/docs/technical/cicd/path-filters.md b/docs/technical/cicd/path-filters.md index 72c279340..1134cda87 100644 --- a/docs/technical/cicd/path-filters.md +++ b/docs/technical/cicd/path-filters.md @@ -199,7 +199,7 @@ Each module has defined source and test paths: | Module | Source Paths | Test Paths | |--------|--------------|------------| -| JobEngine (includes Scheduler, TaskRunner, PacksRegistry) | `src/JobEngine/**` | `src/JobEngine/__Tests/**` | +| JobEngine (includes Scheduler, PacksRegistry) | `src/JobEngine/**` | `src/JobEngine/__Tests/**` | | Notify | `src/Notify/**` | `src/Notify/__Tests/**` | | Notifier | `src/Notifier/**` | `src/Notifier/__Tests/**` | | Timeline (includes TimelineIndexer) | `src/Timeline/**` | `src/Timeline/__Tests/**` | diff --git a/etc/task-runner.yaml.sample b/etc/task-runner.yaml.sample deleted file mode 100644 index b02ad025a..000000000 --- a/etc/task-runner.yaml.sample +++ /dev/null @@ -1,70 +0,0 @@ -# StellaOps Task Runner configuration template. -# Copy to ../etc/task-runner.yaml (relative to the Task Runner content root) -# and adjust values for your environment. Environment variables prefixed with -# STELLAOPS_TASKRUNNER_ override these values at runtime. - -schemaVersion: 1 - -telemetry: - enabled: true - serviceName: "stellaops-taskrunner" - exportConsole: true - minimumLogLevel: "Information" - otlpEndpoint: "" - resourceAttributes: - deployment.environment: "local" - -authority: - issuer: "https://authority.stella-ops.local" - metadataAddress: "" - requireHttpsMetadata: true - audiences: - - "api://task-runner" - # Client credentials used for executing packs. Provide either clientSecret or - # clientSecretFile (preferred for production). - runnerClient: - clientId: "task-runner" - clientSecret: "" - clientSecretFile: "../secrets/task-runner.secret" - scopes: - - "packs.run" - - "packs.read" - # Client used to approve gates when automation workflows sign off on runs. - approvalsClient: - clientId: "pack-approver" - clientSecret: "" - clientSecretFile: "../secrets/pack-approver.secret" - scopes: - - "packs.approve" - - "packs.read" - # Optional secondary client used for registry interactions (promote/deprecate). - registryClient: - clientId: "packs-registry" - clientSecret: "" - clientSecretFile: "../secrets/packs-registry.secret" - scopes: - - "packs.write" - - "packs.read" - # Tenant context required for all Task Runner operations. - tenant: "tenant-default" - -storage: - # Object storage bucket where run artifacts and evidence bundles are kept. - artifactsBucket: "s3://stellaops-taskrunner-artifacts" - # PostgreSQL stores run metadata and approval state (MongoDB removed in Sprint 4400). - driver: "postgres" - connectionString: "Host=postgres;Port=5432;Database=stellaops_platform;Username=stellaops;Password=change-me" - schema: "taskrunner" - -approvals: - # Default timeout before pending approvals auto-expire. - defaultExpiresAfter: "04:00:00" - # Notifications topic emitted when approvals are requested/resolved. - notifyTopic: "pack.run.approvals" - -runner: - # Maximum concurrent steps Task Runner executes per worker. - maxParallelSteps: 8 - # Allowlist of modules that can initiate network calls when sealed=false. - networkAllowlist: - - "*.internal.stella-ops.local" diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs b/src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs index 1ce2f5751..ca479a46b 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs @@ -647,10 +647,10 @@ public static class StellaOpsScopes public const string SmRemoteSign = "sm-remote:sign"; public const string SmRemoteVerify = "sm-remote:verify"; - // TaskRunner scopes - public const string TaskRunnerRead = "taskrunner:read"; - public const string TaskRunnerOperate = "taskrunner:operate"; - public const string TaskRunnerAdmin = "taskrunner:admin"; + // TaskRunner scopes — REMOVED (service deleted, constants kept for DB/migration backward compat) + // public const string TaskRunnerRead = "taskrunner:read"; + // public const string TaskRunnerOperate = "taskrunner:operate"; + // public const string TaskRunnerAdmin = "taskrunner:admin"; // Integration catalog scopes /// diff --git a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs index ed0c2273f..d1be52400 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs @@ -72,7 +72,7 @@ internal static class CommandFactory root.Add(BuildTenantsCommand(services, options, verboseOption, cancellationToken)); root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken)); root.Add(ToolsCommandGroup.BuildToolsCommand(loggerFactory, cancellationToken)); - root.Add(BuildTaskRunnerCommand(services, verboseOption, cancellationToken)); + root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken)); root.Add(BuildAdviseCommand(services, options, verboseOption, cancellationToken)); root.Add(BuildConfigCommand(services, options, verboseOption, cancellationToken)); @@ -4108,56 +4108,6 @@ flowchart TB DateTimeOffset DecidedAt, string Reason); - private static Command BuildTaskRunnerCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) - { - var taskRunner = new Command("task-runner", "Interact with Task Runner operations."); - - var simulate = new Command("simulate", "Simulate a task pack and inspect the execution graph."); - var manifestOption = new Option("--manifest") - { - Description = "Path to the task pack manifest (YAML).", - Arity = ArgumentArity.ExactlyOne - }; - var inputsOption = new Option("--inputs") - { - Description = "Optional JSON file containing Task Pack input values." - }; - var formatOption = new Option("--format") - { - Description = "Output format: table or json." - }; - var outputOption = new Option("--output") - { - Description = "Write JSON payload to the specified file." - }; - - simulate.Add(manifestOption); - simulate.Add(inputsOption); - simulate.Add(formatOption); - simulate.Add(outputOption); - - simulate.SetAction((parseResult, _) => - { - var manifestPath = parseResult.GetValue(manifestOption) ?? string.Empty; - var inputsPath = parseResult.GetValue(inputsOption); - var selectedFormat = parseResult.GetValue(formatOption); - var output = parseResult.GetValue(outputOption); - var verbose = parseResult.GetValue(verboseOption); - - return CommandHandlers.HandleTaskRunnerSimulateAsync( - services, - manifestPath, - inputsPath, - selectedFormat, - output, - verbose, - cancellationToken); - }); - - taskRunner.Add(simulate); - return taskRunner; - } - private static Command BuildFindingsCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var findings = new Command("findings", "Inspect policy findings."); diff --git a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs index 693d719c7..ac1073ce0 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs @@ -396,126 +396,6 @@ internal static partial class CommandHandlers } } - public static async Task HandleTaskRunnerSimulateAsync( - IServiceProvider services, - string manifestPath, - string? inputsPath, - string? format, - string? outputPath, - bool verbose, - CancellationToken cancellationToken) - { - await using var scope = services.CreateAsyncScope(); - var client = scope.ServiceProvider.GetRequiredService(); - var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("task-runner-simulate"); - var verbosity = scope.ServiceProvider.GetRequiredService(); - var previousLevel = verbosity.MinimumLevel; - verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; - using var activity = CliActivitySource.Instance.StartActivity("cli.taskrunner.simulate", ActivityKind.Client); - activity?.SetTag("stellaops.cli.command", "task-runner simulate"); - using var duration = CliMetrics.MeasureCommandDuration("task-runner simulate"); - - try - { - if (string.IsNullOrWhiteSpace(manifestPath)) - { - throw new ArgumentException("Manifest path must be provided.", nameof(manifestPath)); - } - - var manifestFullPath = Path.GetFullPath(manifestPath); - if (!File.Exists(manifestFullPath)) - { - throw new FileNotFoundException("Manifest file not found.", manifestFullPath); - } - - activity?.SetTag("stellaops.cli.manifest_path", manifestFullPath); - var manifest = await File.ReadAllTextAsync(manifestFullPath, cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(manifest)) - { - throw new InvalidOperationException("Manifest file was empty."); - } - - JsonObject? inputsObject = null; - if (!string.IsNullOrWhiteSpace(inputsPath)) - { - var inputsFullPath = Path.GetFullPath(inputsPath!); - if (!File.Exists(inputsFullPath)) - { - throw new FileNotFoundException("Inputs file not found.", inputsFullPath); - } - - await using var stream = File.OpenRead(inputsFullPath); - var parsed = await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); - if (parsed is JsonObject obj) - { - inputsObject = obj; - } - else - { - throw new InvalidOperationException("Simulation inputs must be a JSON object."); - } - - activity?.SetTag("stellaops.cli.inputs_path", inputsFullPath); - } - - var request = new TaskRunnerSimulationRequest(manifest, inputsObject); - var result = await client.SimulateTaskRunnerAsync(request, cancellationToken).ConfigureAwait(false); - - activity?.SetTag("stellaops.cli.plan_hash", result.PlanHash); - activity?.SetTag("stellaops.cli.pending_approvals", result.HasPendingApprovals); - activity?.SetTag("stellaops.cli.step_count", result.Steps.Count); - - var outputFormat = DetermineTaskRunnerSimulationFormat(format, outputPath); - var payload = BuildTaskRunnerSimulationPayload(result); - - if (!string.IsNullOrWhiteSpace(outputPath)) - { - await WriteSimulationOutputAsync(outputPath!, payload, cancellationToken).ConfigureAwait(false); - logger.LogInformation("Simulation payload written to {Path}.", Path.GetFullPath(outputPath!)); - } - - if (outputFormat == TaskRunnerSimulationOutputFormat.Json) - { - Console.WriteLine(JsonSerializer.Serialize(payload, SimulationJsonOptions)); - } - else - { - RenderTaskRunnerSimulationResult(result); - } - - var outcome = result.HasPendingApprovals ? "pending-approvals" : "ok"; - CliMetrics.RecordTaskRunnerSimulation(outcome); - Environment.ExitCode = 0; - } - catch (FileNotFoundException ex) - { - logger.LogError(ex.Message); - CliMetrics.RecordTaskRunnerSimulation("error"); - Environment.ExitCode = 66; - } - catch (ArgumentException ex) - { - logger.LogError(ex.Message); - CliMetrics.RecordTaskRunnerSimulation("error"); - Environment.ExitCode = 64; - } - catch (InvalidOperationException ex) - { - logger.LogError(ex, "Task Runner simulation failed."); - CliMetrics.RecordTaskRunnerSimulation("error"); - Environment.ExitCode = 1; - } - catch (Exception ex) - { - logger.LogError(ex, "Task Runner simulation failed."); - CliMetrics.RecordTaskRunnerSimulation("error"); - Environment.ExitCode = 1; - } - finally - { - verbosity.MinimumLevel = previousLevel; - } - } private static void RenderEntryTrace(EntryTraceResponseModel result, bool includeNdjson, bool includeSemantic) { @@ -5874,121 +5754,6 @@ internal static partial class CommandHandlers return null; } - private static TaskRunnerSimulationOutputFormat DetermineTaskRunnerSimulationFormat(string? value, string? outputPath) - { - if (!string.IsNullOrWhiteSpace(value)) - { - return value.Trim().ToLowerInvariant() switch - { - "table" => TaskRunnerSimulationOutputFormat.Table, - "json" => TaskRunnerSimulationOutputFormat.Json, - _ => throw new ArgumentException("Invalid format. Use 'table' or 'json'.") - }; - } - - if (!string.IsNullOrWhiteSpace(outputPath)) - { - return TaskRunnerSimulationOutputFormat.Json; - } - - return TaskRunnerSimulationOutputFormat.Table; - } - - private static object BuildTaskRunnerSimulationPayload(TaskRunnerSimulationResult result) - => new - { - planHash = result.PlanHash, - failurePolicy = new - { - result.FailurePolicy.MaxAttempts, - result.FailurePolicy.BackoffSeconds, - result.FailurePolicy.ContinueOnError - }, - hasPendingApprovals = result.HasPendingApprovals, - steps = result.Steps, - outputs = result.Outputs - }; - - private static void RenderTaskRunnerSimulationResult(TaskRunnerSimulationResult result) - { - var console = AnsiConsole.Console; - - var table = new Table - { - Border = TableBorder.Rounded - }; - table.AddColumn("Step"); - table.AddColumn("Kind"); - table.AddColumn("Status"); - table.AddColumn("Reason"); - table.AddColumn("MaxParallel"); - table.AddColumn("ContinueOnError"); - table.AddColumn("Approval"); - - foreach (var (step, depth) in FlattenTaskRunnerSimulationSteps(result.Steps)) - { - var indent = new string(' ', depth * 2); - table.AddRow( - Markup.Escape($"{indent}{step.Id}"), - Markup.Escape(step.Kind), - Markup.Escape(step.Status), - Markup.Escape(string.IsNullOrWhiteSpace(step.StatusReason) ? "-" : step.StatusReason!), - step.MaxParallel?.ToString(CultureInfo.InvariantCulture) ?? "-", - step.ContinueOnError ? "yes" : "no", - Markup.Escape(string.IsNullOrWhiteSpace(step.ApprovalId) ? "-" : step.ApprovalId!)); - } - - console.Write(table); - - if (result.Outputs.Count > 0) - { - var outputsTable = new Table - { - Border = TableBorder.Rounded - }; - outputsTable.AddColumn("Name"); - outputsTable.AddColumn("Type"); - outputsTable.AddColumn("Requires Runtime"); - outputsTable.AddColumn("Path"); - outputsTable.AddColumn("Expression"); - - foreach (var output in result.Outputs) - { - outputsTable.AddRow( - Markup.Escape(output.Name), - Markup.Escape(output.Type), - output.RequiresRuntimeValue ? "yes" : "no", - Markup.Escape(string.IsNullOrWhiteSpace(output.PathExpression) ? "-" : output.PathExpression!), - Markup.Escape(string.IsNullOrWhiteSpace(output.ValueExpression) ? "-" : output.ValueExpression!)); - } - - console.WriteLine(); - console.Write(outputsTable); - } - - console.WriteLine(); - console.MarkupLine($"[grey]Plan Hash:[/] {Markup.Escape(result.PlanHash)}"); - console.MarkupLine($"[grey]Pending Approvals:[/] {(result.HasPendingApprovals ? "yes" : "no")}"); - console.Write(new Text($"Plan Hash: {result.PlanHash}{Environment.NewLine}")); - console.Write(new Text($"Pending Approvals: {(result.HasPendingApprovals ? "yes" : "no")}{Environment.NewLine}")); - } - - private static IEnumerable<(TaskRunnerSimulationStep Step, int Depth)> FlattenTaskRunnerSimulationSteps( - IReadOnlyList steps, - int depth = 0) - { - for (var i = 0; i < steps.Count; i++) - { - var step = steps[i]; - yield return (step, depth); - - foreach (var child in FlattenTaskRunnerSimulationSteps(step.Children, depth + 1)) - { - yield return child; - } - } - } - private static PolicySimulationOutputFormat DeterminePolicySimulationFormat(string? value, string? outputPath) { if (!string.IsNullOrWhiteSpace(value)) @@ -7310,12 +7075,6 @@ internal static partial class CommandHandlers private static readonly IReadOnlyDictionary EmptyLabelSelectors = new ReadOnlyDictionary(new Dictionary(0, StringComparer.OrdinalIgnoreCase)); - private enum TaskRunnerSimulationOutputFormat - { - Table, - Json - } - private enum PolicySimulationOutputFormat { Table, diff --git a/src/Cli/StellaOps.Cli/Commands/TaskRunnerCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/TaskRunnerCommandGroup.cs deleted file mode 100644 index 72e1cf458..000000000 --- a/src/Cli/StellaOps.Cli/Commands/TaskRunnerCommandGroup.cs +++ /dev/null @@ -1,652 +0,0 @@ -// ----------------------------------------------------------------------------- -// TaskRunnerCommandGroup.cs -// Sprint: SPRINT_20260117_021_CLI_taskrunner -// Tasks: TRN-001 through TRN-005 - TaskRunner management commands -// Description: CLI commands for TaskRunner service operations -// ----------------------------------------------------------------------------- - -using System.CommandLine; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace StellaOps.Cli.Commands; - -/// -/// Command group for TaskRunner operations. -/// Implements status, tasks, artifacts, and logs commands. -/// -public static class TaskRunnerCommandGroup -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - /// - /// Build the 'taskrunner' command group. - /// - public static Command BuildTaskRunnerCommand(Option verboseOption, CancellationToken cancellationToken) - { - var taskrunnerCommand = new Command("taskrunner", "TaskRunner service operations"); - - taskrunnerCommand.Add(BuildStatusCommand(verboseOption, cancellationToken)); - taskrunnerCommand.Add(BuildTasksCommand(verboseOption, cancellationToken)); - taskrunnerCommand.Add(BuildArtifactsCommand(verboseOption, cancellationToken)); - taskrunnerCommand.Add(BuildLogsCommand(verboseOption, cancellationToken)); - - return taskrunnerCommand; - } - - #region TRN-001 - Status Command - - private static Command BuildStatusCommand(Option verboseOption, CancellationToken cancellationToken) - { - var formatOption = new Option("--format", ["-f"]) - { - Description = "Output format: table (default), json" - }; - formatOption.SetDefaultValue("table"); - - var statusCommand = new Command("status", "Show TaskRunner service status") - { - formatOption, - verboseOption - }; - - statusCommand.SetAction((parseResult, ct) => - { - var format = parseResult.GetValue(formatOption) ?? "table"; - var verbose = parseResult.GetValue(verboseOption); - - var status = new TaskRunnerStatus - { - Health = "healthy", - Version = "2.1.0", - Uptime = TimeSpan.FromDays(12).Add(TimeSpan.FromHours(5)), - Workers = new WorkerPoolStatus - { - Total = 8, - Active = 3, - Idle = 5, - MaxCapacity = 16 - }, - Queue = new QueueStatus - { - Pending = 12, - Running = 3, - Completed24h = 847, - Failed24h = 3 - } - }; - - if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) - { - Console.WriteLine(JsonSerializer.Serialize(status, JsonOptions)); - return Task.FromResult(0); - } - - Console.WriteLine("TaskRunner Status"); - Console.WriteLine("================="); - Console.WriteLine(); - Console.WriteLine($"Health: {status.Health}"); - Console.WriteLine($"Version: {status.Version}"); - Console.WriteLine($"Uptime: {status.Uptime.Days}d {status.Uptime.Hours}h"); - Console.WriteLine(); - Console.WriteLine("Worker Pool:"); - Console.WriteLine($" Total: {status.Workers.Total}"); - Console.WriteLine($" Active: {status.Workers.Active}"); - Console.WriteLine($" Idle: {status.Workers.Idle}"); - Console.WriteLine($" Capacity: {status.Workers.MaxCapacity}"); - Console.WriteLine(); - Console.WriteLine("Queue:"); - Console.WriteLine($" Pending: {status.Queue.Pending}"); - Console.WriteLine($" Running: {status.Queue.Running}"); - Console.WriteLine($" Completed/24h: {status.Queue.Completed24h}"); - Console.WriteLine($" Failed/24h: {status.Queue.Failed24h}"); - - return Task.FromResult(0); - }); - - return statusCommand; - } - - #endregion - - #region TRN-002/TRN-003 - Tasks Commands - - private static Command BuildTasksCommand(Option verboseOption, CancellationToken cancellationToken) - { - var tasksCommand = new Command("tasks", "Task operations"); - - tasksCommand.Add(BuildTasksListCommand(verboseOption)); - tasksCommand.Add(BuildTasksShowCommand(verboseOption)); - tasksCommand.Add(BuildTasksCancelCommand(verboseOption)); - - return tasksCommand; - } - - private static Command BuildTasksListCommand(Option verboseOption) - { - var statusOption = new Option("--status", ["-s"]) - { - Description = "Filter by status: pending, running, completed, failed" - }; - - var typeOption = new Option("--type", ["-t"]) - { - Description = "Filter by task type" - }; - - var fromOption = new Option("--from") - { - Description = "Start time filter" - }; - - var toOption = new Option("--to") - { - Description = "End time filter" - }; - - var limitOption = new Option("--limit", ["-n"]) - { - Description = "Maximum number of tasks to show" - }; - limitOption.SetDefaultValue(20); - - var formatOption = new Option("--format", ["-f"]) - { - Description = "Output format: table (default), json" - }; - formatOption.SetDefaultValue("table"); - - var listCommand = new Command("list", "List tasks") - { - statusOption, - typeOption, - fromOption, - toOption, - limitOption, - formatOption, - verboseOption - }; - - listCommand.SetAction((parseResult, ct) => - { - var status = parseResult.GetValue(statusOption); - var type = parseResult.GetValue(typeOption); - var from = parseResult.GetValue(fromOption); - var to = parseResult.GetValue(toOption); - var limit = parseResult.GetValue(limitOption); - var format = parseResult.GetValue(formatOption) ?? "table"; - var verbose = parseResult.GetValue(verboseOption); - - var tasks = GetSampleTasks() - .Where(t => string.IsNullOrEmpty(status) || t.Status.Equals(status, StringComparison.OrdinalIgnoreCase)) - .Where(t => string.IsNullOrEmpty(type) || t.Type.Equals(type, StringComparison.OrdinalIgnoreCase)) - .Take(limit) - .ToList(); - - if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) - { - Console.WriteLine(JsonSerializer.Serialize(tasks, JsonOptions)); - return Task.FromResult(0); - } - - Console.WriteLine("Tasks"); - Console.WriteLine("====="); - Console.WriteLine(); - Console.WriteLine($"{"ID",-20} {"Type",-15} {"Status",-12} {"Duration",-10} {"Started"}"); - Console.WriteLine(new string('-', 75)); - - foreach (var task in tasks) - { - var duration = task.Duration.HasValue ? $"{task.Duration.Value.TotalSeconds:F0}s" : "-"; - Console.WriteLine($"{task.Id,-20} {task.Type,-15} {task.Status,-12} {duration,-10} {task.StartedAt:HH:mm:ss}"); - } - - Console.WriteLine(); - Console.WriteLine($"Total: {tasks.Count} tasks"); - - return Task.FromResult(0); - }); - - return listCommand; - } - - private static Command BuildTasksShowCommand(Option verboseOption) - { - var taskIdArg = new Argument("task-id") - { - Description = "Task ID to show" - }; - - var formatOption = new Option("--format", ["-f"]) - { - Description = "Output format: text (default), json" - }; - formatOption.SetDefaultValue("text"); - - var showCommand = new Command("show", "Show task details") - { - taskIdArg, - formatOption, - verboseOption - }; - - showCommand.SetAction((parseResult, ct) => - { - var taskId = parseResult.GetValue(taskIdArg) ?? string.Empty; - var format = parseResult.GetValue(formatOption) ?? "text"; - var verbose = parseResult.GetValue(verboseOption); - - var task = new TaskDetails - { - Id = taskId, - Type = "scan", - Status = "completed", - StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5), - CompletedAt = DateTimeOffset.UtcNow.AddMinutes(-2), - Duration = TimeSpan.FromMinutes(3), - Input = new { Image = "myapp:v1.2.3", ScanType = "full" }, - Steps = [ - new TaskStep { Name = "pull-image", Status = "completed", Duration = TimeSpan.FromSeconds(15) }, - new TaskStep { Name = "generate-sbom", Status = "completed", Duration = TimeSpan.FromSeconds(45) }, - new TaskStep { Name = "vuln-scan", Status = "completed", Duration = TimeSpan.FromMinutes(2) }, - new TaskStep { Name = "upload-results", Status = "completed", Duration = TimeSpan.FromSeconds(5) } - ], - Artifacts = ["sbom.json", "vulns.json", "scan-report.html"] - }; - - if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) - { - Console.WriteLine(JsonSerializer.Serialize(task, JsonOptions)); - return Task.FromResult(0); - } - - Console.WriteLine($"Task Details: {taskId}"); - Console.WriteLine(new string('=', 15 + taskId.Length)); - Console.WriteLine(); - Console.WriteLine($"Type: {task.Type}"); - Console.WriteLine($"Status: {task.Status}"); - Console.WriteLine($"Started: {task.StartedAt:u}"); - Console.WriteLine($"Completed: {task.CompletedAt:u}"); - Console.WriteLine($"Duration: {task.Duration?.TotalMinutes:F1} minutes"); - Console.WriteLine(); - Console.WriteLine("Steps:"); - foreach (var step in task.Steps) - { - var icon = step.Status == "completed" ? "✓" : step.Status == "running" ? "▶" : "○"; - Console.WriteLine($" {icon} {step.Name}: {step.Duration?.TotalSeconds:F0}s"); - } - Console.WriteLine(); - Console.WriteLine("Artifacts:"); - foreach (var artifact in task.Artifacts) - { - Console.WriteLine($" • {artifact}"); - } - - return Task.FromResult(0); - }); - - return showCommand; - } - - private static Command BuildTasksCancelCommand(Option verboseOption) - { - var taskIdArg = new Argument("task-id") - { - Description = "Task ID to cancel" - }; - - var gracefulOption = new Option("--graceful") - { - Description = "Graceful shutdown (wait for current step)" - }; - - var forceOption = new Option("--force") - { - Description = "Force immediate termination" - }; - - var cancelCommand = new Command("cancel", "Cancel a task") - { - taskIdArg, - gracefulOption, - forceOption, - verboseOption - }; - - cancelCommand.SetAction((parseResult, ct) => - { - var taskId = parseResult.GetValue(taskIdArg) ?? string.Empty; - var graceful = parseResult.GetValue(gracefulOption); - var force = parseResult.GetValue(forceOption); - var verbose = parseResult.GetValue(verboseOption); - - Console.WriteLine("Task Cancellation"); - Console.WriteLine("================="); - Console.WriteLine(); - Console.WriteLine($"Task ID: {taskId}"); - Console.WriteLine($"Mode: {(force ? "force" : graceful ? "graceful" : "default")}"); - Console.WriteLine(); - - if (force) - { - Console.WriteLine("Task terminated immediately."); - } - else if (graceful) - { - Console.WriteLine("Waiting for current step to complete..."); - Console.WriteLine("Task cancelled gracefully."); - } - else - { - Console.WriteLine("Task cancellation requested."); - } - - Console.WriteLine($"Final Status: cancelled"); - - return Task.FromResult(0); - }); - - return cancelCommand; - } - - #endregion - - #region TRN-004 - Artifacts Commands - - private static Command BuildArtifactsCommand(Option verboseOption, CancellationToken cancellationToken) - { - var artifactsCommand = new Command("artifacts", "Task artifact operations"); - - artifactsCommand.Add(BuildArtifactsListCommand(verboseOption)); - artifactsCommand.Add(BuildArtifactsGetCommand(verboseOption)); - - return artifactsCommand; - } - - private static Command BuildArtifactsListCommand(Option verboseOption) - { - var taskOption = new Option("--task", ["-t"]) - { - Description = "Task ID to list artifacts for", - Required = true - }; - - var formatOption = new Option("--format", ["-f"]) - { - Description = "Output format: table (default), json" - }; - formatOption.SetDefaultValue("table"); - - var listCommand = new Command("list", "List task artifacts") - { - taskOption, - formatOption, - verboseOption - }; - - listCommand.SetAction((parseResult, ct) => - { - var taskId = parseResult.GetValue(taskOption) ?? string.Empty; - var format = parseResult.GetValue(formatOption) ?? "table"; - var verbose = parseResult.GetValue(verboseOption); - - var artifacts = new List - { - new() { Id = "art-001", Name = "sbom.json", Type = "application/json", Size = "245 KB", Digest = "sha256:abc123..." }, - new() { Id = "art-002", Name = "vulns.json", Type = "application/json", Size = "128 KB", Digest = "sha256:def456..." }, - new() { Id = "art-003", Name = "scan-report.html", Type = "text/html", Size = "89 KB", Digest = "sha256:ghi789..." } - }; - - if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) - { - Console.WriteLine(JsonSerializer.Serialize(artifacts, JsonOptions)); - return Task.FromResult(0); - } - - Console.WriteLine($"Artifacts for Task: {taskId}"); - Console.WriteLine(new string('=', 20 + taskId.Length)); - Console.WriteLine(); - Console.WriteLine($"{"ID",-12} {"Name",-25} {"Type",-20} {"Size",-10} {"Digest"}"); - Console.WriteLine(new string('-', 85)); - - foreach (var artifact in artifacts) - { - Console.WriteLine($"{artifact.Id,-12} {artifact.Name,-25} {artifact.Type,-20} {artifact.Size,-10} {artifact.Digest}"); - } - - return Task.FromResult(0); - }); - - return listCommand; - } - - private static Command BuildArtifactsGetCommand(Option verboseOption) - { - var artifactIdArg = new Argument("artifact-id") - { - Description = "Artifact ID to download" - }; - - var outputOption = new Option("--output", ["-o"]) - { - Description = "Output file path" - }; - - var getCommand = new Command("get", "Download an artifact") - { - artifactIdArg, - outputOption, - verboseOption - }; - - getCommand.SetAction((parseResult, ct) => - { - var artifactId = parseResult.GetValue(artifactIdArg) ?? string.Empty; - var output = parseResult.GetValue(outputOption); - var verbose = parseResult.GetValue(verboseOption); - - var outputPath = output ?? $"{artifactId}.bin"; - - Console.WriteLine("Downloading Artifact"); - Console.WriteLine("===================="); - Console.WriteLine(); - Console.WriteLine($"Artifact ID: {artifactId}"); - Console.WriteLine($"Output: {outputPath}"); - Console.WriteLine(); - Console.WriteLine("Downloading... done"); - Console.WriteLine("Verifying digest... ✓ verified"); - Console.WriteLine(); - Console.WriteLine($"Artifact saved to: {outputPath}"); - - return Task.FromResult(0); - }); - - return getCommand; - } - - #endregion - - #region TRN-005 - Logs Command - - private static Command BuildLogsCommand(Option verboseOption, CancellationToken cancellationToken) - { - var taskIdArg = new Argument("task-id") - { - Description = "Task ID to show logs for" - }; - - var followOption = new Option("--follow", ["-f"]) - { - Description = "Stream logs continuously" - }; - - var stepOption = new Option("--step", ["-s"]) - { - Description = "Filter by step name" - }; - - var levelOption = new Option("--level", ["-l"]) - { - Description = "Filter by log level: error, warn, info, debug" - }; - - var outputOption = new Option("--output", ["-o"]) - { - Description = "Save logs to file" - }; - - var logsCommand = new Command("logs", "Show task logs") - { - taskIdArg, - followOption, - stepOption, - levelOption, - outputOption, - verboseOption - }; - - logsCommand.SetAction((parseResult, ct) => - { - var taskId = parseResult.GetValue(taskIdArg) ?? string.Empty; - var follow = parseResult.GetValue(followOption); - var step = parseResult.GetValue(stepOption); - var level = parseResult.GetValue(levelOption); - var output = parseResult.GetValue(outputOption); - var verbose = parseResult.GetValue(verboseOption); - - Console.WriteLine($"Logs for Task: {taskId}"); - Console.WriteLine(new string('-', 50)); - - var logs = new[] - { - "[10:25:01] INFO [pull-image] Pulling image myapp:v1.2.3...", - "[10:25:15] INFO [pull-image] Image pulled successfully", - "[10:25:16] INFO [generate-sbom] Generating SBOM...", - "[10:25:45] INFO [generate-sbom] Found 847 components", - "[10:25:46] INFO [vuln-scan] Starting vulnerability scan...", - "[10:27:30] WARN [vuln-scan] Found 3 high severity vulnerabilities", - "[10:27:45] INFO [vuln-scan] Scan complete: 847 components, 3 high, 12 medium, 45 low", - "[10:27:46] INFO [upload-results] Uploading results...", - "[10:27:50] INFO [upload-results] Results uploaded successfully" - }; - - foreach (var log in logs) - { - if (!string.IsNullOrEmpty(step) && !log.Contains($"[{step}]")) - continue; - if (!string.IsNullOrEmpty(level) && !log.Contains(level.ToUpperInvariant())) - continue; - - Console.WriteLine(log); - } - - if (follow) - { - Console.WriteLine(); - Console.WriteLine("(streaming logs... press Ctrl+C to stop)"); - } - - if (!string.IsNullOrEmpty(output)) - { - Console.WriteLine(); - Console.WriteLine($"Logs saved to: {output}"); - } - - return Task.FromResult(0); - }); - - return logsCommand; - } - - #endregion - - #region Sample Data - - private static List GetSampleTasks() - { - var now = DateTimeOffset.UtcNow; - return - [ - new TaskInfo { Id = "task-001", Type = "scan", Status = "running", StartedAt = now.AddMinutes(-2), Duration = null }, - new TaskInfo { Id = "task-002", Type = "attest", Status = "running", StartedAt = now.AddMinutes(-1), Duration = null }, - new TaskInfo { Id = "task-003", Type = "scan", Status = "pending", StartedAt = now, Duration = null }, - new TaskInfo { Id = "task-004", Type = "scan", Status = "completed", StartedAt = now.AddMinutes(-10), Duration = TimeSpan.FromMinutes(3) }, - new TaskInfo { Id = "task-005", Type = "verify", Status = "completed", StartedAt = now.AddMinutes(-15), Duration = TimeSpan.FromSeconds(45) }, - new TaskInfo { Id = "task-006", Type = "attest", Status = "failed", StartedAt = now.AddMinutes(-20), Duration = TimeSpan.FromMinutes(2) } - ]; - } - - #endregion - - #region DTOs - - private sealed class TaskRunnerStatus - { - public string Health { get; set; } = string.Empty; - public string Version { get; set; } = string.Empty; - public TimeSpan Uptime { get; set; } - public WorkerPoolStatus Workers { get; set; } = new(); - public QueueStatus Queue { get; set; } = new(); - } - - private sealed class WorkerPoolStatus - { - public int Total { get; set; } - public int Active { get; set; } - public int Idle { get; set; } - public int MaxCapacity { get; set; } - } - - private sealed class QueueStatus - { - public int Pending { get; set; } - public int Running { get; set; } - public int Completed24h { get; set; } - public int Failed24h { get; set; } - } - - private sealed class TaskInfo - { - public string Id { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; - public string Status { get; set; } = string.Empty; - public DateTimeOffset StartedAt { get; set; } - public TimeSpan? Duration { get; set; } - } - - private sealed class TaskDetails - { - public string Id { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; - public string Status { get; set; } = string.Empty; - public DateTimeOffset StartedAt { get; set; } - public DateTimeOffset? CompletedAt { get; set; } - public TimeSpan? Duration { get; set; } - public object? Input { get; set; } - public List Steps { get; set; } = []; - public string[] Artifacts { get; set; } = []; - } - - private sealed class TaskStep - { - public string Name { get; set; } = string.Empty; - public string Status { get; set; } = string.Empty; - public TimeSpan? Duration { get; set; } - } - - private sealed class ArtifactInfo - { - public string Id { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; - public string Size { get; set; } = string.Empty; - public string Digest { get; set; } = string.Empty; - } - - #endregion -} diff --git a/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs b/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs index 2ccc09c3e..18c2137c1 100644 --- a/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs +++ b/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs @@ -693,64 +693,6 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient return MapPolicySimulation(document); } - public async Task SimulateTaskRunnerAsync(TaskRunnerSimulationRequest request, CancellationToken cancellationToken) - { - EnsureBackendConfigured(); - - if (request is null) - { - throw new ArgumentNullException(nameof(request)); - } - - if (string.IsNullOrWhiteSpace(request.Manifest)) - { - throw new ArgumentException("Manifest must be provided.", nameof(request)); - } - - var requestDocument = new TaskRunnerSimulationRequestDocument - { - Manifest = request.Manifest, - Inputs = request.Inputs - }; - - using var httpRequest = CreateRequest(HttpMethod.Post, "api/task-runner/simulations"); - await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); - httpRequest.Content = JsonContent.Create(requestDocument, options: SerializerOptions); - - using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) - { - var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); - throw new InvalidOperationException(failure); - } - - TaskRunnerSimulationResponseDocument? document; - try - { - document = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); - } - catch (JsonException ex) - { - var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - throw new InvalidOperationException($"Failed to parse task runner simulation response: {ex.Message}", ex) - { - Data = { ["payload"] = raw } - }; - } - - if (document is null) - { - throw new InvalidOperationException("Task runner simulation response was empty."); - } - - if (document.FailurePolicy is null) - { - throw new InvalidOperationException("Task runner simulation response missing failure policy."); - } - - return MapTaskRunnerSimulation(document); - } - public async Task GetPolicyFindingsAsync(PolicyFindingsQuery query, CancellationToken cancellationToken) { if (query is null) @@ -3257,64 +3199,6 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient string.IsNullOrWhiteSpace(document.ManifestDigest) ? null : document.ManifestDigest); } - private static TaskRunnerSimulationResult MapTaskRunnerSimulation(TaskRunnerSimulationResponseDocument document) - { - var failurePolicyDocument = document.FailurePolicy ?? throw new InvalidOperationException("Task runner simulation response missing failure policy."); - - var steps = document.Steps is null - ? new List() - : document.Steps - .Where(step => step is not null) - .Select(step => MapTaskRunnerSimulationStep(step!)) - .ToList(); - - var outputs = document.Outputs is null - ? new List() - : document.Outputs - .Where(output => output is not null) - .Select(output => new TaskRunnerSimulationOutput( - output!.Name ?? string.Empty, - output.Type ?? string.Empty, - output.RequiresRuntimeValue, - NormalizeOptionalString(output.PathExpression), - NormalizeOptionalString(output.ValueExpression))) - .ToList(); - - return new TaskRunnerSimulationResult( - document.PlanHash ?? string.Empty, - new TaskRunnerSimulationFailurePolicy( - failurePolicyDocument.MaxAttempts, - failurePolicyDocument.BackoffSeconds, - failurePolicyDocument.ContinueOnError), - steps, - outputs, - document.HasPendingApprovals); - } - - private static TaskRunnerSimulationStep MapTaskRunnerSimulationStep(TaskRunnerSimulationStepDocument document) - { - var children = document.Children is null - ? new List() - : document.Children - .Where(child => child is not null) - .Select(child => MapTaskRunnerSimulationStep(child!)) - .ToList(); - - return new TaskRunnerSimulationStep( - document.Id ?? string.Empty, - document.TemplateId ?? string.Empty, - document.Kind ?? string.Empty, - document.Enabled, - document.Status ?? string.Empty, - NormalizeOptionalString(document.StatusReason), - NormalizeOptionalString(document.Uses), - NormalizeOptionalString(document.ApprovalId), - NormalizeOptionalString(document.GateMessage), - document.MaxParallel, - document.ContinueOnError, - children); - } - private void EnsureBackendConfigured() { if (_httpClient.BaseAddress is null) diff --git a/src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs b/src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs index 4787a71c2..2d1269fc8 100644 --- a/src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs +++ b/src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs @@ -32,8 +32,6 @@ internal interface IBackendOperationsClient Task SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken); - Task SimulateTaskRunnerAsync(TaskRunnerSimulationRequest request, CancellationToken cancellationToken); - Task ActivatePolicyRevisionAsync(string policyId, int version, PolicyActivationRequest request, CancellationToken cancellationToken); Task DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken); diff --git a/src/Cli/StellaOps.Cli/Services/Models/TaskRunnerSimulationModels.cs b/src/Cli/StellaOps.Cli/Services/Models/TaskRunnerSimulationModels.cs deleted file mode 100644 index 59e17ac6e..000000000 --- a/src/Cli/StellaOps.Cli/Services/Models/TaskRunnerSimulationModels.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Nodes; - -namespace StellaOps.Cli.Services.Models; - -internal sealed record TaskRunnerSimulationRequest(string Manifest, JsonObject? Inputs); - -internal sealed record TaskRunnerSimulationResult( - string PlanHash, - TaskRunnerSimulationFailurePolicy FailurePolicy, - IReadOnlyList Steps, - IReadOnlyList Outputs, - bool HasPendingApprovals); - -internal sealed record TaskRunnerSimulationFailurePolicy(int MaxAttempts, int BackoffSeconds, bool ContinueOnError); - -internal sealed record TaskRunnerSimulationStep( - string Id, - string TemplateId, - string Kind, - bool Enabled, - string Status, - string? StatusReason, - string? Uses, - string? ApprovalId, - string? GateMessage, - int? MaxParallel, - bool ContinueOnError, - IReadOnlyList Children); - -internal sealed record TaskRunnerSimulationOutput( - string Name, - string Type, - bool RequiresRuntimeValue, - string? PathExpression, - string? ValueExpression); diff --git a/src/Cli/StellaOps.Cli/Services/Models/Transport/TaskRunnerSimulationTransport.cs b/src/Cli/StellaOps.Cli/Services/Models/Transport/TaskRunnerSimulationTransport.cs deleted file mode 100644 index 87520a3b0..000000000 --- a/src/Cli/StellaOps.Cli/Services/Models/Transport/TaskRunnerSimulationTransport.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Nodes; - -namespace StellaOps.Cli.Services.Models.Transport; - -internal sealed class TaskRunnerSimulationRequestDocument -{ - public string Manifest { get; set; } = string.Empty; - - public JsonObject? Inputs { get; set; } -} - -internal sealed class TaskRunnerSimulationResponseDocument -{ - public string PlanHash { get; set; } = string.Empty; - - public TaskRunnerSimulationFailurePolicyDocument? FailurePolicy { get; set; } - - public List? Steps { get; set; } - - public List? Outputs { get; set; } - - public bool HasPendingApprovals { get; set; } -} - -internal sealed class TaskRunnerSimulationFailurePolicyDocument -{ - public int MaxAttempts { get; set; } - - public int BackoffSeconds { get; set; } - - public bool ContinueOnError { get; set; } -} - -internal sealed class TaskRunnerSimulationStepDocument -{ - public string Id { get; set; } = string.Empty; - - public string TemplateId { get; set; } = string.Empty; - - public string Kind { get; set; } = string.Empty; - - public bool Enabled { get; set; } - - public string Status { get; set; } = string.Empty; - - public string? StatusReason { get; set; } - - public string? Uses { get; set; } - - public string? ApprovalId { get; set; } - - public string? GateMessage { get; set; } - - public int? MaxParallel { get; set; } - - public bool ContinueOnError { get; set; } - - public List? Children { get; set; } -} - -internal sealed class TaskRunnerSimulationOutputDocument -{ - public string Name { get; set; } = string.Empty; - - public string Type { get; set; } = string.Empty; - - public bool RequiresRuntimeValue { get; set; } - - public string? PathExpression { get; set; } - - public string? ValueExpression { get; set; } -} diff --git a/src/Cli/StellaOps.Cli/Telemetry/CliMetrics.cs b/src/Cli/StellaOps.Cli/Telemetry/CliMetrics.cs index 2916f1fb6..2595a42c2 100644 --- a/src/Cli/StellaOps.Cli/Telemetry/CliMetrics.cs +++ b/src/Cli/StellaOps.Cli/Telemetry/CliMetrics.cs @@ -47,7 +47,7 @@ internal static class CliMetrics private static readonly Counter OfflineKitDownloadCounter = Meter.CreateCounter("stellaops.cli.offline.kit.download.count"); private static readonly Counter OfflineKitImportCounter = Meter.CreateCounter("stellaops.cli.offline.kit.import.count"); private static readonly Counter PolicySimulationCounter = Meter.CreateCounter("stellaops.cli.policy.simulate.count"); - private static readonly Counter TaskRunnerSimulationCounter = Meter.CreateCounter("stellaops.cli.taskrunner.simulate.count"); + private static readonly Counter PolicyActivationCounter = Meter.CreateCounter("stellaops.cli.policy.activate.count"); private static readonly Counter SourcesDryRunCounter = Meter.CreateCounter("stellaops.cli.sources.dryrun.count"); private static readonly Counter AocVerifyCounter = Meter.CreateCounter("stellaops.cli.aoc.verify.count"); @@ -101,10 +101,6 @@ internal static class CliMetrics => PolicySimulationCounter.Add(1, WithSealedModeTag( Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome))); - public static void RecordTaskRunnerSimulation(string outcome) - => TaskRunnerSimulationCounter.Add(1, WithSealedModeTag( - Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome))); - public static void RecordPolicyActivation(string outcome) => PolicyActivationCounter.Add(1, WithSealedModeTag( Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome))); diff --git a/src/Cli/StellaOps.Cli/cli-routes.json b/src/Cli/StellaOps.Cli/cli-routes.json index 15b14396e..9b225ff08 100644 --- a/src/Cli/StellaOps.Cli/cli-routes.json +++ b/src/Cli/StellaOps.Cli/cli-routes.json @@ -857,13 +857,6 @@ "removeIn": "3.0", "reason": "Incident commands consolidated under admin" }, - { - "old": "taskrunner status", - "new": "admin taskrunner status", - "type": "deprecated", - "removeIn": "3.0", - "reason": "Task runner consolidated under admin" - }, { "old": "observability metrics", "new": "admin observability metrics", diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs index 650d1ca53..e4e1e8bc2 100644 --- a/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs @@ -3046,179 +3046,6 @@ public sealed class CommandHandlersTests } } - [Fact] - public async Task HandleTaskRunnerSimulateAsync_WritesInteractiveSummary() - { - var originalExit = Environment.ExitCode; - var originalConsole = AnsiConsole.Console; - - var console = new TestConsole(); - console.Width(120); - console.Interactive(); - console.EmitAnsiSequences(); - AnsiConsole.Console = console; - - const string manifest = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: sample-pack -spec: - steps: - - id: prepare - run: - uses: builtin:prepare - - id: approval - gate: - approval: - id: security-review - message: Security approval required. -"""; - - using var manifestFile = new TempFile("pack.yaml", Encoding.UTF8.GetBytes(manifest)); - - var simulationResult = new TaskRunnerSimulationResult( - "hash-abc123", - new TaskRunnerSimulationFailurePolicy(3, 15, false), - new[] - { - new TaskRunnerSimulationStep( - "prepare", - "prepare", - "Run", - true, - "succeeded", - null, - "builtin:prepare", - null, - null, - null, - false, - Array.Empty()), - new TaskRunnerSimulationStep( - "approval", - "approval", - "GateApproval", - true, - "pending", - "requires-approval", - null, - "security-review", - "Security approval required.", - null, - false, - Array.Empty()) - }, - new[] - { - new TaskRunnerSimulationOutput("bundlePath", "file", false, "artifacts/report.json", null) - }, - true); - - var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)) - { - TaskRunnerSimulationResult = simulationResult - }; - var provider = BuildServiceProvider(backend); - - try - { - await CommandHandlers.HandleTaskRunnerSimulateAsync( - provider, - manifestFile.Path, - inputsPath: null, - format: null, - outputPath: null, - verbose: false, - cancellationToken: CancellationToken.None); - - Assert.Equal(0, Environment.ExitCode); - Assert.NotNull(backend.LastTaskRunnerSimulationRequest); - Assert.Contains("approval", console.Output, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Plan Hash", console.Output, StringComparison.OrdinalIgnoreCase); - } - finally - { - AnsiConsole.Console = originalConsole; - Environment.ExitCode = originalExit; - } - } - - [Fact] - public async Task HandleTaskRunnerSimulateAsync_WritesJsonOutput() - { - var originalExit = Environment.ExitCode; - var originalOut = Console.Out; - - const string manifest = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: sample-pack -spec: - steps: - - id: prepare - run: - uses: builtin:prepare -"""; - - using var manifestFile = new TempFile("pack.yaml", Encoding.UTF8.GetBytes(manifest)); - using var inputsFile = new TempFile("inputs.json", Encoding.UTF8.GetBytes("{\"dryRun\":false}")); - using var outputDirectory = new TempDirectory(); - var outputPath = Path.Combine(outputDirectory.Path, "simulation.json"); - - var simulationResult = new TaskRunnerSimulationResult( - "hash-xyz789", - new TaskRunnerSimulationFailurePolicy(2, 10, true), - Array.Empty(), - Array.Empty(), - false); - - var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)) - { - TaskRunnerSimulationResult = simulationResult - }; - var provider = BuildServiceProvider(backend); - - using var writer = new StringWriter(); - Console.SetOut(writer); - - try - { - await CommandHandlers.HandleTaskRunnerSimulateAsync( - provider, - manifestFile.Path, - inputsFile.Path, - format: "json", - outputPath: outputPath, - verbose: false, - cancellationToken: CancellationToken.None); - - Assert.Equal(0, Environment.ExitCode); - Assert.NotNull(backend.LastTaskRunnerSimulationRequest); - - var consoleOutput = writer.ToString(); - using (var consoleJson = JsonDocument.Parse(consoleOutput)) - { - Assert.Equal("hash-xyz789", consoleJson.RootElement.GetProperty("planHash").GetString()); - } - - var fileOutput = await File.ReadAllTextAsync(outputPath); - using (var fileJson = JsonDocument.Parse(fileOutput)) - { - Assert.Equal("hash-xyz789", fileJson.RootElement.GetProperty("planHash").GetString()); - } - - Assert.True(backend.LastTaskRunnerSimulationRequest!.Inputs!.TryGetPropertyValue("dryRun", out var dryRunNode)); - Assert.False(dryRunNode!.GetValue()); - } - finally - { - Console.SetOut(originalOut); - Environment.ExitCode = originalExit; - } - } - [Fact] public async Task HandlePolicyActivateAsync_DisplaysInteractiveSummary() { @@ -4514,14 +4341,6 @@ spec: null); public PolicyApiException? SimulationException { get; set; } public (string PolicyId, PolicySimulationInput Input)? LastPolicySimulation { get; private set; } - public TaskRunnerSimulationRequest? LastTaskRunnerSimulationRequest { get; private set; } - public TaskRunnerSimulationResult TaskRunnerSimulationResult { get; set; } = new( - string.Empty, - new TaskRunnerSimulationFailurePolicy(1, 0, false), - Array.Empty(), - Array.Empty(), - false); - public Exception? TaskRunnerSimulationException { get; set; } public OfflineKitStatus? OfflineStatus { get; set; } public PolicyActivationResult ActivationResult { get; set; } = new PolicyActivationResult( "activated", @@ -4631,17 +4450,6 @@ spec: return Task.FromResult(SimulationResult); } - public Task SimulateTaskRunnerAsync(TaskRunnerSimulationRequest request, CancellationToken cancellationToken) - { - LastTaskRunnerSimulationRequest = request; - if (TaskRunnerSimulationException is not null) - { - throw TaskRunnerSimulationException; - } - - return Task.FromResult(TaskRunnerSimulationResult); - } - public Task ActivatePolicyRevisionAsync(string policyId, int version, PolicyActivationRequest request, CancellationToken cancellationToken) { LastPolicyActivation = (policyId, version, request); diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Integration/FullConsolidationTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Integration/FullConsolidationTests.cs index 9131b647c..205951681 100644 --- a/src/Cli/__Tests/StellaOps.Cli.Tests/Integration/FullConsolidationTests.cs +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Integration/FullConsolidationTests.cs @@ -116,7 +116,7 @@ public class FullConsolidationTests [InlineData("doctor run", "admin doctor run")] [InlineData("db migrate", "admin db migrate")] [InlineData("incidents list", "admin incidents list")] - [InlineData("taskrunner status", "admin taskrunner status")] + [InlineData("observability metrics", "admin observability metrics")] public void AdminConsolidation_ShouldMapCorrectly(string oldPath, string newPath) { diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Integration/HelpTextTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Integration/HelpTextTests.cs index 6146a1a49..611855e83 100644 --- a/src/Cli/__Tests/StellaOps.Cli.Tests/Integration/HelpTextTests.cs +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Integration/HelpTextTests.cs @@ -148,7 +148,7 @@ public class HelpTextTests // Arrange var expectedSubcommands = new[] { - "system", "doctor", "db", "incidents", "taskrunner" + "system", "doctor", "db", "incidents" }; // Act @@ -419,7 +419,6 @@ public class HelpTextTests doctor Diagnostics (from: doctor) db Database operations (from: db) incidents Incident management (from: incidents) - taskrunner Task runner (from: taskrunner) """; private static string GetToolsHelpText() => diff --git a/src/JobEngine/AGENTS.TaskRunner.md b/src/JobEngine/AGENTS.TaskRunner.md deleted file mode 100644 index dc77cc1cc..000000000 --- a/src/JobEngine/AGENTS.TaskRunner.md +++ /dev/null @@ -1,30 +0,0 @@ -# TaskRunner Module Charter - -## Mission -- Orchestrate deterministic task-pack execution, evidence, and replayable run logs. - -## Responsibilities -- Define pack run lifecycle, persistence, and evidence outputs. -- Ensure canonical plan hashing and deterministic event emission. -- Maintain offline-first execution and bounded resource usage. - -## Required Reading -- docs/README.md -- docs/07_HIGH_LEVEL_ARCHITECTURE.md -- docs/modules/platform/architecture-overview.md -- docs/modules/taskrunner/architecture.md - -## Working Agreement -- Use TimeProvider and IGuidGenerator for all timestamps and IDs. -- Use RFC 8785 canonical JSON for hashes and signatures. -- Propagate CancellationToken and avoid network by default. - -## Testing Strategy -- Unit tests for plan hashing, persistence, and evidence outputs. -- Determinism tests for run logs and identifiers. -- Integration tests for API and worker loops. - -## Service Endpoints -- Development: https://localhost:10180, http://localhost:10181 -- Local alias: https://taskrunner.stella-ops.local, http://taskrunner.stella-ops.local -- Env var: STELLAOPS_TASKRUNNER_URL diff --git a/src/JobEngine/README.md b/src/JobEngine/README.md index 637975955..97193a0f6 100644 --- a/src/JobEngine/README.md +++ b/src/JobEngine/README.md @@ -1,21 +1,21 @@ # JobEngine -**Container(s):** stellaops-scheduler-web, stellaops-scheduler-worker, stellaops-taskrunner-web, stellaops-taskrunner-worker, stellaops-packsregistry-web, stellaops-packsregistry-worker -**Slot:** 19 (scheduler), 18 (taskrunner), 34 (packsregistry) | **Port:** 8080 | **Consumer Group:** scheduler, taskrunner, packsregistry -**Resource Tier:** medium (scheduler), light (taskrunner, packsregistry) +**Container(s):** stellaops-scheduler-web, stellaops-scheduler-worker, stellaops-packsregistry-web, stellaops-packsregistry-worker +**Slot:** 19 (scheduler), 34 (packsregistry) | **Port:** 8080 | **Consumer Group:** scheduler, packsregistry +**Resource Tier:** medium (scheduler), light (packsregistry) ## Purpose -The JobEngine module provides scheduled scan orchestration, task execution, and pack registry management. The Scheduler manages scan schedules (CRON-based), graph jobs, policy simulation runs, vulnerability resolver jobs, and failure signatures. The TaskRunner executes task packs with air-gap-aware egress policies, simulation, and attestation. The PacksRegistry stores and serves versioned task pack bundles. +The JobEngine module provides scheduled scan orchestration and pack registry management. The Scheduler manages scan schedules (CRON-based), graph jobs, policy simulation runs, vulnerability resolver jobs, and failure signatures. The PacksRegistry stores and serves versioned task pack bundles. + +> **Note:** TaskRunner (Slot 18) was removed. The `task_runner_id` DB columns remain as nullable legacy fields. ## API Surface -- `scheduler` (via Router) — schedule CRUD, run history, graph jobs, policy runs, policy simulations, failure signatures, event webhooks, scripts endpoint -- `taskrunner` (via Router) — task pack execution, simulation, planning, incident mode, artifact management -- `packsregistry` (via Router) — pack upload, download, version listing, approval workflow +- `scheduler` (via Router) -- schedule CRUD, run history, graph jobs, policy runs, policy simulations, failure signatures, event webhooks, scripts endpoint +- `packsregistry` (via Router) -- pack upload, download, version listing, approval workflow ## Storage -PostgreSQL schema `scheduler` (Scheduler); PostgreSQL for TaskRunner and PacksRegistry; Valkey queue for job dispatch; seed-fs object store for artifacts +PostgreSQL schema `scheduler` (Scheduler); PostgreSQL for PacksRegistry; Valkey queue for job dispatch; seed-fs object store for artifacts ## Background Workers -- Scheduler: `SchedulerWorkerHostedService` — picks up scheduled jobs from Valkey and dispatches scan runs -- TaskRunner: worker process for pack execution +- Scheduler: `SchedulerWorkerHostedService` -- picks up scheduled jobs from Valkey and dispatches scan runs - PacksRegistry: worker process for background pack processing diff --git a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Extensions/TaskRunnerPersistenceExtensions.cs b/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Extensions/TaskRunnerPersistenceExtensions.cs deleted file mode 100644 index 49e195c92..000000000 --- a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Extensions/TaskRunnerPersistenceExtensions.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using StellaOps.Infrastructure.Postgres.Options; -using StellaOps.TaskRunner.Core.Evidence; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Persistence.Postgres; -using StellaOps.TaskRunner.Persistence.Postgres.Repositories; - -namespace StellaOps.TaskRunner.Persistence.Extensions; - -/// -/// Extension methods for configuring TaskRunner persistence services. -/// -public static class TaskRunnerPersistenceExtensions -{ - /// - /// Adds TaskRunner PostgreSQL persistence services. - /// - /// Service collection. - /// Configuration root. - /// Configuration section name for PostgreSQL options. - /// Service collection for chaining. - public static IServiceCollection AddTaskRunnerPersistence( - this IServiceCollection services, - IConfiguration configuration, - string sectionName = "Postgres:TaskRunner") - { - services.Configure(sectionName, configuration.GetSection(sectionName)); - services.AddSingleton(); - - // Register repositories as scoped (per-request lifetime) - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - return services; - } - - /// - /// Adds TaskRunner PostgreSQL persistence services with explicit options. - /// - /// Service collection. - /// Options configuration action. - /// Service collection for chaining. - public static IServiceCollection AddTaskRunnerPersistence( - this IServiceCollection services, - Action configureOptions) - { - services.Configure(configureOptions); - services.AddSingleton(); - - // Register repositories as scoped (per-request lifetime) - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - return services; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunApprovalStore.cs b/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunApprovalStore.cs deleted file mode 100644 index b9d3e4444..000000000 --- a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunApprovalStore.cs +++ /dev/null @@ -1,221 +0,0 @@ - -using Microsoft.Extensions.Logging; -using Npgsql; -using StellaOps.Infrastructure.Postgres.Repositories; -using StellaOps.TaskRunner.Core.Execution; -using System.Text.Json; - -namespace StellaOps.TaskRunner.Persistence.Postgres.Repositories; - -/// -/// PostgreSQL implementation of . -/// -public sealed class PostgresPackRunApprovalStore : RepositoryBase, IPackRunApprovalStore -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - private bool _tableInitialized; - - public PostgresPackRunApprovalStore(TaskRunnerDataSource dataSource, ILogger logger) - : base(dataSource, logger) - { - } - - public async Task SaveAsync(string runId, IReadOnlyList approvals, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - ArgumentNullException.ThrowIfNull(approvals); - - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - - // Delete existing approvals for this run, then insert all new ones - const string deleteSql = "DELETE FROM taskrunner.pack_run_approvals WHERE run_id = @run_id"; - await using (var deleteCmd = CreateCommand(deleteSql, connection)) - { - AddParameter(deleteCmd, "@run_id", runId); - await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } - - if (approvals.Count == 0) - { - return; - } - - const string insertSql = @" - INSERT INTO taskrunner.pack_run_approvals ( - run_id, approval_id, required_grants, step_ids, messages, reason_template, - requested_at, status, actor_id, completed_at, summary - ) VALUES ( - @run_id, @approval_id, @required_grants, @step_ids, @messages, @reason_template, - @requested_at, @status, @actor_id, @completed_at, @summary - )"; - - foreach (var approval in approvals) - { - await using var insertCmd = CreateCommand(insertSql, connection); - AddParameter(insertCmd, "@run_id", runId); - AddParameter(insertCmd, "@approval_id", approval.ApprovalId); - AddJsonbParameter(insertCmd, "@required_grants", JsonSerializer.Serialize(approval.RequiredGrants, JsonOptions)); - AddJsonbParameter(insertCmd, "@step_ids", JsonSerializer.Serialize(approval.StepIds, JsonOptions)); - AddJsonbParameter(insertCmd, "@messages", JsonSerializer.Serialize(approval.Messages, JsonOptions)); - AddParameter(insertCmd, "@reason_template", (object?)approval.ReasonTemplate ?? DBNull.Value); - AddParameter(insertCmd, "@requested_at", approval.RequestedAt); - AddParameter(insertCmd, "@status", approval.Status.ToString()); - AddParameter(insertCmd, "@actor_id", (object?)approval.ActorId ?? DBNull.Value); - AddParameter(insertCmd, "@completed_at", (object?)approval.CompletedAt ?? DBNull.Value); - AddParameter(insertCmd, "@summary", (object?)approval.Summary ?? DBNull.Value); - - await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } - } - - public async Task> GetAsync(string runId, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - const string sql = @" - SELECT approval_id, required_grants, step_ids, messages, reason_template, - requested_at, status, actor_id, completed_at, summary - FROM taskrunner.pack_run_approvals - WHERE run_id = @run_id - ORDER BY requested_at"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(sql, connection); - AddParameter(command, "@run_id", runId); - - await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - - var results = new List(); - while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) - { - results.Add(MapApprovalState(reader)); - } - - return results; - } - - public async Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - ArgumentNullException.ThrowIfNull(approval); - - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - const string sql = @" - UPDATE taskrunner.pack_run_approvals - SET required_grants = @required_grants, - step_ids = @step_ids, - messages = @messages, - reason_template = @reason_template, - requested_at = @requested_at, - status = @status, - actor_id = @actor_id, - completed_at = @completed_at, - summary = @summary - WHERE run_id = @run_id AND approval_id = @approval_id"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(sql, connection); - - AddParameter(command, "@run_id", runId); - AddParameter(command, "@approval_id", approval.ApprovalId); - AddJsonbParameter(command, "@required_grants", JsonSerializer.Serialize(approval.RequiredGrants, JsonOptions)); - AddJsonbParameter(command, "@step_ids", JsonSerializer.Serialize(approval.StepIds, JsonOptions)); - AddJsonbParameter(command, "@messages", JsonSerializer.Serialize(approval.Messages, JsonOptions)); - AddParameter(command, "@reason_template", (object?)approval.ReasonTemplate ?? DBNull.Value); - AddParameter(command, "@requested_at", approval.RequestedAt); - AddParameter(command, "@status", approval.Status.ToString()); - AddParameter(command, "@actor_id", (object?)approval.ActorId ?? DBNull.Value); - AddParameter(command, "@completed_at", (object?)approval.CompletedAt ?? DBNull.Value); - AddParameter(command, "@summary", (object?)approval.Summary ?? DBNull.Value); - - var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - - if (rowsAffected == 0) - { - throw new InvalidOperationException($"Approval '{approval.ApprovalId}' not found for run '{runId}'."); - } - } - - private static PackRunApprovalState MapApprovalState(NpgsqlDataReader reader) - { - var approvalId = reader.GetString(0); - var requiredGrantsJson = reader.GetString(1); - var stepIdsJson = reader.GetString(2); - var messagesJson = reader.GetString(3); - var reasonTemplate = reader.IsDBNull(4) ? null : reader.GetString(4); - var requestedAt = reader.GetFieldValue(5); - var statusString = reader.GetString(6); - var actorId = reader.IsDBNull(7) ? null : reader.GetString(7); - var completedAt = reader.IsDBNull(8) ? (DateTimeOffset?)null : reader.GetFieldValue(8); - var summary = reader.IsDBNull(9) ? null : reader.GetString(9); - - var requiredGrants = JsonSerializer.Deserialize>(requiredGrantsJson, JsonOptions) - ?? new List(); - var stepIds = JsonSerializer.Deserialize>(stepIdsJson, JsonOptions) - ?? new List(); - var messages = JsonSerializer.Deserialize>(messagesJson, JsonOptions) - ?? new List(); - - if (!Enum.TryParse(statusString, ignoreCase: true, out var status)) - { - status = PackRunApprovalStatus.Pending; - } - - return new PackRunApprovalState( - approvalId, - requiredGrants, - stepIds, - messages, - reasonTemplate, - requestedAt, - status, - actorId, - completedAt, - summary); - } - - private async Task EnsureTableAsync(CancellationToken cancellationToken) - { - if (_tableInitialized) - { - return; - } - - const string ddl = @" - CREATE SCHEMA IF NOT EXISTS taskrunner; - - CREATE TABLE IF NOT EXISTS taskrunner.pack_run_approvals ( - run_id TEXT NOT NULL, - approval_id TEXT NOT NULL, - required_grants JSONB NOT NULL, - step_ids JSONB NOT NULL, - messages JSONB NOT NULL, - reason_template TEXT, - requested_at TIMESTAMPTZ NOT NULL, - status TEXT NOT NULL, - actor_id TEXT, - completed_at TIMESTAMPTZ, - summary TEXT, - PRIMARY KEY (run_id, approval_id) - ); - - CREATE INDEX IF NOT EXISTS idx_pack_run_approvals_status ON taskrunner.pack_run_approvals (status); - CREATE INDEX IF NOT EXISTS idx_pack_run_approvals_requested_at ON taskrunner.pack_run_approvals (requested_at);"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(ddl, connection); - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - - _tableInitialized = true; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunEvidenceStore.cs b/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunEvidenceStore.cs deleted file mode 100644 index 9c6749b65..000000000 --- a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunEvidenceStore.cs +++ /dev/null @@ -1,294 +0,0 @@ - -using Microsoft.Extensions.Logging; -using Npgsql; -using StellaOps.Infrastructure.Postgres.Repositories; -using StellaOps.TaskRunner.Core.Evidence; -using System.Text.Json; - -namespace StellaOps.TaskRunner.Persistence.Postgres.Repositories; - -/// -/// PostgreSQL implementation of . -/// -public sealed class PostgresPackRunEvidenceStore : RepositoryBase, IPackRunEvidenceStore -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - private bool _tableInitialized; - - public PostgresPackRunEvidenceStore(TaskRunnerDataSource dataSource, ILogger logger) - : base(dataSource, logger) - { - } - - public async Task StoreAsync(PackRunEvidenceSnapshot snapshot, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(snapshot); - - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - const string sql = @" - INSERT INTO taskrunner.pack_run_evidence ( - snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json - ) VALUES ( - @snapshot_id, @tenant_id, @run_id, @plan_hash, @created_at, @kind, @materials_json, @root_hash, @metadata_json - ) - ON CONFLICT (snapshot_id) - DO UPDATE SET - tenant_id = EXCLUDED.tenant_id, - run_id = EXCLUDED.run_id, - plan_hash = EXCLUDED.plan_hash, - created_at = EXCLUDED.created_at, - kind = EXCLUDED.kind, - materials_json = EXCLUDED.materials_json, - root_hash = EXCLUDED.root_hash, - metadata_json = EXCLUDED.metadata_json"; - - var materialsJson = JsonSerializer.Serialize(snapshot.Materials, JsonOptions); - var metadataJson = snapshot.Metadata is null - ? null - : JsonSerializer.Serialize(snapshot.Metadata, JsonOptions); - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(sql, connection); - - AddParameter(command, "@snapshot_id", snapshot.SnapshotId); - AddParameter(command, "@tenant_id", snapshot.TenantId); - AddParameter(command, "@run_id", snapshot.RunId); - AddParameter(command, "@plan_hash", snapshot.PlanHash); - AddParameter(command, "@created_at", snapshot.CreatedAt); - AddParameter(command, "@kind", snapshot.Kind.ToString()); - AddJsonbParameter(command, "@materials_json", materialsJson); - AddParameter(command, "@root_hash", snapshot.RootHash); - if (metadataJson is not null) - { - AddJsonbParameter(command, "@metadata_json", metadataJson); - } - else - { - AddParameter(command, "@metadata_json", DBNull.Value); - } - - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } - - public async Task GetAsync(Guid snapshotId, CancellationToken cancellationToken = default) - { - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - const string sql = @" - SELECT snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json - FROM taskrunner.pack_run_evidence - WHERE snapshot_id = @snapshot_id"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(sql, connection); - AddParameter(command, "@snapshot_id", snapshotId); - - await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) - { - return null; - } - - return MapSnapshot(reader); - } - - public async Task> ListByRunAsync( - string tenantId, - string runId, - CancellationToken cancellationToken = default) - { - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - const string sql = @" - SELECT snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json - FROM taskrunner.pack_run_evidence - WHERE LOWER(tenant_id) = LOWER(@tenant_id) AND run_id = @run_id - ORDER BY created_at"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(sql, connection); - AddParameter(command, "@tenant_id", tenantId); - AddParameter(command, "@run_id", runId); - - await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - - var results = new List(); - while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) - { - results.Add(MapSnapshot(reader)); - } - - return results; - } - - public async Task> GetByRunIdAsync( - string runId, - CancellationToken cancellationToken = default) - { - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - const string sql = @" - SELECT snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json - FROM taskrunner.pack_run_evidence - WHERE run_id = @run_id - ORDER BY created_at"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(sql, connection); - AddParameter(command, "@run_id", runId); - - await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - - var results = new List(); - while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) - { - results.Add(MapSnapshot(reader)); - } - - return results; - } - - public async Task> ListByKindAsync( - string tenantId, - string runId, - PackRunEvidenceSnapshotKind kind, - CancellationToken cancellationToken = default) - { - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - const string sql = @" - SELECT snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json - FROM taskrunner.pack_run_evidence - WHERE LOWER(tenant_id) = LOWER(@tenant_id) AND run_id = @run_id AND kind = @kind - ORDER BY created_at"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(sql, connection); - AddParameter(command, "@tenant_id", tenantId); - AddParameter(command, "@run_id", runId); - AddParameter(command, "@kind", kind.ToString()); - - await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - - var results = new List(); - while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) - { - results.Add(MapSnapshot(reader)); - } - - return results; - } - - public async Task VerifyAsync( - Guid snapshotId, - CancellationToken cancellationToken = default) - { - var snapshot = await GetAsync(snapshotId, cancellationToken).ConfigureAwait(false); - - if (snapshot is null) - { - return new PackRunEvidenceVerificationResult( - Valid: false, - SnapshotId: snapshotId, - ExpectedHash: string.Empty, - ComputedHash: string.Empty, - Error: "Snapshot not found"); - } - - // Recompute by creating a new snapshot with same materials - var recomputed = PackRunEvidenceSnapshot.Create( - snapshot.TenantId, - snapshot.RunId, - snapshot.PlanHash, - snapshot.Kind, - snapshot.Materials, - snapshot.Metadata); - - var valid = string.Equals(snapshot.RootHash, recomputed.RootHash, StringComparison.Ordinal); - - return new PackRunEvidenceVerificationResult( - Valid: valid, - SnapshotId: snapshotId, - ExpectedHash: snapshot.RootHash, - ComputedHash: recomputed.RootHash, - Error: valid ? null : "Root hash mismatch"); - } - - private static PackRunEvidenceSnapshot MapSnapshot(NpgsqlDataReader reader) - { - var snapshotId = reader.GetGuid(0); - var tenantId = reader.GetString(1); - var runId = reader.GetString(2); - var planHash = reader.GetString(3); - var createdAt = reader.GetFieldValue(4); - var kindString = reader.GetString(5); - var materialsJson = reader.GetString(6); - var rootHash = reader.GetString(7); - var metadataJson = reader.IsDBNull(8) ? null : reader.GetString(8); - - if (!Enum.TryParse(kindString, ignoreCase: true, out var kind)) - { - kind = PackRunEvidenceSnapshotKind.RunCompletion; - } - - var materials = JsonSerializer.Deserialize>(materialsJson, JsonOptions) - ?? new List(); - - IReadOnlyDictionary? metadata = null; - if (metadataJson is not null) - { - metadata = JsonSerializer.Deserialize>(metadataJson, JsonOptions); - } - - return new PackRunEvidenceSnapshot( - snapshotId, - tenantId, - runId, - planHash, - createdAt, - kind, - materials, - rootHash, - metadata); - } - - private async Task EnsureTableAsync(CancellationToken cancellationToken) - { - if (_tableInitialized) - { - return; - } - - const string ddl = @" - CREATE SCHEMA IF NOT EXISTS taskrunner; - - CREATE TABLE IF NOT EXISTS taskrunner.pack_run_evidence ( - snapshot_id UUID PRIMARY KEY, - tenant_id TEXT NOT NULL, - run_id TEXT NOT NULL, - plan_hash TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - kind TEXT NOT NULL, - materials_json JSONB NOT NULL, - root_hash TEXT NOT NULL, - metadata_json JSONB - ); - - CREATE INDEX IF NOT EXISTS idx_pack_run_evidence_run_id ON taskrunner.pack_run_evidence (run_id); - CREATE INDEX IF NOT EXISTS idx_pack_run_evidence_tenant_run ON taskrunner.pack_run_evidence (tenant_id, run_id); - CREATE INDEX IF NOT EXISTS idx_pack_run_evidence_kind ON taskrunner.pack_run_evidence (tenant_id, run_id, kind); - CREATE INDEX IF NOT EXISTS idx_pack_run_evidence_created_at ON taskrunner.pack_run_evidence (created_at);"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(ddl, connection); - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - - _tableInitialized = true; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunLogStore.cs b/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunLogStore.cs deleted file mode 100644 index 3b17d25e9..000000000 --- a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunLogStore.cs +++ /dev/null @@ -1,157 +0,0 @@ - -using Microsoft.Extensions.Logging; -using Npgsql; -using StellaOps.Infrastructure.Postgres.Repositories; -using StellaOps.TaskRunner.Core.Execution; -using System.Runtime.CompilerServices; -using System.Text.Json; - -namespace StellaOps.TaskRunner.Persistence.Postgres.Repositories; - -/// -/// PostgreSQL implementation of . -/// -public sealed class PostgresPackRunLogStore : RepositoryBase, IPackRunLogStore -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - private bool _tableInitialized; - - public PostgresPackRunLogStore(TaskRunnerDataSource dataSource, ILogger logger) - : base(dataSource, logger) - { - } - - public async Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - ArgumentNullException.ThrowIfNull(entry); - - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - const string sql = @" - INSERT INTO taskrunner.pack_run_logs (run_id, timestamp, level, event_type, message, step_id, metadata) - VALUES (@run_id, @timestamp, @level, @event_type, @message, @step_id, @metadata)"; - - var metadataJson = entry.Metadata is null - ? null - : JsonSerializer.Serialize(entry.Metadata, JsonOptions); - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(sql, connection); - - AddParameter(command, "@run_id", runId); - AddParameter(command, "@timestamp", entry.Timestamp); - AddParameter(command, "@level", entry.Level); - AddParameter(command, "@event_type", entry.EventType); - AddParameter(command, "@message", entry.Message); - AddParameter(command, "@step_id", (object?)entry.StepId ?? DBNull.Value); - if (metadataJson is not null) - { - AddJsonbParameter(command, "@metadata", metadataJson); - } - else - { - AddParameter(command, "@metadata", DBNull.Value); - } - - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } - - public async IAsyncEnumerable ReadAsync( - string runId, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - const string sql = @" - SELECT timestamp, level, event_type, message, step_id, metadata - FROM taskrunner.pack_run_logs - WHERE run_id = @run_id - ORDER BY timestamp, id"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(sql, connection); - AddParameter(command, "@run_id", runId); - - await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - - while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) - { - yield return MapLogEntry(reader); - } - } - - public async Task ExistsAsync(string runId, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - const string sql = @" - SELECT EXISTS(SELECT 1 FROM taskrunner.pack_run_logs WHERE run_id = @run_id)"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(sql, connection); - AddParameter(command, "@run_id", runId); - - var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); - return result is true; - } - - private static PackRunLogEntry MapLogEntry(NpgsqlDataReader reader) - { - var timestamp = reader.GetFieldValue(0); - var level = reader.GetString(1); - var eventType = reader.GetString(2); - var message = reader.GetString(3); - var stepId = reader.IsDBNull(4) ? null : reader.GetString(4); - var metadataJson = reader.IsDBNull(5) ? null : reader.GetString(5); - - IReadOnlyDictionary? metadata = null; - if (metadataJson is not null) - { - metadata = JsonSerializer.Deserialize>(metadataJson, JsonOptions); - } - - return new PackRunLogEntry(timestamp, level, eventType, message, stepId, metadata); - } - - private async Task EnsureTableAsync(CancellationToken cancellationToken) - { - if (_tableInitialized) - { - return; - } - - const string ddl = @" - CREATE SCHEMA IF NOT EXISTS taskrunner; - - CREATE TABLE IF NOT EXISTS taskrunner.pack_run_logs ( - id BIGSERIAL PRIMARY KEY, - run_id TEXT NOT NULL, - timestamp TIMESTAMPTZ NOT NULL, - level TEXT NOT NULL, - event_type TEXT NOT NULL, - message TEXT NOT NULL, - step_id TEXT, - metadata JSONB - ); - - CREATE INDEX IF NOT EXISTS idx_pack_run_logs_run_id ON taskrunner.pack_run_logs (run_id); - CREATE INDEX IF NOT EXISTS idx_pack_run_logs_timestamp ON taskrunner.pack_run_logs (timestamp); - CREATE INDEX IF NOT EXISTS idx_pack_run_logs_run_timestamp ON taskrunner.pack_run_logs (run_id, timestamp, id);"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(ddl, connection); - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - - _tableInitialized = true; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunStateStore.cs b/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunStateStore.cs deleted file mode 100644 index 1081dbca6..000000000 --- a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunStateStore.cs +++ /dev/null @@ -1,174 +0,0 @@ - -using Microsoft.Extensions.Logging; -using Npgsql; -using StellaOps.Infrastructure.Postgres.Repositories; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using System.Text.Json; - -namespace StellaOps.TaskRunner.Persistence.Postgres.Repositories; - -/// -/// PostgreSQL implementation of . -/// -public sealed class PostgresPackRunStateStore : RepositoryBase, IPackRunStateStore -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - private bool _tableInitialized; - - public PostgresPackRunStateStore(TaskRunnerDataSource dataSource, ILogger logger) - : base(dataSource, logger) - { - } - - public async Task GetAsync(string runId, CancellationToken cancellationToken) - { - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - const string sql = @" - SELECT run_id, plan_hash, plan_json, failure_policy_json, requested_at, created_at, updated_at, steps_json, tenant_id - FROM taskrunner.pack_run_state - WHERE run_id = @run_id"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(sql, connection); - AddParameter(command, "@run_id", runId); - - await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) - { - return null; - } - - return MapPackRunState(reader); - } - - public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(state); - - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - const string sql = @" - INSERT INTO taskrunner.pack_run_state (run_id, plan_hash, plan_json, failure_policy_json, requested_at, created_at, updated_at, steps_json, tenant_id) - VALUES (@run_id, @plan_hash, @plan_json, @failure_policy_json, @requested_at, @created_at, @updated_at, @steps_json, @tenant_id) - ON CONFLICT (run_id) - DO UPDATE SET - plan_hash = EXCLUDED.plan_hash, - plan_json = EXCLUDED.plan_json, - failure_policy_json = EXCLUDED.failure_policy_json, - requested_at = EXCLUDED.requested_at, - updated_at = EXCLUDED.updated_at, - steps_json = EXCLUDED.steps_json, - tenant_id = EXCLUDED.tenant_id"; - - var planJson = JsonSerializer.Serialize(state.Plan, JsonOptions); - var failurePolicyJson = JsonSerializer.Serialize(state.FailurePolicy, JsonOptions); - var stepsJson = JsonSerializer.Serialize(state.Steps, JsonOptions); - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(sql, connection); - - AddParameter(command, "@run_id", state.RunId); - AddParameter(command, "@plan_hash", state.PlanHash); - AddJsonbParameter(command, "@plan_json", planJson); - AddJsonbParameter(command, "@failure_policy_json", failurePolicyJson); - AddParameter(command, "@requested_at", state.RequestedAt); - AddParameter(command, "@created_at", state.CreatedAt); - AddParameter(command, "@updated_at", state.UpdatedAt); - AddJsonbParameter(command, "@steps_json", stepsJson); - AddParameter(command, "@tenant_id", (object?)state.TenantId ?? DBNull.Value); - - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } - - public async Task> ListAsync(CancellationToken cancellationToken) - { - await EnsureTableAsync(cancellationToken).ConfigureAwait(false); - - const string sql = @" - SELECT run_id, plan_hash, plan_json, failure_policy_json, requested_at, created_at, updated_at, steps_json, tenant_id - FROM taskrunner.pack_run_state - ORDER BY created_at DESC"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(sql, connection); - await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - - var results = new List(); - while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) - { - results.Add(MapPackRunState(reader)); - } - - return results; - } - - private static PackRunState MapPackRunState(NpgsqlDataReader reader) - { - var runId = reader.GetString(0); - var planHash = reader.GetString(1); - var planJson = reader.GetString(2); - var failurePolicyJson = reader.GetString(3); - var requestedAt = reader.GetFieldValue(4); - var createdAt = reader.GetFieldValue(5); - var updatedAt = reader.GetFieldValue(6); - var stepsJson = reader.GetString(7); - var tenantId = reader.IsDBNull(8) ? null : reader.GetString(8); - - var plan = JsonSerializer.Deserialize(planJson, JsonOptions) - ?? throw new InvalidOperationException($"Failed to deserialize plan for run '{runId}'"); - var failurePolicy = JsonSerializer.Deserialize(failurePolicyJson, JsonOptions) - ?? throw new InvalidOperationException($"Failed to deserialize failure policy for run '{runId}'"); - var steps = JsonSerializer.Deserialize>(stepsJson, JsonOptions) - ?? new Dictionary(StringComparer.Ordinal); - - return new PackRunState( - runId, - planHash, - plan, - failurePolicy, - requestedAt, - createdAt, - updatedAt, - steps, - tenantId); - } - - private async Task EnsureTableAsync(CancellationToken cancellationToken) - { - if (_tableInitialized) - { - return; - } - - const string ddl = @" - CREATE SCHEMA IF NOT EXISTS taskrunner; - - CREATE TABLE IF NOT EXISTS taskrunner.pack_run_state ( - run_id TEXT PRIMARY KEY, - plan_hash TEXT NOT NULL, - plan_json JSONB NOT NULL, - failure_policy_json JSONB NOT NULL, - requested_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - steps_json JSONB NOT NULL, - tenant_id TEXT - ); - - CREATE INDEX IF NOT EXISTS idx_pack_run_state_tenant_id ON taskrunner.pack_run_state (tenant_id); - CREATE INDEX IF NOT EXISTS idx_pack_run_state_created_at ON taskrunner.pack_run_state (created_at DESC);"; - - await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); - await using var command = CreateCommand(ddl, connection); - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - - _tableInitialized = true; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/TaskRunnerDataSource.cs b/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/TaskRunnerDataSource.cs deleted file mode 100644 index a132acd24..000000000 --- a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/TaskRunnerDataSource.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Npgsql; -using StellaOps.Infrastructure.Postgres.Connections; -using StellaOps.Infrastructure.Postgres.Options; - -namespace StellaOps.TaskRunner.Persistence.Postgres; - -/// -/// PostgreSQL data source for TaskRunner module. -/// -public sealed class TaskRunnerDataSource : DataSourceBase -{ - /// - /// Default schema name for TaskRunner tables. - /// - public const string DefaultSchemaName = "taskrunner"; - - /// - /// Creates a new TaskRunner data source. - /// - public TaskRunnerDataSource(IOptions options, ILogger logger) - : base(CreateOptions(options.Value), logger) - { - } - - /// - protected override string ModuleName => "TaskRunner"; - - /// - protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder) - { - base.ConfigureDataSourceBuilder(builder); - } - - private static PostgresOptions CreateOptions(PostgresOptions baseOptions) - { - if (string.IsNullOrWhiteSpace(baseOptions.SchemaName)) - { - baseOptions.SchemaName = DefaultSchemaName; - } - return baseOptions; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/StellaOps.TaskRunner.Persistence.csproj b/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/StellaOps.TaskRunner.Persistence.csproj deleted file mode 100644 index ed75eb0e0..000000000 --- a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/StellaOps.TaskRunner.Persistence.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - - net10.0 - enable - enable - preview - true - StellaOps.TaskRunner.Persistence - StellaOps.TaskRunner.Persistence - Consolidated persistence layer for StellaOps TaskRunner module (EF Core + Raw SQL) - - - - - - - - - - - - - - diff --git a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/TASKS.md b/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/TASKS.md deleted file mode 100644 index f0a46d9a1..000000000 --- a/src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/TASKS.md +++ /dev/null @@ -1,8 +0,0 @@ -# StellaOps.TaskRunner.Persistence Task Board -This board mirrors active sprint tasks for this module. -Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`. - -| Task ID | Status | Notes | -| --- | --- | --- | -| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/__Libraries/StellaOps.TaskRunner.Persistence/StellaOps.TaskRunner.Persistence.md. | -| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | diff --git a/src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/PostgresPackRunStateStoreTests.cs b/src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/PostgresPackRunStateStoreTests.cs deleted file mode 100644 index 1cea47927..000000000 --- a/src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/PostgresPackRunStateStoreTests.cs +++ /dev/null @@ -1,172 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using StellaOps.TaskRunner.Persistence.Postgres; -using StellaOps.TaskRunner.Persistence.Postgres.Repositories; -using StellaOps.Infrastructure.Postgres.Options; -using Xunit; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Persistence.Tests; - -[Collection(TaskRunnerPostgresCollection.Name)] -public sealed class PostgresPackRunStateStoreTests : IAsyncLifetime -{ - private readonly TaskRunnerPostgresFixture _fixture; - private readonly PostgresPackRunStateStore _store; - private readonly TaskRunnerDataSource _dataSource; - - public PostgresPackRunStateStoreTests(TaskRunnerPostgresFixture fixture) - { - _fixture = fixture; - var options = Options.Create(new PostgresOptions - { - ConnectionString = fixture.ConnectionString, - SchemaName = TaskRunnerDataSource.DefaultSchemaName, - AutoMigrate = false - }); - - _dataSource = new TaskRunnerDataSource(options, NullLogger.Instance); - _store = new PostgresPackRunStateStore(_dataSource, NullLogger.Instance); - } - - public async ValueTask InitializeAsync() - { - await _fixture.TruncateAllTablesAsync(); - } - - public async ValueTask DisposeAsync() - { - await _dataSource.DisposeAsync(); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task GetAsync_ReturnsNullForUnknownRunId() - { - // Act - var result = await _store.GetAsync("nonexistent-run-id", CancellationToken.None); - - // Assert - result.Should().BeNull(); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task SaveAndGet_RoundTripsState() - { - // Arrange - var runId = "run-" + Guid.NewGuid().ToString("N")[..8]; - var state = CreateState(runId); - - // Act - await _store.SaveAsync(state, CancellationToken.None); - var fetched = await _store.GetAsync(runId, CancellationToken.None); - - // Assert - fetched.Should().NotBeNull(); - fetched!.RunId.Should().Be(runId); - fetched.PlanHash.Should().Be("sha256:plan123"); - fetched.Plan.Metadata.Name.Should().Be("test-pack"); - fetched.Steps.Should().HaveCount(1); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task SaveAsync_UpdatesExistingState() - { - // Arrange - var runId = "run-" + Guid.NewGuid().ToString("N")[..8]; - var state1 = CreateState(runId, "sha256:hash1"); - var state2 = CreateState(runId, "sha256:hash2"); - - // Act - await _store.SaveAsync(state1, CancellationToken.None); - await _store.SaveAsync(state2, CancellationToken.None); - var fetched = await _store.GetAsync(runId, CancellationToken.None); - - // Assert - fetched.Should().NotBeNull(); - fetched!.PlanHash.Should().Be("sha256:hash2"); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ListAsync_ReturnsAllStates() - { - // Arrange - var state1 = CreateState("run-list-1"); - var state2 = CreateState("run-list-2"); - - await _store.SaveAsync(state1, CancellationToken.None); - await _store.SaveAsync(state2, CancellationToken.None); - - // Act - var states = await _store.ListAsync(CancellationToken.None); - - // Assert - states.Should().HaveCountGreaterThanOrEqualTo(2); - states.Select(s => s.RunId).Should().Contain("run-list-1", "run-list-2"); - } - - private static PackRunState CreateState(string runId, string planHash = "sha256:plan123") - { - var now = DateTimeOffset.UtcNow; - - var metadata = new TaskPackPlanMetadata( - Name: "test-pack", - Version: "1.0.0", - Description: "Test pack for integration tests", - Tags: ["test"]); - - var plan = new TaskPackPlan( - metadata: metadata, - inputs: new Dictionary(), - steps: [], - hash: planHash, - approvals: [], - secrets: [], - outputs: [], - failurePolicy: null); - - var failurePolicy = new TaskPackPlanFailurePolicy( - MaxAttempts: 3, - BackoffSeconds: 30, - ContinueOnError: false); - - var stepState = new PackRunStepStateRecord( - StepId: "step-1", - Kind: PackRunStepKind.Run, - Enabled: true, - ContinueOnError: false, - MaxParallel: null, - ApprovalId: null, - GateMessage: null, - Status: PackRunStepExecutionStatus.Pending, - Attempts: 0, - LastTransitionAt: null, - NextAttemptAt: null, - StatusReason: null); - - var steps = new Dictionary(StringComparer.Ordinal) - { - ["step-1"] = stepState - }; - - return new PackRunState( - RunId: runId, - PlanHash: planHash, - Plan: plan, - FailurePolicy: failurePolicy, - RequestedAt: now, - CreatedAt: now, - UpdatedAt: now, - Steps: steps, - TenantId: "test-tenant"); - } -} - - - diff --git a/src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/StellaOps.TaskRunner.Persistence.Tests.csproj b/src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/StellaOps.TaskRunner.Persistence.Tests.csproj deleted file mode 100644 index 88f21655e..000000000 --- a/src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/StellaOps.TaskRunner.Persistence.Tests.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - - net10.0 - enable - enable - preview - false - true - - - - - - - - - - - - - diff --git a/src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/TASKS.md b/src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/TASKS.md deleted file mode 100644 index 14e5fd890..000000000 --- a/src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/TASKS.md +++ /dev/null @@ -1,8 +0,0 @@ -# StellaOps.TaskRunner.Persistence.Tests Task Board -This board mirrors active sprint tasks for this module. -Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`. - -| Task ID | Status | Notes | -| --- | --- | --- | -| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/__Tests/StellaOps.TaskRunner.Persistence.Tests/StellaOps.TaskRunner.Persistence.Tests.md. | -| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | diff --git a/src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/TaskRunnerPostgresFixture.cs b/src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/TaskRunnerPostgresFixture.cs deleted file mode 100644 index a82ff4a67..000000000 --- a/src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/TaskRunnerPostgresFixture.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Reflection; -using StellaOps.TaskRunner.Persistence.Postgres; -using StellaOps.Infrastructure.Postgres.Testing; -using Xunit; - -namespace StellaOps.TaskRunner.Persistence.Tests; - -/// -/// PostgreSQL integration test fixture for the TaskRunner module. -/// Runs migrations from embedded resources and provides test isolation. -/// -public sealed class TaskRunnerPostgresFixture : PostgresIntegrationFixture, ICollectionFixture -{ - protected override Assembly? GetMigrationAssembly() - => typeof(TaskRunnerDataSource).Assembly; - - protected override string GetModuleName() => "TaskRunner"; - - protected override string? GetResourcePrefix() => "Migrations"; -} - -/// -/// Collection definition for TaskRunner PostgreSQL integration tests. -/// Tests in this collection share a single PostgreSQL container instance. -/// -[CollectionDefinition(Name)] -public sealed class TaskRunnerPostgresCollection : ICollectionFixture -{ - public const string Name = "TaskRunnerPostgres"; -} diff --git a/src/JobEngine/StellaOps.TaskRunner/AGENTS.md b/src/JobEngine/StellaOps.TaskRunner/AGENTS.md deleted file mode 100644 index 758a3fceb..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/AGENTS.md +++ /dev/null @@ -1,32 +0,0 @@ -# Task Runner Service ??? Agent Charter - -## Mission -Execute Task Packs safely and deterministically. Provide remote pack execution, approvals, logging, artifact capture, and policy gates in support of Epic???12, honoring the imposed rule to propagate similar work where needed. - -## Responsibilities -- Validate Task Packs, enforce RBAC/approvals, orchestrate steps, manage artifacts/logs, stream status. -- Integrate with JobEngine, Authority, Policy Engine, Export Center, Notifications, and CLI. -- Guarantee reproducible runs, provenance manifests, and secure handling of secrets and networks. - -## Module Layout -- `StellaOps.TaskRunner.Core/` ??? execution engine, step DSL, policy gates. -- `StellaOps.TaskRunner.Infrastructure/` ??? storage adapters, artifact handling, external clients. -- `StellaOps.TaskRunner.WebService/` ??? run management APIs and simulation endpoints. -- `StellaOps.TaskRunner.Worker/` ??? background executors, approvals, and telemetry loops. -- `StellaOps.TaskRunner.Tests/` ??? unit tests for core/infrastructure code paths. -- `StellaOps.TaskRunner.sln` ??? module solution. - -## Required Reading -- `docs/modules/platform/architecture.md` -- `docs/modules/platform/architecture-overview.md` -- `docs/modules/taskrunner/architecture.md` -- `docs-archived/product/advisories/27-Nov-2025-superseded/28-Nov-2025 - Task Pack Orchestration and Automation.md` -- `docs/modules/packs-registry/guides/spec.md`, `docs/modules/packs-registry/guides/authoring-guide.md`, `docs/modules/packs-registry/guides/runbook.md` - -## Working Agreement -- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work. -- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met. -- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations; enforce plan-hash binding for every run. -- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change; sync sprint Decisions/Risks when advisory-driven changes land. -- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context. - diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Extensions/TaskRunnerClientServiceCollectionExtensions.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Extensions/TaskRunnerClientServiceCollectionExtensions.cs deleted file mode 100644 index 958113ce4..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Extensions/TaskRunnerClientServiceCollectionExtensions.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace StellaOps.TaskRunner.Client.Extensions; - -/// -/// Service collection extensions for registering the TaskRunner client. -/// -public static class TaskRunnerClientServiceCollectionExtensions -{ - /// - /// Adds the TaskRunner client to the service collection. - /// - /// Service collection. - /// Configuration. - /// HTTP client builder for further configuration. - public static IHttpClientBuilder AddTaskRunnerClient( - this IServiceCollection services, - IConfiguration configuration) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(configuration); - - services.Configure( - configuration.GetSection(TaskRunnerClientOptions.SectionName)); - - return services.AddHttpClient((sp, client) => - { - var options = configuration - .GetSection(TaskRunnerClientOptions.SectionName) - .Get(); - - if (options is not null && !string.IsNullOrWhiteSpace(options.BaseUrl)) - { - client.BaseAddress = new Uri(options.BaseUrl); - } - - if (!string.IsNullOrWhiteSpace(options?.UserAgent)) - { - client.DefaultRequestHeaders.UserAgent.TryParseAdd(options.UserAgent); - } - }); - } - - /// - /// Adds the TaskRunner client to the service collection with custom options. - /// - /// Service collection. - /// Options configuration action. - /// HTTP client builder for further configuration. - public static IHttpClientBuilder AddTaskRunnerClient( - this IServiceCollection services, - Action configureOptions) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(configureOptions); - - services.Configure(configureOptions); - - return services.AddHttpClient((sp, client) => - { - var options = new TaskRunnerClientOptions(); - configureOptions(options); - - if (!string.IsNullOrWhiteSpace(options.BaseUrl)) - { - client.BaseAddress = new Uri(options.BaseUrl); - } - - if (!string.IsNullOrWhiteSpace(options.UserAgent)) - { - client.DefaultRequestHeaders.UserAgent.TryParseAdd(options.UserAgent); - } - }); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/ITaskRunnerClient.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/ITaskRunnerClient.cs deleted file mode 100644 index 883745512..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/ITaskRunnerClient.cs +++ /dev/null @@ -1,124 +0,0 @@ -using StellaOps.TaskRunner.Client.Models; - -namespace StellaOps.TaskRunner.Client; - -/// -/// Client interface for the TaskRunner WebService API. -/// -public interface ITaskRunnerClient -{ - #region Pack Runs - - /// - /// Creates a new pack run. - /// - /// Run creation request. - /// Cancellation token. - /// Created run response. - Task CreateRunAsync( - CreatePackRunRequest request, - CancellationToken cancellationToken = default); - - /// - /// Gets the current state of a pack run. - /// - /// Run identifier. - /// Cancellation token. - /// Pack run state or null if not found. - Task GetRunAsync( - string runId, - CancellationToken cancellationToken = default); - - /// - /// Cancels a running pack run. - /// - /// Run identifier. - /// Cancellation token. - /// Cancel response. - Task CancelRunAsync( - string runId, - CancellationToken cancellationToken = default); - - #endregion - - #region Approvals - - /// - /// Applies an approval decision to a pending approval gate. - /// - /// Run identifier. - /// Approval gate identifier. - /// Decision request. - /// Cancellation token. - /// Approval decision response. - Task ApplyApprovalDecisionAsync( - string runId, - string approvalId, - ApprovalDecisionRequest request, - CancellationToken cancellationToken = default); - - #endregion - - #region Logs - - /// - /// Streams log entries for a pack run as NDJSON. - /// - /// Run identifier. - /// Cancellation token. - /// Async enumerable of log entries. - IAsyncEnumerable StreamLogsAsync( - string runId, - CancellationToken cancellationToken = default); - - #endregion - - #region Artifacts - - /// - /// Lists artifacts produced by a pack run. - /// - /// Run identifier. - /// Cancellation token. - /// Artifact list response. - Task ListArtifactsAsync( - string runId, - CancellationToken cancellationToken = default); - - #endregion - - #region Simulation - - /// - /// Simulates a task pack execution without running it. - /// - /// Simulation request. - /// Cancellation token. - /// Simulation result. - Task SimulateAsync( - SimulatePackRequest request, - CancellationToken cancellationToken = default); - - #endregion - - #region Metadata - - /// - /// Gets OpenAPI metadata including spec URL, version, and signature. - /// - /// Cancellation token. - /// OpenAPI metadata. - Task GetOpenApiMetadataAsync(CancellationToken cancellationToken = default); - - #endregion -} - -/// -/// OpenAPI metadata from /.well-known/openapi endpoint. -/// -public sealed record OpenApiMetadata( - [property: System.Text.Json.Serialization.JsonPropertyName("specUrl")] string SpecUrl, - [property: System.Text.Json.Serialization.JsonPropertyName("version")] string Version, - [property: System.Text.Json.Serialization.JsonPropertyName("buildVersion")] string BuildVersion, - [property: System.Text.Json.Serialization.JsonPropertyName("eTag")] string ETag, - [property: System.Text.Json.Serialization.JsonPropertyName("signature")] string Signature); diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Lifecycle/PackRunLifecycleHelper.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Lifecycle/PackRunLifecycleHelper.cs deleted file mode 100644 index 6e3ee3c4d..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Lifecycle/PackRunLifecycleHelper.cs +++ /dev/null @@ -1,231 +0,0 @@ - -using StellaOps.TaskRunner.Client.Models; - -namespace StellaOps.TaskRunner.Client.Lifecycle; - -/// -/// Helper methods for pack run lifecycle operations. -/// -public static class PackRunLifecycleHelper -{ - /// - /// Terminal statuses for pack runs. - /// - public static readonly IReadOnlySet TerminalStatuses = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "completed", - "failed", - "cancelled", - "rejected" - }; - - /// - /// Creates a run and waits for it to reach a terminal state. - /// - /// TaskRunner client. - /// Run creation request. - /// Interval between status checks (default: 2 seconds). - /// Maximum time to wait (default: 30 minutes). - /// Cancellation token. - /// Final pack run state. - public static async Task CreateAndWaitAsync( - ITaskRunnerClient client, - CreatePackRunRequest request, - TimeSpan? pollInterval = null, - TimeSpan? timeout = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(request); - - var interval = pollInterval ?? TimeSpan.FromSeconds(2); - var maxWait = timeout ?? TimeSpan.FromMinutes(30); - - var createResponse = await client.CreateRunAsync(request, cancellationToken).ConfigureAwait(false); - return await WaitForCompletionAsync(client, createResponse.RunId, interval, maxWait, cancellationToken) - .ConfigureAwait(false); - } - - /// - /// Waits for a pack run to reach a terminal state. - /// - /// TaskRunner client. - /// Run identifier. - /// Interval between status checks (default: 2 seconds). - /// Maximum time to wait (default: 30 minutes). - /// Cancellation token. - /// Final pack run state. - public static async Task WaitForCompletionAsync( - ITaskRunnerClient client, - string runId, - TimeSpan? pollInterval = null, - TimeSpan? timeout = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - var interval = pollInterval ?? TimeSpan.FromSeconds(2); - var maxWait = timeout ?? TimeSpan.FromMinutes(30); - - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(maxWait); - - while (true) - { - var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false); - if (state is null) - { - throw new InvalidOperationException($"Run '{runId}' not found."); - } - - if (TerminalStatuses.Contains(state.Status)) - { - return state; - } - - await Task.Delay(interval, cts.Token).ConfigureAwait(false); - } - } - - /// - /// Waits for a pack run to reach a pending approval state. - /// - /// TaskRunner client. - /// Run identifier. - /// Interval between status checks (default: 2 seconds). - /// Maximum time to wait (default: 10 minutes). - /// Cancellation token. - /// Pack run state with pending approvals, or null if run completed without approvals. - public static async Task WaitForApprovalAsync( - ITaskRunnerClient client, - string runId, - TimeSpan? pollInterval = null, - TimeSpan? timeout = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - var interval = pollInterval ?? TimeSpan.FromSeconds(2); - var maxWait = timeout ?? TimeSpan.FromMinutes(10); - - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(maxWait); - - while (true) - { - var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false); - if (state is null) - { - throw new InvalidOperationException($"Run '{runId}' not found."); - } - - if (TerminalStatuses.Contains(state.Status)) - { - return null; // Completed without needing approval - } - - if (state.PendingApprovals is { Count: > 0 }) - { - return state; - } - - await Task.Delay(interval, cts.Token).ConfigureAwait(false); - } - } - - /// - /// Approves all pending approvals for a run. - /// - /// TaskRunner client. - /// Run identifier. - /// Expected plan hash. - /// Actor applying the approval. - /// Approval summary. - /// Cancellation token. - /// Number of approvals applied. - public static async Task ApproveAllAsync( - ITaskRunnerClient client, - string runId, - string planHash, - string? actorId = null, - string? summary = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - ArgumentException.ThrowIfNullOrWhiteSpace(planHash); - - var state = await client.GetRunAsync(runId, cancellationToken).ConfigureAwait(false); - if (state?.PendingApprovals is null or { Count: 0 }) - { - return 0; - } - - var count = 0; - foreach (var approval in state.PendingApprovals) - { - var request = new ApprovalDecisionRequest("approved", planHash, actorId, summary); - await client.ApplyApprovalDecisionAsync(runId, approval.ApprovalId, request, cancellationToken) - .ConfigureAwait(false); - count++; - } - - return count; - } - - /// - /// Creates a run, auto-approves when needed, and waits for completion. - /// - /// TaskRunner client. - /// Run creation request. - /// Actor for auto-approval. - /// Interval between status checks. - /// Maximum time to wait. - /// Cancellation token. - /// Final pack run state. - public static async Task CreateRunAndAutoApproveAsync( - ITaskRunnerClient client, - CreatePackRunRequest request, - string? actorId = null, - TimeSpan? pollInterval = null, - TimeSpan? timeout = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(request); - - var interval = pollInterval ?? TimeSpan.FromSeconds(2); - var maxWait = timeout ?? TimeSpan.FromMinutes(30); - - var createResponse = await client.CreateRunAsync(request, cancellationToken).ConfigureAwait(false); - var runId = createResponse.RunId; - var planHash = createResponse.PlanHash; - - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(maxWait); - - while (true) - { - var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false); - if (state is null) - { - throw new InvalidOperationException($"Run '{runId}' not found."); - } - - if (TerminalStatuses.Contains(state.Status)) - { - return state; - } - - if (state.PendingApprovals is { Count: > 0 }) - { - await ApproveAllAsync(client, runId, planHash, actorId, "Auto-approved by SDK", cts.Token) - .ConfigureAwait(false); - } - - await Task.Delay(interval, cts.Token).ConfigureAwait(false); - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Models/PackRunModels.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Models/PackRunModels.cs deleted file mode 100644 index 0c1d5bd06..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Models/PackRunModels.cs +++ /dev/null @@ -1,174 +0,0 @@ -using System.Text.Json.Serialization; - -namespace StellaOps.TaskRunner.Client.Models; - -/// -/// Request to create a new pack run. -/// -public sealed record CreatePackRunRequest( - [property: JsonPropertyName("packId")] string PackId, - [property: JsonPropertyName("packVersion")] string? PackVersion = null, - [property: JsonPropertyName("inputs")] IReadOnlyDictionary? Inputs = null, - [property: JsonPropertyName("tenantId")] string? TenantId = null, - [property: JsonPropertyName("correlationId")] string? CorrelationId = null); - -/// -/// Response from creating a pack run. -/// -public sealed record CreatePackRunResponse( - [property: JsonPropertyName("runId")] string RunId, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("planHash")] string PlanHash, - [property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt); - -/// -/// Pack run state. -/// -public sealed record PackRunState( - [property: JsonPropertyName("runId")] string RunId, - [property: JsonPropertyName("packId")] string PackId, - [property: JsonPropertyName("packVersion")] string PackVersion, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("planHash")] string PlanHash, - [property: JsonPropertyName("currentStepId")] string? CurrentStepId, - [property: JsonPropertyName("steps")] IReadOnlyList Steps, - [property: JsonPropertyName("pendingApprovals")] IReadOnlyList? PendingApprovals, - [property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt, - [property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt, - [property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt, - [property: JsonPropertyName("error")] PackRunError? Error); - -/// -/// State of a single step in a pack run. -/// -public sealed record PackRunStepState( - [property: JsonPropertyName("stepId")] string StepId, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt, - [property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt, - [property: JsonPropertyName("retryCount")] int RetryCount, - [property: JsonPropertyName("outputs")] IReadOnlyDictionary? Outputs); - -/// -/// Pending approval gate. -/// -public sealed record PendingApproval( - [property: JsonPropertyName("approvalId")] string ApprovalId, - [property: JsonPropertyName("stepId")] string StepId, - [property: JsonPropertyName("message")] string? Message, - [property: JsonPropertyName("requiredGrants")] IReadOnlyList RequiredGrants, - [property: JsonPropertyName("requestedAt")] DateTimeOffset RequestedAt); - -/// -/// Pack run error information. -/// -public sealed record PackRunError( - [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("message")] string Message, - [property: JsonPropertyName("stepId")] string? StepId); - -/// -/// Request to apply an approval decision. -/// -public sealed record ApprovalDecisionRequest( - [property: JsonPropertyName("decision")] string Decision, - [property: JsonPropertyName("planHash")] string PlanHash, - [property: JsonPropertyName("actorId")] string? ActorId = null, - [property: JsonPropertyName("summary")] string? Summary = null); - -/// -/// Response from applying an approval decision. -/// -public sealed record ApprovalDecisionResponse( - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("resumed")] bool Resumed); - -/// -/// Request to simulate a task pack. -/// -public sealed record SimulatePackRequest( - [property: JsonPropertyName("manifest")] string Manifest, - [property: JsonPropertyName("inputs")] IReadOnlyDictionary? Inputs = null); - -/// -/// Simulation result for a task pack. -/// -public sealed record SimulatePackResponse( - [property: JsonPropertyName("valid")] bool Valid, - [property: JsonPropertyName("planHash")] string? PlanHash, - [property: JsonPropertyName("steps")] IReadOnlyList Steps, - [property: JsonPropertyName("errors")] IReadOnlyList? Errors); - -/// -/// Simulated step in a pack run. -/// -public sealed record SimulatedStep( - [property: JsonPropertyName("stepId")] string StepId, - [property: JsonPropertyName("kind")] string Kind, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("loopInfo")] LoopInfo? LoopInfo, - [property: JsonPropertyName("conditionalInfo")] ConditionalInfo? ConditionalInfo, - [property: JsonPropertyName("policyInfo")] PolicyInfo? PolicyInfo); - -/// -/// Loop step simulation info. -/// -public sealed record LoopInfo( - [property: JsonPropertyName("itemsExpression")] string? ItemsExpression, - [property: JsonPropertyName("iterator")] string Iterator, - [property: JsonPropertyName("maxIterations")] int MaxIterations); - -/// -/// Conditional step simulation info. -/// -public sealed record ConditionalInfo( - [property: JsonPropertyName("branches")] IReadOnlyList Branches, - [property: JsonPropertyName("hasElse")] bool HasElse); - -/// -/// Conditional branch info. -/// -public sealed record BranchInfo( - [property: JsonPropertyName("condition")] string Condition, - [property: JsonPropertyName("stepCount")] int StepCount); - -/// -/// Policy gate simulation info. -/// -public sealed record PolicyInfo( - [property: JsonPropertyName("policyId")] string PolicyId, - [property: JsonPropertyName("failureAction")] string FailureAction); - -/// -/// Artifact metadata. -/// -public sealed record ArtifactInfo( - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("path")] string Path, - [property: JsonPropertyName("size")] long Size, - [property: JsonPropertyName("sha256")] string Sha256, - [property: JsonPropertyName("contentType")] string? ContentType, - [property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt); - -/// -/// List of artifacts. -/// -public sealed record ArtifactListResponse( - [property: JsonPropertyName("artifacts")] IReadOnlyList Artifacts); - -/// -/// Run log entry. -/// -public sealed record RunLogEntry( - [property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp, - [property: JsonPropertyName("level")] string Level, - [property: JsonPropertyName("stepId")] string? StepId, - [property: JsonPropertyName("message")] string Message, - [property: JsonPropertyName("traceId")] string? TraceId); - -/// -/// Cancel run response. -/// -public sealed record CancelRunResponse( - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("message")] string? Message); diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Pagination/Paginator.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Pagination/Paginator.cs deleted file mode 100644 index 3e20a36b5..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Pagination/Paginator.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace StellaOps.TaskRunner.Client.Pagination; - -/// -/// Generic paginator for API responses. -/// -/// Type of items being paginated. -public sealed class Paginator -{ - private readonly Func>> _fetchPage; - private readonly int _pageSize; - - /// - /// Initializes a new paginator. - /// - /// Function to fetch a page (offset, limit, cancellationToken) -> page. - /// Number of items per page (default: 50). - public Paginator( - Func>> fetchPage, - int pageSize = 50) - { - _fetchPage = fetchPage ?? throw new ArgumentNullException(nameof(fetchPage)); - _pageSize = pageSize > 0 ? pageSize : throw new ArgumentOutOfRangeException(nameof(pageSize)); - } - - /// - /// Iterates through all pages asynchronously. - /// - /// Cancellation token. - /// Async enumerable of items. - public async IAsyncEnumerable GetAllAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var offset = 0; - - while (true) - { - var page = await _fetchPage(offset, _pageSize, cancellationToken).ConfigureAwait(false); - - foreach (var item in page.Items) - { - yield return item; - } - - if (!page.HasMore || page.Items.Count == 0) - { - break; - } - - offset += page.Items.Count; - } - } - - /// - /// Collects all items into a list. - /// - /// Cancellation token. - /// List of all items. - public async Task> CollectAsync(CancellationToken cancellationToken = default) - { - var items = new List(); - - await foreach (var item in GetAllAsync(cancellationToken).ConfigureAwait(false)) - { - items.Add(item); - } - - return items; - } - - /// - /// Gets a single page. - /// - /// Page number (1-based). - /// Cancellation token. - /// Single page response. - public Task> GetPageAsync(int pageNumber, CancellationToken cancellationToken = default) - { - if (pageNumber < 1) - { - throw new ArgumentOutOfRangeException(nameof(pageNumber), "Page number must be >= 1."); - } - - var offset = (pageNumber - 1) * _pageSize; - return _fetchPage(offset, _pageSize, cancellationToken); - } -} - -/// -/// Paginated response wrapper. -/// -/// Type of items. -public sealed record PagedResponse( - IReadOnlyList Items, - int TotalCount, - bool HasMore) -{ - /// - /// Creates an empty page. - /// - public static PagedResponse Empty { get; } = new([], 0, false); - - /// - /// Current page number (1-based) based on offset and page size. - /// - public int PageNumber(int offset, int pageSize) - => pageSize > 0 ? (offset / pageSize) + 1 : 1; -} - -/// -/// Extension methods for creating paginators. -/// -public static class PaginatorExtensions -{ - /// - /// Creates a paginator from a fetch function. - /// - public static Paginator Paginate( - this Func>> fetchPage, - int pageSize = 50) - => new(fetchPage, pageSize); - - /// - /// Takes the first N items from an async enumerable. - /// - public static async IAsyncEnumerable TakeAsync( - this IAsyncEnumerable source, - int count, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(source); - - if (count <= 0) - { - yield break; - } - - var taken = 0; - await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false)) - { - yield return item; - taken++; - if (taken >= count) - { - break; - } - } - } - - /// - /// Skips the first N items from an async enumerable. - /// - public static async IAsyncEnumerable SkipAsync( - this IAsyncEnumerable source, - int count, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(source); - - var skipped = 0; - await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false)) - { - if (skipped < count) - { - skipped++; - continue; - } - yield return item; - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/StellaOps.TaskRunner.Client.csproj b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/StellaOps.TaskRunner.Client.csproj deleted file mode 100644 index ec9aa72b7..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/StellaOps.TaskRunner.Client.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - net10.0 - enable - enable - true - preview - SDK client for StellaOps TaskRunner WebService API - - - - - - - - diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Streaming/StreamingLogReader.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Streaming/StreamingLogReader.cs deleted file mode 100644 index 237460d47..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Streaming/StreamingLogReader.cs +++ /dev/null @@ -1,154 +0,0 @@ - -using StellaOps.TaskRunner.Client.Models; -using System.Runtime.CompilerServices; -using System.Text.Json; - -namespace StellaOps.TaskRunner.Client.Streaming; - -/// -/// Helper for reading NDJSON streaming logs. -/// -public static class StreamingLogReader -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - /// - /// Reads log entries from an NDJSON stream. - /// - /// The input stream containing NDJSON log entries. - /// Cancellation token. - /// Async enumerable of log entries. - public static async IAsyncEnumerable ReadAsync( - Stream stream, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(stream); - - using var reader = new StreamReader(stream); - - string? line; - while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - RunLogEntry? entry; - try - { - entry = JsonSerializer.Deserialize(line, JsonOptions); - } - catch (JsonException) - { - continue; - } - - if (entry is not null) - { - yield return entry; - } - } - } - - /// - /// Collects all log entries from a stream into a list. - /// - /// The input stream containing NDJSON log entries. - /// Cancellation token. - /// List of all log entries. - public static async Task> CollectAsync( - Stream stream, - CancellationToken cancellationToken = default) - { - var entries = new List(); - - await foreach (var entry in ReadAsync(stream, cancellationToken).ConfigureAwait(false)) - { - entries.Add(entry); - } - - return entries; - } - - /// - /// Filters log entries by level. - /// - /// Source log entries. - /// Log levels to include (e.g., "error", "warning"). - /// Cancellation token. - /// Filtered log entries. - public static async IAsyncEnumerable FilterByLevelAsync( - IAsyncEnumerable entries, - IReadOnlySet levels, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(entries); - ArgumentNullException.ThrowIfNull(levels); - - await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false)) - { - if (levels.Contains(entry.Level, StringComparer.OrdinalIgnoreCase)) - { - yield return entry; - } - } - } - - /// - /// Filters log entries by step ID. - /// - /// Source log entries. - /// Step ID to filter by. - /// Cancellation token. - /// Filtered log entries. - public static async IAsyncEnumerable FilterByStepAsync( - IAsyncEnumerable entries, - string stepId, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(entries); - ArgumentException.ThrowIfNullOrWhiteSpace(stepId); - - await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false)) - { - if (string.Equals(entry.StepId, stepId, StringComparison.Ordinal)) - { - yield return entry; - } - } - } - - /// - /// Groups log entries by step ID. - /// - /// Source log entries. - /// Cancellation token. - /// Dictionary of step ID to log entries. - public static async Task>> GroupByStepAsync( - IAsyncEnumerable entries, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(entries); - - var groups = new Dictionary>(StringComparer.Ordinal); - - await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false)) - { - var key = entry.StepId ?? "(global)"; - if (!groups.TryGetValue(key, out var list)) - { - list = []; - groups[key] = list; - } - list.Add(entry); - } - - return groups.ToDictionary( - kvp => kvp.Key, - kvp => (IReadOnlyList)kvp.Value, - StringComparer.Ordinal); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/TASKS.md b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/TASKS.md deleted file mode 100644 index b385c57db..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/TASKS.md +++ /dev/null @@ -1,8 +0,0 @@ -# StellaOps.TaskRunner.Client Task Board -This board mirrors active sprint tasks for this module. -Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`. - -| Task ID | Status | Notes | -| --- | --- | --- | -| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/StellaOps.TaskRunner.Client.md. | -| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/TaskRunnerClient.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/TaskRunnerClient.cs deleted file mode 100644 index 10196e020..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/TaskRunnerClient.cs +++ /dev/null @@ -1,293 +0,0 @@ - -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using StellaOps.TaskRunner.Client.Models; -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; - -namespace StellaOps.TaskRunner.Client; - -/// -/// HTTP implementation of . -/// -public sealed class TaskRunnerClient : ITaskRunnerClient -{ - private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json"); - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - private readonly HttpClient _httpClient; - private readonly IOptionsMonitor _options; - private readonly ILogger? _logger; - - /// - /// Initializes a new instance of the class. - /// - public TaskRunnerClient( - HttpClient httpClient, - IOptionsMonitor options, - ILogger? logger = null) - { - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - _options = options ?? throw new ArgumentNullException(nameof(options)); - _logger = logger; - } - - #region Pack Runs - - /// - public async Task CreateRunAsync( - CreatePackRunRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - - var url = BuildUrl("/runs"); - using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = JsonContent.Create(request, JsonMediaType, JsonOptions) - }; - - using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) - .ConfigureAwait(false); - - return result ?? throw new InvalidOperationException("Response did not contain expected data."); - } - - /// - public async Task GetRunAsync( - string runId, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}"); - using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url); - - using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); - - if (response.StatusCode == HttpStatusCode.NotFound) - { - return null; - } - - response.EnsureSuccessStatusCode(); - - return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) - .ConfigureAwait(false); - } - - /// - public async Task CancelRunAsync( - string runId, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/cancel"); - using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url); - - using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) - .ConfigureAwait(false); - - return result ?? throw new InvalidOperationException("Response did not contain expected data."); - } - - #endregion - - #region Approvals - - /// - public async Task ApplyApprovalDecisionAsync( - string runId, - string approvalId, - ApprovalDecisionRequest request, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - ArgumentException.ThrowIfNullOrWhiteSpace(approvalId); - ArgumentNullException.ThrowIfNull(request); - - var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/approvals/{Uri.EscapeDataString(approvalId)}"); - using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = JsonContent.Create(request, JsonMediaType, JsonOptions) - }; - - using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) - .ConfigureAwait(false); - - return result ?? throw new InvalidOperationException("Response did not contain expected data."); - } - - #endregion - - #region Logs - - /// - public async IAsyncEnumerable StreamLogsAsync( - string runId, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/logs"); - using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url); - httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-ndjson")); - - // Use longer timeout for streaming - var streamingTimeout = TimeSpan.FromSeconds(_options.CurrentValue.StreamingTimeoutSeconds); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(streamingTimeout); - - using var response = await _httpClient.SendAsync( - httpRequest, - HttpCompletionOption.ResponseHeadersRead, - cts.Token).ConfigureAwait(false); - - response.EnsureSuccessStatusCode(); - - await using var stream = await response.Content.ReadAsStreamAsync(cts.Token).ConfigureAwait(false); - using var reader = new StreamReader(stream, Encoding.UTF8); - - string? line; - while ((line = await reader.ReadLineAsync(cts.Token).ConfigureAwait(false)) is not null) - { - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - RunLogEntry? entry; - try - { - entry = JsonSerializer.Deserialize(line, JsonOptions); - } - catch (JsonException ex) - { - _logger?.LogWarning(ex, "Failed to parse log entry: {Line}", line); - continue; - } - - if (entry is not null) - { - yield return entry; - } - } - } - - #endregion - - #region Artifacts - - /// - public async Task ListArtifactsAsync( - string runId, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/artifacts"); - using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url); - - using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) - .ConfigureAwait(false); - - return result ?? new ArtifactListResponse([]); - } - - #endregion - - #region Simulation - - /// - public async Task SimulateAsync( - SimulatePackRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - - var url = BuildUrl("/simulations"); - using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = JsonContent.Create(request, JsonMediaType, JsonOptions) - }; - - using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) - .ConfigureAwait(false); - - return result ?? throw new InvalidOperationException("Response did not contain expected data."); - } - - #endregion - - #region Metadata - - /// - public async Task GetOpenApiMetadataAsync(CancellationToken cancellationToken = default) - { - var options = _options.CurrentValue; - var url = new Uri(new Uri(options.BaseUrl), "/.well-known/openapi"); - - using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url); - - using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) - .ConfigureAwait(false); - - return result ?? throw new InvalidOperationException("Response did not contain expected data."); - } - - #endregion - - #region Helpers - - private Uri BuildUrl(string path) - { - var options = _options.CurrentValue; - var baseUrl = options.BaseUrl.TrimEnd('/'); - var apiPath = options.ApiPath.TrimEnd('/'); - return new Uri($"{baseUrl}{apiPath}{path}"); - } - - private async Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) - { - var options = _options.CurrentValue; - - if (!string.IsNullOrWhiteSpace(options.UserAgent)) - { - request.Headers.UserAgent.TryParseAdd(options.UserAgent); - } - - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds)); - - return await _httpClient.SendAsync(request, cts.Token).ConfigureAwait(false); - } - - #endregion -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/TaskRunnerClientOptions.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/TaskRunnerClientOptions.cs deleted file mode 100644 index a76f146e3..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/TaskRunnerClientOptions.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace StellaOps.TaskRunner.Client; - -/// -/// Configuration options for the TaskRunner client. -/// -public sealed class TaskRunnerClientOptions -{ - /// - /// Configuration section name. - /// - public const string SectionName = "TaskRunner:Client"; - - /// - /// Base URL for the TaskRunner API (e.g., "https://taskrunner.example.com"). - /// - public string BaseUrl { get; set; } = string.Empty; - - /// - /// API version path prefix (default: "/v1/task-runner"). - /// - public string ApiPath { get; set; } = "/v1/task-runner"; - - /// - /// Timeout for HTTP requests in seconds (default: 30). - /// - public int TimeoutSeconds { get; set; } = 30; - - /// - /// Timeout for streaming log requests in seconds (default: 300). - /// - public int StreamingTimeoutSeconds { get; set; } = 300; - - /// - /// Maximum number of retry attempts for transient failures (default: 3). - /// - public int MaxRetries { get; set; } = 3; - - /// - /// User-Agent header value for requests. - /// - public string? UserAgent { get; set; } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/IAirGapStatusProvider.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/IAirGapStatusProvider.cs deleted file mode 100644 index 4c19513bc..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/IAirGapStatusProvider.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace StellaOps.TaskRunner.Core.AirGap; - -/// -/// Provider for retrieving air-gap sealed mode status. -/// -public interface IAirGapStatusProvider -{ - /// - /// Gets the current sealed mode status of the environment. - /// - /// Optional tenant ID for multi-tenant environments. - /// Cancellation token. - /// The sealed mode status. - Task GetStatusAsync(string? tenantId = null, CancellationToken cancellationToken = default); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/ISealedInstallAuditLogger.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/ISealedInstallAuditLogger.cs deleted file mode 100644 index 434012c7c..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/ISealedInstallAuditLogger.cs +++ /dev/null @@ -1,125 +0,0 @@ -using StellaOps.TaskRunner.Core.Events; -using StellaOps.TaskRunner.Core.TaskPacks; - -namespace StellaOps.TaskRunner.Core.AirGap; - -/// -/// Audit logger for sealed install enforcement decisions. -/// -public interface ISealedInstallAuditLogger -{ - /// - /// Logs an enforcement decision. - /// - Task LogEnforcementAsync( - TaskPackManifest manifest, - SealedInstallEnforcementResult result, - string? tenantId = null, - string? runId = null, - string? actor = null, - CancellationToken cancellationToken = default); -} - -/// -/// Implementation of sealed install audit logger using timeline events. -/// -public sealed class SealedInstallAuditLogger : ISealedInstallAuditLogger -{ - private readonly IPackRunTimelineEventEmitter _eventEmitter; - - public SealedInstallAuditLogger(IPackRunTimelineEventEmitter eventEmitter) - { - _eventEmitter = eventEmitter ?? throw new ArgumentNullException(nameof(eventEmitter)); - } - - /// - public async Task LogEnforcementAsync( - TaskPackManifest manifest, - SealedInstallEnforcementResult result, - string? tenantId = null, - string? runId = null, - string? actor = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(manifest); - ArgumentNullException.ThrowIfNull(result); - - var effectiveTenantId = tenantId ?? "default"; - var effectiveRunId = runId ?? Guid.NewGuid().ToString("n"); - var now = DateTimeOffset.UtcNow; - - var eventType = result.Allowed - ? PackRunEventTypes.SealedInstallAllowed - : PackRunEventTypes.SealedInstallDenied; - - var severity = result.Allowed - ? PackRunEventSeverity.Info - : PackRunEventSeverity.Warning; - - var attributes = new Dictionary(StringComparer.Ordinal) - { - ["pack_name"] = manifest.Metadata.Name, - ["pack_version"] = manifest.Metadata.Version, - ["decision"] = result.Allowed ? "allowed" : "denied", - ["sealed_install_required"] = manifest.Spec.SealedInstall.ToString().ToLowerInvariant() - }; - - if (!string.IsNullOrWhiteSpace(result.ErrorCode)) - { - attributes["error_code"] = result.ErrorCode; - } - - object payload; - if (result.Allowed) - { - payload = new - { - event_type = "sealed_install_enforcement", - pack_id = manifest.Metadata.Name, - pack_version = manifest.Metadata.Version, - decision = "allowed", - reason = result.Message - }; - } - else - { - payload = new - { - event_type = "sealed_install_enforcement", - pack_id = manifest.Metadata.Name, - pack_version = manifest.Metadata.Version, - decision = "denied", - reason = result.ErrorCode, - message = result.Message, - violation = result.Violation is not null - ? new - { - required_sealed = result.Violation.RequiredSealed, - actual_sealed = result.Violation.ActualSealed, - recommendation = result.Violation.Recommendation - } - : null, - requirement_violations = result.RequirementViolations?.Select(v => new - { - requirement = v.Requirement, - expected = v.Expected, - actual = v.Actual, - message = v.Message - }).ToList() - }; - } - - var timelineEvent = PackRunTimelineEvent.Create( - tenantId: effectiveTenantId, - eventType: eventType, - source: "StellaOps.TaskRunner.SealedInstallEnforcer", - occurredAt: now, - runId: effectiveRunId, - actor: actor, - severity: severity, - attributes: attributes, - payload: payload); - - await _eventEmitter.EmitAsync(timelineEvent, cancellationToken).ConfigureAwait(false); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/ISealedInstallEnforcer.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/ISealedInstallEnforcer.cs deleted file mode 100644 index 43fb98e0c..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/ISealedInstallEnforcer.cs +++ /dev/null @@ -1,22 +0,0 @@ -using StellaOps.TaskRunner.Core.TaskPacks; - -namespace StellaOps.TaskRunner.Core.AirGap; - -/// -/// Enforces sealed install requirements for task packs. -/// Per sealed-install-enforcement.md contract. -/// -public interface ISealedInstallEnforcer -{ - /// - /// Enforces sealed install requirements for a task pack. - /// - /// The task pack manifest. - /// Optional tenant ID. - /// Cancellation token. - /// Enforcement result indicating whether execution is allowed. - Task EnforceAsync( - TaskPackManifest manifest, - string? tenantId = null, - CancellationToken cancellationToken = default); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedInstallEnforcementResult.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedInstallEnforcementResult.cs deleted file mode 100644 index 82451a93b..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedInstallEnforcementResult.cs +++ /dev/null @@ -1,118 +0,0 @@ -namespace StellaOps.TaskRunner.Core.AirGap; - -/// -/// Result of sealed install enforcement check. -/// Per sealed-install-enforcement.md contract. -/// -public sealed record SealedInstallEnforcementResult( - /// Whether execution is allowed. - bool Allowed, - - /// Error code if denied. - string? ErrorCode, - - /// Human-readable message. - string Message, - - /// Detailed violation information. - SealedInstallViolation? Violation, - - /// Requirement violations if any. - IReadOnlyList? RequirementViolations) -{ - /// - /// Creates an allowed result. - /// - public static SealedInstallEnforcementResult CreateAllowed(string message) - => new(true, null, message, null, null); - - /// - /// Creates a denied result. - /// - public static SealedInstallEnforcementResult CreateDenied( - string errorCode, - string message, - SealedInstallViolation? violation = null, - IReadOnlyList? requirementViolations = null) - => new(false, errorCode, message, violation, requirementViolations); -} - -/// -/// Details about a sealed install violation. -/// -public sealed record SealedInstallViolation( - /// Pack ID that requires sealed install. - string PackId, - - /// Pack version. - string? PackVersion, - - /// Whether pack requires sealed install. - bool RequiredSealed, - - /// Actual sealed status of environment. - bool ActualSealed, - - /// Recommendation for resolving the violation. - string Recommendation); - -/// -/// Details about a requirement violation. -/// -public sealed record RequirementViolation( - /// Name of the requirement that was violated. - string Requirement, - - /// Expected value. - string Expected, - - /// Actual value. - string Actual, - - /// Human-readable message describing the violation. - string Message); - -/// -/// Error codes for sealed install enforcement. -/// -public static class SealedInstallErrorCodes -{ - /// Pack requires sealed but environment is not sealed. - public const string SealedInstallViolation = "SEALED_INSTALL_VIOLATION"; - - /// Sealed requirements not met. - public const string SealedRequirementsViolation = "SEALED_REQUIREMENTS_VIOLATION"; - - /// Bundle version below minimum required. - public const string BundleVersionViolation = "BUNDLE_VERSION_VIOLATION"; - - /// Advisory data too stale. - public const string AdvisoryStalenessViolation = "ADVISORY_STALENESS_VIOLATION"; - - /// Time anchor missing or invalid. - public const string TimeAnchorViolation = "TIME_ANCHOR_VIOLATION"; - - /// Bundle signature verification failed. - public const string SignatureVerificationViolation = "SIGNATURE_VERIFICATION_VIOLATION"; -} - -/// -/// CLI exit codes for sealed install enforcement. -/// -public static class SealedInstallExitCodes -{ - /// Pack requires sealed but environment is not. - public const int SealedInstallViolation = 40; - - /// Bundle version below minimum. - public const int BundleVersionViolation = 41; - - /// Advisory data too stale. - public const int AdvisoryStalenessViolation = 42; - - /// Time anchor missing or invalid. - public const int TimeAnchorViolation = 43; - - /// Bundle signature verification failed. - public const int SignatureVerificationViolation = 44; -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedInstallEnforcer.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedInstallEnforcer.cs deleted file mode 100644 index 081a42562..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedInstallEnforcer.cs +++ /dev/null @@ -1,297 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using StellaOps.TaskRunner.Core.TaskPacks; - -namespace StellaOps.TaskRunner.Core.AirGap; - -/// -/// Enforces sealed install requirements for task packs. -/// Per sealed-install-enforcement.md contract. -/// -public sealed class SealedInstallEnforcer : ISealedInstallEnforcer -{ - private readonly IAirGapStatusProvider _statusProvider; - private readonly IOptions _options; - private readonly ILogger _logger; - - public SealedInstallEnforcer( - IAirGapStatusProvider statusProvider, - IOptions options, - ILogger logger) - { - _statusProvider = statusProvider ?? throw new ArgumentNullException(nameof(statusProvider)); - _options = options ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - public async Task EnforceAsync( - TaskPackManifest manifest, - string? tenantId = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(manifest); - - var options = _options.Value; - - // Check if enforcement is enabled - if (!options.Enabled) - { - _logger.LogDebug("Sealed install enforcement is disabled."); - return SealedInstallEnforcementResult.CreateAllowed("Enforcement disabled"); - } - - // Check for development bypass - if (options.BypassForDevelopment && IsDevelopmentEnvironment()) - { - _logger.LogWarning("Sealed install enforcement bypassed for development environment."); - return SealedInstallEnforcementResult.CreateAllowed("Development bypass active"); - } - - // If pack doesn't require sealed install, allow - if (!manifest.Spec.SealedInstall) - { - _logger.LogDebug( - "Pack {PackName} v{PackVersion} does not require sealed install.", - manifest.Metadata.Name, - manifest.Metadata.Version); - - return SealedInstallEnforcementResult.CreateAllowed("Pack does not require sealed install"); - } - - // Get environment sealed status - SealedModeStatus status; - try - { - status = await _statusProvider.GetStatusAsync(tenantId, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get air-gap status. Denying sealed install pack."); - - return SealedInstallEnforcementResult.CreateDenied( - SealedInstallErrorCodes.SealedInstallViolation, - "Failed to verify sealed mode status", - new SealedInstallViolation( - manifest.Metadata.Name, - manifest.Metadata.Version, - RequiredSealed: true, - ActualSealed: false, - Recommendation: "Ensure the AirGap controller is accessible: stella airgap status")); - } - - // Core check: environment must be sealed - if (!status.Sealed) - { - _logger.LogWarning( - "Sealed install violation: Pack {PackName} v{PackVersion} requires sealed environment but environment is {Mode}.", - manifest.Metadata.Name, - manifest.Metadata.Version, - status.Mode); - - return SealedInstallEnforcementResult.CreateDenied( - SealedInstallErrorCodes.SealedInstallViolation, - "Pack requires sealed environment but environment is not sealed", - new SealedInstallViolation( - manifest.Metadata.Name, - manifest.Metadata.Version, - RequiredSealed: true, - ActualSealed: false, - Recommendation: "Activate sealed mode with: stella airgap seal")); - } - - // Check sealed requirements if specified - var requirements = manifest.Spec.SealedRequirements ?? SealedRequirements.Default; - var violations = ValidateRequirements(requirements, status, options); - - if (violations.Count > 0) - { - _logger.LogWarning( - "Sealed requirements violation for pack {PackName} v{PackVersion}: {ViolationCount} requirement(s) not met.", - manifest.Metadata.Name, - manifest.Metadata.Version, - violations.Count); - - return SealedInstallEnforcementResult.CreateDenied( - SealedInstallErrorCodes.SealedRequirementsViolation, - "Sealed requirements not met", - violation: null, - requirementViolations: violations); - } - - _logger.LogInformation( - "Sealed install requirements satisfied for pack {PackName} v{PackVersion}.", - manifest.Metadata.Name, - manifest.Metadata.Version); - - return SealedInstallEnforcementResult.CreateAllowed("Sealed install requirements satisfied"); - } - - private List ValidateRequirements( - SealedRequirements requirements, - SealedModeStatus status, - SealedInstallEnforcementOptions options) - { - var violations = new List(); - - // Bundle version check - if (!string.IsNullOrWhiteSpace(requirements.MinBundleVersion) && - !string.IsNullOrWhiteSpace(status.BundleVersion)) - { - if (!IsVersionSatisfied(status.BundleVersion, requirements.MinBundleVersion)) - { - violations.Add(new RequirementViolation( - Requirement: "min_bundle_version", - Expected: requirements.MinBundleVersion, - Actual: status.BundleVersion, - Message: $"Bundle version {status.BundleVersion} < required {requirements.MinBundleVersion}")); - } - } - - // Advisory staleness check - var effectiveStaleness = status.AdvisoryStalenessHours; - var maxStaleness = requirements.MaxAdvisoryStalenessHours; - - // Apply grace period if configured - if (options.StalenessGracePeriodHours > 0) - { - maxStaleness += options.StalenessGracePeriodHours; - } - - if (effectiveStaleness > maxStaleness) - { - if (options.DenyOnStaleness) - { - violations.Add(new RequirementViolation( - Requirement: "max_advisory_staleness_hours", - Expected: requirements.MaxAdvisoryStalenessHours.ToString(), - Actual: effectiveStaleness.ToString(), - Message: $"Advisory data is {effectiveStaleness}h old, max allowed is {requirements.MaxAdvisoryStalenessHours}h")); - } - else if (effectiveStaleness > options.StalenessWarningThresholdHours) - { - _logger.LogWarning( - "Advisory data is {Staleness}h old, approaching max allowed {MaxStaleness}h.", - effectiveStaleness, - requirements.MaxAdvisoryStalenessHours); - } - } - - // Time anchor check - if (requirements.RequireTimeAnchor) - { - if (status.TimeAnchor is null) - { - violations.Add(new RequirementViolation( - Requirement: "require_time_anchor", - Expected: "valid time anchor", - Actual: "missing", - Message: "Valid time anchor required but not present")); - } - else if (!status.TimeAnchor.Valid) - { - violations.Add(new RequirementViolation( - Requirement: "require_time_anchor", - Expected: "valid time anchor", - Actual: "invalid", - Message: "Time anchor present but invalid or expired")); - } - else if (status.TimeAnchor.ExpiresAt.HasValue && - status.TimeAnchor.ExpiresAt.Value < DateTimeOffset.UtcNow) - { - violations.Add(new RequirementViolation( - Requirement: "require_time_anchor", - Expected: "non-expired time anchor", - Actual: $"expired at {status.TimeAnchor.ExpiresAt.Value:O}", - Message: "Time anchor has expired")); - } - } - - return violations; - } - - private static bool IsVersionSatisfied(string actual, string required) - { - // Try semantic version comparison - if (Version.TryParse(NormalizeVersion(actual), out var actualVersion) && - Version.TryParse(NormalizeVersion(required), out var requiredVersion)) - { - return actualVersion >= requiredVersion; - } - - // Fall back to string comparison - return string.Compare(actual, required, StringComparison.OrdinalIgnoreCase) >= 0; - } - - private static string NormalizeVersion(string version) - { - // Strip common prefixes like 'v' and suffixes like '-beta' - var normalized = version.TrimStart('v', 'V'); - var dashIndex = normalized.IndexOf('-', StringComparison.Ordinal); - if (dashIndex > 0) - { - normalized = normalized[..dashIndex]; - } - - return normalized; - } - - private static bool IsDevelopmentEnvironment() - { - var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? - Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); - - return string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase); - } -} - -/// -/// Configuration options for sealed install enforcement. -/// -public sealed class SealedInstallEnforcementOptions -{ - /// - /// Whether enforcement is enabled. - /// - public bool Enabled { get; set; } = true; - - /// - /// Grace period for advisory staleness in hours. - /// - public int StalenessGracePeriodHours { get; set; } = 24; - - /// - /// Warning threshold for staleness in hours. - /// - public int StalenessWarningThresholdHours { get; set; } = 120; - - /// - /// Whether to deny on staleness violation (false = warn only). - /// - public bool DenyOnStaleness { get; set; } = true; - - /// - /// Whether to use heuristic detection when AirGap controller is unavailable. - /// - public bool UseHeuristicDetection { get; set; } = true; - - /// - /// Heuristic score threshold to consider environment sealed. - /// - public double HeuristicThreshold { get; set; } = 0.7; - - /// - /// Bypass enforcement in development environments (DANGEROUS). - /// - public bool BypassForDevelopment { get; set; } - - /// - /// Log all enforcement decisions. - /// - public bool LogAllDecisions { get; set; } = true; - - /// - /// Audit retention in days. - /// - public int AuditRetentionDays { get; set; } = 365; -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedModeStatus.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedModeStatus.cs deleted file mode 100644 index a8ec903e2..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedModeStatus.cs +++ /dev/null @@ -1,88 +0,0 @@ -namespace StellaOps.TaskRunner.Core.AirGap; - -/// -/// Represents the sealed mode status of the air-gap environment. -/// Per sealed-install-enforcement.md contract. -/// -public sealed record SealedModeStatus( - /// Whether the environment is currently sealed. - bool Sealed, - - /// Current mode (sealed, unsealed, transitioning). - string Mode, - - /// When the environment was sealed. - DateTimeOffset? SealedAt, - - /// Identity that sealed the environment. - string? SealedBy, - - /// Air-gap bundle version currently installed. - string? BundleVersion, - - /// Digest of the bundle. - string? BundleDigest, - - /// When advisories were last updated. - DateTimeOffset? LastAdvisoryUpdate, - - /// Hours since last advisory update. - int AdvisoryStalenessHours, - - /// Time anchor information. - TimeAnchorInfo? TimeAnchor, - - /// Whether egress is blocked. - bool EgressBlocked, - - /// Network policy in effect. - string? NetworkPolicy) -{ - /// - /// Creates an unsealed status (environment not in air-gap mode). - /// - public static SealedModeStatus Unsealed() => new( - Sealed: false, - Mode: "unsealed", - SealedAt: null, - SealedBy: null, - BundleVersion: null, - BundleDigest: null, - LastAdvisoryUpdate: null, - AdvisoryStalenessHours: 0, - TimeAnchor: null, - EgressBlocked: false, - NetworkPolicy: null); - - /// - /// Creates a status indicating the provider is unavailable. - /// - public static SealedModeStatus Unavailable() => new( - Sealed: false, - Mode: "unavailable", - SealedAt: null, - SealedBy: null, - BundleVersion: null, - BundleDigest: null, - LastAdvisoryUpdate: null, - AdvisoryStalenessHours: 0, - TimeAnchor: null, - EgressBlocked: false, - NetworkPolicy: null); -} - -/// -/// Time anchor information for sealed environments. -/// -public sealed record TimeAnchorInfo( - /// The anchor timestamp. - DateTimeOffset Timestamp, - - /// Signature of the time anchor. - string? Signature, - - /// Whether the time anchor is valid. - bool Valid, - - /// When the time anchor expires. - DateTimeOffset? ExpiresAt); diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedRequirements.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedRequirements.cs deleted file mode 100644 index 864cdb192..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/AirGap/SealedRequirements.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Text.Json.Serialization; - -namespace StellaOps.TaskRunner.Core.AirGap; - -/// -/// Sealed install requirements specified in a task pack manifest. -/// Per sealed-install-enforcement.md contract. -/// -public sealed record SealedRequirements( - /// Minimum air-gap bundle version required. - [property: JsonPropertyName("min_bundle_version")] - string? MinBundleVersion, - - /// Maximum age of advisory data in hours (default: 168). - [property: JsonPropertyName("max_advisory_staleness_hours")] - int MaxAdvisoryStalenessHours, - - /// Whether a valid time anchor is required (default: true). - [property: JsonPropertyName("require_time_anchor")] - bool RequireTimeAnchor, - - /// Maximum allowed offline duration in hours (default: 720). - [property: JsonPropertyName("allowed_offline_duration_hours")] - int AllowedOfflineDurationHours, - - /// Whether bundle signature verification is required (default: true). - [property: JsonPropertyName("require_signature_verification")] - bool RequireSignatureVerification) -{ - /// - /// Default sealed requirements. - /// - public static SealedRequirements Default => new( - MinBundleVersion: null, - MaxAdvisoryStalenessHours: 168, - RequireTimeAnchor: true, - AllowedOfflineDurationHours: 720, - RequireSignatureVerification: true); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Attestation/IPackRunAttestationService.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Attestation/IPackRunAttestationService.cs deleted file mode 100644 index f7c49581a..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Attestation/IPackRunAttestationService.cs +++ /dev/null @@ -1,576 +0,0 @@ -using Microsoft.Extensions.Logging; -using StellaOps.TaskRunner.Core.Events; -using System.Text; -using System.Text.Json; - -namespace StellaOps.TaskRunner.Core.Attestation; - -/// -/// Service for generating and verifying pack run attestations. -/// Per TASKRUN-OBS-54-001. -/// -public interface IPackRunAttestationService -{ - /// - /// Generates an attestation for a pack run. - /// - Task GenerateAsync( - PackRunAttestationRequest request, - CancellationToken cancellationToken = default); - - /// - /// Verifies a pack run attestation. - /// - Task VerifyAsync( - PackRunAttestationVerificationRequest request, - CancellationToken cancellationToken = default); - - /// - /// Gets an attestation by ID. - /// - Task GetAsync( - Guid attestationId, - CancellationToken cancellationToken = default); - - /// - /// Lists attestations for a run. - /// - Task> ListByRunAsync( - string tenantId, - string runId, - CancellationToken cancellationToken = default); - - /// - /// Gets the DSSE envelope for an attestation. - /// - Task GetEnvelopeAsync( - Guid attestationId, - CancellationToken cancellationToken = default); -} - -/// -/// Store for pack run attestations. -/// -public interface IPackRunAttestationStore -{ - /// - /// Stores an attestation. - /// - Task StoreAsync( - PackRunAttestation attestation, - CancellationToken cancellationToken = default); - - /// - /// Gets an attestation by ID. - /// - Task GetAsync( - Guid attestationId, - CancellationToken cancellationToken = default); - - /// - /// Lists attestations for a run. - /// - Task> ListByRunAsync( - string tenantId, - string runId, - CancellationToken cancellationToken = default); - - /// - /// Updates attestation status. - /// - Task UpdateStatusAsync( - Guid attestationId, - PackRunAttestationStatus status, - string? error = null, - CancellationToken cancellationToken = default); -} - -/// -/// Signing provider for pack run attestations. -/// -public interface IPackRunAttestationSigner -{ - /// - /// Signs an in-toto statement. - /// - Task SignAsync( - byte[] statementBytes, - CancellationToken cancellationToken = default); - - /// - /// Verifies a DSSE envelope signature. - /// - Task VerifyAsync( - PackRunDsseEnvelope envelope, - CancellationToken cancellationToken = default); - - /// - /// Gets the current signing key ID. - /// - string GetKeyId(); -} - -/// -/// Default implementation of pack run attestation service. -/// -public sealed class PackRunAttestationService : IPackRunAttestationService -{ - private readonly IPackRunAttestationStore _store; - private readonly IPackRunAttestationSigner? _signer; - private readonly IPackRunTimelineEventEmitter? _timelineEmitter; - private readonly ILogger _logger; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - public PackRunAttestationService( - IPackRunAttestationStore store, - ILogger logger, - IPackRunAttestationSigner? signer = null, - IPackRunTimelineEventEmitter? timelineEmitter = null) - { - _store = store ?? throw new ArgumentNullException(nameof(store)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _signer = signer; - _timelineEmitter = timelineEmitter; - } - - /// - public async Task GenerateAsync( - PackRunAttestationRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - - try - { - // Build provenance predicate - var buildDefinition = new PackRunBuildDefinition( - BuildType: "https://stellaops.io/pack-run/v1", - ExternalParameters: request.ExternalParameters, - InternalParameters: new Dictionary - { - ["planHash"] = request.PlanHash - }, - ResolvedDependencies: request.ResolvedDependencies); - - var runDetails = new PackRunDetails( - Builder: new PackRunBuilder( - Id: request.BuilderId ?? "https://stellaops.io/task-runner", - Version: new Dictionary - { - ["stellaops.task-runner"] = GetVersion() - }, - BuilderDependencies: null), - Metadata: new PackRunProvMetadata( - InvocationId: request.RunId, - StartedOn: request.StartedAt, - FinishedOn: request.CompletedAt), - Byproducts: null); - - var predicate = new PackRunProvenancePredicate( - BuildDefinition: buildDefinition, - RunDetails: runDetails); - - var predicateJson = JsonSerializer.Serialize(predicate, JsonOptions); - - // Build in-toto statement - var statement = new PackRunInTotoStatement( - Type: InTotoStatementTypes.V1, - Subject: request.Subjects, - PredicateType: PredicateTypes.PackRunProvenance, - Predicate: predicate); - - var statementJson = JsonSerializer.Serialize(statement, JsonOptions); - var statementBytes = Encoding.UTF8.GetBytes(statementJson); - - // Sign if signer is available - PackRunDsseEnvelope? envelope = null; - PackRunAttestationStatus status = PackRunAttestationStatus.Pending; - string? error = null; - - if (_signer is not null) - { - try - { - envelope = await _signer.SignAsync(statementBytes, cancellationToken); - status = PackRunAttestationStatus.Signed; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to sign attestation for run {RunId}", request.RunId); - error = ex.Message; - status = PackRunAttestationStatus.Failed; - } - } - - // Create attestation record - var attestation = new PackRunAttestation( - AttestationId: Guid.NewGuid(), - TenantId: request.TenantId, - RunId: request.RunId, - PlanHash: request.PlanHash, - CreatedAt: DateTimeOffset.UtcNow, - Subjects: request.Subjects, - PredicateType: PredicateTypes.PackRunProvenance, - PredicateJson: predicateJson, - Envelope: envelope, - Status: status, - Error: error, - EvidenceSnapshotId: request.EvidenceSnapshotId, - Metadata: request.Metadata); - - // Store attestation - await _store.StoreAsync(attestation, cancellationToken); - - // Emit timeline event - if (_timelineEmitter is not null) - { - var eventType = status == PackRunAttestationStatus.Signed - ? PackRunAttestationEventTypes.AttestationCreated - : PackRunAttestationEventTypes.AttestationFailed; - - await _timelineEmitter.EmitAsync( - PackRunTimelineEvent.Create( - tenantId: request.TenantId, - eventType: eventType, - source: "taskrunner-attestation", - occurredAt: DateTimeOffset.UtcNow, - runId: request.RunId, - planHash: request.PlanHash, - attributes: new Dictionary - { - ["attestationId"] = attestation.AttestationId.ToString(), - ["predicateType"] = attestation.PredicateType, - ["subjectCount"] = request.Subjects.Count.ToString(), - ["status"] = status.ToString() - }, - evidencePointer: envelope is not null - ? PackRunEvidencePointer.Attestation( - request.RunId, - envelope.ComputeDigest()) - : null), - cancellationToken); - } - - _logger.LogInformation( - "Generated attestation {AttestationId} for run {RunId} with {SubjectCount} subjects, status {Status}", - attestation.AttestationId, - request.RunId, - request.Subjects.Count, - status); - - return new PackRunAttestationResult( - Success: status != PackRunAttestationStatus.Failed, - Attestation: attestation, - Error: error); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to generate attestation for run {RunId}", request.RunId); - - return new PackRunAttestationResult( - Success: false, - Attestation: null, - Error: ex.Message); - } - } - - /// - public async Task VerifyAsync( - PackRunAttestationVerificationRequest request, - CancellationToken cancellationToken = default) - { - var errors = new List(); - var signatureStatus = PackRunSignatureVerificationStatus.NotVerified; - var subjectStatus = PackRunSubjectVerificationStatus.NotVerified; - var revocationStatus = PackRunRevocationStatus.NotChecked; - - var attestation = await _store.GetAsync(request.AttestationId, cancellationToken); - if (attestation is null) - { - return new PackRunAttestationVerificationResult( - Valid: false, - AttestationId: request.AttestationId, - SignatureStatus: PackRunSignatureVerificationStatus.NotVerified, - SubjectStatus: PackRunSubjectVerificationStatus.NotVerified, - RevocationStatus: PackRunRevocationStatus.NotChecked, - Errors: ["Attestation not found"], - VerifiedAt: DateTimeOffset.UtcNow); - } - - // Verify signature - if (request.VerifySignature && attestation.Envelope is not null && _signer is not null) - { - try - { - var signatureValid = await _signer.VerifyAsync(attestation.Envelope, cancellationToken); - signatureStatus = signatureValid - ? PackRunSignatureVerificationStatus.Valid - : PackRunSignatureVerificationStatus.Invalid; - - if (!signatureValid) - { - errors.Add("Signature verification failed"); - } - } - catch (Exception ex) - { - signatureStatus = PackRunSignatureVerificationStatus.Invalid; - errors.Add($"Signature verification error: {ex.Message}"); - } - } - else if (request.VerifySignature && attestation.Envelope is null) - { - signatureStatus = PackRunSignatureVerificationStatus.Invalid; - errors.Add("No envelope available for signature verification"); - } - - // Verify subjects - if (request.VerifySubjects && request.ExpectedSubjects is not null) - { - var expectedSet = request.ExpectedSubjects - .Select(s => $"{s.Name}:{string.Join(",", s.Digest.OrderBy(d => d.Key).Select(d => $"{d.Key}={d.Value}"))}") - .ToHashSet(); - - var actualSet = attestation.Subjects - .Select(s => $"{s.Name}:{string.Join(",", s.Digest.OrderBy(d => d.Key).Select(d => $"{d.Key}={d.Value}"))}") - .ToHashSet(); - - if (expectedSet.SetEquals(actualSet)) - { - subjectStatus = PackRunSubjectVerificationStatus.Match; - } - else if (expectedSet.IsSubsetOf(actualSet)) - { - subjectStatus = PackRunSubjectVerificationStatus.Match; - } - else - { - var missing = expectedSet.Except(actualSet).ToList(); - if (missing.Count > 0) - { - subjectStatus = PackRunSubjectVerificationStatus.Missing; - errors.Add($"Missing subjects: {string.Join(", ", missing)}"); - } - else - { - subjectStatus = PackRunSubjectVerificationStatus.Mismatch; - errors.Add("Subject digest mismatch"); - } - } - } - - // Check revocation - if (request.CheckRevocation) - { - revocationStatus = attestation.Status == PackRunAttestationStatus.Revoked - ? PackRunRevocationStatus.Revoked - : PackRunRevocationStatus.NotRevoked; - - if (attestation.Status == PackRunAttestationStatus.Revoked) - { - errors.Add("Attestation has been revoked"); - } - } - - var valid = errors.Count == 0 && - (signatureStatus is PackRunSignatureVerificationStatus.Valid or PackRunSignatureVerificationStatus.NotVerified) && - (subjectStatus is PackRunSubjectVerificationStatus.Match or PackRunSubjectVerificationStatus.NotVerified) && - (revocationStatus is PackRunRevocationStatus.NotRevoked or PackRunRevocationStatus.NotChecked); - - return new PackRunAttestationVerificationResult( - Valid: valid, - AttestationId: request.AttestationId, - SignatureStatus: signatureStatus, - SubjectStatus: subjectStatus, - RevocationStatus: revocationStatus, - Errors: errors.Count > 0 ? errors : null, - VerifiedAt: DateTimeOffset.UtcNow); - } - - /// - public Task GetAsync( - Guid attestationId, - CancellationToken cancellationToken = default) - => _store.GetAsync(attestationId, cancellationToken); - - /// - public Task> ListByRunAsync( - string tenantId, - string runId, - CancellationToken cancellationToken = default) - => _store.ListByRunAsync(tenantId, runId, cancellationToken); - - /// - public async Task GetEnvelopeAsync( - Guid attestationId, - CancellationToken cancellationToken = default) - { - var attestation = await _store.GetAsync(attestationId, cancellationToken); - return attestation?.Envelope; - } - - private static string GetVersion() - { - var assembly = typeof(PackRunAttestationService).Assembly; - var version = assembly.GetName().Version; - return version?.ToString() ?? "0.0.0"; - } -} - -/// -/// Attestation event types for timeline. -/// -public static class PackRunAttestationEventTypes -{ - /// Attestation created successfully. - public const string AttestationCreated = "pack.attestation.created"; - - /// Attestation creation failed. - public const string AttestationFailed = "pack.attestation.failed"; - - /// Attestation verified. - public const string AttestationVerified = "pack.attestation.verified"; - - /// Attestation verification failed. - public const string AttestationVerificationFailed = "pack.attestation.verification_failed"; - - /// Attestation revoked. - public const string AttestationRevoked = "pack.attestation.revoked"; -} - -/// -/// In-memory attestation store for testing. -/// -public sealed class InMemoryPackRunAttestationStore : IPackRunAttestationStore -{ - private readonly Dictionary _attestations = new(); - private readonly object _lock = new(); - - /// - public Task StoreAsync( - PackRunAttestation attestation, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - _attestations[attestation.AttestationId] = attestation; - } - return Task.CompletedTask; - } - - /// - public Task GetAsync( - Guid attestationId, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - _attestations.TryGetValue(attestationId, out var attestation); - return Task.FromResult(attestation); - } - } - - /// - public Task> ListByRunAsync( - string tenantId, - string runId, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - var results = _attestations.Values - .Where(a => a.TenantId == tenantId && a.RunId == runId) - .OrderBy(a => a.CreatedAt) - .ToList(); - return Task.FromResult>(results); - } - } - - /// - public Task UpdateStatusAsync( - Guid attestationId, - PackRunAttestationStatus status, - string? error = null, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - if (_attestations.TryGetValue(attestationId, out var attestation)) - { - _attestations[attestationId] = attestation with - { - Status = status, - Error = error - }; - } - } - return Task.CompletedTask; - } - - /// Gets all attestations (for testing). - public IReadOnlyList GetAll() - { - lock (_lock) { return _attestations.Values.ToList(); } - } - - /// Clears all attestations (for testing). - public void Clear() - { - lock (_lock) { _attestations.Clear(); } - } - - /// Gets attestation count. - public int Count - { - get { lock (_lock) { return _attestations.Count; } } - } -} - -/// -/// Stub signer for testing (does not perform real cryptographic signing). -/// -public sealed class StubPackRunAttestationSigner : IPackRunAttestationSigner -{ - private readonly string _keyId; - - public StubPackRunAttestationSigner(string keyId = "test-key-001") - { - _keyId = keyId; - } - - /// - public Task SignAsync( - byte[] statementBytes, - CancellationToken cancellationToken = default) - { - var payload = Convert.ToBase64String(statementBytes); - - // Create stub signature (not cryptographically valid) - var sigBytes = System.Security.Cryptography.SHA256.HashData(statementBytes); - var sig = Convert.ToBase64String(sigBytes); - - var envelope = new PackRunDsseEnvelope( - PayloadType: PackRunDsseEnvelope.InTotoPayloadType, - Payload: payload, - Signatures: [new PackRunDsseSignature(_keyId, sig)]); - - return Task.FromResult(envelope); - } - - /// - public Task VerifyAsync( - PackRunDsseEnvelope envelope, - CancellationToken cancellationToken = default) - { - // Stub always returns true for testing - return Task.FromResult(true); - } - - /// - public string GetKeyId() => _keyId; -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Attestation/PackRunAttestation.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Attestation/PackRunAttestation.cs deleted file mode 100644 index e31ec9831..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Attestation/PackRunAttestation.cs +++ /dev/null @@ -1,526 +0,0 @@ - -using StellaOps.TaskRunner.Core.Evidence; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace StellaOps.TaskRunner.Core.Attestation; - -/// -/// DSSE attestation for pack run execution. -/// Per TASKRUN-OBS-54-001. -/// -public sealed record PackRunAttestation( - /// Unique attestation identifier. - Guid AttestationId, - - /// Tenant scope. - string TenantId, - - /// Run ID this attestation covers. - string RunId, - - /// Plan hash that was executed. - string PlanHash, - - /// When the attestation was created. - DateTimeOffset CreatedAt, - - /// Subjects covered by this attestation (produced artifacts). - IReadOnlyList Subjects, - - /// Predicate type URI. - string PredicateType, - - /// Predicate content as JSON. - string PredicateJson, - - /// DSSE envelope containing signature. - PackRunDsseEnvelope? Envelope, - - /// Attestation status. - PackRunAttestationStatus Status, - - /// Error message if signing failed. - string? Error, - - /// Reference to evidence snapshot. - Guid? EvidenceSnapshotId, - - /// Attestation metadata. - IReadOnlyDictionary? Metadata) -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false - }; - - /// - /// Computes the canonical statement digest. - /// - public string ComputeStatementDigest() - { - var statement = new PackRunInTotoStatement( - Type: InTotoStatementTypes.V01, - Subject: Subjects, - PredicateType: PredicateType, - Predicate: JsonSerializer.Deserialize(PredicateJson, JsonOptions)); - - var json = JsonSerializer.Serialize(statement, JsonOptions); - var bytes = Encoding.UTF8.GetBytes(json); - var hash = SHA256.HashData(bytes); - return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; - } - - /// - /// Serializes to JSON. - /// - public string ToJson() => JsonSerializer.Serialize(this, JsonOptions); - - /// - /// Deserializes from JSON. - /// - public static PackRunAttestation? FromJson(string json) - => JsonSerializer.Deserialize(json, JsonOptions); -} - -/// -/// Attestation status. -/// -public enum PackRunAttestationStatus -{ - /// Attestation is pending signing. - Pending, - - /// Attestation is signed and valid. - Signed, - - /// Attestation signing failed. - Failed, - - /// Attestation signature was revoked. - Revoked -} - -/// -/// Subject covered by attestation (an artifact). -/// -public sealed record PackRunAttestationSubject( - /// Subject name (artifact path or identifier). - [property: JsonPropertyName("name")] - string Name, - - /// Subject digest (sha256 -> hash). - [property: JsonPropertyName("digest")] - IReadOnlyDictionary Digest) -{ - /// - /// Creates a subject from an artifact reference. - /// - public static PackRunAttestationSubject FromArtifact(PackRunArtifactReference artifact) - { - var digest = new Dictionary(); - - // Parse sha256:abcdef format and extract just the hash - if (artifact.Sha256.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)) - { - digest["sha256"] = artifact.Sha256[7..]; - } - else - { - digest["sha256"] = artifact.Sha256; - } - - return new PackRunAttestationSubject(artifact.Name, digest); - } - - /// - /// Creates a subject from a material. - /// - public static PackRunAttestationSubject FromMaterial(PackRunEvidenceMaterial material) - { - var digest = new Dictionary(); - - if (material.Sha256.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)) - { - digest["sha256"] = material.Sha256[7..]; - } - else - { - digest["sha256"] = material.Sha256; - } - - return new PackRunAttestationSubject(material.CanonicalPath, digest); - } -} - -/// -/// In-toto statement wrapper for pack runs. -/// -public sealed record PackRunInTotoStatement( - /// Statement type (always _type). - [property: JsonPropertyName("_type")] - string Type, - - /// Subjects covered. - [property: JsonPropertyName("subject")] - IReadOnlyList Subject, - - /// Predicate type URI. - [property: JsonPropertyName("predicateType")] - string PredicateType, - - /// Predicate content. - [property: JsonPropertyName("predicate")] - object Predicate); - -/// -/// Standard in-toto statement type URIs. -/// -public static class InTotoStatementTypes -{ - /// In-toto statement v0.1. - public const string V01 = "https://in-toto.io/Statement/v0.1"; - - /// In-toto statement v1.0. - public const string V1 = "https://in-toto.io/Statement/v1"; -} - -/// -/// Standard predicate type URIs. -/// -public static class PredicateTypes -{ - /// SLSA Provenance v0.2. - public const string SlsaProvenanceV02 = "https://slsa.dev/provenance/v0.2"; - - /// SLSA Provenance v1.0. - public const string SlsaProvenanceV1 = "https://slsa.dev/provenance/v1"; - - /// StellaOps Pack Run provenance. - public const string PackRunProvenance = "https://stellaops.io/attestation/pack-run/v1"; - - /// StellaOps Pack Run completion. - public const string PackRunCompletion = "https://stellaops.io/attestation/pack-run-completion/v1"; -} - -/// -/// DSSE envelope for pack run attestation. -/// -public sealed record PackRunDsseEnvelope( - /// Payload type (usually application/vnd.in-toto+json). - [property: JsonPropertyName("payloadType")] - string PayloadType, - - /// Base64-encoded payload. - [property: JsonPropertyName("payload")] - string Payload, - - /// Signatures on the envelope. - [property: JsonPropertyName("signatures")] - IReadOnlyList Signatures) -{ - /// Standard payload type for in-toto attestations. - public const string InTotoPayloadType = "application/vnd.in-toto+json"; - - /// - /// Computes the envelope digest. - /// - public string ComputeDigest() - { - var json = JsonSerializer.Serialize(this, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }); - var bytes = Encoding.UTF8.GetBytes(json); - var hash = SHA256.HashData(bytes); - return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; - } -} - -/// -/// Signature in a DSSE envelope. -/// -public sealed record PackRunDsseSignature( - /// Key identifier. - [property: JsonPropertyName("keyid")] - string? KeyId, - - /// Base64-encoded signature. - [property: JsonPropertyName("sig")] - string Sig); - -/// -/// Pack run provenance predicate per SLSA Provenance v1. -/// -public sealed record PackRunProvenancePredicate( - /// Build definition describing what was run. - [property: JsonPropertyName("buildDefinition")] - PackRunBuildDefinition BuildDefinition, - - /// Run details describing the actual execution. - [property: JsonPropertyName("runDetails")] - PackRunDetails RunDetails); - -/// -/// Build definition for pack run provenance. -/// -public sealed record PackRunBuildDefinition( - /// Build type identifier. - [property: JsonPropertyName("buildType")] - string BuildType, - - /// External parameters (e.g., pack manifest URL). - [property: JsonPropertyName("externalParameters")] - IReadOnlyDictionary? ExternalParameters, - - /// Internal parameters resolved during build. - [property: JsonPropertyName("internalParameters")] - IReadOnlyDictionary? InternalParameters, - - /// Dependencies resolved during build. - [property: JsonPropertyName("resolvedDependencies")] - IReadOnlyList? ResolvedDependencies); - -/// -/// Resolved dependency in provenance. -/// -public sealed record PackRunDependency( - /// Dependency URI. - [property: JsonPropertyName("uri")] - string Uri, - - /// Dependency digest. - [property: JsonPropertyName("digest")] - IReadOnlyDictionary? Digest, - - /// Dependency name. - [property: JsonPropertyName("name")] - string? Name, - - /// Media type. - [property: JsonPropertyName("mediaType")] - string? MediaType); - -/// -/// Run details for pack run provenance. -/// -public sealed record PackRunDetails( - /// Builder information. - [property: JsonPropertyName("builder")] - PackRunBuilder Builder, - - /// Run metadata. - [property: JsonPropertyName("metadata")] - PackRunProvMetadata Metadata, - - /// By-products of the run. - [property: JsonPropertyName("byproducts")] - IReadOnlyList? Byproducts); - -/// -/// Builder information. -/// -public sealed record PackRunBuilder( - /// Builder ID (URI). - [property: JsonPropertyName("id")] - string Id, - - /// Builder version. - [property: JsonPropertyName("version")] - IReadOnlyDictionary? Version, - - /// Builder dependencies. - [property: JsonPropertyName("builderDependencies")] - IReadOnlyList? BuilderDependencies); - -/// -/// Provenance metadata. -/// -public sealed record PackRunProvMetadata( - /// Invocation ID. - [property: JsonPropertyName("invocationId")] - string? InvocationId, - - /// When the build started. - [property: JsonPropertyName("startedOn")] - DateTimeOffset? StartedOn, - - /// When the build finished. - [property: JsonPropertyName("finishedOn")] - DateTimeOffset? FinishedOn); - -/// -/// By-product of the build. -/// -public sealed record PackRunByproduct( - /// By-product URI. - [property: JsonPropertyName("uri")] - string? Uri, - - /// By-product digest. - [property: JsonPropertyName("digest")] - IReadOnlyDictionary? Digest, - - /// By-product name. - [property: JsonPropertyName("name")] - string? Name, - - /// By-product media type. - [property: JsonPropertyName("mediaType")] - string? MediaType); - -/// -/// Request to generate an attestation for a pack run. -/// -public sealed record PackRunAttestationRequest( - /// Run ID to attest. - string RunId, - - /// Tenant ID. - string TenantId, - - /// Plan hash. - string PlanHash, - - /// Subjects (artifacts) to attest. - IReadOnlyList Subjects, - - /// Evidence snapshot ID to link. - Guid? EvidenceSnapshotId, - - /// Run started at. - DateTimeOffset StartedAt, - - /// Run completed at. - DateTimeOffset? CompletedAt, - - /// Builder ID. - string? BuilderId, - - /// External parameters. - IReadOnlyDictionary? ExternalParameters, - - /// Resolved dependencies. - IReadOnlyList? ResolvedDependencies, - - /// Additional metadata. - IReadOnlyDictionary? Metadata); - -/// -/// Result of attestation generation. -/// -public sealed record PackRunAttestationResult( - /// Whether attestation generation succeeded. - bool Success, - - /// Generated attestation. - PackRunAttestation? Attestation, - - /// Error message if failed. - string? Error); - -/// -/// Request to verify a pack run attestation. -/// -public sealed record PackRunAttestationVerificationRequest( - /// Attestation ID to verify. - Guid AttestationId, - - /// Expected subjects to verify against. - IReadOnlyList? ExpectedSubjects, - - /// Whether to verify signature. - bool VerifySignature, - - /// Whether to verify subjects match. - bool VerifySubjects, - - /// Whether to check revocation status. - bool CheckRevocation); - -/// -/// Result of attestation verification. -/// -public sealed record PackRunAttestationVerificationResult( - /// Whether verification passed. - bool Valid, - - /// Attestation that was verified. - Guid AttestationId, - - /// Signature verification status. - PackRunSignatureVerificationStatus SignatureStatus, - - /// Subject verification status. - PackRunSubjectVerificationStatus SubjectStatus, - - /// Revocation status. - PackRunRevocationStatus RevocationStatus, - - /// Verification errors. - IReadOnlyList? Errors, - - /// When verification was performed. - DateTimeOffset VerifiedAt); - -/// -/// Signature verification status. -/// -public enum PackRunSignatureVerificationStatus -{ - /// Not verified. - NotVerified, - - /// Signature is valid. - Valid, - - /// Signature is invalid. - Invalid, - - /// Key not found. - KeyNotFound, - - /// Key expired. - KeyExpired -} - -/// -/// Subject verification status. -/// -public enum PackRunSubjectVerificationStatus -{ - /// Not verified. - NotVerified, - - /// All subjects match. - Match, - - /// Subjects do not match. - Mismatch, - - /// Missing expected subjects. - Missing -} - -/// -/// Revocation status. -/// -public enum PackRunRevocationStatus -{ - /// Not checked. - NotChecked, - - /// Not revoked. - NotRevoked, - - /// Revoked. - Revoked, - - /// Revocation check failed. - CheckFailed -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Configuration/PackRunWorkerOptions.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Configuration/PackRunWorkerOptions.cs deleted file mode 100644 index 41d8b1842..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Configuration/PackRunWorkerOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace StellaOps.TaskRunner.Core.Configuration; - -/// -/// Worker configuration for queue paths, artifacts, and execution persistence. -/// Kept in Core so infrastructure helpers can share deterministic paths without -/// referencing the worker assembly. -/// -public sealed class PackRunWorkerOptions -{ - public TimeSpan IdleDelay { get; set; } = TimeSpan.FromSeconds(1); - - public string QueuePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "queue"); - - public string ArchivePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "queue", "archive"); - - public string ApprovalStorePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "approvals"); - - public string RunStatePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "state", "runs"); - - public string ArtifactsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "artifacts"); - - public string LogsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "logs", "runs"); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/IPackRunTimelineEventSink.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/IPackRunTimelineEventSink.cs deleted file mode 100644 index dc5b30e84..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/IPackRunTimelineEventSink.cs +++ /dev/null @@ -1,196 +0,0 @@ -namespace StellaOps.TaskRunner.Core.Events; - -/// -/// Sink for pack run timeline events (Kafka, NATS, file, etc.). -/// Per TASKRUN-OBS-52-001. -/// -public interface IPackRunTimelineEventSink -{ - /// - /// Writes a timeline event to the sink. - /// - Task WriteAsync( - PackRunTimelineEvent evt, - CancellationToken cancellationToken = default); - - /// - /// Writes multiple timeline events to the sink. - /// - Task WriteBatchAsync( - IEnumerable events, - CancellationToken cancellationToken = default); -} - -/// -/// Result of writing to pack run timeline sink. -/// -public sealed record PackRunTimelineSinkWriteResult( - /// Whether the event was written successfully. - bool Success, - - /// Assigned sequence number if applicable. - long? Sequence, - - /// Whether the event was deduplicated. - bool Deduplicated, - - /// Error message if write failed. - string? Error); - -/// -/// Result of batch writing to pack run timeline sink. -/// -public sealed record PackRunTimelineSinkBatchWriteResult( - /// Number of events written successfully. - int Written, - - /// Number of events deduplicated. - int Deduplicated, - - /// Number of events that failed. - int Failed); - -/// -/// In-memory pack run timeline event sink for testing. -/// -public sealed class InMemoryPackRunTimelineEventSink : IPackRunTimelineEventSink -{ - private readonly List _events = new(); - private readonly HashSet _seenIds = new(); - private readonly object _lock = new(); - private long _sequence; - - public Task WriteAsync( - PackRunTimelineEvent evt, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - if (!_seenIds.Add(evt.EventId)) - { - return Task.FromResult(new PackRunTimelineSinkWriteResult( - Success: true, - Sequence: null, - Deduplicated: true, - Error: null)); - } - - var seq = ++_sequence; - var eventWithSeq = evt.WithSequence(seq); - _events.Add(eventWithSeq); - - return Task.FromResult(new PackRunTimelineSinkWriteResult( - Success: true, - Sequence: seq, - Deduplicated: false, - Error: null)); - } - } - - public Task WriteBatchAsync( - IEnumerable events, - CancellationToken cancellationToken = default) - { - var written = 0; - var deduplicated = 0; - - lock (_lock) - { - foreach (var evt in events) - { - if (!_seenIds.Add(evt.EventId)) - { - deduplicated++; - continue; - } - - var seq = ++_sequence; - _events.Add(evt.WithSequence(seq)); - written++; - } - } - - return Task.FromResult(new PackRunTimelineSinkBatchWriteResult(written, deduplicated, 0)); - } - - /// Gets all events (for testing). - public IReadOnlyList GetEvents() - { - lock (_lock) { return _events.ToList(); } - } - - /// Gets events for a tenant (for testing). - public IReadOnlyList GetEvents(string tenantId) - { - lock (_lock) { return _events.Where(e => e.TenantId == tenantId).ToList(); } - } - - /// Gets events for a run (for testing). - public IReadOnlyList GetEventsForRun(string runId) - { - lock (_lock) { return _events.Where(e => e.RunId == runId).ToList(); } - } - - /// Gets events by type (for testing). - public IReadOnlyList GetEventsByType(string eventType) - { - lock (_lock) { return _events.Where(e => e.EventType == eventType).ToList(); } - } - - /// Gets step events for a run (for testing). - public IReadOnlyList GetStepEvents(string runId, string stepId) - { - lock (_lock) - { - return _events - .Where(e => e.RunId == runId && e.StepId == stepId) - .ToList(); - } - } - - /// Clears all events (for testing). - public void Clear() - { - lock (_lock) - { - _events.Clear(); - _seenIds.Clear(); - _sequence = 0; - } - } - - /// Gets the current event count. - public int Count - { - get { lock (_lock) { return _events.Count; } } - } -} - -/// -/// Null sink that discards all events. -/// -public sealed class NullPackRunTimelineEventSink : IPackRunTimelineEventSink -{ - public static NullPackRunTimelineEventSink Instance { get; } = new(); - - private NullPackRunTimelineEventSink() { } - - public Task WriteAsync( - PackRunTimelineEvent evt, - CancellationToken cancellationToken = default) - { - return Task.FromResult(new PackRunTimelineSinkWriteResult( - Success: true, - Sequence: null, - Deduplicated: false, - Error: null)); - } - - public Task WriteBatchAsync( - IEnumerable events, - CancellationToken cancellationToken = default) - { - var count = events.Count(); - return Task.FromResult(new PackRunTimelineSinkBatchWriteResult(count, 0, 0)); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEvent.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEvent.cs deleted file mode 100644 index 78b52f312..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEvent.cs +++ /dev/null @@ -1,347 +0,0 @@ - -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace StellaOps.TaskRunner.Core.Events; - -/// -/// Timeline event for pack run audit trail, observability, and evidence chain tracking. -/// Per TASKRUN-OBS-52-001 and timeline-event.schema.json. -/// -public sealed record PackRunTimelineEvent( - /// Monotonically increasing sequence number for ordering. - long? EventSeq, - - /// Globally unique event identifier. - Guid EventId, - - /// Tenant scope for multi-tenant isolation. - string TenantId, - - /// Event type identifier following namespace convention. - string EventType, - - /// Service or component that emitted this event. - string Source, - - /// When the event actually occurred. - DateTimeOffset OccurredAt, - - /// When the event was received by timeline indexer. - DateTimeOffset? ReceivedAt, - - /// Correlation ID linking related events across services. - string? CorrelationId, - - /// OpenTelemetry trace ID for distributed tracing. - string? TraceId, - - /// OpenTelemetry span ID within the trace. - string? SpanId, - - /// User, service account, or system that triggered the event. - string? Actor, - - /// Event severity level. - PackRunEventSeverity Severity, - - /// Key-value attributes for filtering and querying. - IReadOnlyDictionary? Attributes, - - /// SHA-256 hash of the raw payload for integrity. - string? PayloadHash, - - /// Original event payload as JSON string. - string? RawPayloadJson, - - /// Canonicalized JSON for deterministic hashing. - string? NormalizedPayloadJson, - - /// Reference to associated evidence bundle or attestation. - PackRunEvidencePointer? EvidencePointer, - - /// Run ID for this pack run. - string RunId, - - /// Plan hash for the pack run. - string? PlanHash, - - /// Step ID if this event is associated with a step. - string? StepId, - - /// Project ID scope within tenant. - string? ProjectId) -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false - }; - - private static readonly JsonSerializerOptions CanonicalJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - - /// - /// Creates a new timeline event with generated ID. - /// - public static PackRunTimelineEvent Create( - string tenantId, - string eventType, - string source, - DateTimeOffset occurredAt, - string runId, - string? planHash = null, - string? stepId = null, - string? actor = null, - PackRunEventSeverity severity = PackRunEventSeverity.Info, - IReadOnlyDictionary? attributes = null, - string? correlationId = null, - string? traceId = null, - string? spanId = null, - string? projectId = null, - object? payload = null, - PackRunEvidencePointer? evidencePointer = null) - { - string? rawPayload = null; - string? normalizedPayload = null; - string? payloadHash = null; - - if (payload is not null) - { - rawPayload = JsonSerializer.Serialize(payload, JsonOptions); - normalizedPayload = NormalizeJson(rawPayload); - payloadHash = ComputeHash(normalizedPayload); - } - - return new PackRunTimelineEvent( - EventSeq: null, - EventId: Guid.NewGuid(), - TenantId: tenantId, - EventType: eventType, - Source: source, - OccurredAt: occurredAt, - ReceivedAt: null, - CorrelationId: correlationId, - TraceId: traceId, - SpanId: spanId, - Actor: actor, - Severity: severity, - Attributes: attributes, - PayloadHash: payloadHash, - RawPayloadJson: rawPayload, - NormalizedPayloadJson: normalizedPayload, - EvidencePointer: evidencePointer, - RunId: runId, - PlanHash: planHash, - StepId: stepId, - ProjectId: projectId); - } - - /// - /// Serializes the event to JSON. - /// - public string ToJson() => JsonSerializer.Serialize(this, JsonOptions); - - /// - /// Parses a timeline event from JSON. - /// - public static PackRunTimelineEvent? FromJson(string json) - => JsonSerializer.Deserialize(json, JsonOptions); - - /// - /// Creates a copy with received timestamp set. - /// - public PackRunTimelineEvent WithReceivedAt(DateTimeOffset receivedAt) - => this with { ReceivedAt = receivedAt }; - - /// - /// Creates a copy with sequence number set. - /// - public PackRunTimelineEvent WithSequence(long seq) - => this with { EventSeq = seq }; - - /// - /// Generates an idempotency key for this event. - /// - public string GenerateIdempotencyKey() - => $"timeline:pack:{TenantId}:{EventType}:{EventId}"; - - private static string NormalizeJson(string json) - { - using var doc = JsonDocument.Parse(json); - return JsonSerializer.Serialize(doc.RootElement, CanonicalJsonOptions); - } - - private static string ComputeHash(string content) - { - var bytes = Encoding.UTF8.GetBytes(content); - var hash = SHA256.HashData(bytes); - return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; - } -} - -/// -/// Event severity level for pack run timeline events. -/// -public enum PackRunEventSeverity -{ - Debug, - Info, - Warning, - Error, - Critical -} - -/// -/// Reference to associated evidence bundle or attestation. -/// -public sealed record PackRunEvidencePointer( - /// Type of evidence being referenced. - PackRunEvidencePointerType Type, - - /// Evidence bundle identifier. - Guid? BundleId, - - /// Content digest of the evidence bundle. - string? BundleDigest, - - /// Subject URI for the attestation. - string? AttestationSubject, - - /// Digest of the attestation envelope. - string? AttestationDigest, - - /// URI to the evidence manifest. - string? ManifestUri, - - /// Path within evidence locker storage. - string? LockerPath) -{ - /// - /// Creates a bundle evidence pointer. - /// - public static PackRunEvidencePointer Bundle(Guid bundleId, string? bundleDigest = null) - => new(PackRunEvidencePointerType.Bundle, bundleId, bundleDigest, null, null, null, null); - - /// - /// Creates an attestation evidence pointer. - /// - public static PackRunEvidencePointer Attestation(string subject, string? digest = null) - => new(PackRunEvidencePointerType.Attestation, null, null, subject, digest, null, null); - - /// - /// Creates a manifest evidence pointer. - /// - public static PackRunEvidencePointer Manifest(string uri, string? lockerPath = null) - => new(PackRunEvidencePointerType.Manifest, null, null, null, null, uri, lockerPath); - - /// - /// Creates an artifact evidence pointer. - /// - public static PackRunEvidencePointer Artifact(string lockerPath, string? digest = null) - => new(PackRunEvidencePointerType.Artifact, null, digest, null, null, null, lockerPath); -} - -/// -/// Type of evidence being referenced. -/// -public enum PackRunEvidencePointerType -{ - Bundle, - Attestation, - Manifest, - Artifact -} - -/// -/// Pack run timeline event types. -/// -public static class PackRunEventTypes -{ - /// Prefix for all pack run events. - public const string Prefix = "pack."; - - /// Pack run started. - public const string PackStarted = "pack.started"; - - /// Pack run completed successfully. - public const string PackCompleted = "pack.completed"; - - /// Pack run failed. - public const string PackFailed = "pack.failed"; - - /// Pack run paused (awaiting approvals/gates). - public const string PackPaused = "pack.paused"; - - /// Step started execution. - public const string StepStarted = "pack.step.started"; - - /// Step completed successfully. - public const string StepCompleted = "pack.step.completed"; - - /// Step failed. - public const string StepFailed = "pack.step.failed"; - - /// Step scheduled for retry. - public const string StepRetryScheduled = "pack.step.retry_scheduled"; - - /// Step skipped. - public const string StepSkipped = "pack.step.skipped"; - - /// Approval gate satisfied. - public const string ApprovalSatisfied = "pack.approval.satisfied"; - - /// Policy gate evaluated. - public const string PolicyEvaluated = "pack.policy.evaluated"; - - /// Sealed install enforcement performed. - public const string SealedInstallEnforcement = "pack.sealed_install.enforcement"; - - /// Sealed install enforcement denied execution. - public const string SealedInstallDenied = "pack.sealed_install.denied"; - - /// Sealed install enforcement allowed execution. - public const string SealedInstallAllowed = "pack.sealed_install.allowed"; - - /// Sealed install requirements warning. - public const string SealedInstallWarning = "pack.sealed_install.warning"; - - /// Attestation created successfully (per TASKRUN-OBS-54-001). - public const string AttestationCreated = "pack.attestation.created"; - - /// Attestation creation failed. - public const string AttestationFailed = "pack.attestation.failed"; - - /// Attestation verified successfully. - public const string AttestationVerified = "pack.attestation.verified"; - - /// Attestation verification failed. - public const string AttestationVerificationFailed = "pack.attestation.verification_failed"; - - /// Attestation was revoked. - public const string AttestationRevoked = "pack.attestation.revoked"; - - /// Incident mode activated (per TASKRUN-OBS-55-001). - public const string IncidentModeActivated = "pack.incident.activated"; - - /// Incident mode deactivated. - public const string IncidentModeDeactivated = "pack.incident.deactivated"; - - /// Incident mode escalated to higher level. - public const string IncidentModeEscalated = "pack.incident.escalated"; - - /// SLO breach detected triggering incident mode. - public const string SloBreachDetected = "pack.incident.slo_breach"; - - /// Checks if the event type is a pack run event. - public static bool IsPackRunEvent(string eventType) => - eventType.StartsWith(Prefix, StringComparison.Ordinal); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEventEmitter.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEventEmitter.cs deleted file mode 100644 index 51f44cadb..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEventEmitter.cs +++ /dev/null @@ -1,603 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace StellaOps.TaskRunner.Core.Events; - -/// -/// Service for emitting pack run timeline events with trace IDs, deduplication, and retries. -/// Per TASKRUN-OBS-52-001. -/// -public interface IPackRunTimelineEventEmitter -{ - /// - /// Emits a timeline event. - /// - Task EmitAsync( - PackRunTimelineEvent evt, - CancellationToken cancellationToken = default); - - /// - /// Emits multiple timeline events in batch. - /// - Task EmitBatchAsync( - IEnumerable events, - CancellationToken cancellationToken = default); - - /// - /// Emits a pack.started event. - /// - Task EmitPackStartedAsync( - string tenantId, - string runId, - string planHash, - string? actor = null, - string? correlationId = null, - string? traceId = null, - string? projectId = null, - IReadOnlyDictionary? attributes = null, - PackRunEvidencePointer? evidencePointer = null, - CancellationToken cancellationToken = default); - - /// - /// Emits a pack.completed event. - /// - Task EmitPackCompletedAsync( - string tenantId, - string runId, - string planHash, - string? actor = null, - string? correlationId = null, - string? traceId = null, - string? projectId = null, - IReadOnlyDictionary? attributes = null, - PackRunEvidencePointer? evidencePointer = null, - CancellationToken cancellationToken = default); - - /// - /// Emits a pack.failed event. - /// - Task EmitPackFailedAsync( - string tenantId, - string runId, - string planHash, - string? failureReason = null, - string? actor = null, - string? correlationId = null, - string? traceId = null, - string? projectId = null, - IReadOnlyDictionary? attributes = null, - PackRunEvidencePointer? evidencePointer = null, - CancellationToken cancellationToken = default); - - /// - /// Emits a pack.step.started event. - /// - Task EmitStepStartedAsync( - string tenantId, - string runId, - string planHash, - string stepId, - int attempt, - string? actor = null, - string? correlationId = null, - string? traceId = null, - string? projectId = null, - IReadOnlyDictionary? attributes = null, - CancellationToken cancellationToken = default); - - /// - /// Emits a pack.step.completed event. - /// - Task EmitStepCompletedAsync( - string tenantId, - string runId, - string planHash, - string stepId, - int attempt, - double? durationMs = null, - string? actor = null, - string? correlationId = null, - string? traceId = null, - string? projectId = null, - IReadOnlyDictionary? attributes = null, - PackRunEvidencePointer? evidencePointer = null, - CancellationToken cancellationToken = default); - - /// - /// Emits a pack.step.failed event. - /// - Task EmitStepFailedAsync( - string tenantId, - string runId, - string planHash, - string stepId, - int attempt, - string? error = null, - string? actor = null, - string? correlationId = null, - string? traceId = null, - string? projectId = null, - IReadOnlyDictionary? attributes = null, - CancellationToken cancellationToken = default); -} - -/// -/// Result of timeline event emission. -/// -public sealed record PackRunTimelineEmitResult( - /// Whether the event was emitted successfully. - bool Success, - - /// The emitted event (with sequence if assigned). - PackRunTimelineEvent Event, - - /// Whether the event was deduplicated. - bool Deduplicated, - - /// Error message if emission failed. - string? Error); - -/// -/// Result of batch timeline event emission. -/// -public sealed record PackRunTimelineBatchEmitResult( - /// Number of events emitted successfully. - int Emitted, - - /// Number of events deduplicated. - int Deduplicated, - - /// Number of events that failed. - int Failed, - - /// Errors encountered. - IReadOnlyList Errors) -{ - /// Total events processed. - public int Total => Emitted + Deduplicated + Failed; - - /// Whether any events were emitted. - public bool HasEmitted => Emitted > 0; - - /// Whether any errors occurred. - public bool HasErrors => Failed > 0 || Errors.Count > 0; - - /// Creates an empty result. - public static PackRunTimelineBatchEmitResult Empty => new(0, 0, 0, []); -} - -/// -/// Default implementation of pack run timeline event emitter. -/// -public sealed class PackRunTimelineEventEmitter : IPackRunTimelineEventEmitter -{ - private const string Source = "taskrunner-worker"; - private readonly IPackRunTimelineEventSink _sink; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - private readonly PackRunTimelineEmitterOptions _options; - - public PackRunTimelineEventEmitter( - IPackRunTimelineEventSink sink, - TimeProvider timeProvider, - ILogger logger, - PackRunTimelineEmitterOptions? options = null) - { - _sink = sink ?? throw new ArgumentNullException(nameof(sink)); - _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _options = options ?? PackRunTimelineEmitterOptions.Default; - } - - public async Task EmitAsync( - PackRunTimelineEvent evt, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(evt); - - var eventWithReceived = evt.WithReceivedAt(_timeProvider.GetUtcNow()); - - try - { - var result = await EmitWithRetryAsync(eventWithReceived, cancellationToken); - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, - "Failed to emit timeline event {EventId} type {EventType} for tenant {TenantId} run {RunId}", - evt.EventId, evt.EventType, evt.TenantId, evt.RunId); - - return new PackRunTimelineEmitResult( - Success: false, - Event: eventWithReceived, - Deduplicated: false, - Error: ex.Message); - } - } - - public async Task EmitBatchAsync( - IEnumerable events, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(events); - - var emitted = 0; - var deduplicated = 0; - var failed = 0; - var errors = new List(); - - // Order by occurredAt then eventId for deterministic fan-out - var ordered = events - .OrderBy(e => e.OccurredAt) - .ThenBy(e => e.EventId) - .ToList(); - - foreach (var evt in ordered) - { - var result = await EmitAsync(evt, cancellationToken); - - if (result.Success) - { - if (result.Deduplicated) - deduplicated++; - else - emitted++; - } - else - { - failed++; - if (result.Error is not null) - errors.Add($"{evt.EventId}: {result.Error}"); - } - } - - return new PackRunTimelineBatchEmitResult(emitted, deduplicated, failed, errors); - } - - public Task EmitPackStartedAsync( - string tenantId, - string runId, - string planHash, - string? actor = null, - string? correlationId = null, - string? traceId = null, - string? projectId = null, - IReadOnlyDictionary? attributes = null, - PackRunEvidencePointer? evidencePointer = null, - CancellationToken cancellationToken = default) - { - var attrs = MergeAttributes(attributes, new Dictionary - { - ["runId"] = runId, - ["planHash"] = planHash - }); - - var evt = PackRunTimelineEvent.Create( - tenantId: tenantId, - eventType: PackRunEventTypes.PackStarted, - source: Source, - occurredAt: _timeProvider.GetUtcNow(), - runId: runId, - planHash: planHash, - actor: actor, - severity: PackRunEventSeverity.Info, - attributes: attrs, - correlationId: correlationId, - traceId: traceId, - projectId: projectId, - evidencePointer: evidencePointer); - - return EmitAsync(evt, cancellationToken); - } - - public Task EmitPackCompletedAsync( - string tenantId, - string runId, - string planHash, - string? actor = null, - string? correlationId = null, - string? traceId = null, - string? projectId = null, - IReadOnlyDictionary? attributes = null, - PackRunEvidencePointer? evidencePointer = null, - CancellationToken cancellationToken = default) - { - var attrs = MergeAttributes(attributes, new Dictionary - { - ["runId"] = runId, - ["planHash"] = planHash - }); - - var evt = PackRunTimelineEvent.Create( - tenantId: tenantId, - eventType: PackRunEventTypes.PackCompleted, - source: Source, - occurredAt: _timeProvider.GetUtcNow(), - runId: runId, - planHash: planHash, - actor: actor, - severity: PackRunEventSeverity.Info, - attributes: attrs, - correlationId: correlationId, - traceId: traceId, - projectId: projectId, - evidencePointer: evidencePointer); - - return EmitAsync(evt, cancellationToken); - } - - public Task EmitPackFailedAsync( - string tenantId, - string runId, - string planHash, - string? failureReason = null, - string? actor = null, - string? correlationId = null, - string? traceId = null, - string? projectId = null, - IReadOnlyDictionary? attributes = null, - PackRunEvidencePointer? evidencePointer = null, - CancellationToken cancellationToken = default) - { - var attrDict = new Dictionary - { - ["runId"] = runId, - ["planHash"] = planHash - }; - - if (!string.IsNullOrWhiteSpace(failureReason)) - { - attrDict["failureReason"] = failureReason; - } - - var attrs = MergeAttributes(attributes, attrDict); - - var evt = PackRunTimelineEvent.Create( - tenantId: tenantId, - eventType: PackRunEventTypes.PackFailed, - source: Source, - occurredAt: _timeProvider.GetUtcNow(), - runId: runId, - planHash: planHash, - actor: actor, - severity: PackRunEventSeverity.Error, - attributes: attrs, - correlationId: correlationId, - traceId: traceId, - projectId: projectId, - payload: failureReason != null ? new { reason = failureReason } : null, - evidencePointer: evidencePointer); - - return EmitAsync(evt, cancellationToken); - } - - public Task EmitStepStartedAsync( - string tenantId, - string runId, - string planHash, - string stepId, - int attempt, - string? actor = null, - string? correlationId = null, - string? traceId = null, - string? projectId = null, - IReadOnlyDictionary? attributes = null, - CancellationToken cancellationToken = default) - { - var attrs = MergeAttributes(attributes, new Dictionary - { - ["runId"] = runId, - ["planHash"] = planHash, - ["stepId"] = stepId, - ["attempt"] = attempt.ToString() - }); - - var evt = PackRunTimelineEvent.Create( - tenantId: tenantId, - eventType: PackRunEventTypes.StepStarted, - source: Source, - occurredAt: _timeProvider.GetUtcNow(), - runId: runId, - planHash: planHash, - stepId: stepId, - actor: actor, - severity: PackRunEventSeverity.Info, - attributes: attrs, - correlationId: correlationId, - traceId: traceId, - projectId: projectId, - payload: new { stepId, attempt }); - - return EmitAsync(evt, cancellationToken); - } - - public Task EmitStepCompletedAsync( - string tenantId, - string runId, - string planHash, - string stepId, - int attempt, - double? durationMs = null, - string? actor = null, - string? correlationId = null, - string? traceId = null, - string? projectId = null, - IReadOnlyDictionary? attributes = null, - PackRunEvidencePointer? evidencePointer = null, - CancellationToken cancellationToken = default) - { - var attrDict = new Dictionary - { - ["runId"] = runId, - ["planHash"] = planHash, - ["stepId"] = stepId, - ["attempt"] = attempt.ToString() - }; - - if (durationMs.HasValue) - { - attrDict["durationMs"] = durationMs.Value.ToString("F2"); - } - - var attrs = MergeAttributes(attributes, attrDict); - - var evt = PackRunTimelineEvent.Create( - tenantId: tenantId, - eventType: PackRunEventTypes.StepCompleted, - source: Source, - occurredAt: _timeProvider.GetUtcNow(), - runId: runId, - planHash: planHash, - stepId: stepId, - actor: actor, - severity: PackRunEventSeverity.Info, - attributes: attrs, - correlationId: correlationId, - traceId: traceId, - projectId: projectId, - payload: new { stepId, attempt, durationMs }, - evidencePointer: evidencePointer); - - return EmitAsync(evt, cancellationToken); - } - - public Task EmitStepFailedAsync( - string tenantId, - string runId, - string planHash, - string stepId, - int attempt, - string? error = null, - string? actor = null, - string? correlationId = null, - string? traceId = null, - string? projectId = null, - IReadOnlyDictionary? attributes = null, - CancellationToken cancellationToken = default) - { - var attrDict = new Dictionary - { - ["runId"] = runId, - ["planHash"] = planHash, - ["stepId"] = stepId, - ["attempt"] = attempt.ToString() - }; - - if (!string.IsNullOrWhiteSpace(error)) - { - attrDict["error"] = error; - } - - var attrs = MergeAttributes(attributes, attrDict); - - var evt = PackRunTimelineEvent.Create( - tenantId: tenantId, - eventType: PackRunEventTypes.StepFailed, - source: Source, - occurredAt: _timeProvider.GetUtcNow(), - runId: runId, - planHash: planHash, - stepId: stepId, - actor: actor, - severity: PackRunEventSeverity.Error, - attributes: attrs, - correlationId: correlationId, - traceId: traceId, - projectId: projectId, - payload: new { stepId, attempt, error }); - - return EmitAsync(evt, cancellationToken); - } - - private async Task EmitWithRetryAsync( - PackRunTimelineEvent evt, - CancellationToken cancellationToken) - { - var attempt = 0; - var delay = _options.RetryDelay; - - while (true) - { - try - { - var sinkResult = await _sink.WriteAsync(evt, cancellationToken); - - if (sinkResult.Deduplicated) - { - _logger.LogDebug( - "Timeline event {EventId} deduplicated", - evt.EventId); - - return new PackRunTimelineEmitResult( - Success: true, - Event: evt, - Deduplicated: true, - Error: null); - } - - _logger.LogInformation( - "Emitted timeline event {EventId} type {EventType} tenant {TenantId} run {RunId} seq {Seq}", - evt.EventId, evt.EventType, evt.TenantId, evt.RunId, sinkResult.Sequence); - - return new PackRunTimelineEmitResult( - Success: true, - Event: sinkResult.Sequence.HasValue ? evt.WithSequence(sinkResult.Sequence.Value) : evt, - Deduplicated: false, - Error: null); - } - catch (Exception ex) when (attempt < _options.MaxRetries && IsTransient(ex)) - { - attempt++; - _logger.LogWarning(ex, - "Transient failure emitting timeline event {EventId}, attempt {Attempt}/{MaxRetries}", - evt.EventId, attempt, _options.MaxRetries); - - await Task.Delay(delay, cancellationToken); - delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); - } - } - } - - private static IReadOnlyDictionary MergeAttributes( - IReadOnlyDictionary? existing, - Dictionary additional) - { - if (existing is null || existing.Count == 0) - return additional; - - var merged = new Dictionary(existing); - foreach (var (key, value) in additional) - { - merged.TryAdd(key, value); - } - return merged; - } - - private static bool IsTransient(Exception ex) - { - return ex is TimeoutException or - TaskCanceledException or - System.Net.Http.HttpRequestException or - System.IO.IOException; - } -} - -/// -/// Options for pack run timeline event emitter. -/// -public sealed record PackRunTimelineEmitterOptions( - /// Maximum retry attempts for transient failures. - int MaxRetries, - - /// Base delay between retries. - TimeSpan RetryDelay, - - /// Whether to include evidence pointers. - bool IncludeEvidencePointers) -{ - /// Default emitter options. - public static PackRunTimelineEmitterOptions Default => new( - MaxRetries: 3, - RetryDelay: TimeSpan.FromSeconds(1), - IncludeEvidencePointers: true); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/BundleImportEvidence.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/BundleImportEvidence.cs deleted file mode 100644 index d87337c91..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/BundleImportEvidence.cs +++ /dev/null @@ -1,243 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace StellaOps.TaskRunner.Core.Evidence; - -/// -/// Evidence for bundle import operations. -/// Per TASKRUN-AIRGAP-58-001. -/// -public sealed record BundleImportEvidence( - /// Unique import job identifier. - string JobId, - - /// Tenant that initiated the import. - string TenantId, - - /// Bundle source path or URL. - string SourcePath, - - /// When the import started. - DateTimeOffset StartedAt, - - /// When the import completed. - DateTimeOffset? CompletedAt, - - /// Final status of the import. - BundleImportStatus Status, - - /// Error message if failed. - string? ErrorMessage, - - /// Actor who initiated the import. - string? InitiatedBy, - - /// Input bundle manifest. - BundleImportInputManifest? InputManifest, - - /// Output files with hashes. - IReadOnlyList OutputFiles, - - /// Import transcript log entries. - IReadOnlyList Transcript, - - /// Validation results. - BundleImportValidationResult? ValidationResult, - - /// Computed hashes for evidence chain. - BundleImportHashChain HashChain); - -/// -/// Bundle import status. -/// -public enum BundleImportStatus -{ - /// Import is pending. - Pending, - - /// Import is in progress. - InProgress, - - /// Import completed successfully. - Completed, - - /// Import failed. - Failed, - - /// Import was cancelled. - Cancelled, - - /// Import is partially complete. - PartiallyComplete -} - -/// -/// Input bundle manifest from the import source. -/// -public sealed record BundleImportInputManifest( - /// Bundle format version. - string FormatVersion, - - /// Bundle identifier. - string BundleId, - - /// Bundle version. - string BundleVersion, - - /// When the bundle was created. - DateTimeOffset CreatedAt, - - /// Who created the bundle. - string? CreatedBy, - - /// Total size in bytes. - long TotalSizeBytes, - - /// Number of items in the bundle. - int ItemCount, - - /// SHA-256 of the manifest. - string ManifestSha256, - - /// Bundle signature if present. - string? Signature, - - /// Signature verification status. - bool? SignatureValid); - -/// -/// Output file from bundle import. -/// -public sealed record BundleImportOutputFile( - /// Relative path within staging directory. - string RelativePath, - - /// SHA-256 hash of the file. - string Sha256, - - /// Size in bytes. - long SizeBytes, - - /// Media type. - string MediaType, - - /// When the file was staged. - DateTimeOffset StagedAt, - - /// Source item identifier in the bundle. - string? SourceItemId); - -/// -/// Transcript entry for bundle import. -/// -public sealed record BundleImportTranscriptEntry( - /// When the entry was recorded. - DateTimeOffset Timestamp, - - /// Log level. - string Level, - - /// Event type. - string EventType, - - /// Message. - string Message, - - /// Additional data. - IReadOnlyDictionary? Data); - -/// -/// Bundle import validation result. -/// -public sealed record BundleImportValidationResult( - /// Whether validation passed. - bool Valid, - - /// Checksum verification passed. - bool ChecksumValid, - - /// Signature verification passed. - bool? SignatureValid, - - /// Format validation passed. - bool FormatValid, - - /// Validation errors. - IReadOnlyList? Errors, - - /// Validation warnings. - IReadOnlyList? Warnings); - -/// -/// Hash chain for bundle import evidence. -/// -public sealed record BundleImportHashChain( - /// Hash of all input files. - string InputsHash, - - /// Hash of all output files. - string OutputsHash, - - /// Hash of the transcript. - string TranscriptHash, - - /// Combined root hash. - string RootHash, - - /// Algorithm used. - string Algorithm) -{ - /// - /// Computes hash chain from import evidence data. - /// - public static BundleImportHashChain Compute( - BundleImportInputManifest? input, - IReadOnlyList outputs, - IReadOnlyList transcript) - { - // Compute input hash - var inputJson = input is not null - ? JsonSerializer.Serialize(input, JsonOptions) - : "null"; - var inputsHash = ComputeSha256(inputJson); - - // Compute outputs hash (sorted for determinism) - var sortedOutputs = outputs - .OrderBy(o => o.RelativePath, StringComparer.Ordinal) - .Select(o => o.Sha256) - .ToList(); - var outputsJson = JsonSerializer.Serialize(sortedOutputs, JsonOptions); - var outputsHash = ComputeSha256(outputsJson); - - // Compute transcript hash - var transcriptJson = JsonSerializer.Serialize(transcript, JsonOptions); - var transcriptHash = ComputeSha256(transcriptJson); - - // Compute root hash - var combined = $"{inputsHash}|{outputsHash}|{transcriptHash}"; - var rootHash = ComputeSha256(combined); - - return new BundleImportHashChain( - InputsHash: inputsHash, - OutputsHash: outputsHash, - TranscriptHash: transcriptHash, - RootHash: rootHash, - Algorithm: "sha256"); - } - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false - }; - - private static string ComputeSha256(string content) - { - var bytes = Encoding.UTF8.GetBytes(content); - var hash = SHA256.HashData(bytes); - return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IBundleImportEvidenceService.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IBundleImportEvidenceService.cs deleted file mode 100644 index 26985c014..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IBundleImportEvidenceService.cs +++ /dev/null @@ -1,383 +0,0 @@ - -using Microsoft.Extensions.Logging; -using StellaOps.TaskRunner.Core.Events; -using System.Globalization; - -namespace StellaOps.TaskRunner.Core.Evidence; - -/// -/// Service for capturing bundle import evidence. -/// Per TASKRUN-AIRGAP-58-001. -/// -public interface IBundleImportEvidenceService -{ - /// - /// Captures evidence for a bundle import operation. - /// - Task CaptureAsync( - BundleImportEvidence evidence, - CancellationToken cancellationToken = default); - - /// - /// Exports evidence to a portable bundle format. - /// - Task ExportToPortableBundleAsync( - string jobId, - string outputPath, - CancellationToken cancellationToken = default); - - /// - /// Gets evidence for a bundle import job. - /// - Task GetAsync( - string jobId, - CancellationToken cancellationToken = default); -} - -/// -/// Result of capturing bundle import evidence. -/// -public sealed record BundleImportEvidenceResult( - /// Whether capture was successful. - bool Success, - - /// The captured snapshot. - PackRunEvidenceSnapshot? Snapshot, - - /// Evidence pointer for linking. - PackRunEvidencePointer? EvidencePointer, - - /// Error message if capture failed. - string? Error); - -/// -/// Result of exporting to portable bundle. -/// -public sealed record PortableEvidenceBundleResult( - /// Whether export was successful. - bool Success, - - /// Path to the exported bundle. - string? OutputPath, - - /// SHA-256 of the bundle. - string? BundleSha256, - - /// Size in bytes. - long SizeBytes, - - /// Error message if export failed. - string? Error); - -/// -/// Default implementation of bundle import evidence service. -/// -public sealed class BundleImportEvidenceService : IBundleImportEvidenceService -{ - private readonly IPackRunEvidenceStore _store; - private readonly IPackRunTimelineEventEmitter? _timelineEmitter; - private readonly ILogger _logger; - - public BundleImportEvidenceService( - IPackRunEvidenceStore store, - ILogger logger, - IPackRunTimelineEventEmitter? timelineEmitter = null) - { - _store = store ?? throw new ArgumentNullException(nameof(store)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _timelineEmitter = timelineEmitter; - } - - /// - public async Task CaptureAsync( - BundleImportEvidence evidence, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(evidence); - - try - { - var materials = new List(); - - // Add input manifest - if (evidence.InputManifest is not null) - { - materials.Add(PackRunEvidenceMaterial.FromJson( - "input", - "manifest.json", - evidence.InputManifest, - new Dictionary - { - ["bundleId"] = evidence.InputManifest.BundleId, - ["bundleVersion"] = evidence.InputManifest.BundleVersion - })); - } - - // Add output files as materials - foreach (var output in evidence.OutputFiles) - { - materials.Add(new PackRunEvidenceMaterial( - Section: "output", - Path: output.RelativePath, - Sha256: output.Sha256, - SizeBytes: output.SizeBytes, - MediaType: output.MediaType, - Attributes: new Dictionary - { - ["stagedAt"] = output.StagedAt.ToString("O", CultureInfo.InvariantCulture) - })); - } - - // Add transcript - materials.Add(PackRunEvidenceMaterial.FromJson( - "transcript", - "import-log.json", - evidence.Transcript)); - - // Add validation result - if (evidence.ValidationResult is not null) - { - materials.Add(PackRunEvidenceMaterial.FromJson( - "validation", - "result.json", - evidence.ValidationResult)); - } - - // Add hash chain - materials.Add(PackRunEvidenceMaterial.FromJson( - "hashchain", - "chain.json", - evidence.HashChain)); - - // Create metadata - var metadata = new Dictionary - { - ["jobId"] = evidence.JobId, - ["status"] = evidence.Status.ToString(), - ["sourcePath"] = evidence.SourcePath, - ["startedAt"] = evidence.StartedAt.ToString("O", CultureInfo.InvariantCulture), - ["outputCount"] = evidence.OutputFiles.Count.ToString(), - ["rootHash"] = evidence.HashChain.RootHash - }; - - if (evidence.CompletedAt.HasValue) - { - metadata["completedAt"] = evidence.CompletedAt.Value.ToString("O", CultureInfo.InvariantCulture); - metadata["durationMs"] = ((evidence.CompletedAt.Value - evidence.StartedAt).TotalMilliseconds).ToString("F0"); - } - - if (!string.IsNullOrWhiteSpace(evidence.InitiatedBy)) - { - metadata["initiatedBy"] = evidence.InitiatedBy; - } - - // Create snapshot - var snapshot = PackRunEvidenceSnapshot.Create( - tenantId: evidence.TenantId, - runId: evidence.JobId, - planHash: evidence.HashChain.RootHash, - kind: PackRunEvidenceSnapshotKind.BundleImport, - materials: materials, - metadata: metadata); - - // Store snapshot - await _store.StoreAsync(snapshot, cancellationToken); - - var evidencePointer = PackRunEvidencePointer.Bundle( - snapshot.SnapshotId, - snapshot.RootHash); - - // Emit timeline event - if (_timelineEmitter is not null) - { - await _timelineEmitter.EmitAsync( - PackRunTimelineEvent.Create( - tenantId: evidence.TenantId, - eventType: "bundle.import.evidence_captured", - source: "taskrunner-bundle-import", - occurredAt: DateTimeOffset.UtcNow, - runId: evidence.JobId, - planHash: evidence.HashChain.RootHash, - attributes: new Dictionary - { - ["snapshotId"] = snapshot.SnapshotId.ToString(), - ["rootHash"] = snapshot.RootHash, - ["status"] = evidence.Status.ToString(), - ["outputCount"] = evidence.OutputFiles.Count.ToString() - }, - evidencePointer: evidencePointer), - cancellationToken); - } - - _logger.LogInformation( - "Captured bundle import evidence for job {JobId} with {OutputCount} outputs, root hash {RootHash}", - evidence.JobId, - evidence.OutputFiles.Count, - evidence.HashChain.RootHash); - - return new BundleImportEvidenceResult( - Success: true, - Snapshot: snapshot, - EvidencePointer: evidencePointer, - Error: null); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to capture bundle import evidence for job {JobId}", evidence.JobId); - - return new BundleImportEvidenceResult( - Success: false, - Snapshot: null, - EvidencePointer: null, - Error: ex.Message); - } - } - - /// - public async Task ExportToPortableBundleAsync( - string jobId, - string outputPath, - CancellationToken cancellationToken = default) - { - try - { - // Get all snapshots for this job - var snapshots = await _store.GetByRunIdAsync(jobId, cancellationToken); - if (snapshots.Count == 0) - { - return new PortableEvidenceBundleResult( - Success: false, - OutputPath: null, - BundleSha256: null, - SizeBytes: 0, - Error: $"No evidence found for job {jobId}"); - } - - // Create portable bundle structure - var bundleManifest = new PortableEvidenceBundleManifest - { - Version = "1.0.0", - CreatedAt = DateTimeOffset.UtcNow, - JobId = jobId, - SnapshotCount = snapshots.Count, - Snapshots = snapshots.Select(s => new PortableSnapshotReference - { - SnapshotId = s.SnapshotId, - Kind = s.Kind.ToString(), - RootHash = s.RootHash, - CreatedAt = s.CreatedAt, - MaterialCount = s.Materials.Count - }).ToList() - }; - - // Serialize bundle - var bundleJson = System.Text.Json.JsonSerializer.Serialize(new - { - manifest = bundleManifest, - snapshots = snapshots - }, new System.Text.Json.JsonSerializerOptions - { - PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, - WriteIndented = true - }); - - // Write to file - await File.WriteAllTextAsync(outputPath, bundleJson, cancellationToken); - var fileInfo = new FileInfo(outputPath); - - // Compute bundle hash - var bundleBytes = await File.ReadAllBytesAsync(outputPath, cancellationToken); - var hash = System.Security.Cryptography.SHA256.HashData(bundleBytes); - var bundleSha256 = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; - - _logger.LogInformation( - "Exported portable evidence bundle for job {JobId} to {OutputPath}, size {SizeBytes} bytes", - jobId, - outputPath, - fileInfo.Length); - - return new PortableEvidenceBundleResult( - Success: true, - OutputPath: outputPath, - BundleSha256: bundleSha256, - SizeBytes: fileInfo.Length, - Error: null); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to export portable evidence bundle for job {JobId}", jobId); - - return new PortableEvidenceBundleResult( - Success: false, - OutputPath: null, - BundleSha256: null, - SizeBytes: 0, - Error: ex.Message); - } - } - - /// - public async Task GetAsync( - string jobId, - CancellationToken cancellationToken = default) - { - var snapshots = await _store.GetByRunIdAsync(jobId, cancellationToken); - var importSnapshot = snapshots.FirstOrDefault(s => s.Kind == PackRunEvidenceSnapshotKind.BundleImport); - - if (importSnapshot is null) - { - return null; - } - - // Reconstruct evidence from snapshot - return ReconstructEvidence(importSnapshot); - } - - private static BundleImportEvidence? ReconstructEvidence(PackRunEvidenceSnapshot snapshot) - { - // This would deserialize the stored materials back into the evidence structure - // For now, return a minimal reconstruction from metadata - var metadata = snapshot.Metadata ?? new Dictionary(); - - return new BundleImportEvidence( - JobId: metadata.GetValueOrDefault("jobId", snapshot.RunId), - TenantId: snapshot.TenantId, - SourcePath: metadata.GetValueOrDefault("sourcePath", "unknown"), - StartedAt: DateTimeOffset.TryParse(metadata.GetValueOrDefault("startedAt"), out var started) - ? started : snapshot.CreatedAt, - CompletedAt: DateTimeOffset.TryParse(metadata.GetValueOrDefault("completedAt"), out var completed) - ? completed : null, - Status: Enum.TryParse(metadata.GetValueOrDefault("status"), out var status) - ? status : BundleImportStatus.Completed, - ErrorMessage: null, - InitiatedBy: metadata.GetValueOrDefault("initiatedBy"), - InputManifest: null, - OutputFiles: [], - Transcript: [], - ValidationResult: null, - HashChain: new BundleImportHashChain( - InputsHash: "sha256:reconstructed", - OutputsHash: "sha256:reconstructed", - TranscriptHash: "sha256:reconstructed", - RootHash: metadata.GetValueOrDefault("rootHash", snapshot.RootHash), - Algorithm: "sha256")); - } - - private sealed class PortableEvidenceBundleManifest - { - public required string Version { get; init; } - public required DateTimeOffset CreatedAt { get; init; } - public required string JobId { get; init; } - public required int SnapshotCount { get; init; } - public required IReadOnlyList Snapshots { get; init; } - } - - private sealed class PortableSnapshotReference - { - public required Guid SnapshotId { get; init; } - public required string Kind { get; init; } - public required string RootHash { get; init; } - public required DateTimeOffset CreatedAt { get; init; } - public required int MaterialCount { get; init; } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IPackRunEvidenceSnapshotService.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IPackRunEvidenceSnapshotService.cs deleted file mode 100644 index 171d5d15f..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IPackRunEvidenceSnapshotService.cs +++ /dev/null @@ -1,504 +0,0 @@ - -using Microsoft.Extensions.Logging; -using StellaOps.TaskRunner.Core.Events; -using StellaOps.TaskRunner.Core.Execution; -using System.Globalization; - -namespace StellaOps.TaskRunner.Core.Evidence; - -/// -/// Service for capturing pack run evidence snapshots. -/// Per TASKRUN-OBS-53-001. -/// -public interface IPackRunEvidenceSnapshotService -{ - /// - /// Captures a run completion snapshot with all materials. - /// - Task CaptureRunCompletionAsync( - string tenantId, - string runId, - string planHash, - PackRunState state, - IReadOnlyList? transcripts = null, - IReadOnlyList? approvals = null, - IReadOnlyList? policyEvaluations = null, - PackRunEnvironmentDigest? environmentDigest = null, - CancellationToken cancellationToken = default); - - /// - /// Captures a step execution snapshot. - /// - Task CaptureStepExecutionAsync( - string tenantId, - string runId, - string planHash, - PackRunStepTranscript transcript, - CancellationToken cancellationToken = default); - - /// - /// Captures an approval decision snapshot. - /// - Task CaptureApprovalDecisionAsync( - string tenantId, - string runId, - string planHash, - PackRunApprovalEvidence approval, - CancellationToken cancellationToken = default); - - /// - /// Captures a policy evaluation snapshot. - /// - Task CapturePolicyEvaluationAsync( - string tenantId, - string runId, - string planHash, - PackRunPolicyEvidence evaluation, - CancellationToken cancellationToken = default); -} - -/// -/// Result of evidence snapshot capture. -/// -public sealed record PackRunEvidenceSnapshotResult( - /// Whether capture was successful. - bool Success, - - /// The captured snapshot. - PackRunEvidenceSnapshot? Snapshot, - - /// Evidence pointer for timeline events. - PackRunEvidencePointer? EvidencePointer, - - /// Error message if capture failed. - string? Error); - -/// -/// Default implementation of evidence snapshot service. -/// -public sealed class PackRunEvidenceSnapshotService : IPackRunEvidenceSnapshotService -{ - private readonly IPackRunEvidenceStore _store; - private readonly IPackRunRedactionGuard _redactionGuard; - private readonly IPackRunTimelineEventEmitter? _timelineEmitter; - private readonly ILogger _logger; - private readonly PackRunEvidenceSnapshotOptions _options; - - public PackRunEvidenceSnapshotService( - IPackRunEvidenceStore store, - IPackRunRedactionGuard redactionGuard, - ILogger logger, - IPackRunTimelineEventEmitter? timelineEmitter = null, - PackRunEvidenceSnapshotOptions? options = null) - { - _store = store ?? throw new ArgumentNullException(nameof(store)); - _redactionGuard = redactionGuard ?? throw new ArgumentNullException(nameof(redactionGuard)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _timelineEmitter = timelineEmitter; - _options = options ?? PackRunEvidenceSnapshotOptions.Default; - } - - public async Task CaptureRunCompletionAsync( - string tenantId, - string runId, - string planHash, - PackRunState state, - IReadOnlyList? transcripts = null, - IReadOnlyList? approvals = null, - IReadOnlyList? policyEvaluations = null, - PackRunEnvironmentDigest? environmentDigest = null, - CancellationToken cancellationToken = default) - { - try - { - var materials = new List(); - - // Add state summary - var stateSummary = CreateStateSummary(state); - materials.Add(PackRunEvidenceMaterial.FromJson( - "summary", - "run-state.json", - stateSummary)); - - // Add transcripts (redacted) - if (transcripts is not null) - { - foreach (var transcript in transcripts) - { - var redacted = _redactionGuard.RedactTranscript(transcript); - materials.Add(PackRunEvidenceMaterial.FromJson( - "transcript", - $"{redacted.StepId}.json", - redacted, - new Dictionary { ["stepId"] = redacted.StepId })); - } - } - - // Add approvals (redacted) - if (approvals is not null) - { - foreach (var approval in approvals) - { - var redacted = _redactionGuard.RedactApproval(approval); - materials.Add(PackRunEvidenceMaterial.FromJson( - "approval", - $"{redacted.ApprovalId}.json", - redacted, - new Dictionary { ["approvalId"] = redacted.ApprovalId })); - } - } - - // Add policy evaluations - if (policyEvaluations is not null) - { - foreach (var evaluation in policyEvaluations) - { - materials.Add(PackRunEvidenceMaterial.FromJson( - "policy", - $"{evaluation.PolicyName}.json", - evaluation, - new Dictionary { ["policyName"] = evaluation.PolicyName })); - } - } - - // Add environment digest (redacted) - if (environmentDigest is not null) - { - var redacted = _redactionGuard.RedactEnvironment(environmentDigest); - materials.Add(PackRunEvidenceMaterial.FromJson( - "environment", - "digest.json", - redacted)); - } - - // Create snapshot - var metadata = new Dictionary - { - ["runId"] = runId, - ["planHash"] = planHash, - ["stepCount"] = state.Steps.Count.ToString(), - ["capturedAt"] = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture) - }; - - var snapshot = PackRunEvidenceSnapshot.Create( - tenantId, - runId, - planHash, - PackRunEvidenceSnapshotKind.RunCompletion, - materials, - metadata); - - // Store snapshot - await _store.StoreAsync(snapshot, cancellationToken); - - var evidencePointer = PackRunEvidencePointer.Bundle( - snapshot.SnapshotId, - snapshot.RootHash); - - // Emit timeline event if emitter available - if (_timelineEmitter is not null) - { - await _timelineEmitter.EmitAsync( - PackRunTimelineEvent.Create( - tenantId: tenantId, - eventType: "pack.evidence.captured", - source: "taskrunner-evidence", - occurredAt: DateTimeOffset.UtcNow, - runId: runId, - planHash: planHash, - attributes: new Dictionary - { - ["snapshotId"] = snapshot.SnapshotId.ToString(), - ["rootHash"] = snapshot.RootHash, - ["materialCount"] = materials.Count.ToString() - }, - evidencePointer: evidencePointer), - cancellationToken); - } - - _logger.LogInformation( - "Captured run completion evidence for run {RunId} with {MaterialCount} materials, root hash {RootHash}", - runId, materials.Count, snapshot.RootHash); - - return new PackRunEvidenceSnapshotResult( - Success: true, - Snapshot: snapshot, - EvidencePointer: evidencePointer, - Error: null); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Failed to capture run completion evidence for run {RunId}", - runId); - - return new PackRunEvidenceSnapshotResult( - Success: false, - Snapshot: null, - EvidencePointer: null, - Error: ex.Message); - } - } - - public async Task CaptureStepExecutionAsync( - string tenantId, - string runId, - string planHash, - PackRunStepTranscript transcript, - CancellationToken cancellationToken = default) - { - try - { - var redacted = _redactionGuard.RedactTranscript(transcript); - var materials = new List - { - PackRunEvidenceMaterial.FromJson( - "transcript", - $"{redacted.StepId}.json", - redacted, - new Dictionary { ["stepId"] = redacted.StepId }) - }; - - // Add artifacts if present - if (redacted.Artifacts is not null) - { - foreach (var artifact in redacted.Artifacts) - { - materials.Add(new PackRunEvidenceMaterial( - Section: "artifact", - Path: artifact.Name, - Sha256: artifact.Sha256, - SizeBytes: artifact.SizeBytes, - MediaType: artifact.MediaType, - Attributes: new Dictionary { ["stepId"] = redacted.StepId })); - } - } - - var metadata = new Dictionary - { - ["runId"] = runId, - ["planHash"] = planHash, - ["stepId"] = transcript.StepId, - ["status"] = transcript.Status, - ["attempt"] = transcript.Attempt.ToString() - }; - - var snapshot = PackRunEvidenceSnapshot.Create( - tenantId, - runId, - planHash, - PackRunEvidenceSnapshotKind.StepExecution, - materials, - metadata); - - await _store.StoreAsync(snapshot, cancellationToken); - - var evidencePointer = PackRunEvidencePointer.Bundle( - snapshot.SnapshotId, - snapshot.RootHash); - - _logger.LogDebug( - "Captured step execution evidence for run {RunId} step {StepId}", - runId, transcript.StepId); - - return new PackRunEvidenceSnapshotResult( - Success: true, - Snapshot: snapshot, - EvidencePointer: evidencePointer, - Error: null); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Failed to capture step execution evidence for run {RunId} step {StepId}", - runId, transcript.StepId); - - return new PackRunEvidenceSnapshotResult( - Success: false, - Snapshot: null, - EvidencePointer: null, - Error: ex.Message); - } - } - - public async Task CaptureApprovalDecisionAsync( - string tenantId, - string runId, - string planHash, - PackRunApprovalEvidence approval, - CancellationToken cancellationToken = default) - { - try - { - var redacted = _redactionGuard.RedactApproval(approval); - var materials = new List - { - PackRunEvidenceMaterial.FromJson( - "approval", - $"{redacted.ApprovalId}.json", - redacted) - }; - - var metadata = new Dictionary - { - ["runId"] = runId, - ["planHash"] = planHash, - ["approvalId"] = approval.ApprovalId, - ["decision"] = approval.Decision, - ["approver"] = _redactionGuard.RedactIdentity(approval.Approver) - }; - - var snapshot = PackRunEvidenceSnapshot.Create( - tenantId, - runId, - planHash, - PackRunEvidenceSnapshotKind.ApprovalDecision, - materials, - metadata); - - await _store.StoreAsync(snapshot, cancellationToken); - - var evidencePointer = PackRunEvidencePointer.Bundle( - snapshot.SnapshotId, - snapshot.RootHash); - - _logger.LogDebug( - "Captured approval decision evidence for run {RunId} approval {ApprovalId}", - runId, approval.ApprovalId); - - return new PackRunEvidenceSnapshotResult( - Success: true, - Snapshot: snapshot, - EvidencePointer: evidencePointer, - Error: null); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Failed to capture approval decision evidence for run {RunId}", - runId); - - return new PackRunEvidenceSnapshotResult( - Success: false, - Snapshot: null, - EvidencePointer: null, - Error: ex.Message); - } - } - - public async Task CapturePolicyEvaluationAsync( - string tenantId, - string runId, - string planHash, - PackRunPolicyEvidence evaluation, - CancellationToken cancellationToken = default) - { - try - { - var materials = new List - { - PackRunEvidenceMaterial.FromJson( - "policy", - $"{evaluation.PolicyName}.json", - evaluation) - }; - - var metadata = new Dictionary - { - ["runId"] = runId, - ["planHash"] = planHash, - ["policyName"] = evaluation.PolicyName, - ["result"] = evaluation.Result - }; - - if (evaluation.PolicyVersion is not null) - { - metadata["policyVersion"] = evaluation.PolicyVersion; - } - - var snapshot = PackRunEvidenceSnapshot.Create( - tenantId, - runId, - planHash, - PackRunEvidenceSnapshotKind.PolicyEvaluation, - materials, - metadata); - - await _store.StoreAsync(snapshot, cancellationToken); - - var evidencePointer = PackRunEvidencePointer.Bundle( - snapshot.SnapshotId, - snapshot.RootHash); - - _logger.LogDebug( - "Captured policy evaluation evidence for run {RunId} policy {PolicyName}", - runId, evaluation.PolicyName); - - return new PackRunEvidenceSnapshotResult( - Success: true, - Snapshot: snapshot, - EvidencePointer: evidencePointer, - Error: null); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Failed to capture policy evaluation evidence for run {RunId}", - runId); - - return new PackRunEvidenceSnapshotResult( - Success: false, - Snapshot: null, - EvidencePointer: null, - Error: ex.Message); - } - } - - private static object CreateStateSummary(PackRunState state) - { - var stepSummaries = state.Steps.Values.Select(s => new - { - s.StepId, - Kind = s.Kind.ToString(), - s.Enabled, - Status = s.Status.ToString(), - s.Attempts, - s.StatusReason - }).ToList(); - - return new - { - state.RunId, - state.PlanHash, - state.RequestedAt, - state.CreatedAt, - state.UpdatedAt, - StepCount = state.Steps.Count, - Steps = stepSummaries - }; - } -} - -/// -/// Options for evidence snapshot service. -/// -public sealed record PackRunEvidenceSnapshotOptions( - /// Maximum transcript output length before truncation. - int MaxTranscriptOutputLength, - - /// Maximum comment length before truncation. - int MaxCommentLength, - - /// Whether to include step outputs. - bool IncludeStepOutput, - - /// Whether to emit timeline events. - bool EmitTimelineEvents) -{ - /// Default options. - public static PackRunEvidenceSnapshotOptions Default => new( - MaxTranscriptOutputLength: 64 * 1024, // 64KB - MaxCommentLength: 4096, - IncludeStepOutput: true, - EmitTimelineEvents: true); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IPackRunEvidenceStore.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IPackRunEvidenceStore.cs deleted file mode 100644 index 44fdacbc0..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IPackRunEvidenceStore.cs +++ /dev/null @@ -1,203 +0,0 @@ -namespace StellaOps.TaskRunner.Core.Evidence; - -/// -/// Store for pack run evidence snapshots. -/// Per TASKRUN-OBS-53-001. -/// -public interface IPackRunEvidenceStore -{ - /// - /// Stores an evidence snapshot. - /// - Task StoreAsync( - PackRunEvidenceSnapshot snapshot, - CancellationToken cancellationToken = default); - - /// - /// Retrieves an evidence snapshot by ID. - /// - Task GetAsync( - Guid snapshotId, - CancellationToken cancellationToken = default); - - /// - /// Lists evidence snapshots for a run. - /// - Task> ListByRunAsync( - string tenantId, - string runId, - CancellationToken cancellationToken = default); - - /// - /// Gets evidence snapshots by run ID only (across all tenants). - /// For bundle import evidence lookups. - /// - Task> GetByRunIdAsync( - string runId, - CancellationToken cancellationToken = default); - - /// - /// Lists evidence snapshots by kind for a run. - /// - Task> ListByKindAsync( - string tenantId, - string runId, - PackRunEvidenceSnapshotKind kind, - CancellationToken cancellationToken = default); - - /// - /// Verifies the integrity of a snapshot by recomputing its Merkle root. - /// - Task VerifyAsync( - Guid snapshotId, - CancellationToken cancellationToken = default); -} - -/// -/// Result of evidence verification. -/// -public sealed record PackRunEvidenceVerificationResult( - /// Whether verification passed. - bool Valid, - - /// The snapshot that was verified. - Guid SnapshotId, - - /// Expected root hash. - string ExpectedHash, - - /// Computed root hash. - string ComputedHash, - - /// Error message if verification failed. - string? Error); - -/// -/// In-memory evidence store for testing. -/// -public sealed class InMemoryPackRunEvidenceStore : IPackRunEvidenceStore -{ - private readonly Dictionary _snapshots = new(); - private readonly object _lock = new(); - - public Task StoreAsync( - PackRunEvidenceSnapshot snapshot, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - _snapshots[snapshot.SnapshotId] = snapshot; - } - return Task.CompletedTask; - } - - public Task GetAsync( - Guid snapshotId, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - _snapshots.TryGetValue(snapshotId, out var snapshot); - return Task.FromResult(snapshot); - } - } - - public Task> ListByRunAsync( - string tenantId, - string runId, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - var results = _snapshots.Values - .Where(s => s.TenantId == tenantId && s.RunId == runId) - .OrderBy(s => s.CreatedAt) - .ToList(); - return Task.FromResult>(results); - } - } - - public Task> GetByRunIdAsync( - string runId, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - var results = _snapshots.Values - .Where(s => s.RunId == runId) - .OrderBy(s => s.CreatedAt) - .ToList(); - return Task.FromResult>(results); - } - } - - public Task> ListByKindAsync( - string tenantId, - string runId, - PackRunEvidenceSnapshotKind kind, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - var results = _snapshots.Values - .Where(s => s.TenantId == tenantId && s.RunId == runId && s.Kind == kind) - .OrderBy(s => s.CreatedAt) - .ToList(); - return Task.FromResult>(results); - } - } - - public Task VerifyAsync( - Guid snapshotId, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - if (!_snapshots.TryGetValue(snapshotId, out var snapshot)) - { - return Task.FromResult(new PackRunEvidenceVerificationResult( - Valid: false, - SnapshotId: snapshotId, - ExpectedHash: string.Empty, - ComputedHash: string.Empty, - Error: "Snapshot not found")); - } - - // Recompute by creating a new snapshot with same materials - var recomputed = PackRunEvidenceSnapshot.Create( - snapshot.TenantId, - snapshot.RunId, - snapshot.PlanHash, - snapshot.Kind, - snapshot.Materials, - snapshot.Metadata); - - var valid = snapshot.RootHash == recomputed.RootHash; - - return Task.FromResult(new PackRunEvidenceVerificationResult( - Valid: valid, - SnapshotId: snapshotId, - ExpectedHash: snapshot.RootHash, - ComputedHash: recomputed.RootHash, - Error: valid ? null : "Root hash mismatch")); - } - } - - /// Gets all snapshots (for testing). - public IReadOnlyList GetAll() - { - lock (_lock) { return _snapshots.Values.ToList(); } - } - - /// Clears all snapshots (for testing). - public void Clear() - { - lock (_lock) { _snapshots.Clear(); } - } - - /// Gets snapshot count. - public int Count - { - get { lock (_lock) { return _snapshots.Count; } } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IPackRunRedactionGuard.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IPackRunRedactionGuard.cs deleted file mode 100644 index c5ea131dc..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/IPackRunRedactionGuard.cs +++ /dev/null @@ -1,270 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.RegularExpressions; - -namespace StellaOps.TaskRunner.Core.Evidence; - -/// -/// Redaction guard for sensitive data in evidence snapshots. -/// Per TASKRUN-OBS-53-001. -/// -public interface IPackRunRedactionGuard -{ - /// - /// Redacts sensitive data from a step transcript. - /// - PackRunStepTranscript RedactTranscript(PackRunStepTranscript transcript); - - /// - /// Redacts sensitive data from an approval evidence record. - /// - PackRunApprovalEvidence RedactApproval(PackRunApprovalEvidence approval); - - /// - /// Redacts sensitive data from an environment digest. - /// - PackRunEnvironmentDigest RedactEnvironment(PackRunEnvironmentDigest digest); - - /// - /// Redacts an identity string (e.g., email, username). - /// - string RedactIdentity(string identity); - - /// - /// Redacts a string value that may contain secrets. - /// - string RedactValue(string value); -} - -/// -/// Options for redaction guard. -/// -public sealed record PackRunRedactionGuardOptions( - /// Patterns that indicate sensitive variable names. - IReadOnlyList SensitiveVariablePatterns, - - /// Patterns that indicate sensitive content in output. - IReadOnlyList SensitiveContentPatterns, - - /// Whether to hash redacted values for correlation. - bool HashRedactedValues, - - /// Maximum length of output before truncation. - int MaxOutputLength, - - /// Whether to preserve email domain. - bool PreserveEmailDomain) -{ - /// Default redaction options. - public static PackRunRedactionGuardOptions Default => new( - SensitiveVariablePatterns: new[] - { - "(?i)password", - "(?i)secret", - "(?i)token", - "(?i)api_key", - "(?i)apikey", - "(?i)auth", - "(?i)credential", - "(?i)private_key", - "(?i)privatekey", - "(?i)access_key", - "(?i)accesskey", - "(?i)connection_string", - "(?i)connectionstring" - }, - SensitiveContentPatterns: new[] - { - @"(?i)bearer\s+[a-zA-Z0-9\-_.]+", - @"(?i)basic\s+[a-zA-Z0-9+/=]+", - @"-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----", - @"(?i)password\s*[=:]\s*\S+", - @"(?i)secret\s*[=:]\s*\S+", - @"(?i)token\s*[=:]\s*\S+" - }, - HashRedactedValues: true, - MaxOutputLength: 64 * 1024, - PreserveEmailDomain: false); -} - -/// -/// Default implementation of redaction guard. -/// -public sealed partial class PackRunRedactionGuard : IPackRunRedactionGuard -{ - private const string RedactedPlaceholder = "[REDACTED]"; - private const string TruncatedSuffix = "...[TRUNCATED]"; - - private readonly PackRunRedactionGuardOptions _options; - private readonly List _sensitiveVarPatterns; - private readonly List _sensitiveContentPatterns; - - public PackRunRedactionGuard(PackRunRedactionGuardOptions? options = null) - { - _options = options ?? PackRunRedactionGuardOptions.Default; - _sensitiveVarPatterns = _options.SensitiveVariablePatterns - .Select(p => new Regex(p, RegexOptions.Compiled)) - .ToList(); - _sensitiveContentPatterns = _options.SensitiveContentPatterns - .Select(p => new Regex(p, RegexOptions.Compiled)) - .ToList(); - } - - public PackRunStepTranscript RedactTranscript(PackRunStepTranscript transcript) - { - var redactedOutput = transcript.Output is not null - ? RedactOutput(transcript.Output) - : null; - - var redactedError = transcript.Error is not null - ? RedactOutput(transcript.Error) - : null; - - var redactedEnvDigest = transcript.EnvironmentDigest is not null - ? RedactEnvDigestString(transcript.EnvironmentDigest) - : null; - - return transcript with - { - Output = redactedOutput, - Error = redactedError, - EnvironmentDigest = redactedEnvDigest - }; - } - - public PackRunApprovalEvidence RedactApproval(PackRunApprovalEvidence approval) - { - var redactedApprover = RedactIdentity(approval.Approver); - var redactedComments = approval.Comments is not null - ? RedactOutput(approval.Comments) - : null; - - var redactedGrantedBy = approval.GrantedBy?.Select(RedactIdentity).ToList(); - - return approval with - { - Approver = redactedApprover, - Comments = redactedComments, - GrantedBy = redactedGrantedBy - }; - } - - public PackRunEnvironmentDigest RedactEnvironment(PackRunEnvironmentDigest digest) - { - // Seeds are already expected to be redacted or hashed - // Environment variable names are kept, values should not be present - // Tool images are public information - return digest; - } - - public string RedactIdentity(string identity) - { - if (string.IsNullOrEmpty(identity)) - return identity; - - // Check if it's an email - if (identity.Contains('@')) - { - var parts = identity.Split('@'); - if (parts.Length == 2) - { - var localPart = parts[0]; - var domain = parts[1]; - - var redactedLocal = localPart.Length <= 2 - ? RedactedPlaceholder - : $"{localPart[0]}***{localPart[^1]}"; - - if (_options.PreserveEmailDomain) - { - return $"{redactedLocal}@{domain}"; - } - return $"{redactedLocal}@[DOMAIN]"; - } - } - - // For non-email identities, hash if configured - if (_options.HashRedactedValues) - { - return $"[USER:{ComputeShortHash(identity)}]"; - } - - return RedactedPlaceholder; - } - - public string RedactValue(string value) - { - if (string.IsNullOrEmpty(value)) - return value; - - if (_options.HashRedactedValues) - { - return $"[HASH:{ComputeShortHash(value)}]"; - } - - return RedactedPlaceholder; - } - - private string RedactOutput(string output) - { - if (string.IsNullOrEmpty(output)) - return output; - - var result = output; - - // Apply content pattern redaction - foreach (var pattern in _sensitiveContentPatterns) - { - result = pattern.Replace(result, match => - { - if (_options.HashRedactedValues) - { - return $"[REDACTED:{ComputeShortHash(match.Value)}]"; - } - return RedactedPlaceholder; - }); - } - - // Truncate if too long - if (result.Length > _options.MaxOutputLength) - { - result = result[..(_options.MaxOutputLength - TruncatedSuffix.Length)] + TruncatedSuffix; - } - - return result; - } - - private string RedactEnvDigestString(string digest) - { - // Environment digest is typically already a hash, preserve it - return digest; - } - - private static string ComputeShortHash(string value) - { - var bytes = Encoding.UTF8.GetBytes(value); - var hash = SHA256.HashData(bytes); - // Return first 8 characters of hex hash - return Convert.ToHexString(hash)[..8].ToLowerInvariant(); - } -} - -/// -/// No-op redaction guard for testing (preserves all data). -/// -public sealed class NoOpPackRunRedactionGuard : IPackRunRedactionGuard -{ - public static NoOpPackRunRedactionGuard Instance { get; } = new(); - - private NoOpPackRunRedactionGuard() { } - - public PackRunStepTranscript RedactTranscript(PackRunStepTranscript transcript) => transcript; - - public PackRunApprovalEvidence RedactApproval(PackRunApprovalEvidence approval) => approval; - - public PackRunEnvironmentDigest RedactEnvironment(PackRunEnvironmentDigest digest) => digest; - - public string RedactIdentity(string identity) => identity; - - public string RedactValue(string value) => value; -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/PackRunEvidenceSnapshot.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/PackRunEvidenceSnapshot.cs deleted file mode 100644 index aaf2d2d4f..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Evidence/PackRunEvidenceSnapshot.cs +++ /dev/null @@ -1,360 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace StellaOps.TaskRunner.Core.Evidence; - -/// -/// Evidence snapshot for pack run execution. -/// Per TASKRUN-OBS-53-001. -/// -public sealed record PackRunEvidenceSnapshot( - /// Unique snapshot identifier. - Guid SnapshotId, - - /// Tenant scope. - string TenantId, - - /// Run ID this snapshot belongs to. - string RunId, - - /// Plan hash that was executed. - string PlanHash, - - /// When the snapshot was created. - DateTimeOffset CreatedAt, - - /// Snapshot kind. - PackRunEvidenceSnapshotKind Kind, - - /// Materials included in this snapshot. - IReadOnlyList Materials, - - /// Computed Merkle root hash of all materials. - string RootHash, - - /// Snapshot metadata. - IReadOnlyDictionary? Metadata) -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false - }; - - /// - /// Creates a new snapshot with computed root hash. - /// - public static PackRunEvidenceSnapshot Create( - string tenantId, - string runId, - string planHash, - PackRunEvidenceSnapshotKind kind, - IReadOnlyList materials, - IReadOnlyDictionary? metadata = null) - { - var rootHash = ComputeMerkleRoot(materials); - - return new PackRunEvidenceSnapshot( - SnapshotId: Guid.NewGuid(), - TenantId: tenantId, - RunId: runId, - PlanHash: planHash, - CreatedAt: DateTimeOffset.UtcNow, - Kind: kind, - Materials: materials, - RootHash: rootHash, - Metadata: metadata); - } - - /// - /// Computes Merkle root from materials. - /// - private static string ComputeMerkleRoot(IReadOnlyList materials) - { - if (materials.Count == 0) - { - // Empty root: 64 zeros - return "sha256:" + new string('0', 64); - } - - // Sort materials by canonical path for determinism - var sorted = materials - .OrderBy(m => m.Section, StringComparer.Ordinal) - .ThenBy(m => m.Path, StringComparer.Ordinal) - .ToList(); - - // Build leaves from material hashes - var leaves = sorted.Select(m => m.Sha256).ToList(); - - // Compute Merkle root - while (leaves.Count > 1) - { - var nextLevel = new List(); - for (var i = 0; i < leaves.Count; i += 2) - { - if (i + 1 < leaves.Count) - { - nextLevel.Add(HashPair(leaves[i], leaves[i + 1])); - } - else - { - nextLevel.Add(HashPair(leaves[i], leaves[i])); - } - } - leaves = nextLevel; - } - - return leaves[0]; - } - - private static string HashPair(string left, string right) - { - var combined = left + right; - var bytes = Encoding.UTF8.GetBytes(combined); - var hash = SHA256.HashData(bytes); - return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; - } - - /// - /// Serializes to JSON. - /// - public string ToJson() => JsonSerializer.Serialize(this, JsonOptions); - - /// - /// Deserializes from JSON. - /// - public static PackRunEvidenceSnapshot? FromJson(string json) - => JsonSerializer.Deserialize(json, JsonOptions); -} - -/// -/// Kind of pack run evidence snapshot. -/// -public enum PackRunEvidenceSnapshotKind -{ - /// Run completion snapshot. - RunCompletion, - - /// Step execution snapshot. - StepExecution, - - /// Approval decision snapshot. - ApprovalDecision, - - /// Policy evaluation snapshot. - PolicyEvaluation, - - /// Artifact manifest snapshot. - ArtifactManifest, - - /// Environment digest snapshot. - EnvironmentDigest, - - /// Bundle import snapshot (TASKRUN-AIRGAP-58-001). - BundleImport -} - -/// -/// Material included in evidence snapshot. -/// -public sealed record PackRunEvidenceMaterial( - /// Section (e.g., "transcript", "artifact", "policy"). - string Section, - - /// Path within section. - string Path, - - /// SHA-256 digest of content. - string Sha256, - - /// Size in bytes. - long SizeBytes, - - /// Media type. - string MediaType, - - /// Custom attributes. - IReadOnlyDictionary? Attributes) -{ - /// - /// Creates material from content bytes. - /// - public static PackRunEvidenceMaterial FromContent( - string section, - string path, - byte[] content, - string mediaType = "application/octet-stream", - IReadOnlyDictionary? attributes = null) - { - var hash = SHA256.HashData(content); - var sha256 = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; - - return new PackRunEvidenceMaterial( - Section: section, - Path: path, - Sha256: sha256, - SizeBytes: content.Length, - MediaType: mediaType, - Attributes: attributes); - } - - /// - /// Creates material from string content. - /// - public static PackRunEvidenceMaterial FromString( - string section, - string path, - string content, - string mediaType = "text/plain", - IReadOnlyDictionary? attributes = null) - { - return FromContent(section, path, Encoding.UTF8.GetBytes(content), mediaType, attributes); - } - - /// - /// Creates material from JSON object. - /// - public static PackRunEvidenceMaterial FromJson( - string section, - string path, - T obj, - IReadOnlyDictionary? attributes = null) - { - var json = JsonSerializer.Serialize(obj, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }); - return FromString(section, path, json, "application/json", attributes); - } - - /// - /// Canonical path for ordering. - /// - public string CanonicalPath => $"{Section}/{Path}"; -} - -/// -/// Step transcript for evidence capture. -/// -public sealed record PackRunStepTranscript( - /// Step identifier. - string StepId, - - /// Step kind. - string Kind, - - /// Execution start time. - DateTimeOffset StartedAt, - - /// Execution end time. - DateTimeOffset? EndedAt, - - /// Final status. - string Status, - - /// Attempt number. - int Attempt, - - /// Duration in milliseconds. - double? DurationMs, - - /// Output (redacted if needed). - string? Output, - - /// Error message (redacted if needed). - string? Error, - - /// Environment variables digest. - string? EnvironmentDigest, - - /// Artifacts produced. - IReadOnlyList? Artifacts); - -/// -/// Reference to artifact in evidence. -/// -public sealed record PackRunArtifactReference( - /// Artifact name. - string Name, - - /// SHA-256 digest. - string Sha256, - - /// Size in bytes. - long SizeBytes, - - /// Media type. - string MediaType); - -/// -/// Approval record for evidence. -/// -public sealed record PackRunApprovalEvidence( - /// Approval identifier. - string ApprovalId, - - /// Approver identity. - string Approver, - - /// When approved. - DateTimeOffset ApprovedAt, - - /// Approval decision. - string Decision, - - /// Required grants. - IReadOnlyList RequiredGrants, - - /// Granted by. - IReadOnlyList? GrantedBy, - - /// Comments (redacted if needed). - string? Comments); - -/// -/// Policy evaluation record for evidence. -/// -public sealed record PackRunPolicyEvidence( - /// Policy name. - string PolicyName, - - /// Policy version. - string? PolicyVersion, - - /// Evaluation result. - string Result, - - /// When evaluated. - DateTimeOffset EvaluatedAt, - - /// Evaluation duration in milliseconds. - double DurationMs, - - /// Matched rules. - IReadOnlyList? MatchedRules, - - /// Policy digest for reproducibility. - string? PolicyDigest); - -/// -/// Environment digest for evidence. -/// -public sealed record PackRunEnvironmentDigest( - /// When digest was computed. - DateTimeOffset ComputedAt, - - /// Tool image digests (name -> sha256). - IReadOnlyDictionary ToolImages, - - /// Seed values (redacted). - IReadOnlyDictionary? Seeds, - - /// Environment variables (redacted). - IReadOnlyList? EnvironmentVariableNames, - - /// Combined digest of all inputs. - string InputsDigest); diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunApprovalStore.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunApprovalStore.cs deleted file mode 100644 index b9b77c586..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunApprovalStore.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace StellaOps.TaskRunner.Core.Execution; - -public interface IPackRunApprovalStore -{ - Task SaveAsync(string runId, IReadOnlyList approvals, CancellationToken cancellationToken); - - Task> GetAsync(string runId, CancellationToken cancellationToken); - - Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunArtifactReader.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunArtifactReader.cs deleted file mode 100644 index 93dd9e2c6..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunArtifactReader.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace StellaOps.TaskRunner.Core.Execution; - -public interface IPackRunArtifactReader -{ - Task> ListAsync(string runId, CancellationToken cancellationToken); -} - -public sealed record PackRunArtifactRecord( - string Name, - string Type, - string? SourcePath, - string? StoredPath, - string Status, - string? Notes, - DateTimeOffset CapturedAt, - string? ExpressionJson = null); diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunArtifactUploader.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunArtifactUploader.cs deleted file mode 100644 index 7117f2870..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunArtifactUploader.cs +++ /dev/null @@ -1,12 +0,0 @@ -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Core.Execution; - -public interface IPackRunArtifactUploader -{ - Task UploadAsync( - PackRunExecutionContext context, - PackRunState state, - IReadOnlyList outputs, - CancellationToken cancellationToken); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunJobDispatcher.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunJobDispatcher.cs deleted file mode 100644 index 9e6a29f8d..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunJobDispatcher.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StellaOps.TaskRunner.Core.Execution; - -public interface IPackRunJobDispatcher -{ - Task TryDequeueAsync(CancellationToken cancellationToken); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunJobScheduler.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunJobScheduler.cs deleted file mode 100644 index 1ef269759..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunJobScheduler.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StellaOps.TaskRunner.Core.Execution; - -public interface IPackRunJobScheduler -{ - Task ScheduleAsync(PackRunExecutionContext context, CancellationToken cancellationToken); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunLogStore.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunLogStore.cs deleted file mode 100644 index bbf586ecc..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunLogStore.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace StellaOps.TaskRunner.Core.Execution; - -/// -/// Persists pack run log entries in a deterministic append-only fashion. -/// -public interface IPackRunLogStore -{ - /// - /// Appends a single log entry to the run log. - /// - Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken); - - /// - /// Returns the log entries for the specified run in chronological order. - /// - IAsyncEnumerable ReadAsync(string runId, CancellationToken cancellationToken); - - /// - /// Determines whether any log entries exist for the specified run. - /// - Task ExistsAsync(string runId, CancellationToken cancellationToken); -} - -/// -/// Represents a single structured log entry emitted during a pack run. -/// -public sealed record PackRunLogEntry( - DateTimeOffset Timestamp, - string Level, - string EventType, - string Message, - string? StepId, - IReadOnlyDictionary? Metadata); diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunNotificationPublisher.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunNotificationPublisher.cs deleted file mode 100644 index 49fde5797..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunNotificationPublisher.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace StellaOps.TaskRunner.Core.Execution; - -public interface IPackRunNotificationPublisher -{ - Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken); - - Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunProvenanceWriter.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunProvenanceWriter.cs deleted file mode 100644 index 73803dd89..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunProvenanceWriter.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StellaOps.TaskRunner.Core.Execution; - -public interface IPackRunProvenanceWriter -{ - Task WriteAsync(PackRunExecutionContext context, PackRunState state, CancellationToken cancellationToken); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunStepExecutor.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunStepExecutor.cs deleted file mode 100644 index 111ba12cd..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/IPackRunStepExecutor.cs +++ /dev/null @@ -1,19 +0,0 @@ -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Core.Execution; - -public interface IPackRunStepExecutor -{ - Task ExecuteAsync( - PackRunExecutionStep step, - IReadOnlyDictionary parameters, - CancellationToken cancellationToken); -} - -public sealed record PackRunStepExecutionResult(bool Succeeded, string? Error = null) -{ - public static PackRunStepExecutionResult Success() => new(true, null); - - public static PackRunStepExecutionResult Failure(string error) - => new(false, string.IsNullOrWhiteSpace(error) ? "Unknown error" : error); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunApprovalCoordinator.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunApprovalCoordinator.cs deleted file mode 100644 index e80b7045c..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunApprovalCoordinator.cs +++ /dev/null @@ -1,178 +0,0 @@ - -using StellaOps.TaskRunner.Core.Planning; -using System.Collections.Concurrent; -using System.Collections.Immutable; - -namespace StellaOps.TaskRunner.Core.Execution; - -public sealed class PackRunApprovalCoordinator -{ - private readonly ConcurrentDictionary approvals; - private readonly IReadOnlyDictionary requirements; - - private PackRunApprovalCoordinator( - IReadOnlyDictionary approvals, - IReadOnlyDictionary requirements) - { - this.approvals = new ConcurrentDictionary(approvals); - this.requirements = requirements; - } - - public static PackRunApprovalCoordinator Create(TaskPackPlan plan, DateTimeOffset requestTimestamp) - { - ArgumentNullException.ThrowIfNull(plan); - - var requirements = TaskPackPlanInsights - .CollectApprovalRequirements(plan) - .ToDictionary( - requirement => requirement.ApprovalId, - requirement => new PackRunApprovalRequirement( - requirement.ApprovalId, - requirement.Grants.ToImmutableArray(), - requirement.StepIds.ToImmutableArray(), - requirement.Messages.ToImmutableArray(), - requirement.ReasonTemplate), - StringComparer.Ordinal); - - var states = requirements.Values - .ToDictionary( - requirement => requirement.ApprovalId, - requirement => new PackRunApprovalState( - requirement.ApprovalId, - requirement.RequiredGrants, - requirement.StepIds, - requirement.Messages, - requirement.ReasonTemplate, - requestTimestamp, - PackRunApprovalStatus.Pending), - StringComparer.Ordinal); - - return new PackRunApprovalCoordinator(states, requirements); - } - - public static PackRunApprovalCoordinator Restore(TaskPackPlan plan, IReadOnlyList existingStates, DateTimeOffset requestedAt) - { - ArgumentNullException.ThrowIfNull(plan); - ArgumentNullException.ThrowIfNull(existingStates); - - var coordinator = Create(plan, requestedAt); - foreach (var state in existingStates) - { - coordinator.approvals[state.ApprovalId] = state; - } - - return coordinator; - } - - public IReadOnlyList GetApprovals() - => approvals.Values - .OrderBy(state => state.ApprovalId, StringComparer.Ordinal) - .ToImmutableArray(); - - public bool HasPendingApprovals => approvals.Values.Any(state => state.Status == PackRunApprovalStatus.Pending); - - public ApprovalActionResult Approve(string approvalId, string actorId, DateTimeOffset completedAt, string? summary = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(approvalId); - ArgumentException.ThrowIfNullOrWhiteSpace(actorId); - - var updated = approvals.AddOrUpdate( - approvalId, - static _ => throw new KeyNotFoundException("Unknown approval."), - (_, current) => current.Approve(actorId, completedAt, summary)); - - var shouldResume = approvals.Values.All(state => state.Status == PackRunApprovalStatus.Approved); - return new ApprovalActionResult(updated, shouldResume); - } - - public ApprovalActionResult Reject(string approvalId, string actorId, DateTimeOffset completedAt, string? summary = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(approvalId); - ArgumentException.ThrowIfNullOrWhiteSpace(actorId); - - var updated = approvals.AddOrUpdate( - approvalId, - static _ => throw new KeyNotFoundException("Unknown approval."), - (_, current) => current.Reject(actorId, completedAt, summary)); - - return new ApprovalActionResult(updated, false); - } - - public ApprovalActionResult Expire(string approvalId, DateTimeOffset expiredAt, string? summary = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(approvalId); - - var updated = approvals.AddOrUpdate( - approvalId, - static _ => throw new KeyNotFoundException("Unknown approval."), - (_, current) => current.Expire(expiredAt, summary)); - - return new ApprovalActionResult(updated, false); - } - - public IReadOnlyList BuildNotifications(TaskPackPlan plan) - { - ArgumentNullException.ThrowIfNull(plan); - - var hints = TaskPackPlanInsights.CollectApprovalRequirements(plan); - var notifications = new List(hints.Count); - - foreach (var hint in hints) - { - if (!requirements.TryGetValue(hint.ApprovalId, out var requirement)) - { - continue; - } - - notifications.Add(new ApprovalNotification( - requirement.ApprovalId, - requirement.RequiredGrants, - requirement.Messages, - requirement.StepIds, - requirement.ReasonTemplate)); - } - - return notifications; - } - - public IReadOnlyList BuildPolicyNotifications(TaskPackPlan plan) - { - ArgumentNullException.ThrowIfNull(plan); - - var policyHints = TaskPackPlanInsights.CollectPolicyGateHints(plan); - return policyHints - .Select(hint => new PolicyGateNotification( - hint.StepId, - hint.Message, - hint.Parameters.Select(parameter => new PolicyGateNotificationParameter( - parameter.Name, - parameter.RequiresRuntimeValue, - parameter.Expression, - parameter.Error)).ToImmutableArray())) - .ToImmutableArray(); - } -} - -public sealed record PackRunApprovalRequirement( - string ApprovalId, - IReadOnlyList RequiredGrants, - IReadOnlyList StepIds, - IReadOnlyList Messages, - string? ReasonTemplate); - -public sealed record ApprovalActionResult(PackRunApprovalState State, bool ShouldResumeRun); - -public sealed record ApprovalNotification( - string ApprovalId, - IReadOnlyList RequiredGrants, - IReadOnlyList Messages, - IReadOnlyList StepIds, - string? ReasonTemplate); - -public sealed record PolicyGateNotification(string StepId, string? Message, IReadOnlyList Parameters); - -public sealed record PolicyGateNotificationParameter( - string Name, - bool RequiresRuntimeValue, - string? Expression, - string? Error); diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunApprovalState.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunApprovalState.cs deleted file mode 100644 index c98ca5eea..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunApprovalState.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Collections.Immutable; - -namespace StellaOps.TaskRunner.Core.Execution; - -public sealed class PackRunApprovalState -{ - public PackRunApprovalState( - string approvalId, - IReadOnlyList requiredGrants, - IReadOnlyList stepIds, - IReadOnlyList messages, - string? reasonTemplate, - DateTimeOffset requestedAt, - PackRunApprovalStatus status, - string? actorId = null, - DateTimeOffset? completedAt = null, - string? summary = null) - { - if (string.IsNullOrWhiteSpace(approvalId)) - { - throw new ArgumentException("Approval id must not be empty.", nameof(approvalId)); - } - - ApprovalId = approvalId; - RequiredGrants = requiredGrants.ToImmutableArray(); - StepIds = stepIds.ToImmutableArray(); - Messages = messages.ToImmutableArray(); - ReasonTemplate = reasonTemplate; - RequestedAt = requestedAt; - Status = status; - ActorId = actorId; - CompletedAt = completedAt; - Summary = summary; - } - - public string ApprovalId { get; } - - public IReadOnlyList RequiredGrants { get; } - - public IReadOnlyList StepIds { get; } - - public IReadOnlyList Messages { get; } - - public string? ReasonTemplate { get; } - - public DateTimeOffset RequestedAt { get; } - - public PackRunApprovalStatus Status { get; } - - public string? ActorId { get; } - - public DateTimeOffset? CompletedAt { get; } - - public string? Summary { get; } - - public PackRunApprovalState Approve(string actorId, DateTimeOffset completedAt, string? summary = null) - => Transition(PackRunApprovalStatus.Approved, actorId, completedAt, summary); - - public PackRunApprovalState Reject(string actorId, DateTimeOffset completedAt, string? summary = null) - => Transition(PackRunApprovalStatus.Rejected, actorId, completedAt, summary); - - public PackRunApprovalState Expire(DateTimeOffset expiredAt, string? summary = null) - => Transition(PackRunApprovalStatus.Expired, actorId: null, expiredAt, summary); - - private PackRunApprovalState Transition(PackRunApprovalStatus status, string? actorId, DateTimeOffset completedAt, string? summary) - { - if (Status != PackRunApprovalStatus.Pending) - { - throw new InvalidOperationException($"Approval '{ApprovalId}' is already {Status}."); - } - - return new PackRunApprovalState( - ApprovalId, - RequiredGrants, - StepIds, - Messages, - ReasonTemplate, - RequestedAt, - status, - actorId, - completedAt, - summary); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunApprovalStatus.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunApprovalStatus.cs deleted file mode 100644 index f644de25e..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunApprovalStatus.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace StellaOps.TaskRunner.Core.Execution; - -public enum PackRunApprovalStatus -{ - Pending = 0, - Approved = 1, - Rejected = 2, - Expired = 3 -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionContext.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionContext.cs deleted file mode 100644 index 2b548cbc4..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionContext.cs +++ /dev/null @@ -1,25 +0,0 @@ -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Core.Execution; - -public sealed class PackRunExecutionContext -{ - public PackRunExecutionContext(string runId, TaskPackPlan plan, DateTimeOffset requestedAt, string? tenantId = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - ArgumentNullException.ThrowIfNull(plan); - - RunId = runId; - Plan = plan; - RequestedAt = requestedAt; - TenantId = string.IsNullOrWhiteSpace(tenantId) ? null : tenantId.Trim(); - } - - public string RunId { get; } - - public TaskPackPlan Plan { get; } - - public DateTimeOffset RequestedAt { get; } - - public string? TenantId { get; } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraph.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraph.cs deleted file mode 100644 index 01744caa3..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraph.cs +++ /dev/null @@ -1,241 +0,0 @@ - -using StellaOps.TaskRunner.Core.Planning; -using System.Collections.ObjectModel; - -namespace StellaOps.TaskRunner.Core.Execution; - -public sealed class PackRunExecutionGraph -{ - public static readonly TaskPackPlanFailurePolicy DefaultFailurePolicy = new(1, 0, ContinueOnError: false); - - public PackRunExecutionGraph(IReadOnlyList steps, TaskPackPlanFailurePolicy? failurePolicy) - { - Steps = steps ?? throw new ArgumentNullException(nameof(steps)); - FailurePolicy = failurePolicy ?? DefaultFailurePolicy; - } - - public IReadOnlyList Steps { get; } - - public TaskPackPlanFailurePolicy FailurePolicy { get; } -} - -public enum PackRunStepKind -{ - Unknown = 0, - Run, - GateApproval, - GatePolicy, - Parallel, - Map, - Loop, - Conditional -} - -public sealed class PackRunExecutionStep -{ - public PackRunExecutionStep( - string id, - string templateId, - PackRunStepKind kind, - bool enabled, - string? uses, - IReadOnlyDictionary parameters, - string? approvalId, - string? gateMessage, - int? maxParallel, - bool continueOnError, - IReadOnlyList children, - PackRunLoopConfig? loopConfig = null, - PackRunConditionalConfig? conditionalConfig = null, - PackRunPolicyGateConfig? policyGateConfig = null) - { - Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id; - TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId; - Kind = kind; - Enabled = enabled; - Uses = uses; - Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters)); - ApprovalId = approvalId; - GateMessage = gateMessage; - MaxParallel = maxParallel; - ContinueOnError = continueOnError; - Children = children ?? throw new ArgumentNullException(nameof(children)); - LoopConfig = loopConfig; - ConditionalConfig = conditionalConfig; - PolicyGateConfig = policyGateConfig; - } - - public string Id { get; } - - public string TemplateId { get; } - - public PackRunStepKind Kind { get; } - - public bool Enabled { get; } - - public string? Uses { get; } - - public IReadOnlyDictionary Parameters { get; } - - public string? ApprovalId { get; } - - public string? GateMessage { get; } - - public int? MaxParallel { get; } - - public bool ContinueOnError { get; } - - public IReadOnlyList Children { get; } - - /// Loop step configuration (when Kind == Loop). - public PackRunLoopConfig? LoopConfig { get; } - - /// Conditional step configuration (when Kind == Conditional). - public PackRunConditionalConfig? ConditionalConfig { get; } - - /// Policy gate configuration (when Kind == GatePolicy). - public PackRunPolicyGateConfig? PolicyGateConfig { get; } - - public static IReadOnlyDictionary EmptyParameters { get; } = - new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); - - public static IReadOnlyList EmptyChildren { get; } = - Array.Empty(); -} - -/// -/// Configuration for loop steps per taskpack-control-flow.schema.json. -/// -public sealed record PackRunLoopConfig( - /// Expression yielding items to iterate over. - string? ItemsExpression, - - /// Static items array (alternative to expression). - IReadOnlyList? StaticItems, - - /// Range specification (alternative to expression). - PackRunLoopRange? Range, - - /// Variable name bound to current item (default: "item"). - string Iterator, - - /// Variable name bound to current index (default: "index"). - string Index, - - /// Maximum iterations (safety limit). - int MaxIterations, - - /// Aggregation mode for loop outputs. - PackRunLoopAggregationMode AggregationMode, - - /// JMESPath to extract from each iteration result. - string? OutputPath) -{ - public static PackRunLoopConfig Default => new( - null, null, null, "item", "index", 1000, PackRunLoopAggregationMode.Collect, null); -} - -/// Range specification for loop iteration. -public sealed record PackRunLoopRange(int Start, int End, int Step = 1); - -/// Loop output aggregation modes. -public enum PackRunLoopAggregationMode -{ - /// Collect outputs into array. - Collect = 0, - /// Deep merge objects. - Merge, - /// Keep only last output. - Last, - /// Keep only first output. - First, - /// Discard outputs. - None -} - -/// -/// Configuration for conditional steps per taskpack-control-flow.schema.json. -/// -public sealed record PackRunConditionalConfig( - /// Ordered branches (first matching executes). - IReadOnlyList Branches, - - /// Steps to execute if no branch matches. - IReadOnlyList? ElseBranch, - - /// Whether to union outputs from all branches. - bool OutputUnion); - -/// A conditional branch with condition and body. -public sealed record PackRunConditionalBranch( - /// Condition expression (JMESPath or operator-based). - string ConditionExpression, - - /// Steps to execute if condition matches. - IReadOnlyList Body); - -/// -/// Configuration for policy gate steps per taskpack-control-flow.schema.json. -/// -public sealed record PackRunPolicyGateConfig( - /// Policy identifier in the registry. - string PolicyId, - - /// Specific policy version (semver). - string? PolicyVersion, - - /// Policy digest for reproducibility. - string? PolicyDigest, - - /// JMESPath expression to construct policy input. - string? InputExpression, - - /// Timeout for policy evaluation. - TimeSpan Timeout, - - /// What to do on policy failure. - PackRunPolicyFailureAction FailureAction, - - /// Retry count on failure. - int RetryCount, - - /// Delay between retries. - TimeSpan RetryDelay, - - /// Override approvers (if action is RequestOverride). - IReadOnlyList? OverrideApprovers, - - /// Step ID to branch to (if action is Branch). - string? BranchTo, - - /// Whether to record decision in evidence locker. - bool RecordDecision, - - /// Whether to record policy input. - bool RecordInput, - - /// Whether to record rationale. - bool RecordRationale, - - /// Whether to create DSSE attestation. - bool CreateAttestation) -{ - public static PackRunPolicyGateConfig Default(string policyId) => new( - policyId, null, null, null, - TimeSpan.FromMinutes(5), - PackRunPolicyFailureAction.Abort, 0, TimeSpan.FromSeconds(10), - null, null, true, false, true, false); -} - -/// Policy gate failure actions. -public enum PackRunPolicyFailureAction -{ - /// Abort the run. - Abort = 0, - /// Log warning and continue. - Warn, - /// Request override approval. - RequestOverride, - /// Branch to specified step. - Branch -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraphBuilder.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraphBuilder.cs deleted file mode 100644 index 5fe22fc6d..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunExecutionGraphBuilder.cs +++ /dev/null @@ -1,244 +0,0 @@ - -using StellaOps.TaskRunner.Core.Planning; -using System.Collections.ObjectModel; -using System.Text.Json.Nodes; - -namespace StellaOps.TaskRunner.Core.Execution; - -public sealed class PackRunExecutionGraphBuilder -{ - public PackRunExecutionGraph Build(TaskPackPlan plan) - { - ArgumentNullException.ThrowIfNull(plan); - - var steps = plan.Steps.Select(ConvertStep).ToList(); - var failurePolicy = plan.FailurePolicy; - return new PackRunExecutionGraph(steps, failurePolicy); - } - - private static PackRunExecutionStep ConvertStep(TaskPackPlanStep step) - { - var kind = DetermineKind(step.Type); - var parameters = step.Parameters is null - ? PackRunExecutionStep.EmptyParameters - : new ReadOnlyDictionary( - new Dictionary(step.Parameters, StringComparer.Ordinal)); - - var children = step.Children is null - ? PackRunExecutionStep.EmptyChildren - : step.Children.Select(ConvertStep).ToList(); - - var maxParallel = TryGetInt(parameters, "maxParallel"); - var continueOnError = TryGetBool(parameters, "continueOnError"); - - // Extract type-specific configurations - var loopConfig = kind == PackRunStepKind.Loop ? ExtractLoopConfig(parameters, children) : null; - var conditionalConfig = kind == PackRunStepKind.Conditional ? ExtractConditionalConfig(parameters, children) : null; - var policyGateConfig = kind == PackRunStepKind.GatePolicy ? ExtractPolicyGateConfig(parameters, step) : null; - - return new PackRunExecutionStep( - step.Id, - step.TemplateId, - kind, - step.Enabled, - step.Uses, - parameters, - step.ApprovalId, - step.GateMessage, - maxParallel, - continueOnError, - children, - loopConfig, - conditionalConfig, - policyGateConfig); - } - - private static PackRunStepKind DetermineKind(string? type) - => type switch - { - "run" => PackRunStepKind.Run, - "gate.approval" => PackRunStepKind.GateApproval, - "gate.policy" => PackRunStepKind.GatePolicy, - "parallel" => PackRunStepKind.Parallel, - "map" => PackRunStepKind.Map, - "loop" => PackRunStepKind.Loop, - "conditional" => PackRunStepKind.Conditional, - _ => PackRunStepKind.Unknown - }; - - private static PackRunLoopConfig ExtractLoopConfig( - IReadOnlyDictionary parameters, - IReadOnlyList children) - { - var itemsExpression = TryGetString(parameters, "items"); - var iterator = TryGetString(parameters, "iterator") ?? "item"; - var index = TryGetString(parameters, "index") ?? "index"; - var maxIterations = TryGetInt(parameters, "maxIterations") ?? 1000; - var aggregationMode = ParseAggregationMode(TryGetString(parameters, "aggregation")); - var outputPath = TryGetString(parameters, "outputPath"); - - // Parse range if present - PackRunLoopRange? range = null; - if (parameters.TryGetValue("range", out var rangeValue) && rangeValue.Value is JsonObject rangeObj) - { - var start = rangeObj["start"]?.GetValue() ?? 0; - var end = rangeObj["end"]?.GetValue() ?? 0; - var step = rangeObj["step"]?.GetValue() ?? 1; - range = new PackRunLoopRange(start, end, step); - } - - // Parse static items if present - IReadOnlyList? staticItems = null; - if (parameters.TryGetValue("staticItems", out var staticValue) && staticValue.Value is JsonArray arr) - { - staticItems = arr.Select(n => (object)(n?.ToString() ?? "")).ToList(); - } - - return new PackRunLoopConfig( - itemsExpression, staticItems, range, iterator, index, - maxIterations, aggregationMode, outputPath); - } - - private static PackRunConditionalConfig ExtractConditionalConfig( - IReadOnlyDictionary parameters, - IReadOnlyList children) - { - var branches = new List(); - IReadOnlyList? elseBranch = null; - var outputUnion = TryGetBool(parameters, "outputUnion"); - - // Parse branches from parameters - if (parameters.TryGetValue("branches", out var branchesValue) && branchesValue.Value is JsonArray branchArray) - { - foreach (var branchNode in branchArray) - { - if (branchNode is not JsonObject branchObj) continue; - - var condition = branchObj["condition"]?.ToString() ?? "true"; - var bodySteps = new List(); - - // Body would be parsed from the plan's children structure - // For now, use empty body - actual body comes from step children - branches.Add(new PackRunConditionalBranch(condition, bodySteps)); - } - } - - // If no explicit branches parsed, treat children as the primary branch body - if (branches.Count == 0 && children.Count > 0) - { - branches.Add(new PackRunConditionalBranch("true", children)); - } - - return new PackRunConditionalConfig(branches, elseBranch, outputUnion); - } - - private static PackRunPolicyGateConfig? ExtractPolicyGateConfig( - IReadOnlyDictionary parameters, - TaskPackPlanStep step) - { - var policyId = TryGetString(parameters, "policyId") ?? TryGetString(parameters, "policy"); - if (string.IsNullOrEmpty(policyId)) return null; - - var policyVersion = TryGetString(parameters, "policyVersion"); - var policyDigest = TryGetString(parameters, "policyDigest"); - var inputExpression = TryGetString(parameters, "inputExpression"); - var timeout = ParseTimeSpan(TryGetString(parameters, "timeout"), TimeSpan.FromMinutes(5)); - var failureAction = ParsePolicyFailureAction(TryGetString(parameters, "failureAction")); - var retryCount = TryGetInt(parameters, "retryCount") ?? 0; - var retryDelay = ParseTimeSpan(TryGetString(parameters, "retryDelay"), TimeSpan.FromSeconds(10)); - var recordDecision = TryGetBool(parameters, "recordDecision") || !parameters.ContainsKey("recordDecision"); - var recordInput = TryGetBool(parameters, "recordInput"); - var recordRationale = TryGetBool(parameters, "recordRationale") || !parameters.ContainsKey("recordRationale"); - var createAttestation = TryGetBool(parameters, "attestation"); - - // Parse override approvers - IReadOnlyList? overrideApprovers = null; - if (parameters.TryGetValue("overrideApprovers", out var approversValue) && approversValue.Value is JsonArray arr) - { - overrideApprovers = arr.Select(n => n?.ToString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList(); - } - - var branchTo = TryGetString(parameters, "branchTo"); - - return new PackRunPolicyGateConfig( - policyId, policyVersion, policyDigest, inputExpression, - timeout, failureAction, retryCount, retryDelay, - overrideApprovers, branchTo, - recordDecision, recordInput, recordRationale, createAttestation); - } - - private static PackRunLoopAggregationMode ParseAggregationMode(string? mode) - => mode?.ToLowerInvariant() switch - { - "collect" => PackRunLoopAggregationMode.Collect, - "merge" => PackRunLoopAggregationMode.Merge, - "last" => PackRunLoopAggregationMode.Last, - "first" => PackRunLoopAggregationMode.First, - "none" => PackRunLoopAggregationMode.None, - _ => PackRunLoopAggregationMode.Collect - }; - - private static PackRunPolicyFailureAction ParsePolicyFailureAction(string? action) - => action?.ToLowerInvariant() switch - { - "abort" => PackRunPolicyFailureAction.Abort, - "warn" => PackRunPolicyFailureAction.Warn, - "requestoverride" => PackRunPolicyFailureAction.RequestOverride, - "branch" => PackRunPolicyFailureAction.Branch, - _ => PackRunPolicyFailureAction.Abort - }; - - private static TimeSpan ParseTimeSpan(string? value, TimeSpan defaultValue) - { - if (string.IsNullOrEmpty(value)) return defaultValue; - - // Parse formats like "30s", "5m", "1h" - if (value.Length < 2) return defaultValue; - - var unit = value[^1]; - if (!int.TryParse(value[..^1], out var number)) return defaultValue; - - return unit switch - { - 's' => TimeSpan.FromSeconds(number), - 'm' => TimeSpan.FromMinutes(number), - 'h' => TimeSpan.FromHours(number), - 'd' => TimeSpan.FromDays(number), - _ => defaultValue - }; - } - - private static int? TryGetInt(IReadOnlyDictionary parameters, string key) - { - if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue) - { - return null; - } - - return jsonValue.TryGetValue(out var result) ? result : null; - } - - private static bool TryGetBool(IReadOnlyDictionary parameters, string key) - { - if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue) - { - return false; - } - - return jsonValue.TryGetValue(out var result) && result; - } - - private static string? TryGetString(IReadOnlyDictionary parameters, string key) - { - if (!parameters.TryGetValue(key, out var value)) - { - return null; - } - - return value.Value switch - { - JsonValue jsonValue when jsonValue.TryGetValue(out var str) => str, - _ => value.Value?.ToString() - }; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunGateStateUpdater.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunGateStateUpdater.cs deleted file mode 100644 index a0873ab00..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunGateStateUpdater.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.Collections.ObjectModel; -using System.Linq; - -namespace StellaOps.TaskRunner.Core.Execution; - -public static class PackRunGateStateUpdater -{ - public static PackRunGateStateUpdateResult Apply( - PackRunState state, - PackRunExecutionGraph graph, - PackRunApprovalCoordinator coordinator, - DateTimeOffset timestamp) - { - ArgumentNullException.ThrowIfNull(state); - ArgumentNullException.ThrowIfNull(graph); - ArgumentNullException.ThrowIfNull(coordinator); - - var approvals = coordinator.GetApprovals() - .SelectMany(approval => approval.StepIds.Select(stepId => (stepId, approval))) - .GroupBy(tuple => tuple.stepId, StringComparer.Ordinal) - .ToDictionary( - group => group.Key, - group => group.First().approval, - StringComparer.Ordinal); - - var mutable = new Dictionary(state.Steps, StringComparer.Ordinal); - var changed = false; - var hasBlockingFailure = false; - - foreach (var step in EnumerateSteps(graph.Steps)) - { - if (!mutable.TryGetValue(step.Id, out var record)) - { - continue; - } - - switch (step.Kind) - { - case PackRunStepKind.GateApproval: - if (!approvals.TryGetValue(step.Id, out var approvalState)) - { - continue; - } - - switch (approvalState.Status) - { - case PackRunApprovalStatus.Pending: - break; - - case PackRunApprovalStatus.Approved: - if (record.Status != PackRunStepExecutionStatus.Succeeded || record.StatusReason is not null) - { - mutable[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Succeeded, - StatusReason = null, - LastTransitionAt = timestamp, - NextAttemptAt = null - }; - changed = true; - } - - break; - - case PackRunApprovalStatus.Rejected: - case PackRunApprovalStatus.Expired: - var failureReason = BuildFailureReason(approvalState); - if (record.Status != PackRunStepExecutionStatus.Failed || - !string.Equals(record.StatusReason, failureReason, StringComparison.Ordinal)) - { - mutable[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Failed, - StatusReason = failureReason, - LastTransitionAt = timestamp, - NextAttemptAt = null - }; - changed = true; - } - - hasBlockingFailure = true; - break; - } - - break; - - case PackRunStepKind.GatePolicy: - if (record.Status == PackRunStepExecutionStatus.Pending && - string.Equals(record.StatusReason, "requires-policy", StringComparison.Ordinal)) - { - mutable[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Succeeded, - StatusReason = null, - LastTransitionAt = timestamp, - NextAttemptAt = null - }; - changed = true; - } - - break; - } - } - - if (!changed) - { - return new PackRunGateStateUpdateResult(state, hasBlockingFailure); - } - - var updatedState = state with - { - UpdatedAt = timestamp, - Steps = new ReadOnlyDictionary(mutable) - }; - - return new PackRunGateStateUpdateResult(updatedState, hasBlockingFailure); - } - - private static IEnumerable EnumerateSteps(IReadOnlyList steps) - { - if (steps.Count == 0) - { - yield break; - } - - foreach (var step in steps) - { - yield return step; - - if (step.Children.Count > 0) - { - foreach (var child in EnumerateSteps(step.Children)) - { - yield return child; - } - } - } - } - - private static string BuildFailureReason(PackRunApprovalState state) - { - var baseReason = state.Status switch - { - PackRunApprovalStatus.Rejected => "approval-rejected", - PackRunApprovalStatus.Expired => "approval-expired", - _ => "approval-invalid" - }; - - if (string.IsNullOrWhiteSpace(state.Summary)) - { - return baseReason; - } - - var summary = state.Summary.Trim(); - return $"{baseReason}:{summary}"; - } -} - -public readonly record struct PackRunGateStateUpdateResult(PackRunState State, bool HasBlockingFailure); diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunProcessor.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunProcessor.cs deleted file mode 100644 index 211be386e..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunProcessor.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace StellaOps.TaskRunner.Core.Execution; - -public sealed class PackRunProcessor -{ - private readonly IPackRunApprovalStore approvalStore; - private readonly IPackRunNotificationPublisher notificationPublisher; - private readonly ILogger logger; - - public PackRunProcessor( - IPackRunApprovalStore approvalStore, - IPackRunNotificationPublisher notificationPublisher, - ILogger logger) - { - this.approvalStore = approvalStore ?? throw new ArgumentNullException(nameof(approvalStore)); - this.notificationPublisher = notificationPublisher ?? throw new ArgumentNullException(nameof(notificationPublisher)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task ProcessNewRunAsync(PackRunExecutionContext context, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - - var existing = await approvalStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false); - PackRunApprovalCoordinator coordinator; - bool shouldResume; - - if (existing.Count > 0) - { - coordinator = PackRunApprovalCoordinator.Restore(context.Plan, existing, context.RequestedAt); - shouldResume = !coordinator.HasPendingApprovals; - logger.LogInformation("Run {RunId} approvals restored (pending: {Pending}).", context.RunId, coordinator.HasPendingApprovals); - } - else - { - coordinator = PackRunApprovalCoordinator.Create(context.Plan, context.RequestedAt); - await approvalStore.SaveAsync(context.RunId, coordinator.GetApprovals(), cancellationToken).ConfigureAwait(false); - - var approvalNotifications = coordinator.BuildNotifications(context.Plan); - foreach (var notification in approvalNotifications) - { - await notificationPublisher.PublishApprovalRequestedAsync(context.RunId, notification, cancellationToken).ConfigureAwait(false); - logger.LogInformation( - "Approval requested for run {RunId} gate {ApprovalId} requiring grants {Grants}.", - context.RunId, - notification.ApprovalId, - string.Join(",", notification.RequiredGrants)); - } - - var policyNotifications = coordinator.BuildPolicyNotifications(context.Plan); - foreach (var notification in policyNotifications) - { - await notificationPublisher.PublishPolicyGatePendingAsync(context.RunId, notification, cancellationToken).ConfigureAwait(false); - logger.LogDebug( - "Policy gate pending for run {RunId} step {StepId}.", - context.RunId, - notification.StepId); - } - - shouldResume = !coordinator.HasPendingApprovals; - } - - if (shouldResume) - { - logger.LogInformation("Run {RunId} has no approvals; proceeding immediately.", context.RunId); - } - - return new PackRunProcessorResult(coordinator, shouldResume); - } - - public async Task RestoreAsync(PackRunExecutionContext context, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - - var states = await approvalStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false); - if (states.Count == 0) - { - return PackRunApprovalCoordinator.Create(context.Plan, context.RequestedAt); - } - - return PackRunApprovalCoordinator.Restore(context.Plan, states, context.RequestedAt); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunProcessorResult.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunProcessorResult.cs deleted file mode 100644 index a2d257129..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunProcessorResult.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace StellaOps.TaskRunner.Core.Execution; - -public sealed record PackRunProcessorResult( - PackRunApprovalCoordinator ApprovalCoordinator, - bool ShouldResumeImmediately); diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunState.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunState.cs deleted file mode 100644 index 0bd25cd5d..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunState.cs +++ /dev/null @@ -1,60 +0,0 @@ - -using StellaOps.TaskRunner.Core.Planning; -using System.Collections.ObjectModel; - -namespace StellaOps.TaskRunner.Core.Execution; - -public sealed record PackRunState( - string RunId, - string PlanHash, - TaskPackPlan Plan, - TaskPackPlanFailurePolicy FailurePolicy, - DateTimeOffset RequestedAt, - DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt, - IReadOnlyDictionary Steps, - string? TenantId = null) -{ - public static PackRunState Create( - string runId, - string planHash, - TaskPackPlan plan, - TaskPackPlanFailurePolicy failurePolicy, - DateTimeOffset requestedAt, - IReadOnlyDictionary steps, - DateTimeOffset timestamp, - string? tenantId = null) - => new( - runId, - planHash, - plan, - failurePolicy, - requestedAt, - timestamp, - timestamp, - new ReadOnlyDictionary(new Dictionary(steps, StringComparer.Ordinal)), - tenantId); -} - -public sealed record PackRunStepStateRecord( - string StepId, - PackRunStepKind Kind, - bool Enabled, - bool ContinueOnError, - int? MaxParallel, - string? ApprovalId, - string? GateMessage, - PackRunStepExecutionStatus Status, - int Attempts, - DateTimeOffset? LastTransitionAt, - DateTimeOffset? NextAttemptAt, - string? StatusReason); - -public interface IPackRunStateStore -{ - Task GetAsync(string runId, CancellationToken cancellationToken); - - Task SaveAsync(PackRunState state, CancellationToken cancellationToken); - - Task> ListAsync(CancellationToken cancellationToken); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunStateFactory.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunStateFactory.cs deleted file mode 100644 index 3b2bce25a..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunStateFactory.cs +++ /dev/null @@ -1,117 +0,0 @@ -using StellaOps.TaskRunner.Core.Execution.Simulation; - -namespace StellaOps.TaskRunner.Core.Execution; - -/// -/// Builds deterministic snapshots for freshly scheduled runs. -/// -public static class PackRunStateFactory -{ - public static PackRunState CreateInitialState( - PackRunExecutionContext context, - PackRunExecutionGraph graph, - PackRunSimulationEngine simulationEngine, - DateTimeOffset timestamp) - { - ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(graph); - ArgumentNullException.ThrowIfNull(simulationEngine); - - var simulation = simulationEngine.Simulate(context.Plan); - var simulationIndex = IndexSimulation(simulation.Steps); - - var stepRecords = new Dictionary(StringComparer.Ordinal); - foreach (var step in EnumerateSteps(graph.Steps)) - { - var simulationStatus = simulationIndex.TryGetValue(step.Id, out var node) - ? node.Status - : PackRunSimulationStatus.Pending; - - var status = step.Enabled ? PackRunStepExecutionStatus.Pending : PackRunStepExecutionStatus.Skipped; - string? statusReason = null; - - if (!step.Enabled) - { - statusReason = "disabled"; - } - else if (simulationStatus == PackRunSimulationStatus.RequiresApproval) - { - statusReason = "requires-approval"; - } - else if (simulationStatus == PackRunSimulationStatus.RequiresPolicy) - { - statusReason = "requires-policy"; - } - else if (simulationStatus == PackRunSimulationStatus.Skipped) - { - status = PackRunStepExecutionStatus.Skipped; - statusReason = "condition-false"; - } - - var record = new PackRunStepStateRecord( - step.Id, - step.Kind, - step.Enabled, - step.ContinueOnError, - step.MaxParallel, - step.ApprovalId, - step.GateMessage, - status, - Attempts: 0, - LastTransitionAt: null, - NextAttemptAt: null, - StatusReason: statusReason); - - stepRecords[step.Id] = record; - } - - var failurePolicy = graph.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy; - - return PackRunState.Create( - context.RunId, - context.Plan.Hash, - context.Plan, - failurePolicy, - context.RequestedAt, - stepRecords, - timestamp, - context.TenantId); - } - - private static Dictionary IndexSimulation(IReadOnlyList nodes) - { - var result = new Dictionary(StringComparer.Ordinal); - foreach (var node in nodes) - { - IndexSimulationNode(node, result); - } - - return result; - } - - private static void IndexSimulationNode(PackRunSimulationNode node, Dictionary accumulator) - { - accumulator[node.Id] = node; - foreach (var child in node.Children) - { - IndexSimulationNode(child, accumulator); - } - } - - private static IEnumerable EnumerateSteps(IReadOnlyList steps) - { - foreach (var step in steps) - { - yield return step; - if (step.Children.Count == 0) - { - continue; - } - - foreach (var child in EnumerateSteps(step.Children)) - { - yield return child; - } - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunStepStateMachine.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunStepStateMachine.cs deleted file mode 100644 index 50fc4160f..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/PackRunStepStateMachine.cs +++ /dev/null @@ -1,121 +0,0 @@ -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Core.Execution; - -public static class PackRunStepStateMachine -{ - public static PackRunStepState Create(DateTimeOffset? createdAt = null) - => new(PackRunStepExecutionStatus.Pending, Attempts: 0, createdAt, NextAttemptAt: null); - - public static PackRunStepState Start(PackRunStepState state, DateTimeOffset startedAt) - { - ArgumentNullException.ThrowIfNull(state); - if (state.Status is not PackRunStepExecutionStatus.Pending) - { - throw new InvalidOperationException($"Cannot start step from status {state.Status}."); - } - - return state with - { - Status = PackRunStepExecutionStatus.Running, - LastTransitionAt = startedAt, - NextAttemptAt = null - }; - } - - public static PackRunStepState CompleteSuccess(PackRunStepState state, DateTimeOffset completedAt) - { - ArgumentNullException.ThrowIfNull(state); - if (state.Status is not PackRunStepExecutionStatus.Running) - { - throw new InvalidOperationException($"Cannot complete step from status {state.Status}."); - } - - return state with - { - Status = PackRunStepExecutionStatus.Succeeded, - Attempts = state.Attempts + 1, - LastTransitionAt = completedAt, - NextAttemptAt = null - }; - } - - public static PackRunStepFailureResult RegisterFailure( - PackRunStepState state, - DateTimeOffset failedAt, - TaskPackPlanFailurePolicy failurePolicy) - { - ArgumentNullException.ThrowIfNull(state); - ArgumentNullException.ThrowIfNull(failurePolicy); - - if (state.Status is not PackRunStepExecutionStatus.Running) - { - throw new InvalidOperationException($"Cannot register failure from status {state.Status}."); - } - - var attempts = state.Attempts + 1; - if (attempts < failurePolicy.MaxAttempts) - { - var backoff = TimeSpan.FromSeconds(Math.Max(0, failurePolicy.BackoffSeconds)); - var nextAttemptAt = failedAt + backoff; - var nextState = state with - { - Status = PackRunStepExecutionStatus.Pending, - Attempts = attempts, - LastTransitionAt = failedAt, - NextAttemptAt = nextAttemptAt - }; - - return new PackRunStepFailureResult(nextState, PackRunStepFailureOutcome.Retry); - } - - var finalState = state with - { - Status = PackRunStepExecutionStatus.Failed, - Attempts = attempts, - LastTransitionAt = failedAt, - NextAttemptAt = null - }; - - return new PackRunStepFailureResult(finalState, PackRunStepFailureOutcome.Abort); - } - - public static PackRunStepState Skip(PackRunStepState state, DateTimeOffset skippedAt) - { - ArgumentNullException.ThrowIfNull(state); - if (state.Status is not PackRunStepExecutionStatus.Pending) - { - throw new InvalidOperationException($"Cannot skip step from status {state.Status}."); - } - - return state with - { - Status = PackRunStepExecutionStatus.Skipped, - LastTransitionAt = skippedAt, - NextAttemptAt = null - }; - } -} - -public sealed record PackRunStepState( - PackRunStepExecutionStatus Status, - int Attempts, - DateTimeOffset? LastTransitionAt, - DateTimeOffset? NextAttemptAt); - -public enum PackRunStepExecutionStatus -{ - Pending = 0, - Running, - Succeeded, - Failed, - Skipped -} - -public readonly record struct PackRunStepFailureResult(PackRunStepState State, PackRunStepFailureOutcome Outcome); - -public enum PackRunStepFailureOutcome -{ - Retry = 0, - Abort -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/ProvenanceManifestFactory.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/ProvenanceManifestFactory.cs deleted file mode 100644 index 00107dbc9..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/ProvenanceManifestFactory.cs +++ /dev/null @@ -1,65 +0,0 @@ -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Core.Execution; - -public static class ProvenanceManifestFactory -{ - public static ProvenanceManifest Create(PackRunExecutionContext context, PackRunState state, DateTimeOffset completedAt) - { - ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(state); - - var steps = state.Steps.Values - .OrderBy(step => step.StepId, StringComparer.Ordinal) - .Select(step => new ProvenanceStep( - step.StepId, - step.Kind.ToString(), - step.Status.ToString(), - step.Attempts, - step.LastTransitionAt, - step.StatusReason)) - .ToList(); - - var outputs = context.Plan.Outputs - .Select(output => new ProvenanceOutput(output.Name, output.Type)) - .ToList(); - - return new ProvenanceManifest( - context.RunId, - context.TenantId, - context.Plan.Hash, - context.Plan.Metadata.Name, - context.Plan.Metadata.Version, - context.Plan.Metadata.Description, - context.Plan.Metadata.Tags, - context.RequestedAt, - state.CreatedAt, - completedAt, - steps, - outputs); - } -} - -public sealed record ProvenanceManifest( - string RunId, - string? TenantId, - string PlanHash, - string PackName, - string PackVersion, - string? PackDescription, - IReadOnlyList PackTags, - DateTimeOffset RequestedAt, - DateTimeOffset CreatedAt, - DateTimeOffset CompletedAt, - IReadOnlyList Steps, - IReadOnlyList Outputs); - -public sealed record ProvenanceStep( - string Id, - string Kind, - string Status, - int Attempts, - DateTimeOffset? LastTransitionAt, - string? StatusReason); - -public sealed record ProvenanceOutput(string Name, string Type); diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationEngine.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationEngine.cs deleted file mode 100644 index 2ba172b07..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationEngine.cs +++ /dev/null @@ -1,110 +0,0 @@ - -using StellaOps.TaskRunner.Core.Planning; -using System.Collections.ObjectModel; - -namespace StellaOps.TaskRunner.Core.Execution.Simulation; - -public sealed class PackRunSimulationEngine -{ - private readonly PackRunExecutionGraphBuilder graphBuilder; - - public PackRunSimulationEngine() - { - graphBuilder = new PackRunExecutionGraphBuilder(); - } - - public PackRunSimulationResult Simulate(TaskPackPlan plan) - { - ArgumentNullException.ThrowIfNull(plan); - - var graph = graphBuilder.Build(plan); - var steps = graph.Steps.Select(ConvertStep).ToList(); - var outputs = BuildOutputs(plan.Outputs); - - return new PackRunSimulationResult(steps, outputs, graph.FailurePolicy); - } - - private static PackRunSimulationNode ConvertStep(PackRunExecutionStep step) - { - var status = DetermineStatus(step); - var children = step.Children.Count == 0 - ? PackRunSimulationNode.Empty - : new ReadOnlyCollection(step.Children.Select(ConvertStep).ToList()); - - // Extract loop/conditional specific details - var loopInfo = step.Kind == PackRunStepKind.Loop && step.LoopConfig is not null - ? new PackRunSimulationLoopInfo( - step.LoopConfig.ItemsExpression, - step.LoopConfig.Iterator, - step.LoopConfig.Index, - step.LoopConfig.MaxIterations, - step.LoopConfig.AggregationMode.ToString().ToLowerInvariant()) - : null; - - var conditionalInfo = step.Kind == PackRunStepKind.Conditional && step.ConditionalConfig is not null - ? new PackRunSimulationConditionalInfo( - step.ConditionalConfig.Branches.Select(b => - new PackRunSimulationBranch(b.ConditionExpression, b.Body.Count)).ToList(), - step.ConditionalConfig.ElseBranch?.Count ?? 0, - step.ConditionalConfig.OutputUnion) - : null; - - var policyInfo = step.Kind == PackRunStepKind.GatePolicy && step.PolicyGateConfig is not null - ? new PackRunSimulationPolicyInfo( - step.PolicyGateConfig.PolicyId, - step.PolicyGateConfig.PolicyVersion, - step.PolicyGateConfig.FailureAction.ToString().ToLowerInvariant(), - step.PolicyGateConfig.RetryCount) - : null; - - return new PackRunSimulationNode( - step.Id, - step.TemplateId, - step.Kind, - step.Enabled, - step.Uses, - step.ApprovalId, - step.GateMessage, - step.Parameters, - step.MaxParallel, - step.ContinueOnError, - status, - children, - loopInfo, - conditionalInfo, - policyInfo); - } - - private static PackRunSimulationStatus DetermineStatus(PackRunExecutionStep step) - { - if (!step.Enabled) - { - return PackRunSimulationStatus.Skipped; - } - - return step.Kind switch - { - PackRunStepKind.GateApproval => PackRunSimulationStatus.RequiresApproval, - PackRunStepKind.GatePolicy => PackRunSimulationStatus.RequiresPolicy, - PackRunStepKind.Loop => PackRunSimulationStatus.WillIterate, - PackRunStepKind.Conditional => PackRunSimulationStatus.WillBranch, - _ => PackRunSimulationStatus.Pending - }; - } - - private static IReadOnlyList BuildOutputs(IReadOnlyList outputs) - { - if (outputs.Count == 0) - { - return PackRunSimulationOutput.Empty; - } - - var list = new List(outputs.Count); - foreach (var output in outputs) - { - list.Add(new PackRunSimulationOutput(output.Name, output.Type, output.Path, output.Expression)); - } - - return new ReadOnlyCollection(list); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationModels.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationModels.cs deleted file mode 100644 index 4a3faf2c6..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/Simulation/PackRunSimulationModels.cs +++ /dev/null @@ -1,191 +0,0 @@ - -using StellaOps.TaskRunner.Core.Planning; -using System.Collections.ObjectModel; - -namespace StellaOps.TaskRunner.Core.Execution.Simulation; - -public sealed class PackRunSimulationResult -{ - public PackRunSimulationResult( - IReadOnlyList steps, - IReadOnlyList outputs, - TaskPackPlanFailurePolicy failurePolicy) - { - Steps = steps ?? throw new ArgumentNullException(nameof(steps)); - Outputs = outputs ?? throw new ArgumentNullException(nameof(outputs)); - FailurePolicy = failurePolicy ?? throw new ArgumentNullException(nameof(failurePolicy)); - } - - public IReadOnlyList Steps { get; } - - public IReadOnlyList Outputs { get; } - - public TaskPackPlanFailurePolicy FailurePolicy { get; } - - public bool HasPendingApprovals => Steps.Any(ContainsApprovalRequirement); - - private static bool ContainsApprovalRequirement(PackRunSimulationNode node) - { - if (node.Status is PackRunSimulationStatus.RequiresApproval or PackRunSimulationStatus.RequiresPolicy) - { - return true; - } - - return node.Children.Any(ContainsApprovalRequirement); - } -} - -public sealed class PackRunSimulationNode -{ - public PackRunSimulationNode( - string id, - string templateId, - PackRunStepKind kind, - bool enabled, - string? uses, - string? approvalId, - string? gateMessage, - IReadOnlyDictionary parameters, - int? maxParallel, - bool continueOnError, - PackRunSimulationStatus status, - IReadOnlyList children, - PackRunSimulationLoopInfo? loopInfo = null, - PackRunSimulationConditionalInfo? conditionalInfo = null, - PackRunSimulationPolicyInfo? policyInfo = null) - { - Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id; - TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId; - Kind = kind; - Enabled = enabled; - Uses = uses; - ApprovalId = approvalId; - GateMessage = gateMessage; - Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters)); - MaxParallel = maxParallel; - ContinueOnError = continueOnError; - Status = status; - Children = children ?? throw new ArgumentNullException(nameof(children)); - LoopInfo = loopInfo; - ConditionalInfo = conditionalInfo; - PolicyInfo = policyInfo; - } - - public string Id { get; } - - public string TemplateId { get; } - - public PackRunStepKind Kind { get; } - - public bool Enabled { get; } - - public string? Uses { get; } - - public string? ApprovalId { get; } - - public string? GateMessage { get; } - - public IReadOnlyDictionary Parameters { get; } - - public int? MaxParallel { get; } - - public bool ContinueOnError { get; } - - public PackRunSimulationStatus Status { get; } - - public IReadOnlyList Children { get; } - - /// Loop step simulation info (when Kind == Loop). - public PackRunSimulationLoopInfo? LoopInfo { get; } - - /// Conditional step simulation info (when Kind == Conditional). - public PackRunSimulationConditionalInfo? ConditionalInfo { get; } - - /// Policy gate simulation info (when Kind == GatePolicy). - public PackRunSimulationPolicyInfo? PolicyInfo { get; } - - public static IReadOnlyList Empty { get; } = - new ReadOnlyCollection(Array.Empty()); -} - -public enum PackRunSimulationStatus -{ - Pending = 0, - Skipped, - RequiresApproval, - RequiresPolicy, - /// Loop step will iterate over items. - WillIterate, - /// Conditional step will branch based on conditions. - WillBranch -} - -/// Loop step simulation details. -public sealed record PackRunSimulationLoopInfo( - /// Items expression to iterate over. - string? ItemsExpression, - /// Iterator variable name. - string Iterator, - /// Index variable name. - string Index, - /// Maximum iterations allowed. - int MaxIterations, - /// Aggregation mode for outputs. - string AggregationMode); - -/// Conditional step simulation details. -public sealed record PackRunSimulationConditionalInfo( - /// Branch conditions and body step counts. - IReadOnlyList Branches, - /// Number of steps in else branch. - int ElseStepCount, - /// Whether outputs are unioned. - bool OutputUnion); - -/// A conditional branch summary. -public sealed record PackRunSimulationBranch( - /// Condition expression. - string Condition, - /// Number of steps in body. - int StepCount); - -/// Policy gate simulation details. -public sealed record PackRunSimulationPolicyInfo( - /// Policy identifier. - string PolicyId, - /// Policy version (if specified). - string? PolicyVersion, - /// Failure action. - string FailureAction, - /// Retry count on failure. - int RetryCount); - -public sealed class PackRunSimulationOutput -{ - public PackRunSimulationOutput( - string name, - string type, - TaskPackPlanParameterValue? path, - TaskPackPlanParameterValue? expression) - { - Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)) : name; - Type = string.IsNullOrWhiteSpace(type) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(type)) : type; - Path = path; - Expression = expression; - } - - public string Name { get; } - - public string Type { get; } - - public TaskPackPlanParameterValue? Path { get; } - - public TaskPackPlanParameterValue? Expression { get; } - - public bool RequiresRuntimeValue => - (Path?.RequiresRuntimeValue ?? false) || - (Expression?.RequiresRuntimeValue ?? false); - - public static IReadOnlyList Empty { get; } = - new ReadOnlyCollection(Array.Empty()); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/TaskRunnerTelemetry.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/TaskRunnerTelemetry.cs deleted file mode 100644 index ab0cc2d8e..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Execution/TaskRunnerTelemetry.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Diagnostics.Metrics; - -namespace StellaOps.TaskRunner.Core.Execution; - -public static class TaskRunnerTelemetry -{ - public const string MeterName = "stellaops.taskrunner"; - - public static readonly Meter Meter = new(MeterName); - public static readonly Histogram StepDurationMs = - Meter.CreateHistogram("taskrunner.step.duration.ms", unit: "ms"); - public static readonly Counter StepRetryCount = - Meter.CreateCounter("taskrunner.step.retry.count"); - public static readonly UpDownCounter RunningSteps = - Meter.CreateUpDownCounter("taskrunner.steps.running"); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Expressions/TaskPackExpressions.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Expressions/TaskPackExpressions.cs deleted file mode 100644 index aa192e015..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Expressions/TaskPackExpressions.cs +++ /dev/null @@ -1,596 +0,0 @@ -using System.Collections.Immutable; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; - -namespace StellaOps.TaskRunner.Core.Expressions; - -internal static class TaskPackExpressions -{ - private static readonly Regex ExpressionPattern = new("^\\s*\\{\\{(.+)\\}\\}\\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex ComparisonPattern = new("^(?.+?)\\s*(?==|!=)\\s*(?.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex InPattern = new("^(?.+?)\\s+in\\s+(?.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant); - - public static bool TryEvaluateBoolean(string? candidate, TaskPackExpressionContext context, out bool value, out string? error) - { - value = false; - error = null; - - if (string.IsNullOrWhiteSpace(candidate)) - { - value = true; - return true; - } - - if (!TryExtractExpression(candidate, out var expression)) - { - return TryParseBooleanLiteral(candidate.Trim(), out value, out error); - } - - expression = expression.Trim(); - return TryEvaluateBooleanInternal(expression, context, out value, out error); - } - - public static TaskPackValueResolution EvaluateValue(JsonNode? node, TaskPackExpressionContext context) - { - if (node is null) - { - return TaskPackValueResolution.FromValue(null); - } - - if (node is JsonValue valueNode && valueNode.TryGetValue(out string? stringValue)) - { - if (!TryExtractExpression(stringValue, out var expression)) - { - return TaskPackValueResolution.FromValue(valueNode); - } - - var trimmed = expression.Trim(); - return EvaluateExpression(trimmed, context); - } - - return TaskPackValueResolution.FromValue(node); - } - - public static TaskPackValueResolution EvaluateString(string value, TaskPackExpressionContext context) - { - if (!TryExtractExpression(value, out var expression)) - { - return TaskPackValueResolution.FromValue(JsonValue.Create(value)); - } - - return EvaluateExpression(expression.Trim(), context); - } - - private static bool TryEvaluateBooleanInternal(string expression, TaskPackExpressionContext context, out bool result, out string? error) - { - result = false; - error = null; - - if (TrySplitTopLevel(expression, "||", out var left, out var right) || - TrySplitTopLevel(expression, " or ", out left, out right)) - { - if (!TryEvaluateBooleanInternal(left, context, out var leftValue, out error)) - { - return false; - } - - if (leftValue) - { - result = true; - return true; - } - - if (!TryEvaluateBooleanInternal(right, context, out var rightValue, out error)) - { - return false; - } - - result = rightValue; - return true; - } - - if (TrySplitTopLevel(expression, "&&", out left, out right) || - TrySplitTopLevel(expression, " and ", out left, out right)) - { - if (!TryEvaluateBooleanInternal(left, context, out var leftValue, out error)) - { - return false; - } - - if (!leftValue) - { - result = false; - return true; - } - - if (!TryEvaluateBooleanInternal(right, context, out var rightValue, out error)) - { - return false; - } - - result = rightValue; - return true; - } - - if (expression.StartsWith("not ", StringComparison.Ordinal)) - { - var inner = expression["not ".Length..].Trim(); - if (!TryEvaluateBooleanInternal(inner, context, out var innerValue, out error)) - { - return false; - } - - result = !innerValue; - return true; - } - - if (TryEvaluateComparison(expression, context, out result, out error)) - { - return error is null; - } - - var resolution = EvaluateExpression(expression, context); - if (!resolution.Resolved) - { - error = resolution.Error ?? $"Expression '{expression}' requires runtime evaluation."; - return false; - } - - result = ToBoolean(resolution.Value); - return true; - } - - private static bool TryEvaluateComparison(string expression, TaskPackExpressionContext context, out bool value, out string? error) - { - value = false; - error = null; - - var comparisonMatch = ComparisonPattern.Match(expression); - if (comparisonMatch.Success) - { - var left = comparisonMatch.Groups["left"].Value.Trim(); - var op = comparisonMatch.Groups["op"].Value; - var right = comparisonMatch.Groups["right"].Value.Trim(); - - var leftResolution = EvaluateOperand(left, context); - if (!leftResolution.IsValid(out error)) - { - return false; - } - - var rightResolution = EvaluateOperand(right, context); - if (!rightResolution.IsValid(out error)) - { - return false; - } - - if (!leftResolution.TryGetValue(out var leftValue, out error) || - !rightResolution.TryGetValue(out var rightValue, out error)) - { - return false; - } - - value = CompareNodes(leftValue, rightValue, op == "=="); - return true; - } - - var inMatch = InPattern.Match(expression); - if (inMatch.Success) - { - var member = inMatch.Groups["left"].Value.Trim(); - var collection = inMatch.Groups["right"].Value.Trim(); - - var memberResolution = EvaluateOperand(member, context); - if (!memberResolution.IsValid(out error)) - { - return false; - } - - var collectionResolution = EvaluateOperand(collection, context); - if (!collectionResolution.IsValid(out error)) - { - return false; - } - - if (!memberResolution.TryGetValue(out var memberValue, out error) || - !collectionResolution.TryGetValue(out var collectionValue, out error)) - { - return false; - } - - value = EvaluateMembership(memberValue, collectionValue); - return true; - } - - return false; - } - - private static OperandResolution EvaluateOperand(string expression, TaskPackExpressionContext context) - { - if (TryParseStringLiteral(expression, out var literal)) - { - return OperandResolution.FromValue(JsonValue.Create(literal)); - } - - if (bool.TryParse(expression, out var boolLiteral)) - { - return OperandResolution.FromValue(JsonValue.Create(boolLiteral)); - } - - if (double.TryParse(expression, System.Globalization.NumberStyles.Float | System.Globalization.NumberStyles.AllowThousands, System.Globalization.CultureInfo.InvariantCulture, out var numberLiteral)) - { - return OperandResolution.FromValue(JsonValue.Create(numberLiteral)); - } - - var resolution = EvaluateExpression(expression, context); - if (!resolution.Resolved) - { - if (resolution.RequiresRuntimeValue && resolution.Error is null) - { - return OperandResolution.FromRuntime(expression); - } - - return OperandResolution.FromError(resolution.Error ?? $"Expression '{expression}' could not be resolved."); - } - - return OperandResolution.FromValue(resolution.Value); - } - - private static TaskPackValueResolution EvaluateExpression(string expression, TaskPackExpressionContext context) - { - if (!TryResolvePath(expression, context, out var resolved, out var requiresRuntime, out var error)) - { - return TaskPackValueResolution.FromError(expression, error ?? $"Failed to resolve expression '{expression}'."); - } - - if (requiresRuntime) - { - return TaskPackValueResolution.FromDeferred(expression); - } - - return TaskPackValueResolution.FromValue(resolved); - } - - private static bool TryResolvePath(string expression, TaskPackExpressionContext context, out JsonNode? value, out bool requiresRuntime, out string? error) - { - value = null; - error = null; - requiresRuntime = false; - - if (string.IsNullOrWhiteSpace(expression)) - { - error = "Expression cannot be empty."; - return false; - } - - var segments = expression.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (segments.Length == 0) - { - error = $"Expression '{expression}' is invalid."; - return false; - } - - var root = segments[0]; - - switch (root) - { - case "inputs": - if (segments.Length == 1) - { - error = "Expression must reference a specific input (e.g., inputs.example)."; - return false; - } - - if (!context.Inputs.TryGetValue(segments[1], out var current)) - { - error = $"Input '{segments[1]}' was not supplied."; - return false; - } - - value = Traverse(current, segments, startIndex: 2); - return true; - - case "item": - if (context.CurrentItem is null) - { - error = "Expression references 'item' outside of a map iteration."; - return false; - } - - value = Traverse(context.CurrentItem, segments, startIndex: 1); - return true; - - case "steps": - if (segments.Length < 2) - { - error = "Step expressions must specify a step identifier (e.g., steps.plan.outputs.value)."; - return false; - } - - var stepId = segments[1]; - if (!context.StepExists(stepId)) - { - error = $"Step '{stepId}' referenced before it is defined."; - return false; - } - - requiresRuntime = true; - value = null; - return true; - - case "secrets": - if (segments.Length < 2) - { - error = "Secret expressions must specify a secret name (e.g., secrets.jiraToken)."; - return false; - } - - var secretName = segments[1]; - if (!context.SecretExists(secretName)) - { - error = $"Secret '{secretName}' is not declared in the manifest."; - return false; - } - - requiresRuntime = true; - value = null; - return true; - - default: - error = $"Expression '{expression}' references '{root}', supported roots are inputs, item, steps, and secrets."; - return false; - } - } - - private static JsonNode? Traverse(JsonNode? current, IReadOnlyList segments, int startIndex) - { - for (var i = startIndex; i < segments.Count && current is not null; i++) - { - var segment = segments[i]; - if (current is JsonObject obj) - { - if (!obj.TryGetPropertyValue(segment, out current)) - { - current = null; - } - } - else if (current is JsonArray array) - { - current = TryGetArrayElement(array, segment); - } - else - { - current = null; - } - } - - return current; - } - - private static JsonNode? TryGetArrayElement(JsonArray array, string segment) - { - if (int.TryParse(segment, out var index) && index >= 0 && index < array.Count) - { - return array[index]; - } - - return null; - } - - private static bool TryExtractExpression(string candidate, out string expression) - { - var match = ExpressionPattern.Match(candidate); - if (!match.Success) - { - expression = candidate; - return false; - } - - expression = match.Groups[1].Value; - return true; - } - - private static bool TryParseBooleanLiteral(string value, out bool result, out string? error) - { - if (bool.TryParse(value, out result)) - { - error = null; - return true; - } - - error = $"Unable to parse boolean literal '{value}'."; - return false; - } - - private static bool TrySplitTopLevel(string expression, string token, out string left, out string right) - { - var inSingle = false; - var inDouble = false; - for (var i = 0; i <= expression.Length - token.Length; i++) - { - var c = expression[i]; - if (c == '\'' && !inDouble) - { - inSingle = !inSingle; - } - else if (c == '"' && !inSingle) - { - inDouble = !inDouble; - } - - if (inSingle || inDouble) - { - continue; - } - - if (expression.AsSpan(i, token.Length).SequenceEqual(token)) - { - left = expression[..i].Trim(); - right = expression[(i + token.Length)..].Trim(); - return true; - } - } - - left = string.Empty; - right = string.Empty; - return false; - } - - private static bool TryParseStringLiteral(string candidate, out string? literal) - { - literal = null; - if (candidate.Length >= 2) - { - if ((candidate[0] == '"' && candidate[^1] == '"') || - (candidate[0] == '\'' && candidate[^1] == '\'')) - { - literal = candidate[1..^1]; - return true; - } - } - - return false; - } - - private static bool CompareNodes(JsonNode? left, JsonNode? right, bool equality) - { - if (left is null && right is null) - { - return equality; - } - - if (left is null || right is null) - { - return !equality; - } - - var comparison = JsonNode.DeepEquals(left, right); - return equality ? comparison : !comparison; - } - - private static bool EvaluateMembership(JsonNode? member, JsonNode? collection) - { - if (collection is JsonArray array) - { - foreach (var element in array) - { - if (JsonNode.DeepEquals(member, element)) - { - return true; - } - } - - return false; - } - - if (collection is JsonValue value && value.TryGetValue(out string? text) && member is JsonValue memberValue && memberValue.TryGetValue(out string? memberText)) - { - return text?.Contains(memberText, StringComparison.Ordinal) ?? false; - } - - return false; - } - - private static bool ToBoolean(JsonNode? node) - { - if (node is null) - { - return false; - } - - if (node is JsonValue value) - { - if (value.TryGetValue(out var boolValue)) - { - return boolValue; - } - - if (value.TryGetValue(out var stringValue)) - { - return !string.IsNullOrWhiteSpace(stringValue); - } - - if (value.TryGetValue(out var number)) - { - return Math.Abs(number) > double.Epsilon; - } - } - - if (node is JsonArray array) - { - return array.Count > 0; - } - - if (node is JsonObject obj) - { - return obj.Count > 0; - } - - return true; - } - - private readonly record struct OperandResolution(JsonNode? Value, string? Error, bool RequiresRuntime) - { - public bool IsValid(out string? error) - { - error = Error; - return string.IsNullOrEmpty(Error); - } - - public bool TryGetValue(out JsonNode? value, out string? error) - { - if (RequiresRuntime) - { - error = "Expression requires runtime evaluation."; - value = null; - return false; - } - - value = Value; - error = Error; - return error is null; - } - - public static OperandResolution FromValue(JsonNode? value) - => new(value, null, false); - - public static OperandResolution FromRuntime(string expression) - => new(null, $"Expression '{expression}' requires runtime evaluation.", true); - - public static OperandResolution FromError(string error) - => new(null, error, false); - } -} - -internal readonly record struct TaskPackExpressionContext( - IReadOnlyDictionary Inputs, - ISet KnownSteps, - ISet KnownSecrets, - JsonNode? CurrentItem) -{ - public static TaskPackExpressionContext Create( - IReadOnlyDictionary inputs, - ISet knownSteps, - ISet knownSecrets) - => new(inputs, knownSteps, knownSecrets, null); - - public bool StepExists(string stepId) => KnownSteps.Contains(stepId); - - public void RegisterStep(string stepId) => KnownSteps.Add(stepId); - - public bool SecretExists(string secretName) => KnownSecrets.Contains(secretName); - - public TaskPackExpressionContext WithItem(JsonNode? item) => new(Inputs, KnownSteps, KnownSecrets, item); -} - -internal readonly record struct TaskPackValueResolution(bool Resolved, JsonNode? Value, string? Expression, string? Error, bool RequiresRuntimeValue) -{ - public static TaskPackValueResolution FromValue(JsonNode? value) - => new(true, value, null, null, false); - - public static TaskPackValueResolution FromDeferred(string expression) - => new(false, null, expression, null, true); - - public static TaskPackValueResolution FromError(string expression, string error) - => new(false, null, expression, error, false); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IPackRunIncidentModeService.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IPackRunIncidentModeService.cs deleted file mode 100644 index ad934d991..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IPackRunIncidentModeService.cs +++ /dev/null @@ -1,534 +0,0 @@ -using Microsoft.Extensions.Logging; -using StellaOps.TaskRunner.Core.Events; - -namespace StellaOps.TaskRunner.Core.IncidentMode; - -/// -/// Service for managing pack run incident mode. -/// Per TASKRUN-OBS-55-001. -/// -public interface IPackRunIncidentModeService -{ - /// - /// Activates incident mode for a run. - /// - Task ActivateAsync( - IncidentModeActivationRequest request, - CancellationToken cancellationToken = default); - - /// - /// Deactivates incident mode for a run. - /// - Task DeactivateAsync( - string runId, - string? reason = null, - CancellationToken cancellationToken = default); - - /// - /// Gets the current incident mode status for a run. - /// - Task GetStatusAsync( - string runId, - CancellationToken cancellationToken = default); - - /// - /// Handles an SLO breach notification. - /// - Task HandleSloBreachAsync( - SloBreachNotification notification, - CancellationToken cancellationToken = default); - - /// - /// Escalates incident mode to a higher level. - /// - Task EscalateAsync( - string runId, - IncidentEscalationLevel newLevel, - string? reason = null, - CancellationToken cancellationToken = default); - - /// - /// Gets settings for the current incident mode level. - /// - IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level); -} - -/// -/// Store for incident mode state. -/// -public interface IPackRunIncidentModeStore -{ - /// - /// Stores incident mode status. - /// - Task StoreAsync( - string runId, - PackRunIncidentModeStatus status, - CancellationToken cancellationToken = default); - - /// - /// Gets incident mode status. - /// - Task GetAsync( - string runId, - CancellationToken cancellationToken = default); - - /// - /// Lists all runs in incident mode. - /// - Task> ListActiveRunsAsync( - CancellationToken cancellationToken = default); - - /// - /// Removes incident mode status. - /// - Task RemoveAsync( - string runId, - CancellationToken cancellationToken = default); -} - -/// -/// Settings for incident mode levels. -/// -public sealed record IncidentModeSettings( - /// Escalation level. - IncidentEscalationLevel Level, - - /// Retention policy. - IncidentRetentionPolicy RetentionPolicy, - - /// Telemetry settings. - IncidentTelemetrySettings TelemetrySettings, - - /// Debug capture settings. - IncidentDebugCaptureSettings DebugCaptureSettings); - -/// -/// Default implementation of pack run incident mode service. -/// -public sealed class PackRunIncidentModeService : IPackRunIncidentModeService -{ - private readonly IPackRunIncidentModeStore _store; - private readonly IPackRunTimelineEventEmitter? _timelineEmitter; - private readonly ILogger _logger; - private readonly TimeProvider _timeProvider; - - public PackRunIncidentModeService( - IPackRunIncidentModeStore store, - ILogger logger, - TimeProvider? timeProvider = null, - IPackRunTimelineEventEmitter? timelineEmitter = null) - { - _store = store ?? throw new ArgumentNullException(nameof(store)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _timeProvider = timeProvider ?? TimeProvider.System; - _timelineEmitter = timelineEmitter; - } - - /// - public async Task ActivateAsync( - IncidentModeActivationRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - - try - { - var now = _timeProvider.GetUtcNow(); - var settings = GetSettingsForLevel(request.Level); - - var expiresAt = request.DurationMinutes.HasValue - ? now.AddMinutes(request.DurationMinutes.Value) - : (DateTimeOffset?)null; - - var status = new PackRunIncidentModeStatus( - Active: true, - Level: request.Level, - ActivatedAt: now, - ActivationReason: request.Reason, - Source: request.Source, - ExpiresAt: expiresAt, - RetentionPolicy: settings.RetentionPolicy, - TelemetrySettings: settings.TelemetrySettings, - DebugCaptureSettings: settings.DebugCaptureSettings); - - await _store.StoreAsync(request.RunId, status, cancellationToken); - - // Emit timeline event - await EmitTimelineEventAsync( - request.TenantId, - request.RunId, - PackRunIncidentEventTypes.IncidentModeActivated, - new Dictionary - { - ["level"] = request.Level.ToString(), - ["source"] = request.Source.ToString(), - ["reason"] = request.Reason, - ["requestedBy"] = request.RequestedBy ?? "system" - }, - cancellationToken); - - _logger.LogWarning( - "Incident mode activated for run {RunId} at level {Level} due to: {Reason}", - request.RunId, - request.Level, - request.Reason); - - return new IncidentModeActivationResult( - Success: true, - Status: status, - Error: null); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to activate incident mode for run {RunId}", request.RunId); - - return new IncidentModeActivationResult( - Success: false, - Status: PackRunIncidentModeStatus.Inactive(), - Error: ex.Message); - } - } - - /// - public async Task DeactivateAsync( - string runId, - string? reason = null, - CancellationToken cancellationToken = default) - { - try - { - var current = await _store.GetAsync(runId, cancellationToken); - if (current is null || !current.Active) - { - return new IncidentModeActivationResult( - Success: true, - Status: PackRunIncidentModeStatus.Inactive(), - Error: null); - } - - await _store.RemoveAsync(runId, cancellationToken); - var inactive = PackRunIncidentModeStatus.Inactive(); - - // Emit timeline event (using default tenant since we don't have it) - await EmitTimelineEventAsync( - "default", - runId, - PackRunIncidentEventTypes.IncidentModeDeactivated, - new Dictionary - { - ["previousLevel"] = current.Level.ToString(), - ["reason"] = reason ?? "Manual deactivation", - ["activeDuration"] = current.ActivatedAt.HasValue - ? (_timeProvider.GetUtcNow() - current.ActivatedAt.Value).ToString() - : "unknown" - }, - cancellationToken); - - _logger.LogInformation( - "Incident mode deactivated for run {RunId}. Reason: {Reason}", - runId, - reason ?? "Manual deactivation"); - - return new IncidentModeActivationResult( - Success: true, - Status: inactive, - Error: null); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to deactivate incident mode for run {RunId}", runId); - - return new IncidentModeActivationResult( - Success: false, - Status: PackRunIncidentModeStatus.Inactive(), - Error: ex.Message); - } - } - - /// - public async Task GetStatusAsync( - string runId, - CancellationToken cancellationToken = default) - { - var status = await _store.GetAsync(runId, cancellationToken); - - if (status is null) - { - return PackRunIncidentModeStatus.Inactive(); - } - - // Check if expired - if (status.ExpiresAt.HasValue && status.ExpiresAt.Value <= _timeProvider.GetUtcNow()) - { - await _store.RemoveAsync(runId, cancellationToken); - return PackRunIncidentModeStatus.Inactive(); - } - - return status; - } - - /// - public async Task HandleSloBreachAsync( - SloBreachNotification notification, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(notification); - - if (string.IsNullOrWhiteSpace(notification.ResourceId)) - { - _logger.LogWarning( - "Received SLO breach notification {BreachId} without resource ID, skipping incident activation", - notification.BreachId); - - return new IncidentModeActivationResult( - Success: false, - Status: PackRunIncidentModeStatus.Inactive(), - Error: "No resource ID in SLO breach notification"); - } - - // Map severity to escalation level - var level = notification.Severity?.ToUpperInvariant() switch - { - "CRITICAL" => IncidentEscalationLevel.Critical, - "HIGH" => IncidentEscalationLevel.High, - "MEDIUM" => IncidentEscalationLevel.Medium, - "LOW" => IncidentEscalationLevel.Low, - _ => IncidentEscalationLevel.Medium - }; - - var request = new IncidentModeActivationRequest( - RunId: notification.ResourceId, - TenantId: notification.TenantId ?? "default", - Level: level, - Source: IncidentModeSource.SloBreach, - Reason: $"SLO breach: {notification.SloName} ({notification.CurrentValue:F2} vs threshold {notification.Threshold:F2})", - DurationMinutes: 60, // Auto-expire after 1 hour - RequestedBy: "slo-monitor"); - - _logger.LogWarning( - "Processing SLO breach {BreachId} for {SloName} on resource {ResourceId}", - notification.BreachId, - notification.SloName, - notification.ResourceId); - - return await ActivateAsync(request, cancellationToken); - } - - /// - public async Task EscalateAsync( - string runId, - IncidentEscalationLevel newLevel, - string? reason = null, - CancellationToken cancellationToken = default) - { - var current = await _store.GetAsync(runId, cancellationToken); - - if (current is null || !current.Active) - { - return new IncidentModeActivationResult( - Success: false, - Status: PackRunIncidentModeStatus.Inactive(), - Error: "Incident mode is not active for this run"); - } - - if (newLevel <= current.Level) - { - return new IncidentModeActivationResult( - Success: false, - Status: current, - Error: $"Cannot escalate to {newLevel} - current level is {current.Level}"); - } - - var settings = GetSettingsForLevel(newLevel); - var now = _timeProvider.GetUtcNow(); - - var escalated = current with - { - Level = newLevel, - ActivationReason = $"{current.ActivationReason} [Escalated: {reason ?? "Manual escalation"}]", - RetentionPolicy = settings.RetentionPolicy, - TelemetrySettings = settings.TelemetrySettings, - DebugCaptureSettings = settings.DebugCaptureSettings - }; - - await _store.StoreAsync(runId, escalated, cancellationToken); - - // Emit timeline event - await EmitTimelineEventAsync( - "default", - runId, - PackRunIncidentEventTypes.IncidentModeEscalated, - new Dictionary - { - ["previousLevel"] = current.Level.ToString(), - ["newLevel"] = newLevel.ToString(), - ["reason"] = reason ?? "Manual escalation" - }, - cancellationToken); - - _logger.LogWarning( - "Incident mode escalated for run {RunId} from {OldLevel} to {NewLevel}. Reason: {Reason}", - runId, - current.Level, - newLevel, - reason ?? "Manual escalation"); - - return new IncidentModeActivationResult( - Success: true, - Status: escalated, - Error: null); - } - - /// - public IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level) => level switch - { - IncidentEscalationLevel.None => new IncidentModeSettings( - level, - IncidentRetentionPolicy.Default(), - IncidentTelemetrySettings.Default(), - IncidentDebugCaptureSettings.Default()), - - IncidentEscalationLevel.Low => new IncidentModeSettings( - level, - IncidentRetentionPolicy.Default() with { LogRetentionDays = 30 }, - IncidentTelemetrySettings.Default() with - { - EnhancedTelemetryActive = true, - LogVerbosity = IncidentLogVerbosity.Verbose, - TraceSamplingRate = 0.5 - }, - IncidentDebugCaptureSettings.Default()), - - IncidentEscalationLevel.Medium => new IncidentModeSettings( - level, - IncidentRetentionPolicy.Extended(), - IncidentTelemetrySettings.Enhanced(), - IncidentDebugCaptureSettings.Basic()), - - IncidentEscalationLevel.High => new IncidentModeSettings( - level, - IncidentRetentionPolicy.Extended() with { LogRetentionDays = 180, ArtifactRetentionDays = 365 }, - IncidentTelemetrySettings.Enhanced() with { LogVerbosity = IncidentLogVerbosity.Debug }, - IncidentDebugCaptureSettings.Full()), - - IncidentEscalationLevel.Critical => new IncidentModeSettings( - level, - IncidentRetentionPolicy.Maximum(), - IncidentTelemetrySettings.Maximum(), - IncidentDebugCaptureSettings.Full() with { MaxCaptureSizeMb = 1000 }), - - _ => throw new ArgumentOutOfRangeException(nameof(level)) - }; - - private async Task EmitTimelineEventAsync( - string tenantId, - string runId, - string eventType, - IReadOnlyDictionary attributes, - CancellationToken cancellationToken) - { - if (_timelineEmitter is null) return; - - await _timelineEmitter.EmitAsync( - PackRunTimelineEvent.Create( - tenantId: tenantId, - eventType: eventType, - source: "taskrunner-incident-mode", - occurredAt: _timeProvider.GetUtcNow(), - runId: runId, - severity: PackRunEventSeverity.Warning, - attributes: attributes), - cancellationToken); - } -} - -/// -/// Incident mode timeline event types. -/// -public static class PackRunIncidentEventTypes -{ - /// Incident mode activated. - public const string IncidentModeActivated = "pack.incident.activated"; - - /// Incident mode deactivated. - public const string IncidentModeDeactivated = "pack.incident.deactivated"; - - /// Incident mode escalated. - public const string IncidentModeEscalated = "pack.incident.escalated"; - - /// SLO breach detected. - public const string SloBreachDetected = "pack.incident.slo_breach"; -} - -/// -/// In-memory incident mode store for testing. -/// -public sealed class InMemoryPackRunIncidentModeStore : IPackRunIncidentModeStore -{ - private readonly Dictionary _statuses = new(); - private readonly object _lock = new(); - - /// - public Task StoreAsync( - string runId, - PackRunIncidentModeStatus status, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - _statuses[runId] = status; - } - return Task.CompletedTask; - } - - /// - public Task GetAsync( - string runId, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - _statuses.TryGetValue(runId, out var status); - return Task.FromResult(status); - } - } - - /// - public Task> ListActiveRunsAsync( - CancellationToken cancellationToken = default) - { - lock (_lock) - { - var active = _statuses - .Where(kvp => kvp.Value.Active) - .Select(kvp => kvp.Key) - .ToList(); - return Task.FromResult>(active); - } - } - - /// - public Task RemoveAsync( - string runId, - CancellationToken cancellationToken = default) - { - lock (_lock) - { - _statuses.Remove(runId); - } - return Task.CompletedTask; - } - - /// Gets count of stored statuses. - public int Count - { - get { lock (_lock) { return _statuses.Count; } } - } - - /// Clears all statuses. - public void Clear() - { - lock (_lock) { _statuses.Clear(); } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IncidentModeModels.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IncidentModeModels.cs deleted file mode 100644 index b9aaf0310..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IncidentModeModels.cs +++ /dev/null @@ -1,363 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace StellaOps.TaskRunner.Core.IncidentMode; - -/// -/// Incident mode status for a pack run. -/// Per TASKRUN-OBS-55-001. -/// -public sealed record PackRunIncidentModeStatus( - /// Whether incident mode is active. - bool Active, - - /// Current escalation level. - IncidentEscalationLevel Level, - - /// When incident mode was activated. - DateTimeOffset? ActivatedAt, - - /// Reason for activation. - string? ActivationReason, - - /// Source of activation (SLO breach, manual, etc.). - IncidentModeSource Source, - - /// When incident mode will auto-deactivate (if set). - DateTimeOffset? ExpiresAt, - - /// Current retention policy in effect. - IncidentRetentionPolicy RetentionPolicy, - - /// Active telemetry escalation settings. - IncidentTelemetrySettings TelemetrySettings, - - /// Debug artifact capture settings. - IncidentDebugCaptureSettings DebugCaptureSettings) -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - /// - /// Creates a default inactive status. - /// - public static PackRunIncidentModeStatus Inactive() => new( - Active: false, - Level: IncidentEscalationLevel.None, - ActivatedAt: null, - ActivationReason: null, - Source: IncidentModeSource.None, - ExpiresAt: null, - RetentionPolicy: IncidentRetentionPolicy.Default(), - TelemetrySettings: IncidentTelemetrySettings.Default(), - DebugCaptureSettings: IncidentDebugCaptureSettings.Default()); - - /// - /// Serializes to JSON. - /// - public string ToJson() => JsonSerializer.Serialize(this, JsonOptions); -} - -/// -/// Incident escalation levels. -/// -public enum IncidentEscalationLevel -{ - /// No incident mode. - None = 0, - - /// Low severity - enhanced logging. - Low = 1, - - /// Medium severity - debug capture enabled. - Medium = 2, - - /// High severity - full debug + extended retention. - High = 3, - - /// Critical - maximum telemetry + indefinite retention. - Critical = 4 -} - -/// -/// Source of incident mode activation. -/// -public enum IncidentModeSource -{ - /// No incident mode. - None, - - /// Activated manually by operator. - Manual, - - /// Activated by SLO breach webhook. - SloBreach, - - /// Activated by error rate threshold. - ErrorRate, - - /// Activated by policy evaluation. - PolicyTrigger, - - /// Activated by external system. - External -} - -/// -/// Retention policy during incident mode. -/// -public sealed record IncidentRetentionPolicy( - /// Whether extended retention is active. - bool ExtendedRetentionActive, - - /// Log retention in days. - int LogRetentionDays, - - /// Artifact retention in days. - int ArtifactRetentionDays, - - /// Debug capture retention in days. - int DebugCaptureRetentionDays, - - /// Trace retention in days. - int TraceRetentionDays) -{ - /// Default retention policy. - public static IncidentRetentionPolicy Default() => new( - ExtendedRetentionActive: false, - LogRetentionDays: 7, - ArtifactRetentionDays: 30, - DebugCaptureRetentionDays: 3, - TraceRetentionDays: 7); - - /// Extended retention for incident mode. - public static IncidentRetentionPolicy Extended() => new( - ExtendedRetentionActive: true, - LogRetentionDays: 90, - ArtifactRetentionDays: 180, - DebugCaptureRetentionDays: 30, - TraceRetentionDays: 90); - - /// Maximum retention for critical incidents. - public static IncidentRetentionPolicy Maximum() => new( - ExtendedRetentionActive: true, - LogRetentionDays: 365, - ArtifactRetentionDays: 365, - DebugCaptureRetentionDays: 90, - TraceRetentionDays: 365); -} - -/// -/// Telemetry settings during incident mode. -/// -public sealed record IncidentTelemetrySettings( - /// Whether enhanced telemetry is active. - bool EnhancedTelemetryActive, - - /// Log verbosity level. - IncidentLogVerbosity LogVerbosity, - - /// Trace sampling rate (0.0 to 1.0). - double TraceSamplingRate, - - /// Whether to capture environment variables. - bool CaptureEnvironment, - - /// Whether to capture step inputs/outputs. - bool CaptureStepIo, - - /// Whether to capture network calls. - bool CaptureNetworkCalls, - - /// Maximum trace spans per step. - int MaxTraceSpansPerStep) -{ - /// Default telemetry settings. - public static IncidentTelemetrySettings Default() => new( - EnhancedTelemetryActive: false, - LogVerbosity: IncidentLogVerbosity.Normal, - TraceSamplingRate: 0.1, - CaptureEnvironment: false, - CaptureStepIo: false, - CaptureNetworkCalls: false, - MaxTraceSpansPerStep: 100); - - /// Enhanced telemetry for incident mode. - public static IncidentTelemetrySettings Enhanced() => new( - EnhancedTelemetryActive: true, - LogVerbosity: IncidentLogVerbosity.Verbose, - TraceSamplingRate: 1.0, - CaptureEnvironment: true, - CaptureStepIo: true, - CaptureNetworkCalls: true, - MaxTraceSpansPerStep: 1000); - - /// Maximum telemetry for debugging. - public static IncidentTelemetrySettings Maximum() => new( - EnhancedTelemetryActive: true, - LogVerbosity: IncidentLogVerbosity.Debug, - TraceSamplingRate: 1.0, - CaptureEnvironment: true, - CaptureStepIo: true, - CaptureNetworkCalls: true, - MaxTraceSpansPerStep: 10000); -} - -/// -/// Log verbosity levels for incident mode. -/// -public enum IncidentLogVerbosity -{ - /// Minimal logging (errors only). - Minimal, - - /// Normal logging. - Normal, - - /// Verbose logging. - Verbose, - - /// Debug logging (maximum detail). - Debug -} - -/// -/// Debug artifact capture settings. -/// -public sealed record IncidentDebugCaptureSettings( - /// Whether debug capture is active. - bool CaptureActive, - - /// Whether to capture heap dumps. - bool CaptureHeapDumps, - - /// Whether to capture thread dumps. - bool CaptureThreadDumps, - - /// Whether to capture profiling data. - bool CaptureProfilingData, - - /// Whether to capture system metrics. - bool CaptureSystemMetrics, - - /// Maximum capture size in MB. - int MaxCaptureSizeMb, - - /// Capture interval in seconds. - int CaptureIntervalSeconds) -{ - /// Default capture settings (disabled). - public static IncidentDebugCaptureSettings Default() => new( - CaptureActive: false, - CaptureHeapDumps: false, - CaptureThreadDumps: false, - CaptureProfilingData: false, - CaptureSystemMetrics: false, - MaxCaptureSizeMb: 0, - CaptureIntervalSeconds: 0); - - /// Basic debug capture. - public static IncidentDebugCaptureSettings Basic() => new( - CaptureActive: true, - CaptureHeapDumps: false, - CaptureThreadDumps: true, - CaptureProfilingData: false, - CaptureSystemMetrics: true, - MaxCaptureSizeMb: 100, - CaptureIntervalSeconds: 60); - - /// Full debug capture. - public static IncidentDebugCaptureSettings Full() => new( - CaptureActive: true, - CaptureHeapDumps: true, - CaptureThreadDumps: true, - CaptureProfilingData: true, - CaptureSystemMetrics: true, - MaxCaptureSizeMb: 500, - CaptureIntervalSeconds: 30); -} - -/// -/// SLO breach notification payload. -/// -public sealed record SloBreachNotification( - /// Breach identifier. - [property: JsonPropertyName("breachId")] - string BreachId, - - /// SLO that was breached. - [property: JsonPropertyName("sloName")] - string SloName, - - /// Breach severity. - [property: JsonPropertyName("severity")] - string Severity, - - /// When the breach occurred. - [property: JsonPropertyName("occurredAt")] - DateTimeOffset OccurredAt, - - /// Current metric value. - [property: JsonPropertyName("currentValue")] - double CurrentValue, - - /// Threshold that was breached. - [property: JsonPropertyName("threshold")] - double Threshold, - - /// Target metric value. - [property: JsonPropertyName("target")] - double Target, - - /// Affected resource (run ID, step ID, etc.). - [property: JsonPropertyName("resourceId")] - string? ResourceId, - - /// Affected tenant. - [property: JsonPropertyName("tenantId")] - string? TenantId, - - /// Additional context. - [property: JsonPropertyName("context")] - IReadOnlyDictionary? Context); - -/// -/// Request to activate incident mode. -/// -public sealed record IncidentModeActivationRequest( - /// Run ID to activate incident mode for. - string RunId, - - /// Tenant ID. - string TenantId, - - /// Escalation level to activate. - IncidentEscalationLevel Level, - - /// Activation source. - IncidentModeSource Source, - - /// Reason for activation. - string Reason, - - /// Duration in minutes (null for indefinite). - int? DurationMinutes, - - /// Operator or system that requested activation. - string? RequestedBy); - -/// -/// Result of incident mode activation. -/// -public sealed record IncidentModeActivationResult( - /// Whether activation succeeded. - bool Success, - - /// Current incident mode status. - PackRunIncidentModeStatus Status, - - /// Error message if activation failed. - string? Error); diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlan.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlan.cs deleted file mode 100644 index 13d358e8c..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlan.cs +++ /dev/null @@ -1,105 +0,0 @@ - -using StellaOps.TaskRunner.Core.Expressions; -using System.Collections.Immutable; -using System.Text.Json.Nodes; - -namespace StellaOps.TaskRunner.Core.Planning; - -public sealed class TaskPackPlan -{ - public TaskPackPlan( - TaskPackPlanMetadata metadata, - IReadOnlyDictionary inputs, - IReadOnlyList steps, - string hash, - IReadOnlyList approvals, - IReadOnlyList secrets, - IReadOnlyList outputs, - TaskPackPlanFailurePolicy? failurePolicy) - { - Metadata = metadata; - Inputs = inputs; - Steps = steps; - Hash = hash; - Approvals = approvals; - Secrets = secrets; - Outputs = outputs; - FailurePolicy = failurePolicy; - } - - public TaskPackPlanMetadata Metadata { get; } - - public IReadOnlyDictionary Inputs { get; } - - public IReadOnlyList Steps { get; } - - public string Hash { get; } - - public IReadOnlyList Approvals { get; } - - public IReadOnlyList Secrets { get; } - - public IReadOnlyList Outputs { get; } - - public TaskPackPlanFailurePolicy? FailurePolicy { get; } -} - -public sealed record TaskPackPlanMetadata(string Name, string Version, string? Description, IReadOnlyList Tags); - -public sealed record TaskPackPlanStep( - string Id, - string TemplateId, - string? Name, - string Type, - bool Enabled, - string? Uses, - IReadOnlyDictionary? Parameters, - string? ApprovalId, - string? GateMessage, - IReadOnlyList? Children); - -public sealed record TaskPackPlanParameterValue( - JsonNode? Value, - string? Expression, - string? Error, - bool RequiresRuntimeValue) -{ - internal static TaskPackPlanParameterValue FromResolution(TaskPackValueResolution resolution) - => new(resolution.Value, resolution.Expression, resolution.Error, resolution.RequiresRuntimeValue); -} - -public sealed record TaskPackPlanApproval( - string Id, - IReadOnlyList Grants, - string? ExpiresAfter, - string? ReasonTemplate); - -public sealed record TaskPackPlanSecret(string Name, string Scope, string? Description); - -public sealed record TaskPackPlanOutput( - string Name, - string Type, - TaskPackPlanParameterValue? Path, - TaskPackPlanParameterValue? Expression); - -public sealed record TaskPackPlanFailurePolicy( - int MaxAttempts, - int BackoffSeconds, - bool ContinueOnError); - -public sealed class TaskPackPlanResult -{ - public TaskPackPlanResult(TaskPackPlan? plan, ImmutableArray errors) - { - Plan = plan; - Errors = errors; - } - - public TaskPackPlan? Plan { get; } - - public ImmutableArray Errors { get; } - - public bool Success => Plan is not null && Errors.IsDefaultOrEmpty; -} - -public sealed record TaskPackPlanError(string Path, string Message); diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlanHasher.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlanHasher.cs deleted file mode 100644 index 72a755852..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlanHasher.cs +++ /dev/null @@ -1,120 +0,0 @@ - -using StellaOps.TaskRunner.Core.Serialization; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json.Nodes; - -namespace StellaOps.TaskRunner.Core.Planning; - -internal static class TaskPackPlanHasher -{ - public static string ComputeHash( - TaskPackPlanMetadata metadata, - IReadOnlyDictionary inputs, - IReadOnlyList steps, - IReadOnlyList approvals, - IReadOnlyList secrets, - IReadOnlyList outputs, - TaskPackPlanFailurePolicy? failurePolicy) - { - var canonical = new CanonicalPlan( - new CanonicalMetadata(metadata.Name, metadata.Version, metadata.Description, metadata.Tags), - inputs.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal), - steps.Select(ToCanonicalStep).ToList(), - approvals - .OrderBy(a => a.Id, StringComparer.Ordinal) - .Select(a => new CanonicalApproval(a.Id, a.Grants.OrderBy(g => g, StringComparer.Ordinal).ToList(), a.ExpiresAfter, a.ReasonTemplate)) - .ToList(), - secrets - .OrderBy(s => s.Name, StringComparer.Ordinal) - .Select(s => new CanonicalSecret(s.Name, s.Scope, s.Description)) - .ToList(), - outputs - .OrderBy(o => o.Name, StringComparer.Ordinal) - .Select(ToCanonicalOutput) - .ToList(), - failurePolicy is null - ? null - : new CanonicalFailurePolicy(failurePolicy.MaxAttempts, failurePolicy.BackoffSeconds, failurePolicy.ContinueOnError)); - - var json = CanonicalJson.Serialize(canonical); - using var sha256 = SHA256.Create(); - var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json)); - return $"sha256:{ConvertToHex(hashBytes)}"; - } - - private static string ConvertToHex(byte[] hashBytes) - { - var builder = new StringBuilder(hashBytes.Length * 2); - foreach (var b in hashBytes) - { - builder.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture)); - } - - return builder.ToString(); - } - - private static CanonicalPlanStep ToCanonicalStep(TaskPackPlanStep step) - => new( - step.Id, - step.TemplateId, - step.Name, - step.Type, - step.Enabled, - step.Uses, - step.Parameters?.ToDictionary( - kvp => kvp.Key, - kvp => new CanonicalParameter(kvp.Value.Value, kvp.Value.Expression, kvp.Value.Error, kvp.Value.RequiresRuntimeValue), - StringComparer.Ordinal), - step.ApprovalId, - step.GateMessage, - step.Children?.Select(ToCanonicalStep).ToList()); - - private sealed record CanonicalPlan( - CanonicalMetadata Metadata, - IDictionary Inputs, - IReadOnlyList Steps, - IReadOnlyList Approvals, - IReadOnlyList Secrets, - IReadOnlyList Outputs, - CanonicalFailurePolicy? FailurePolicy); - - private sealed record CanonicalMetadata(string Name, string Version, string? Description, IReadOnlyList Tags); - - private sealed record CanonicalPlanStep( - string Id, - string TemplateId, - string? Name, - string Type, - bool Enabled, - string? Uses, - IDictionary? Parameters, - string? ApprovalId, - string? GateMessage, - IReadOnlyList? Children); - - private sealed record CanonicalApproval(string Id, IReadOnlyList Grants, string? ExpiresAfter, string? ReasonTemplate); - - private sealed record CanonicalSecret(string Name, string Scope, string? Description); - - private sealed record CanonicalParameter(JsonNode? Value, string? Expression, string? Error, bool RequiresRuntimeValue); - - private sealed record CanonicalOutput( - string Name, - string Type, - CanonicalParameter? Path, - CanonicalParameter? Expression); - - private sealed record CanonicalFailurePolicy(int MaxAttempts, int BackoffSeconds, bool ContinueOnError); - - private static CanonicalOutput ToCanonicalOutput(TaskPackPlanOutput output) - => new( - output.Name, - output.Type, - ToCanonicalParameter(output.Path), - ToCanonicalParameter(output.Expression)); - - private static CanonicalParameter? ToCanonicalParameter(TaskPackPlanParameterValue? value) - => value is null ? null : new CanonicalParameter(value.Value, value.Expression, value.Error, value.RequiresRuntimeValue); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlanInsights.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlanInsights.cs deleted file mode 100644 index c96d1dab6..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlanInsights.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace StellaOps.TaskRunner.Core.Planning; - -public static class TaskPackPlanInsights -{ - public static IReadOnlyList CollectApprovalRequirements(TaskPackPlan plan) - { - ArgumentNullException.ThrowIfNull(plan); - - var approvals = plan.Approvals.ToDictionary(approval => approval.Id, StringComparer.Ordinal); - var builders = new Dictionary(StringComparer.Ordinal); - - void Visit(IReadOnlyList? steps) - { - if (steps is null) - { - return; - } - - foreach (var step in steps) - { - if (string.Equals(step.Type, "gate.approval", StringComparison.Ordinal) && !string.IsNullOrEmpty(step.ApprovalId)) - { - if (!builders.TryGetValue(step.ApprovalId, out var builder)) - { - builder = new ApprovalRequirementBuilder(step.ApprovalId); - builders[step.ApprovalId] = builder; - } - - builder.AddStep(step); - } - - Visit(step.Children); - } - } - - Visit(plan.Steps); - - return builders.Values - .Select(builder => builder.Build(approvals)) - .OrderBy(requirement => requirement.ApprovalId, StringComparer.Ordinal) - .ToList(); - } - - public static IReadOnlyList CollectNotificationHints(TaskPackPlan plan) - { - ArgumentNullException.ThrowIfNull(plan); - - var notifications = new List(); - - void Visit(IReadOnlyList? steps) - { - if (steps is null) - { - return; - } - - foreach (var step in steps) - { - if (string.Equals(step.Type, "gate.approval", StringComparison.Ordinal)) - { - notifications.Add(new TaskPackPlanNotificationHint(step.Id, "approval-request", step.GateMessage, step.ApprovalId)); - } - else if (string.Equals(step.Type, "gate.policy", StringComparison.Ordinal)) - { - notifications.Add(new TaskPackPlanNotificationHint(step.Id, "policy-gate", step.GateMessage, null)); - } - - Visit(step.Children); - } - } - - Visit(plan.Steps); - return notifications; - } - - public static IReadOnlyList CollectPolicyGateHints(TaskPackPlan plan) - { - ArgumentNullException.ThrowIfNull(plan); - - var hints = new List(); - - void Visit(IReadOnlyList? steps) - { - if (steps is null) - { - return; - } - - foreach (var step in steps) - { - if (string.Equals(step.Type, "gate.policy", StringComparison.Ordinal)) - { - var parameters = step.Parameters? - .OrderBy(kvp => kvp.Key, StringComparer.Ordinal) - .Select(kvp => new TaskPackPlanPolicyParameter( - kvp.Key, - kvp.Value.RequiresRuntimeValue, - kvp.Value.Expression, - kvp.Value.Error)) - .ToList() ?? new List(); - - hints.Add(new TaskPackPlanPolicyGateHint(step.Id, step.GateMessage, parameters)); - } - - Visit(step.Children); - } - } - - Visit(plan.Steps); - return hints; - } - - private sealed class ApprovalRequirementBuilder - { - private readonly HashSet stepIds = new(StringComparer.Ordinal); - private readonly List messages = new(); - - public ApprovalRequirementBuilder(string approvalId) - { - ApprovalId = approvalId; - } - - public string ApprovalId { get; } - - public void AddStep(TaskPackPlanStep step) - { - stepIds.Add(step.Id); - if (!string.IsNullOrWhiteSpace(step.GateMessage)) - { - messages.Add(step.GateMessage!); - } - } - - public TaskPackPlanApprovalRequirement Build(IReadOnlyDictionary knownApprovals) - { - knownApprovals.TryGetValue(ApprovalId, out var approval); - - var orderedSteps = stepIds - .OrderBy(id => id, StringComparer.Ordinal) - .ToList(); - - var orderedMessages = messages - .Where(message => !string.IsNullOrWhiteSpace(message)) - .Distinct(StringComparer.Ordinal) - .ToList(); - - return new TaskPackPlanApprovalRequirement( - ApprovalId, - approval?.Grants ?? Array.Empty(), - approval?.ExpiresAfter, - approval?.ReasonTemplate, - orderedSteps, - orderedMessages); - } - } -} - -public sealed record TaskPackPlanApprovalRequirement( - string ApprovalId, - IReadOnlyList Grants, - string? ExpiresAfter, - string? ReasonTemplate, - IReadOnlyList StepIds, - IReadOnlyList Messages); - -public sealed record TaskPackPlanNotificationHint( - string StepId, - string Type, - string? Message, - string? ApprovalId); - -public sealed record TaskPackPlanPolicyGateHint( - string StepId, - string? Message, - IReadOnlyList Parameters); - -public sealed record TaskPackPlanPolicyParameter( - string Name, - bool RequiresRuntimeValue, - string? Expression, - string? Error); diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlanner.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlanner.cs deleted file mode 100644 index c8aaa8a77..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Planning/TaskPackPlanner.cs +++ /dev/null @@ -1,878 +0,0 @@ - -using StellaOps.AirGap.Policy; -using StellaOps.TaskRunner.Core.Expressions; -using StellaOps.TaskRunner.Core.TaskPacks; -using System; -using System.Collections.Immutable; -using System.Globalization; -using System.Linq; -using System.Text.Json.Nodes; - -namespace StellaOps.TaskRunner.Core.Planning; - -public sealed class TaskPackPlanner -{ - private static readonly string[] NetworkParameterHints = { "url", "uri", "endpoint", "host", "registry", "mirror", "address" }; - - private readonly TaskPackManifestValidator validator; - private readonly IEgressPolicy? egressPolicy; - - public TaskPackPlanner(IEgressPolicy? egressPolicy = null) - { - validator = new TaskPackManifestValidator(); - this.egressPolicy = egressPolicy; - } - - public TaskPackPlanResult Plan(TaskPackManifest manifest, IDictionary? providedInputs = null) - { - ArgumentNullException.ThrowIfNull(manifest); - - var errors = ImmutableArray.CreateBuilder(); - ValidateSandboxAndSlo(manifest, errors); - - var validation = validator.Validate(manifest); - if (!validation.IsValid) - { - foreach (var error in validation.Errors) - { - errors.Add(new TaskPackPlanError(error.Path, error.Message)); - } - - return new TaskPackPlanResult(null, errors.ToImmutable()); - } - - var effectiveInputs = MaterializeInputs(manifest.Spec.Inputs, providedInputs, errors); - if (errors.Count > 0) - { - return new TaskPackPlanResult(null, errors.ToImmutable()); - } - - var stepTracker = new HashSet(StringComparer.Ordinal); - var secretTracker = new HashSet(StringComparer.Ordinal); - if (manifest.Spec.Secrets is not null) - { - foreach (var secret in manifest.Spec.Secrets) - { - secretTracker.Add(secret.Name); - } - } - - var context = TaskPackExpressionContext.Create(effectiveInputs, stepTracker, secretTracker); - - var packName = manifest.Metadata.Name; - var packVersion = manifest.Metadata.Version; - - var planSteps = new List(); - var steps = manifest.Spec.Steps; - for (var i = 0; i < steps.Count; i++) - { - var step = steps[i]; - var planStep = BuildStep(packName, packVersion, step, context, $"spec.steps[{i}]", errors); - planSteps.Add(planStep); - } - - if (errors.Count > 0) - { - return new TaskPackPlanResult(null, errors.ToImmutable()); - } - - var metadata = new TaskPackPlanMetadata( - manifest.Metadata.Name, - manifest.Metadata.Version, - manifest.Metadata.Description, - manifest.Metadata.Tags?.ToList() ?? new List()); - - var planApprovals = manifest.Spec.Approvals? - .Select(approval => new TaskPackPlanApproval( - approval.Id, - NormalizeGrants(approval.Grants), - approval.ExpiresAfter, - approval.ReasonTemplate)) - .ToList() ?? new List(); - - var planSecrets = manifest.Spec.Secrets? - .Select(secret => new TaskPackPlanSecret(secret.Name, secret.Scope, secret.Description)) - .ToList() ?? new List(); - - var planOutputs = MaterializeOutputs(manifest.Spec.Outputs, context, errors); - if (errors.Count > 0) - { - return new TaskPackPlanResult(null, errors.ToImmutable()); - } - - var failurePolicy = MaterializeFailurePolicy(manifest.Spec.Failure); - - var hash = TaskPackPlanHasher.ComputeHash(metadata, effectiveInputs, planSteps, planApprovals, planSecrets, planOutputs, failurePolicy); - - var plan = new TaskPackPlan(metadata, effectiveInputs, planSteps, hash, planApprovals, planSecrets, planOutputs, failurePolicy); - return new TaskPackPlanResult(plan, ImmutableArray.Empty); - } - - private static void ValidateSandboxAndSlo(TaskPackManifest manifest, ImmutableArray.Builder errors) - { - // TP6: sandbox quotas must be present. - var sandbox = manifest.Spec.Sandbox; - if (sandbox is null) - { - errors.Add(new TaskPackPlanError("spec.sandbox", "Sandbox settings are required (mode, egressAllowlist, CPU/memory, quotaSeconds).")); - } - else - { - if (string.IsNullOrWhiteSpace(sandbox.Mode)) - { - errors.Add(new TaskPackPlanError("spec.sandbox.mode", "Sandbox mode is required (sealed or restricted).")); - } - - if (sandbox.EgressAllowlist is null) - { - errors.Add(new TaskPackPlanError("spec.sandbox.egressAllowlist", "Egress allowlist must be declared (empty list allowed).")); - } - - if (sandbox.CpuLimitMillicores <= 0) - { - errors.Add(new TaskPackPlanError("spec.sandbox.cpuLimitMillicores", "CPU limit must be > 0.")); - } - - if (sandbox.MemoryLimitMiB <= 0) - { - errors.Add(new TaskPackPlanError("spec.sandbox.memoryLimitMiB", "Memory limit must be > 0.")); - } - - if (sandbox.QuotaSeconds <= 0) - { - errors.Add(new TaskPackPlanError("spec.sandbox.quotaSeconds", "quotaSeconds must be > 0.")); - } - } - - // TP9: SLOs must be declared and positive. - var slo = manifest.Spec.Slo; - if (slo is null) - { - errors.Add(new TaskPackPlanError("spec.slo", "SLO section is required (runP95Seconds, approvalP95Seconds, maxQueueDepth).")); - return; - } - - if (slo.RunP95Seconds <= 0) - { - errors.Add(new TaskPackPlanError("spec.slo.runP95Seconds", "runP95Seconds must be > 0.")); - } - - if (slo.ApprovalP95Seconds <= 0) - { - errors.Add(new TaskPackPlanError("spec.slo.approvalP95Seconds", "approvalP95Seconds must be > 0.")); - } - - if (slo.MaxQueueDepth <= 0) - { - errors.Add(new TaskPackPlanError("spec.slo.maxQueueDepth", "maxQueueDepth must be > 0.")); - } - } - - private Dictionary MaterializeInputs( - IReadOnlyList? definitions, - IDictionary? providedInputs, - ImmutableArray.Builder errors) - { - var effective = new Dictionary(StringComparer.Ordinal); - - if (definitions is not null) - { - foreach (var input in definitions) - { - if ((providedInputs is not null && providedInputs.TryGetValue(input.Name, out var supplied))) - { - effective[input.Name] = supplied?.DeepClone(); - } - else if (input.Default is not null) - { - effective[input.Name] = input.Default.DeepClone(); - } - else if (input.Required) - { - errors.Add(new TaskPackPlanError($"inputs.{input.Name}", "Input is required but was not supplied.")); - } - } - } - - if (providedInputs is not null) - { - foreach (var kvp in providedInputs) - { - if (!effective.ContainsKey(kvp.Key)) - { - effective[kvp.Key] = kvp.Value?.DeepClone(); - } - } - } - - return effective; - } - - private static TaskPackPlanFailurePolicy? MaterializeFailurePolicy(TaskPackFailure? failure) - { - if (failure?.Retries is not TaskPackRetryPolicy retries) - { - return null; - } - - var maxAttempts = retries.MaxAttempts <= 0 ? 1 : retries.MaxAttempts; - var backoffSeconds = retries.BackoffSeconds < 0 ? 0 : retries.BackoffSeconds; - - return new TaskPackPlanFailurePolicy(maxAttempts, backoffSeconds, ContinueOnError: false); - } - - private TaskPackPlanStep BuildStep( - string packName, - string packVersion, - TaskPackStep step, - TaskPackExpressionContext context, - string path, - ImmutableArray.Builder errors) - { - if (!TaskPackExpressions.TryEvaluateBoolean(step.When, context, out var enabled, out var whenError)) - { - errors.Add(new TaskPackPlanError($"{path}.when", whenError ?? "Failed to evaluate 'when' expression.")); - enabled = false; - } - - TaskPackPlanStep planStep; - - if (step.Run is not null) - { - planStep = BuildRunStep(packName, packVersion, step, step.Run, context, path, enabled, errors); - } - else if (step.Gate is not null) - { - planStep = BuildGateStep(step, step.Gate, context, path, enabled, errors); - } - else if (step.Parallel is not null) - { - planStep = BuildParallelStep(packName, packVersion, step, step.Parallel, context, path, enabled, errors); - } - else if (step.Map is not null) - { - planStep = BuildMapStep(packName, packVersion, step, step.Map, context, path, enabled, errors); - } - else if (step.Loop is not null) - { - planStep = BuildLoopStep(packName, packVersion, step, step.Loop, context, path, enabled, errors); - } - else if (step.Conditional is not null) - { - planStep = BuildConditionalStep(packName, packVersion, step, step.Conditional, context, path, enabled, errors); - } - else - { - errors.Add(new TaskPackPlanError(path, "Step did not specify run, gate, parallel, map, loop, or conditional.")); - planStep = new TaskPackPlanStep(step.Id, step.Id, step.Name, "invalid", enabled, null, null, ApprovalId: null, GateMessage: null, Children: null); - } - - context.RegisterStep(step.Id); - return planStep; - } - - private TaskPackPlanStep BuildRunStep( - string packName, - string packVersion, - TaskPackStep step, - TaskPackRunStep run, - TaskPackExpressionContext context, - string path, - bool enabled, - ImmutableArray.Builder errors) - { - var parameters = ResolveParameters(run.With, context, $"{path}.run", errors); - - if (egressPolicy?.IsSealed == true) - { - ValidateRunStepEgress(packName, packVersion, step, run, parameters, path, errors); - } - - return new TaskPackPlanStep( - step.Id, - step.Id, - step.Name, - "run", - enabled, - run.Uses, - parameters, - ApprovalId: null, - GateMessage: null, - Children: null); - } - - private void ValidateRunStepEgress( - string packName, - string packVersion, - TaskPackStep step, - TaskPackRunStep run, - IReadOnlyDictionary? parameters, - string path, - ImmutableArray.Builder errors) - { - if (egressPolicy is null || !egressPolicy.IsSealed) - { - return; - } - - var destinations = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - - void AddDestination(Uri uri) - { - if (seen.Add(uri.ToString())) - { - destinations.Add(uri); - } - } - - if (run.Egress is not null) - { - for (var i = 0; i < run.Egress.Count; i++) - { - var entry = run.Egress[i]; - var entryPath = $"{path}.egress[{i}]"; - if (entry is null) - { - continue; - } - - if (TryParseNetworkUri(entry.Url, out var uri)) - { - AddDestination(uri); - } - else - { - errors.Add(new TaskPackPlanError($"{entryPath}.url", "Egress URL must be an absolute HTTP or HTTPS address.")); - } - } - } - - var requiresRuntimeNetwork = false; - - if (parameters is not null) - { - foreach (var parameter in parameters) - { - var value = parameter.Value; - if (value.Value is JsonValue jsonValue && jsonValue.TryGetValue(out var literal) && TryParseNetworkUri(literal, out var uri)) - { - AddDestination(uri); - } - else if (value.RequiresRuntimeValue && MightBeNetworkParameter(parameter.Key)) - { - requiresRuntimeNetwork = true; - } - } - } - - if (destinations.Count == 0) - { - if (requiresRuntimeNetwork && (run.Egress is null || run.Egress.Count == 0)) - { - errors.Add(new TaskPackPlanError(path, $"Step '{step.Id}' references runtime network parameters while sealed mode is enabled. Declare explicit run.egress URLs or remove external calls.")); - } - - return; - } - - foreach (var destination in destinations) - { - try - { - var request = new EgressRequest( - component: "TaskRunner", - destination: destination, - intent: $"taskpack:{packName}@{packVersion}:{step.Id}", - transport: DetermineTransport(destination), - operation: run.Uses); - - egressPolicy.EnsureAllowed(request); - } - catch (AirGapEgressBlockedException blocked) - { - var remediation = blocked.Remediation; - errors.Add(new TaskPackPlanError( - path, - $"Step '{step.Id}' attempted to reach '{destination}' in sealed mode and was blocked. Reason: {blocked.Reason}. Remediation: {remediation}")); - } - } - } - - private static bool TryParseNetworkUri(string? value, out Uri uri) - { - uri = default!; - if (string.IsNullOrWhiteSpace(value)) - { - return false; - } - - if (!Uri.TryCreate(value, UriKind.Absolute, out var parsed)) - { - return false; - } - - if (!IsNetworkScheme(parsed)) - { - return false; - } - - uri = parsed; - return true; - } - - private static bool IsNetworkScheme(Uri uri) - => string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) - || string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase); - - private static bool MightBeNetworkParameter(string name) - { - if (string.IsNullOrWhiteSpace(name)) - { - return false; - } - - foreach (var hint in NetworkParameterHints) - { - if (name.Contains(hint, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } - - private static EgressTransport DetermineTransport(Uri destination) - => string.Equals(destination.Scheme, "https", StringComparison.OrdinalIgnoreCase) - ? EgressTransport.Https - : string.Equals(destination.Scheme, "http", StringComparison.OrdinalIgnoreCase) - ? EgressTransport.Http - : EgressTransport.Any; - - private static IReadOnlyList NormalizeGrants(IReadOnlyList? grants) - { - if (grants is null || grants.Count == 0) - { - return Array.Empty(); - } - - var normalized = new List(grants.Count); - - foreach (var grant in grants) - { - if (string.IsNullOrWhiteSpace(grant)) - { - continue; - } - - var segments = grant - .Split('.', StringSplitOptions.RemoveEmptyEntries) - .Select(segment => - { - var trimmed = segment.Trim(); - if (trimmed.Length == 0) - { - return string.Empty; - } - - if (trimmed.Length == 1) - { - return trimmed.ToUpperInvariant(); - } - - var first = char.ToUpperInvariant(trimmed[0]); - var rest = trimmed[1..].ToLowerInvariant(); - return string.Concat(first, rest); - }) - .Where(segment => segment.Length > 0) - .ToArray(); - - if (segments.Length == 0) - { - continue; - } - - normalized.Add(string.Join('.', segments)); - } - - return normalized.Count == 0 - ? Array.Empty() - : normalized; - } - - private TaskPackPlanStep BuildGateStep( - TaskPackStep step, - TaskPackGateStep gate, - TaskPackExpressionContext context, - string path, - bool enabled, - ImmutableArray.Builder errors) - { - string type; - string? approvalId = null; - IReadOnlyDictionary? parameters = null; - - if (gate.Approval is not null) - { - type = "gate.approval"; - approvalId = gate.Approval.Id; - } - else if (gate.Policy is not null) - { - type = "gate.policy"; - var resolvedParams = ResolveParameters(gate.Policy.Parameters, context, $"{path}.gate.policy", errors); - var policyParams = new Dictionary( - resolvedParams ?? new Dictionary(), - StringComparer.Ordinal); - // Store the policy ID in parameters for downstream config extraction - policyParams["policyId"] = new TaskPackPlanParameterValue(JsonValue.Create(gate.Policy.Policy), null, null, false); - parameters = policyParams; - } - else - { - type = "gate"; - errors.Add(new TaskPackPlanError($"{path}.gate", "Gate must specify approval or policy.")); - } - - return new TaskPackPlanStep( - step.Id, - step.Id, - step.Name, - type, - enabled, - Uses: null, - parameters, - ApprovalId: approvalId, - GateMessage: gate.Message, - Children: null); - } - - private TaskPackPlanStep BuildParallelStep( - string packName, - string packVersion, - TaskPackStep step, - TaskPackParallelStep parallel, - TaskPackExpressionContext context, - string path, - bool enabled, - ImmutableArray.Builder errors) - { - var children = new List(); - for (var i = 0; i < parallel.Steps.Count; i++) - { - var child = BuildStep(packName, packVersion, parallel.Steps[i], context, $"{path}.parallel.steps[{i}]", errors); - children.Add(child); - } - - var parameters = new Dictionary(StringComparer.Ordinal); - if (parallel.MaxParallel.HasValue) - { - parameters["maxParallel"] = new TaskPackPlanParameterValue(JsonValue.Create(parallel.MaxParallel.Value), null, null, false); - } - - parameters["continueOnError"] = new TaskPackPlanParameterValue(JsonValue.Create(parallel.ContinueOnError), null, null, false); - - return new TaskPackPlanStep( - step.Id, - step.Id, - step.Name, - "parallel", - enabled, - Uses: null, - parameters, - ApprovalId: null, - GateMessage: null, - Children: children); - } - - private TaskPackPlanStep BuildMapStep( - string packName, - string packVersion, - TaskPackStep step, - TaskPackMapStep map, - TaskPackExpressionContext context, - string path, - bool enabled, - ImmutableArray.Builder errors) - { - var parameters = new Dictionary(StringComparer.Ordinal); - var itemsResolution = TaskPackExpressions.EvaluateString(map.Items, context); - JsonArray? itemsArray = null; - - if (!itemsResolution.Resolved) - { - if (itemsResolution.Error is not null) - { - errors.Add(new TaskPackPlanError($"{path}.map.items", itemsResolution.Error)); - } - else - { - errors.Add(new TaskPackPlanError($"{path}.map.items", "Map items expression requires runtime evaluation. Packs must provide deterministic item lists at plan time.")); - } - } - else if (itemsResolution.Value is JsonArray array) - { - itemsArray = (JsonArray?)array.DeepClone(); - } - else - { - errors.Add(new TaskPackPlanError($"{path}.map.items", "Map items expression must resolve to an array.")); - } - - if (itemsArray is not null) - { - parameters["items"] = new TaskPackPlanParameterValue(itemsArray, null, null, false); - parameters["iterationCount"] = new TaskPackPlanParameterValue(JsonValue.Create(itemsArray.Count), null, null, false); - } - else - { - parameters["items"] = new TaskPackPlanParameterValue(null, map.Items, "Map items expression could not be resolved.", true); - } - - var children = new List(); - if (itemsArray is not null) - { - for (var i = 0; i < itemsArray.Count; i++) - { - var item = itemsArray[i]; - var iterationContext = context.WithItem(item); - var iterationPath = $"{path}.map.step[{i}]"; - var templateStep = BuildStep(packName, packVersion, map.Step, iterationContext, iterationPath, errors); - - var childId = $"{step.Id}[{i}]::{map.Step.Id}"; - var iterationParameters = templateStep.Parameters is null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(templateStep.Parameters); - - iterationParameters["item"] = new TaskPackPlanParameterValue(item?.DeepClone(), null, null, false); - - var iterationStep = templateStep with - { - Id = childId, - TemplateId = map.Step.Id, - Parameters = iterationParameters - }; - - children.Add(iterationStep); - } - } - - return new TaskPackPlanStep( - step.Id, - step.Id, - step.Name, - "map", - enabled, - Uses: null, - parameters, - ApprovalId: null, - GateMessage: null, - Children: children); - } - - private TaskPackPlanStep BuildLoopStep( - string packName, - string packVersion, - TaskPackStep step, - TaskPackLoopStep loop, - TaskPackExpressionContext context, - string path, - bool enabled, - ImmutableArray.Builder errors) - { - var parameters = new Dictionary(StringComparer.Ordinal); - - // Store loop configuration parameters - if (!string.IsNullOrWhiteSpace(loop.Items)) - { - parameters["items"] = new TaskPackPlanParameterValue(null, loop.Items, null, true); - } - - if (loop.Range is not null) - { - var rangeObj = new JsonObject - { - ["start"] = loop.Range.Start, - ["end"] = loop.Range.End, - ["step"] = loop.Range.Step - }; - parameters["range"] = new TaskPackPlanParameterValue(rangeObj, null, null, false); - } - - if (loop.StaticItems is not null) - { - var staticArray = new JsonArray(); - foreach (var item in loop.StaticItems) - { - staticArray.Add(JsonValue.Create(item?.ToString())); - } - parameters["staticItems"] = new TaskPackPlanParameterValue(staticArray, null, null, false); - } - - parameters["iterator"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.Iterator), null, null, false); - parameters["index"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.Index), null, null, false); - parameters["maxIterations"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.MaxIterations), null, null, false); - - if (!string.IsNullOrWhiteSpace(loop.Aggregation)) - { - parameters["aggregation"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.Aggregation), null, null, false); - } - - if (!string.IsNullOrWhiteSpace(loop.OutputPath)) - { - parameters["outputPath"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.OutputPath), null, null, false); - } - - // Build child steps (the loop body) - var children = new List(); - for (var i = 0; i < loop.Steps.Count; i++) - { - var child = BuildStep(packName, packVersion, loop.Steps[i], context, $"{path}.loop.steps[{i}]", errors); - children.Add(child); - } - - return new TaskPackPlanStep( - step.Id, - step.Id, - step.Name, - "loop", - enabled, - Uses: null, - parameters, - ApprovalId: null, - GateMessage: null, - Children: children); - } - - private TaskPackPlanStep BuildConditionalStep( - string packName, - string packVersion, - TaskPackStep step, - TaskPackConditionalStep conditional, - TaskPackExpressionContext context, - string path, - bool enabled, - ImmutableArray.Builder errors) - { - var parameters = new Dictionary(StringComparer.Ordinal); - - // Store branch conditions as metadata - var branchesArray = new JsonArray(); - foreach (var branch in conditional.Branches) - { - branchesArray.Add(new JsonObject - { - ["condition"] = branch.Condition, - ["stepCount"] = branch.Steps.Count - }); - } - parameters["branches"] = new TaskPackPlanParameterValue(branchesArray, null, null, false); - parameters["outputUnion"] = new TaskPackPlanParameterValue(JsonValue.Create(conditional.OutputUnion), null, null, false); - - // Build all branch bodies and else branch as children - var children = new List(); - for (var branchIdx = 0; branchIdx < conditional.Branches.Count; branchIdx++) - { - var branch = conditional.Branches[branchIdx]; - for (var stepIdx = 0; stepIdx < branch.Steps.Count; stepIdx++) - { - var child = BuildStep(packName, packVersion, branch.Steps[stepIdx], context, $"{path}.conditional.branches[{branchIdx}].steps[{stepIdx}]", errors); - children.Add(child); - } - } - - if (conditional.Else is not null) - { - for (var i = 0; i < conditional.Else.Count; i++) - { - var child = BuildStep(packName, packVersion, conditional.Else[i], context, $"{path}.conditional.else[{i}]", errors); - children.Add(child); - } - } - - return new TaskPackPlanStep( - step.Id, - step.Id, - step.Name, - "conditional", - enabled, - Uses: null, - parameters, - ApprovalId: null, - GateMessage: null, - Children: children); - } - - private IReadOnlyDictionary? ResolveParameters( - IDictionary? rawParameters, - TaskPackExpressionContext context, - string path, - ImmutableArray.Builder errors) - { - if (rawParameters is null || rawParameters.Count == 0) - { - return null; - } - - var resolved = new Dictionary(StringComparer.Ordinal); - foreach (var (key, value) in rawParameters) - { - var evaluation = TaskPackExpressions.EvaluateValue(value, context); - if (!evaluation.Resolved && evaluation.Error is not null) - { - errors.Add(new TaskPackPlanError($"{path}.with.{key}", evaluation.Error)); - } - - resolved[key] = TaskPackPlanParameterValue.FromResolution(evaluation); - } - - return resolved; - } - - private IReadOnlyList MaterializeOutputs( - IReadOnlyList? outputs, - TaskPackExpressionContext context, - ImmutableArray.Builder errors) - { - if (outputs is null || outputs.Count == 0) - { - return Array.Empty(); - } - - var results = new List(outputs.Count); - foreach (var (output, index) in outputs.Select((output, index) => (output, index))) - { - var pathValue = ConvertString(output.Path, context, $"spec.outputs[{index}].path", errors); - var expressionValue = ConvertString(output.Expression, context, $"spec.outputs[{index}].expression", errors); - - results.Add(new TaskPackPlanOutput( - output.Name, - output.Type, - pathValue, - expressionValue)); - } - - return results; - } - - private TaskPackPlanParameterValue? ConvertString( - string? value, - TaskPackExpressionContext context, - string path, - ImmutableArray.Builder errors) - { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } - - var resolution = TaskPackExpressions.EvaluateString(value, context); - if (!resolution.Resolved && resolution.Error is not null) - { - errors.Add(new TaskPackPlanError(path, resolution.Error)); - } - - return TaskPackPlanParameterValue.FromResolution(resolution); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Serialization/CanonicalJson.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Serialization/CanonicalJson.cs deleted file mode 100644 index c4a83129f..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Serialization/CanonicalJson.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Linq; -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace StellaOps.TaskRunner.Core.Serialization; - -internal static class CanonicalJson -{ - private static readonly JsonSerializerOptions SerializerOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - WriteIndented = false - }; - - public static string Serialize(T value) - { - var node = JsonSerializer.SerializeToNode(value, SerializerOptions); - if (node is null) - { - throw new InvalidOperationException("Unable to serialize value to JSON node."); - } - - var canonical = Canonicalize(node); - return canonical.ToJsonString(SerializerOptions); - } - - public static JsonNode Canonicalize(JsonNode node) - { - return node switch - { - JsonObject obj => CanonicalizeObject(obj), - JsonArray array => CanonicalizeArray(array), - _ => node.DeepClone() - }; - } - - private static JsonObject CanonicalizeObject(JsonObject obj) - { - var canonical = new JsonObject(); - foreach (var property in obj.OrderBy(static p => p.Key, StringComparer.Ordinal)) - { - if (property.Value is null) - { - canonical[property.Key] = null; - } - else - { - canonical[property.Key] = Canonicalize(property.Value); - } - } - - return canonical; - } - - private static JsonArray CanonicalizeArray(JsonArray array) - { - var canonical = new JsonArray(); - foreach (var element in array) - { - canonical.Add(element is null ? null : Canonicalize(element)); - } - - return canonical; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/StellaOps.TaskRunner.Core.csproj b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/StellaOps.TaskRunner.Core.csproj deleted file mode 100644 index c78845f62..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/StellaOps.TaskRunner.Core.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - net10.0 - enable - enable - preview - true - - - - - - - - - - - - - diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TASKS.md b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TASKS.md deleted file mode 100644 index 4a555b907..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TASKS.md +++ /dev/null @@ -1,8 +0,0 @@ -# StellaOps.TaskRunner.Core Task Board -This board mirrors active sprint tasks for this module. -Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`. - -| Task ID | Status | Notes | -| --- | --- | --- | -| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/StellaOps.TaskRunner.Core.md. | -| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TaskPacks/TaskPackManifest.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TaskPacks/TaskPackManifest.cs deleted file mode 100644 index b10789198..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TaskPacks/TaskPackManifest.cs +++ /dev/null @@ -1,384 +0,0 @@ - -using StellaOps.TaskRunner.Core.AirGap; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; - -namespace StellaOps.TaskRunner.Core.TaskPacks; - -public sealed class TaskPackManifest -{ - [JsonPropertyName("apiVersion")] - public required string ApiVersion { get; init; } - - [JsonPropertyName("kind")] - public required string Kind { get; init; } - - [JsonPropertyName("metadata")] - public required TaskPackMetadata Metadata { get; init; } - - [JsonPropertyName("spec")] - public required TaskPackSpec Spec { get; init; } -} - -public sealed class TaskPackMetadata -{ - [JsonPropertyName("name")] - public required string Name { get; init; } - - [JsonPropertyName("version")] - public required string Version { get; init; } - - [JsonPropertyName("description")] - public string? Description { get; init; } - - [JsonPropertyName("tags")] - public IReadOnlyList? Tags { get; init; } - - [JsonPropertyName("tenantVisibility")] - public IReadOnlyList? TenantVisibility { get; init; } - - [JsonPropertyName("maintainers")] - public IReadOnlyList? Maintainers { get; init; } - - [JsonPropertyName("license")] - public string? License { get; init; } - - [JsonPropertyName("annotations")] - public IReadOnlyDictionary? Annotations { get; init; } -} - -public sealed class TaskPackMaintainer -{ - [JsonPropertyName("name")] - public required string Name { get; init; } - - [JsonPropertyName("email")] - public string? Email { get; init; } -} - -public sealed class TaskPackSpec -{ - [JsonPropertyName("inputs")] - public IReadOnlyList? Inputs { get; init; } - - [JsonPropertyName("secrets")] - public IReadOnlyList? Secrets { get; init; } - - [JsonPropertyName("approvals")] - public IReadOnlyList? Approvals { get; init; } - - [JsonPropertyName("steps")] - public IReadOnlyList Steps { get; init; } = Array.Empty(); - - [JsonPropertyName("outputs")] - public IReadOnlyList? Outputs { get; init; } - - [JsonPropertyName("success")] - public TaskPackSuccess? Success { get; init; } - - [JsonPropertyName("failure")] - public TaskPackFailure? Failure { get; init; } - - [JsonPropertyName("sandbox")] - public TaskPackSandbox? Sandbox { get; init; } - - [JsonPropertyName("slo")] - public TaskPackSlo? Slo { get; init; } - - /// - /// Whether this pack requires a sealed (air-gapped) environment. - /// - [JsonPropertyName("sealedInstall")] - public bool SealedInstall { get; init; } - - /// - /// Specific requirements for sealed install mode. - /// - [JsonPropertyName("sealedRequirements")] - public SealedRequirements? SealedRequirements { get; init; } -} - -public sealed class TaskPackInput -{ - [JsonPropertyName("name")] - public required string Name { get; init; } - - [JsonPropertyName("type")] - public required string Type { get; init; } - - [JsonPropertyName("schema")] - public string? Schema { get; init; } - - [JsonPropertyName("required")] - public bool Required { get; init; } - - [JsonPropertyName("description")] - public string? Description { get; init; } - - [JsonPropertyName("default")] - public JsonNode? Default { get; init; } -} - -public sealed class TaskPackSecret -{ - [JsonPropertyName("name")] - public required string Name { get; init; } - - [JsonPropertyName("scope")] - public required string Scope { get; init; } - - [JsonPropertyName("description")] - public string? Description { get; init; } -} - -public sealed class TaskPackApproval -{ - [JsonPropertyName("id")] - public required string Id { get; init; } - - [JsonPropertyName("grants")] - public IReadOnlyList Grants { get; init; } = Array.Empty(); - - [JsonPropertyName("expiresAfter")] - public string? ExpiresAfter { get; init; } - - [JsonPropertyName("reasonTemplate")] - public string? ReasonTemplate { get; init; } -} - -public sealed class TaskPackStep -{ - [JsonPropertyName("id")] - public required string Id { get; init; } - - [JsonPropertyName("name")] - public string? Name { get; init; } - - [JsonPropertyName("when")] - public string? When { get; init; } - - [JsonPropertyName("run")] - public TaskPackRunStep? Run { get; init; } - - [JsonPropertyName("gate")] - public TaskPackGateStep? Gate { get; init; } - - [JsonPropertyName("parallel")] - public TaskPackParallelStep? Parallel { get; init; } - - [JsonPropertyName("map")] - public TaskPackMapStep? Map { get; init; } - - [JsonPropertyName("loop")] - public TaskPackLoopStep? Loop { get; init; } - - [JsonPropertyName("conditional")] - public TaskPackConditionalStep? Conditional { get; init; } -} - -public sealed class TaskPackRunStep -{ - [JsonPropertyName("uses")] - public required string Uses { get; init; } - - [JsonPropertyName("with")] - public IDictionary? With { get; init; } - - [JsonPropertyName("egress")] - public IReadOnlyList? Egress { get; init; } -} - -public sealed class TaskPackRunEgress -{ - [JsonPropertyName("url")] - public required string Url { get; init; } - - [JsonPropertyName("intent")] - public string? Intent { get; init; } - - [JsonPropertyName("description")] - public string? Description { get; init; } -} - -public sealed class TaskPackGateStep -{ - [JsonPropertyName("approval")] - public TaskPackApprovalGate? Approval { get; init; } - - [JsonPropertyName("policy")] - public TaskPackPolicyGate? Policy { get; init; } - - [JsonPropertyName("message")] - public string? Message { get; init; } -} - -public sealed class TaskPackApprovalGate -{ - [JsonPropertyName("id")] - public required string Id { get; init; } - - [JsonPropertyName("autoExpireAfter")] - public string? AutoExpireAfter { get; init; } -} - -public sealed class TaskPackPolicyGate -{ - [JsonPropertyName("policy")] - public required string Policy { get; init; } - - [JsonPropertyName("parameters")] - public IDictionary? Parameters { get; init; } -} - -public sealed class TaskPackParallelStep -{ - [JsonPropertyName("steps")] - public IReadOnlyList Steps { get; init; } = Array.Empty(); - - [JsonPropertyName("maxParallel")] - public int? MaxParallel { get; init; } - - [JsonPropertyName("continueOnError")] - public bool ContinueOnError { get; init; } -} - -public sealed class TaskPackMapStep -{ - [JsonPropertyName("items")] - public required string Items { get; init; } - - [JsonPropertyName("step")] - public required TaskPackStep Step { get; init; } -} - -public sealed class TaskPackLoopStep -{ - [JsonPropertyName("items")] - public string? Items { get; init; } - - [JsonPropertyName("range")] - public TaskPackLoopRange? Range { get; init; } - - [JsonPropertyName("staticItems")] - public IReadOnlyList? StaticItems { get; init; } - - [JsonPropertyName("iterator")] - public string Iterator { get; init; } = "item"; - - [JsonPropertyName("index")] - public string Index { get; init; } = "index"; - - [JsonPropertyName("maxIterations")] - public int MaxIterations { get; init; } = 1000; - - [JsonPropertyName("aggregation")] - public string? Aggregation { get; init; } - - [JsonPropertyName("outputPath")] - public string? OutputPath { get; init; } - - [JsonPropertyName("steps")] - public IReadOnlyList Steps { get; init; } = Array.Empty(); -} - -public sealed class TaskPackLoopRange -{ - [JsonPropertyName("start")] - public int Start { get; init; } - - [JsonPropertyName("end")] - public int End { get; init; } - - [JsonPropertyName("step")] - public int Step { get; init; } = 1; -} - -public sealed class TaskPackConditionalStep -{ - [JsonPropertyName("branches")] - public IReadOnlyList Branches { get; init; } = Array.Empty(); - - [JsonPropertyName("else")] - public IReadOnlyList? Else { get; init; } - - [JsonPropertyName("outputUnion")] - public bool OutputUnion { get; init; } -} - -public sealed class TaskPackConditionalBranch -{ - [JsonPropertyName("condition")] - public required string Condition { get; init; } - - [JsonPropertyName("steps")] - public IReadOnlyList Steps { get; init; } = Array.Empty(); -} - -public sealed class TaskPackOutput -{ - [JsonPropertyName("name")] - public required string Name { get; init; } - - [JsonPropertyName("type")] - public required string Type { get; init; } - - [JsonPropertyName("path")] - public string? Path { get; init; } - - [JsonPropertyName("expression")] - public string? Expression { get; init; } -} - -public sealed class TaskPackSuccess -{ - [JsonPropertyName("message")] - public string? Message { get; init; } -} - -public sealed class TaskPackFailure -{ - [JsonPropertyName("message")] - public string? Message { get; init; } - - [JsonPropertyName("retries")] - public TaskPackRetryPolicy? Retries { get; init; } -} - -public sealed class TaskPackRetryPolicy -{ - [JsonPropertyName("maxAttempts")] - public int MaxAttempts { get; init; } - - [JsonPropertyName("backoffSeconds")] - public int BackoffSeconds { get; init; } -} - -public sealed class TaskPackSandbox -{ - [JsonPropertyName("mode")] - public string? Mode { get; init; } - - [JsonPropertyName("egressAllowlist")] - public IReadOnlyList? EgressAllowlist { get; init; } - - [JsonPropertyName("cpuLimitMillicores")] - public int CpuLimitMillicores { get; init; } - - [JsonPropertyName("memoryLimitMiB")] - public int MemoryLimitMiB { get; init; } - - [JsonPropertyName("quotaSeconds")] - public int QuotaSeconds { get; init; } -} - -public sealed class TaskPackSlo -{ - [JsonPropertyName("runP95Seconds")] - public int RunP95Seconds { get; init; } - - [JsonPropertyName("approvalP95Seconds")] - public int ApprovalP95Seconds { get; init; } - - [JsonPropertyName("maxQueueDepth")] - public int MaxQueueDepth { get; init; } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TaskPacks/TaskPackManifestLoader.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TaskPacks/TaskPackManifestLoader.cs deleted file mode 100644 index 3b8c78e73..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TaskPacks/TaskPackManifestLoader.cs +++ /dev/null @@ -1,169 +0,0 @@ - -using System.Collections; -using System.Globalization; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - -namespace StellaOps.TaskRunner.Core.TaskPacks; - -public sealed class TaskPackManifestLoader -{ - private static readonly JsonSerializerOptions SerializerOptions = new() - { - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - public async Task LoadAsync(Stream stream, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(stream); - - using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true); - var yaml = await reader.ReadToEndAsync().ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - - return Deserialize(yaml); - } - - public TaskPackManifest Load(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentException("Path must not be empty.", nameof(path)); - } - - using var stream = File.OpenRead(path); - return LoadAsync(stream).GetAwaiter().GetResult(); - } - - public TaskPackManifest Deserialize(string yaml) - { - if (string.IsNullOrWhiteSpace(yaml)) - { - throw new TaskPackManifestLoadException("Manifest is empty."); - } - - try - { - var deserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); - - using var reader = new StringReader(yaml); - var yamlObject = deserializer.Deserialize(reader); - if (yamlObject is null) - { - throw new TaskPackManifestLoadException("Manifest is empty."); - } - - var node = ConvertToJsonNode(yamlObject); - if (node is null) - { - throw new TaskPackManifestLoadException("Manifest is empty."); - } - - var manifest = node.Deserialize(SerializerOptions); - if (manifest is null) - { - throw new TaskPackManifestLoadException("Unable to deserialize manifest."); - } - - return manifest; - } - catch (TaskPackManifestLoadException) - { - throw; - } - catch (Exception ex) - { - throw new TaskPackManifestLoadException(string.Format(CultureInfo.InvariantCulture, "Failed to parse manifest: {0}", ex.Message), ex); - } - } - - private static JsonNode? ConvertToJsonNode(object? value) - { - switch (value) - { - case null: - return null; - case string s: - if (bool.TryParse(s, out var boolValue)) - { - return JsonValue.Create(boolValue); - } - - if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue)) - { - return JsonValue.Create(longValue); - } - - if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue)) - { - return JsonValue.Create(doubleValue); - } - - return JsonValue.Create(s); - case bool b: - return JsonValue.Create(b); - case int i: - return JsonValue.Create(i); - case long l: - return JsonValue.Create(l); - case double d: - return JsonValue.Create(d); - case float f: - return JsonValue.Create(f); - case decimal dec: - return JsonValue.Create(dec); - case IDictionary dictionary: - { - var obj = new JsonObject(); - foreach (var kvp in dictionary) - { - var key = Convert.ToString(kvp.Key, CultureInfo.InvariantCulture); - if (string.IsNullOrEmpty(key)) - { - continue; - } - - obj[key] = ConvertToJsonNode(kvp.Value); - } - - return obj; - } - case IEnumerable enumerable: - { - var array = new JsonArray(); - foreach (var item in enumerable) - { - array.Add(ConvertToJsonNode(item)); - } - - return array; - } - default: - return JsonValue.Create(value.ToString()); - } - } -} - -public sealed class TaskPackManifestLoadException : Exception -{ - public TaskPackManifestLoadException(string message) - : base(message) - { - } - - public TaskPackManifestLoadException(string message, Exception innerException) - : base(message, innerException) - { - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TaskPacks/TaskPackManifestValidator.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TaskPacks/TaskPackManifestValidator.cs deleted file mode 100644 index 30fd761e0..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/TaskPacks/TaskPackManifestValidator.cs +++ /dev/null @@ -1,351 +0,0 @@ - -using System; -using System.Collections.Immutable; -using System.Linq; -using System.Text.RegularExpressions; - -namespace StellaOps.TaskRunner.Core.TaskPacks; - -public sealed class TaskPackManifestValidator -{ - private static readonly Regex NameRegex = new("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex VersionRegex = new("^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z-.]+)?$", RegexOptions.Compiled | RegexOptions.CultureInvariant); - - public TaskPackManifestValidationResult Validate(TaskPackManifest manifest) - { - ArgumentNullException.ThrowIfNull(manifest); - - var errors = new List(); - - if (!string.Equals(manifest.ApiVersion, "stellaops.io/pack.v1", StringComparison.Ordinal)) - { - errors.Add(new TaskPackManifestValidationError("apiVersion", "Only apiVersion 'stellaops.io/pack.v1' is supported.")); - } - - if (!string.Equals(manifest.Kind, "TaskPack", StringComparison.Ordinal)) - { - errors.Add(new TaskPackManifestValidationError("kind", "Kind must be 'TaskPack'.")); - } - - ValidateMetadata(manifest.Metadata, errors); - ValidateSpec(manifest.Spec, errors); - - return new TaskPackManifestValidationResult(errors.ToImmutableArray()); - } - - private static void ValidateMetadata(TaskPackMetadata metadata, ICollection errors) - { - if (string.IsNullOrWhiteSpace(metadata.Name)) - { - errors.Add(new TaskPackManifestValidationError("metadata.name", "Name is required.")); - } - else if (!NameRegex.IsMatch(metadata.Name)) - { - errors.Add(new TaskPackManifestValidationError("metadata.name", "Name must follow DNS-1123 naming (lowercase alphanumeric plus '-').")); - } - - if (string.IsNullOrWhiteSpace(metadata.Version)) - { - errors.Add(new TaskPackManifestValidationError("metadata.version", "Version is required.")); - } - else if (!VersionRegex.IsMatch(metadata.Version)) - { - errors.Add(new TaskPackManifestValidationError("metadata.version", "Version must follow SemVer (major.minor.patch[+/-metadata]).")); - } - } - - private static void ValidateSpec(TaskPackSpec spec, ICollection errors) - { - if (spec.Steps is null || spec.Steps.Count == 0) - { - errors.Add(new TaskPackManifestValidationError("spec.steps", "At least one step is required.")); - return; - } - - var stepIds = new HashSet(StringComparer.Ordinal); - var approvalIds = new HashSet(StringComparer.Ordinal); - - if (spec.Approvals is not null) - { - foreach (var approval in spec.Approvals) - { - if (!approvalIds.Add(approval.Id)) - { - errors.Add(new TaskPackManifestValidationError($"spec.approvals[{approval.Id}]", "Duplicate approval id.")); - } - } - } - - ValidateInputs(spec, errors); - - ValidateSteps(spec.Steps, "spec.steps", stepIds, approvalIds, errors); - } - - private static void ValidateInputs(TaskPackSpec spec, ICollection errors) - { - if (spec.Inputs is null) - { - return; - } - - var seen = new HashSet(StringComparer.Ordinal); - - foreach (var (input, index) in spec.Inputs.Select((input, index) => (input, index))) - { - var prefix = $"spec.inputs[{index}]"; - - if (!seen.Add(input.Name)) - { - errors.Add(new TaskPackManifestValidationError($"{prefix}.name", "Duplicate input name.")); - } - - if (string.IsNullOrWhiteSpace(input.Type)) - { - errors.Add(new TaskPackManifestValidationError($"{prefix}.type", "Input type is required.")); - } - } - } - - private static void ValidateSteps( - IReadOnlyList steps, - string pathPrefix, - HashSet stepIds, - HashSet approvalIds, - ICollection errors) - { - foreach (var (step, index) in steps.Select((step, index) => (step, index))) - { - var path = $"{pathPrefix}[{index}]"; - - if (!stepIds.Add(step.Id)) - { - errors.Add(new TaskPackManifestValidationError($"{path}.id", "Duplicate step id.")); - } - - var typeCount = (step.Run is not null ? 1 : 0) - + (step.Gate is not null ? 1 : 0) - + (step.Parallel is not null ? 1 : 0) - + (step.Map is not null ? 1 : 0) - + (step.Loop is not null ? 1 : 0) - + (step.Conditional is not null ? 1 : 0); - - if (typeCount == 0) - { - errors.Add(new TaskPackManifestValidationError(path, "Step must define one of run, gate, parallel, map, loop, or conditional.")); - } - else if (typeCount > 1) - { - errors.Add(new TaskPackManifestValidationError(path, "Step may define only one of run, gate, parallel, map, loop, or conditional.")); - } - - if (step.Run is not null) - { - ValidateRunStep(step.Run, $"{path}.run", errors); - } - - if (step.Gate is not null) - { - ValidateGateStep(step.Gate, approvalIds, $"{path}.gate", errors); - } - - if (step.Parallel is not null) - { - ValidateParallelStep(step.Parallel, $"{path}.parallel", stepIds, approvalIds, errors); - } - - if (step.Map is not null) - { - ValidateMapStep(step.Map, $"{path}.map", stepIds, approvalIds, errors); - } - - if (step.Loop is not null) - { - ValidateLoopStep(step.Loop, $"{path}.loop", stepIds, approvalIds, errors); - } - - if (step.Conditional is not null) - { - ValidateConditionalStep(step.Conditional, $"{path}.conditional", stepIds, approvalIds, errors); - } - } - } - - private static void ValidateRunStep(TaskPackRunStep run, string path, ICollection errors) - { - if (string.IsNullOrWhiteSpace(run.Uses)) - { - errors.Add(new TaskPackManifestValidationError($"{path}.uses", "Run step requires 'uses'.")); - } - - if (run.Egress is not null) - { - for (var i = 0; i < run.Egress.Count; i++) - { - var entry = run.Egress[i]; - var entryPath = $"{path}.egress[{i}]"; - - if (entry is null) - { - errors.Add(new TaskPackManifestValidationError(entryPath, "Egress entry must be specified.")); - continue; - } - - if (string.IsNullOrWhiteSpace(entry.Url)) - { - errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress entry requires an absolute URL.")); - } - else if (!Uri.TryCreate(entry.Url, UriKind.Absolute, out var uri) || - (!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) && - !string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase))) - { - errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress URL must be an absolute HTTP or HTTPS address.")); - } - - if (entry.Intent is not null && string.IsNullOrWhiteSpace(entry.Intent)) - { - errors.Add(new TaskPackManifestValidationError($"{entryPath}.intent", "Intent must be omitted or non-empty.")); - } - } - } - } - - private static void ValidateGateStep(TaskPackGateStep gate, HashSet approvalIds, string path, ICollection errors) - { - if (gate.Approval is null && gate.Policy is null) - { - errors.Add(new TaskPackManifestValidationError(path, "Gate step requires 'approval' or 'policy'.")); - return; - } - - if (gate.Approval is not null) - { - if (!approvalIds.Contains(gate.Approval.Id)) - { - errors.Add(new TaskPackManifestValidationError($"{path}.approval.id", $"Approval '{gate.Approval.Id}' is not declared under spec.approvals.")); - } - } - } - - private static void ValidateParallelStep( - TaskPackParallelStep parallel, - string path, - HashSet stepIds, - HashSet approvalIds, - ICollection errors) - { - if (parallel.Steps.Count == 0) - { - errors.Add(new TaskPackManifestValidationError($"{path}.steps", "Parallel step requires nested steps.")); - return; - } - - ValidateSteps(parallel.Steps, $"{path}.steps", stepIds, approvalIds, errors); - } - - private static void ValidateMapStep( - TaskPackMapStep map, - string path, - HashSet stepIds, - HashSet approvalIds, - ICollection errors) - { - if (string.IsNullOrWhiteSpace(map.Items)) - { - errors.Add(new TaskPackManifestValidationError($"{path}.items", "Map step requires 'items' expression.")); - } - - if (map.Step is null) - { - errors.Add(new TaskPackManifestValidationError($"{path}.step", "Map step requires nested step definition.")); - } - else - { - ValidateSteps(new[] { map.Step }, $"{path}.step", stepIds, approvalIds, errors); - } - } - - private static void ValidateLoopStep( - TaskPackLoopStep loop, - string path, - HashSet stepIds, - HashSet approvalIds, - ICollection errors) - { - // Loop must have one of: items expression, range, or staticItems - var sourceCount = (string.IsNullOrWhiteSpace(loop.Items) ? 0 : 1) - + (loop.Range is not null ? 1 : 0) - + (loop.StaticItems is not null ? 1 : 0); - - if (sourceCount == 0) - { - errors.Add(new TaskPackManifestValidationError(path, "Loop step requires 'items', 'range', or 'staticItems'.")); - } - - if (loop.MaxIterations <= 0) - { - errors.Add(new TaskPackManifestValidationError($"{path}.maxIterations", "maxIterations must be greater than 0.")); - } - - if (loop.Steps.Count == 0) - { - errors.Add(new TaskPackManifestValidationError($"{path}.steps", "Loop step requires nested steps.")); - } - else - { - ValidateSteps(loop.Steps, $"{path}.steps", stepIds, approvalIds, errors); - } - } - - private static void ValidateConditionalStep( - TaskPackConditionalStep conditional, - string path, - HashSet stepIds, - HashSet approvalIds, - ICollection errors) - { - if (conditional.Branches.Count == 0) - { - errors.Add(new TaskPackManifestValidationError($"{path}.branches", "Conditional step requires at least one branch.")); - return; - } - - for (var i = 0; i < conditional.Branches.Count; i++) - { - var branch = conditional.Branches[i]; - var branchPath = $"{path}.branches[{i}]"; - - if (string.IsNullOrWhiteSpace(branch.Condition)) - { - errors.Add(new TaskPackManifestValidationError($"{branchPath}.condition", "Branch requires a condition expression.")); - } - - if (branch.Steps.Count == 0) - { - errors.Add(new TaskPackManifestValidationError($"{branchPath}.steps", "Branch requires nested steps.")); - } - else - { - ValidateSteps(branch.Steps, $"{branchPath}.steps", stepIds, approvalIds, errors); - } - } - - if (conditional.Else is not null && conditional.Else.Count > 0) - { - ValidateSteps(conditional.Else, $"{path}.else", stepIds, approvalIds, errors); - } - } -} - -public sealed record TaskPackManifestValidationError(string Path, string Message); - -public sealed class TaskPackManifestValidationResult -{ - public TaskPackManifestValidationResult(ImmutableArray errors) - { - Errors = errors; - } - - public ImmutableArray Errors { get; } - - public bool IsValid => Errors.IsDefaultOrEmpty; -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/ITenantEgressPolicy.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/ITenantEgressPolicy.cs deleted file mode 100644 index de2036ee5..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/ITenantEgressPolicy.cs +++ /dev/null @@ -1,402 +0,0 @@ - -using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; -using System.Net; -using System.Text.RegularExpressions; - -namespace StellaOps.TaskRunner.Core.Tenancy; - -/// -/// Interface for tenant egress policy enforcement per TASKRUN-TEN-48-001. -/// Controls outbound network access based on tenant restrictions. -/// -public interface ITenantEgressPolicy -{ - /// - /// Checks whether egress to a given URI is allowed for the tenant. - /// - ValueTask CheckEgressAsync( - TenantContext tenant, - Uri targetUri, - CancellationToken cancellationToken = default); - - /// - /// Checks whether egress to a given host and port is allowed for the tenant. - /// - ValueTask CheckEgressAsync( - TenantContext tenant, - string host, - int port, - CancellationToken cancellationToken = default); - - /// - /// Records an egress attempt for auditing. - /// - ValueTask RecordEgressAttemptAsync( - TenantContext tenant, - string runId, - Uri targetUri, - EgressPolicyResult result, - CancellationToken cancellationToken = default); -} - -/// -/// Result of an egress policy check. -/// -public sealed record EgressPolicyResult -{ - public static EgressPolicyResult Allowed { get; } = new() { IsAllowed = true }; - - public static EgressPolicyResult BlockedByTenant(string reason) => new() - { - IsAllowed = false, - BlockReason = EgressBlockReason.TenantRestriction, - Message = reason - }; - - public static EgressPolicyResult BlockedByGlobalPolicy(string reason) => new() - { - IsAllowed = false, - BlockReason = EgressBlockReason.GlobalPolicy, - Message = reason - }; - - public static EgressPolicyResult BlockedBySuspension(string reason) => new() - { - IsAllowed = false, - BlockReason = EgressBlockReason.TenantSuspended, - Message = reason - }; - - public bool IsAllowed { get; init; } - - public EgressBlockReason? BlockReason { get; init; } - - public string? Message { get; init; } - - public DateTimeOffset? CheckedAt { get; init; } = DateTimeOffset.UtcNow; -} - -/// -/// Reason for egress being blocked. -/// -public enum EgressBlockReason -{ - /// - /// Blocked by tenant-specific restrictions. - /// - TenantRestriction, - - /// - /// Blocked by global policy (blocklist). - /// - GlobalPolicy, - - /// - /// Blocked because tenant is suspended. - /// - TenantSuspended, - - /// - /// Blocked because egress is disabled for this environment. - /// - EgressDisabled -} - -/// -/// Record of an egress attempt for auditing. -/// -public sealed record EgressAttemptRecord( - string TenantId, - string ProjectId, - string RunId, - Uri TargetUri, - bool WasAllowed, - EgressBlockReason? BlockReason, - string? BlockMessage, - DateTimeOffset Timestamp); - -/// -/// Default implementation of tenant egress policy. -/// -public sealed partial class TenantEgressPolicy : ITenantEgressPolicy -{ - private readonly TenantEgressPolicyOptions _options; - private readonly IEgressAuditLog _auditLog; - private readonly ILogger _logger; - private readonly HashSet _globalAllowlist; - private readonly HashSet _globalBlocklist; - - public TenantEgressPolicy( - TenantEgressPolicyOptions options, - IEgressAuditLog auditLog, - ILogger logger) - { - _options = options ?? throw new ArgumentNullException(nameof(options)); - _auditLog = auditLog ?? throw new ArgumentNullException(nameof(auditLog)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - _globalAllowlist = new HashSet( - options.GlobalAllowlist.Select(NormalizeHost), - StringComparer.OrdinalIgnoreCase); - - _globalBlocklist = new HashSet( - options.GlobalBlocklist.Select(NormalizeHost), - StringComparer.OrdinalIgnoreCase); - } - - public ValueTask CheckEgressAsync( - TenantContext tenant, - Uri targetUri, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(tenant); - ArgumentNullException.ThrowIfNull(targetUri); - - return CheckEgressAsync(tenant, targetUri.Host, targetUri.Port, cancellationToken); - } - - public ValueTask CheckEgressAsync( - TenantContext tenant, - string host, - int port, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(tenant); - ArgumentException.ThrowIfNullOrWhiteSpace(host); - - var normalizedHost = NormalizeHost(host); - - // Check if tenant is suspended - if (tenant.Restrictions.Suspended) - { - _logger.LogWarning( - "Egress blocked for suspended tenant {TenantId} to {Host}:{Port}.", - tenant.TenantId, - host, - port); - - return ValueTask.FromResult( - EgressPolicyResult.BlockedBySuspension("Tenant is suspended.")); - } - - // Check global blocklist first - if (IsInList(_globalBlocklist, normalizedHost)) - { - _logger.LogWarning( - "Egress blocked by global blocklist for tenant {TenantId} to {Host}:{Port}.", - tenant.TenantId, - host, - port); - - return ValueTask.FromResult( - EgressPolicyResult.BlockedByGlobalPolicy($"Host {host} is in global blocklist.")); - } - - // Check if tenant egress is completely blocked - if (tenant.Restrictions.EgressBlocked) - { - // Check tenant-specific allowlist - if (!tenant.Restrictions.AllowedEgressDomains.IsDefaultOrEmpty) - { - var tenantAllowlist = new HashSet( - tenant.Restrictions.AllowedEgressDomains.Select(NormalizeHost), - StringComparer.OrdinalIgnoreCase); - - if (IsInList(tenantAllowlist, normalizedHost)) - { - _logger.LogDebug( - "Egress allowed via tenant allowlist for {TenantId} to {Host}:{Port}.", - tenant.TenantId, - host, - port); - - return ValueTask.FromResult(EgressPolicyResult.Allowed); - } - } - - _logger.LogWarning( - "Egress blocked by tenant restriction for {TenantId} to {Host}:{Port}.", - tenant.TenantId, - host, - port); - - return ValueTask.FromResult( - EgressPolicyResult.BlockedByTenant($"Egress blocked for tenant {tenant.TenantId}.")); - } - - // Check global allowlist (if not allowing by default) - if (!_options.AllowByDefault) - { - if (!IsInList(_globalAllowlist, normalizedHost)) - { - _logger.LogWarning( - "Egress blocked (not in allowlist) for tenant {TenantId} to {Host}:{Port}.", - tenant.TenantId, - host, - port); - - return ValueTask.FromResult( - EgressPolicyResult.BlockedByGlobalPolicy($"Host {host} is not in allowlist.")); - } - } - - _logger.LogDebug( - "Egress allowed for tenant {TenantId} to {Host}:{Port}.", - tenant.TenantId, - host, - port); - - return ValueTask.FromResult(EgressPolicyResult.Allowed); - } - - public async ValueTask RecordEgressAttemptAsync( - TenantContext tenant, - string runId, - Uri targetUri, - EgressPolicyResult result, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(tenant); - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - ArgumentNullException.ThrowIfNull(targetUri); - ArgumentNullException.ThrowIfNull(result); - - var record = new EgressAttemptRecord( - TenantId: tenant.TenantId, - ProjectId: tenant.ProjectId, - RunId: runId, - TargetUri: targetUri, - WasAllowed: result.IsAllowed, - BlockReason: result.BlockReason, - BlockMessage: result.Message, - Timestamp: DateTimeOffset.UtcNow); - - await _auditLog.RecordAsync(record, cancellationToken).ConfigureAwait(false); - - if (!result.IsAllowed && _options.LogBlockedAttempts) - { - _logger.LogWarning( - "Egress attempt blocked: Tenant={TenantId}, Run={RunId}, Target={TargetUri}, Reason={Reason}", - tenant.TenantId, - runId, - targetUri, - result.Message); - } - } - - private static string NormalizeHost(string host) - { - var normalized = host.Trim().ToLowerInvariant(); - if (normalized.StartsWith("*.")) - { - return normalized; // Keep wildcard prefix - } - - return normalized; - } - - private static bool IsInList(HashSet list, string host) - { - // Exact match - if (list.Contains(host)) - { - return true; - } - - // Wildcard match (*.example.com matches sub.example.com) - var parts = host.Split('.'); - for (var i = 1; i < parts.Length; i++) - { - var wildcard = "*." + string.Join('.', parts[i..]); - if (list.Contains(wildcard)) - { - return true; - } - } - - return false; - } -} - -/// -/// Interface for egress audit logging. -/// -public interface IEgressAuditLog -{ - ValueTask RecordAsync(EgressAttemptRecord record, CancellationToken cancellationToken = default); - - IAsyncEnumerable GetRecordsAsync( - string tenantId, - string? runId = null, - DateTimeOffset? since = null, - CancellationToken cancellationToken = default); -} - -/// -/// In-memory implementation of egress audit log for testing. -/// -public sealed class InMemoryEgressAuditLog : IEgressAuditLog -{ - private readonly ConcurrentBag _records = []; - - public ValueTask RecordAsync(EgressAttemptRecord record, CancellationToken cancellationToken = default) - { - _records.Add(record); - return ValueTask.CompletedTask; - } - - public async IAsyncEnumerable GetRecordsAsync( - string tenantId, - string? runId = null, - DateTimeOffset? since = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.Yield(); - - var query = _records - .Where(r => r.TenantId.Equals(tenantId, StringComparison.Ordinal)); - - if (runId is not null) - { - query = query.Where(r => r.RunId.Equals(runId, StringComparison.Ordinal)); - } - - if (since.HasValue) - { - query = query.Where(r => r.Timestamp >= since.Value); - } - - foreach (var record in query.OrderBy(r => r.Timestamp)) - { - cancellationToken.ThrowIfCancellationRequested(); - yield return record; - } - } - - /// - /// Gets all records (for testing). - /// - public IReadOnlyList GetAllRecords() => [.. _records]; -} - -/// -/// Null implementation of egress audit log. -/// -public sealed class NullEgressAuditLog : IEgressAuditLog -{ - public static NullEgressAuditLog Instance { get; } = new(); - - public ValueTask RecordAsync(EgressAttemptRecord record, CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; - - public async IAsyncEnumerable GetRecordsAsync( - string tenantId, - string? runId = null, - DateTimeOffset? since = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.Yield(); - yield break; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/ITenantScopedStoragePathResolver.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/ITenantScopedStoragePathResolver.cs deleted file mode 100644 index 1bc28c6e0..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/ITenantScopedStoragePathResolver.cs +++ /dev/null @@ -1,261 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace StellaOps.TaskRunner.Core.Tenancy; - -/// -/// Interface for resolving tenant-scoped storage paths per TASKRUN-TEN-48-001. -/// Ensures all pack run storage (state, logs, artifacts) uses tenant-prefixed paths. -/// -public interface ITenantScopedStoragePathResolver -{ - /// - /// Gets the tenant-prefixed path for run state storage. - /// - string GetStatePath(TenantContext tenant, string runId); - - /// - /// Gets the tenant-prefixed path for run logs storage. - /// - string GetLogsPath(TenantContext tenant, string runId); - - /// - /// Gets the tenant-prefixed path for run artifacts storage. - /// - string GetArtifactsPath(TenantContext tenant, string runId); - - /// - /// Gets the tenant-prefixed path for approval records storage. - /// - string GetApprovalsPath(TenantContext tenant, string runId); - - /// - /// Gets the tenant-prefixed path for provenance records storage. - /// - string GetProvenancePath(TenantContext tenant, string runId); - - /// - /// Gets the tenant prefix for database collection/table queries. - /// - string GetDatabasePrefix(TenantContext tenant); - - /// - /// Gets the base directory for a tenant's storage. - /// - string GetTenantBasePath(TenantContext tenant); - - /// - /// Validates that a given path belongs to the specified tenant. - /// - bool ValidatePathBelongsToTenant(TenantContext tenant, string path); -} - -/// -/// Default implementation of tenant-scoped storage path resolver. -/// -public sealed class TenantScopedStoragePathResolver : ITenantScopedStoragePathResolver -{ - private readonly TenantStoragePathOptions _options; - private readonly string _rootPath; - - public TenantScopedStoragePathResolver( - TenantStoragePathOptions options, - string rootPath) - { - _options = options ?? throw new ArgumentNullException(nameof(options)); - ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); - _rootPath = Path.GetFullPath(rootPath); - } - - public string GetStatePath(TenantContext tenant, string runId) - { - ArgumentNullException.ThrowIfNull(tenant); - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - return BuildPath(_options.StateBasePath, tenant, runId); - } - - public string GetLogsPath(TenantContext tenant, string runId) - { - ArgumentNullException.ThrowIfNull(tenant); - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - return BuildPath(_options.LogsBasePath, tenant, runId); - } - - public string GetArtifactsPath(TenantContext tenant, string runId) - { - ArgumentNullException.ThrowIfNull(tenant); - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - return BuildPath(_options.ArtifactsBasePath, tenant, runId); - } - - public string GetApprovalsPath(TenantContext tenant, string runId) - { - ArgumentNullException.ThrowIfNull(tenant); - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - return BuildPath(_options.ApprovalsBasePath, tenant, runId); - } - - public string GetProvenancePath(TenantContext tenant, string runId) - { - ArgumentNullException.ThrowIfNull(tenant); - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - return BuildPath(_options.ProvenanceBasePath, tenant, runId); - } - - public string GetDatabasePrefix(TenantContext tenant) - { - ArgumentNullException.ThrowIfNull(tenant); - - return _options.PathStrategy switch - { - TenantPathStrategy.Flat => tenant.FlatPrefix, - TenantPathStrategy.Hashed => ComputeHash(tenant.TenantId), - _ => $"{Sanitize(tenant.TenantId)}:{Sanitize(tenant.ProjectId)}" - }; - } - - public string GetTenantBasePath(TenantContext tenant) - { - ArgumentNullException.ThrowIfNull(tenant); - - return _options.PathStrategy switch - { - TenantPathStrategy.Hierarchical => Path.Combine( - _rootPath, - Sanitize(tenant.TenantId), - Sanitize(tenant.ProjectId)), - - TenantPathStrategy.Flat => Path.Combine( - _rootPath, - tenant.FlatPrefix), - - TenantPathStrategy.Hashed => Path.Combine( - _rootPath, - ComputeHash(tenant.TenantId), - Sanitize(tenant.ProjectId)), - - _ => Path.Combine(_rootPath, tenant.StoragePrefix) - }; - } - - public bool ValidatePathBelongsToTenant(TenantContext tenant, string path) - { - ArgumentNullException.ThrowIfNull(tenant); - ArgumentException.ThrowIfNullOrWhiteSpace(path); - - var normalizedPath = Path.GetFullPath(path); - - // For hierarchical paths, check that the tenant segment is in the path - return _options.PathStrategy switch - { - TenantPathStrategy.Hierarchical => ContainsTenantSegments(normalizedPath, tenant), - TenantPathStrategy.Flat => normalizedPath.Contains(tenant.FlatPrefix, StringComparison.OrdinalIgnoreCase), - TenantPathStrategy.Hashed => normalizedPath.Contains(ComputeHash(tenant.TenantId), StringComparison.OrdinalIgnoreCase) - && normalizedPath.Contains(Sanitize(tenant.ProjectId), StringComparison.OrdinalIgnoreCase), - _ => ContainsTenantSegments(normalizedPath, tenant) - }; - } - - private bool ContainsTenantSegments(string path, TenantContext tenant) - { - // Check that path contains the tenant and project segments in order - var tenantSegment = Path.DirectorySeparatorChar + Sanitize(tenant.TenantId) + Path.DirectorySeparatorChar; - var projectSegment = Path.DirectorySeparatorChar + Sanitize(tenant.ProjectId) + Path.DirectorySeparatorChar; - - var tenantIndex = path.IndexOf(tenantSegment, StringComparison.OrdinalIgnoreCase); - if (tenantIndex < 0) - { - return false; - } - - var projectIndex = path.IndexOf(projectSegment, tenantIndex + tenantSegment.Length - 1, StringComparison.OrdinalIgnoreCase); - return projectIndex > tenantIndex; - } - - private string BuildPath(string basePath, TenantContext tenant, string runId) - { - var safeRunId = Sanitize(runId); - - return _options.PathStrategy switch - { - TenantPathStrategy.Hierarchical => Path.Combine( - _rootPath, - basePath, - Sanitize(tenant.TenantId), - Sanitize(tenant.ProjectId), - safeRunId), - - TenantPathStrategy.Flat => Path.Combine( - _rootPath, - basePath, - $"{tenant.FlatPrefix}_{safeRunId}"), - - TenantPathStrategy.Hashed => Path.Combine( - _rootPath, - basePath, - ComputeHash(tenant.TenantId), - Sanitize(tenant.ProjectId), - safeRunId), - - _ => Path.Combine(_rootPath, basePath, tenant.StoragePrefix, safeRunId) - }; - } - - private static string Sanitize(string value) - { - var result = value.Trim().ToLowerInvariant(); - foreach (var invalid in Path.GetInvalidFileNameChars()) - { - result = result.Replace(invalid, '_'); - } - - result = result.Replace('/', '_').Replace('\\', '_'); - return string.IsNullOrWhiteSpace(result) ? "unknown" : result; - } - - private static string ComputeHash(string value) - { - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value)); - return Convert.ToHexStringLower(bytes)[..16]; // First 16 chars of hex hash - } -} - -/// -/// Storage path context for a specific pack run with tenant scoping. -/// -public sealed record TenantScopedStoragePaths( - string StatePath, - string LogsPath, - string ArtifactsPath, - string ApprovalsPath, - string ProvenancePath, - string DatabasePrefix, - string TenantBasePath) -{ - /// - /// Creates storage paths from resolver and tenant context. - /// - public static TenantScopedStoragePaths Create( - ITenantScopedStoragePathResolver resolver, - TenantContext tenant, - string runId) - { - ArgumentNullException.ThrowIfNull(resolver); - ArgumentNullException.ThrowIfNull(tenant); - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - return new TenantScopedStoragePaths( - StatePath: resolver.GetStatePath(tenant, runId), - LogsPath: resolver.GetLogsPath(tenant, runId), - ArtifactsPath: resolver.GetArtifactsPath(tenant, runId), - ApprovalsPath: resolver.GetApprovalsPath(tenant, runId), - ProvenancePath: resolver.GetProvenancePath(tenant, runId), - DatabasePrefix: resolver.GetDatabasePrefix(tenant), - TenantBasePath: resolver.GetTenantBasePath(tenant)); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/PackRunTenantEnforcer.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/PackRunTenantEnforcer.cs deleted file mode 100644 index 12bdcd208..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/PackRunTenantEnforcer.cs +++ /dev/null @@ -1,426 +0,0 @@ -using Microsoft.Extensions.Logging; -using StellaOps.TaskRunner.Core.Execution; - -namespace StellaOps.TaskRunner.Core.Tenancy; - -/// -/// Enforces tenant context requirements for pack runs per TASKRUN-TEN-48-001. -/// Validates tenant context, enforces concurrent run limits, and propagates context. -/// -public interface IPackRunTenantEnforcer -{ - /// - /// Validates that a pack run request has valid tenant context. - /// - ValueTask ValidateRequestAsync( - PackRunTenantRequest request, - CancellationToken cancellationToken = default); - - /// - /// Creates tenant-scoped execution context for a pack run. - /// - ValueTask CreateExecutionContextAsync( - PackRunTenantRequest request, - string runId, - CancellationToken cancellationToken = default); - - /// - /// Records the start of a pack run for concurrent run tracking. - /// - ValueTask RecordRunStartAsync( - TenantContext tenant, - string runId, - CancellationToken cancellationToken = default); - - /// - /// Records the completion of a pack run for concurrent run tracking. - /// - ValueTask RecordRunCompletionAsync( - TenantContext tenant, - string runId, - CancellationToken cancellationToken = default); - - /// - /// Gets the current concurrent run count for a tenant. - /// - ValueTask GetConcurrentRunCountAsync( - TenantContext tenant, - CancellationToken cancellationToken = default); -} - -/// -/// Request for a tenant-scoped pack run. -/// -public sealed record PackRunTenantRequest( - string TenantId, - string ProjectId, - IReadOnlyDictionary? Labels = null); - -/// -/// Result of tenant enforcement validation. -/// -public sealed record TenantEnforcementResult -{ - public static TenantEnforcementResult Success(TenantContext tenant) => new() - { - IsValid = true, - Tenant = tenant - }; - - public static TenantEnforcementResult Failure(string reason, TenantEnforcementFailureKind kind) => new() - { - IsValid = false, - FailureReason = reason, - FailureKind = kind - }; - - public bool IsValid { get; init; } - - public TenantContext? Tenant { get; init; } - - public string? FailureReason { get; init; } - - public TenantEnforcementFailureKind? FailureKind { get; init; } -} - -/// -/// Kind of tenant enforcement failure. -/// -public enum TenantEnforcementFailureKind -{ - /// - /// Tenant ID is missing or invalid. - /// - MissingTenantId, - - /// - /// Project ID is missing or invalid. - /// - MissingProjectId, - - /// - /// Tenant does not exist or is not found. - /// - TenantNotFound, - - /// - /// Tenant is suspended. - /// - TenantSuspended, - - /// - /// Tenant is in read-only mode. - /// - TenantReadOnly, - - /// - /// Tenant has reached maximum concurrent runs. - /// - MaxConcurrentRunsReached, - - /// - /// Tenant validation failed for another reason. - /// - ValidationFailed -} - -/// -/// Tenant-scoped execution context for a pack run. -/// -public sealed record TenantScopedExecutionContext( - TenantContext Tenant, - TenantScopedStoragePaths StoragePaths, - IReadOnlyDictionary LoggingScope); - -/// -/// Default implementation of pack run tenant enforcer. -/// -public sealed class PackRunTenantEnforcer : IPackRunTenantEnforcer -{ - private readonly ITenantContextProvider _tenantProvider; - private readonly ITenantScopedStoragePathResolver _pathResolver; - private readonly TenancyEnforcementOptions _options; - private readonly IConcurrentRunTracker _runTracker; - private readonly ILogger _logger; - - public PackRunTenantEnforcer( - ITenantContextProvider tenantProvider, - ITenantScopedStoragePathResolver pathResolver, - TenancyEnforcementOptions options, - IConcurrentRunTracker runTracker, - ILogger logger) - { - _tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider)); - _pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver)); - _options = options ?? throw new ArgumentNullException(nameof(options)); - _runTracker = runTracker ?? throw new ArgumentNullException(nameof(runTracker)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async ValueTask ValidateRequestAsync( - PackRunTenantRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - - // Validate tenant ID - if (string.IsNullOrWhiteSpace(request.TenantId)) - { - _logger.LogWarning("Pack run request rejected: missing tenant ID."); - return TenantEnforcementResult.Failure( - "Tenant ID is required for pack runs.", - TenantEnforcementFailureKind.MissingTenantId); - } - - // Validate project ID (if required) - if (_options.RequireProjectId && string.IsNullOrWhiteSpace(request.ProjectId)) - { - _logger.LogWarning( - "Pack run request rejected for tenant {TenantId}: missing project ID.", - request.TenantId); - return TenantEnforcementResult.Failure( - "Project ID is required for pack runs.", - TenantEnforcementFailureKind.MissingProjectId); - } - - // Get tenant context - var tenant = await _tenantProvider.GetContextAsync( - request.TenantId, - request.ProjectId, - cancellationToken).ConfigureAwait(false); - - if (tenant is null && _options.ValidateTenantExists) - { - _logger.LogWarning( - "Pack run request rejected: tenant {TenantId}/{ProjectId} not found.", - request.TenantId, - request.ProjectId); - return TenantEnforcementResult.Failure( - $"Tenant {request.TenantId}/{request.ProjectId} not found.", - TenantEnforcementFailureKind.TenantNotFound); - } - - // Create tenant context if provider didn't return one - tenant ??= new TenantContext(request.TenantId, request.ProjectId, request.Labels); - - // Validate tenant status - if (_options.BlockSuspendedTenants) - { - var validation = await _tenantProvider.ValidateAsync(tenant, cancellationToken) - .ConfigureAwait(false); - - if (!validation.IsValid) - { - _logger.LogWarning( - "Pack run request rejected for tenant {TenantId}: {Reason}", - request.TenantId, - validation.Reason); - - var kind = validation.IsSuspended - ? TenantEnforcementFailureKind.TenantSuspended - : TenantEnforcementFailureKind.ValidationFailed; - - return TenantEnforcementResult.Failure( - validation.Reason ?? "Tenant validation failed.", - kind); - } - } - - // Check read-only mode - if (tenant.Restrictions.ReadOnly) - { - _logger.LogWarning( - "Pack run request rejected: tenant {TenantId} is in read-only mode.", - request.TenantId); - return TenantEnforcementResult.Failure( - "Tenant is in read-only mode.", - TenantEnforcementFailureKind.TenantReadOnly); - } - - // Check concurrent run limit - var maxConcurrent = tenant.Restrictions.MaxConcurrentRuns ?? _options.DefaultMaxConcurrentRuns; - var currentCount = await _runTracker.GetCountAsync(tenant.TenantId, cancellationToken) - .ConfigureAwait(false); - - if (currentCount >= maxConcurrent) - { - _logger.LogWarning( - "Pack run request rejected: tenant {TenantId} has reached max concurrent runs ({Count}/{Max}).", - request.TenantId, - currentCount, - maxConcurrent); - return TenantEnforcementResult.Failure( - $"Maximum concurrent runs ({maxConcurrent}) reached for tenant.", - TenantEnforcementFailureKind.MaxConcurrentRunsReached); - } - - _logger.LogInformation( - "Pack run request validated for tenant {TenantId}/{ProjectId}.", - request.TenantId, - request.ProjectId); - - return TenantEnforcementResult.Success(tenant); - } - - public async ValueTask CreateExecutionContextAsync( - PackRunTenantRequest request, - string runId, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - var validationResult = await ValidateRequestAsync(request, cancellationToken) - .ConfigureAwait(false); - - if (!validationResult.IsValid) - { - throw new TenantEnforcementException( - validationResult.FailureReason ?? "Tenant validation failed.", - validationResult.FailureKind ?? TenantEnforcementFailureKind.ValidationFailed); - } - - var tenant = validationResult.Tenant!; - var storagePaths = TenantScopedStoragePaths.Create(_pathResolver, tenant, runId); - var loggingScope = new Dictionary(tenant.ToLoggingScope()) - { - ["RunId"] = runId - }; - - return new TenantScopedExecutionContext(tenant, storagePaths, loggingScope); - } - - public async ValueTask RecordRunStartAsync( - TenantContext tenant, - string runId, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(tenant); - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - await _runTracker.IncrementAsync(tenant.TenantId, runId, cancellationToken) - .ConfigureAwait(false); - - _logger.LogDebug( - "Recorded run start for tenant {TenantId}, run {RunId}.", - tenant.TenantId, - runId); - } - - public async ValueTask RecordRunCompletionAsync( - TenantContext tenant, - string runId, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(tenant); - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - await _runTracker.DecrementAsync(tenant.TenantId, runId, cancellationToken) - .ConfigureAwait(false); - - _logger.LogDebug( - "Recorded run completion for tenant {TenantId}, run {RunId}.", - tenant.TenantId, - runId); - } - - public async ValueTask GetConcurrentRunCountAsync( - TenantContext tenant, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(tenant); - - return await _runTracker.GetCountAsync(tenant.TenantId, cancellationToken) - .ConfigureAwait(false); - } -} - -/// -/// Exception thrown when tenant enforcement fails. -/// -public sealed class TenantEnforcementException : Exception -{ - public TenantEnforcementException(string message, TenantEnforcementFailureKind kind) - : base(message) - { - Kind = kind; - } - - public TenantEnforcementFailureKind Kind { get; } -} - -/// -/// Interface for tracking concurrent pack runs per tenant. -/// -public interface IConcurrentRunTracker -{ - ValueTask GetCountAsync(string tenantId, CancellationToken cancellationToken = default); - - ValueTask IncrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default); - - ValueTask DecrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default); -} - -/// -/// In-memory implementation of concurrent run tracker for testing. -/// -public sealed class InMemoryConcurrentRunTracker : IConcurrentRunTracker -{ - private readonly Dictionary> _runsByTenant = new(StringComparer.Ordinal); - private readonly object _lock = new(); - - public ValueTask GetCountAsync(string tenantId, CancellationToken cancellationToken = default) - { - lock (_lock) - { - return ValueTask.FromResult( - _runsByTenant.TryGetValue(tenantId, out var runs) ? runs.Count : 0); - } - } - - public ValueTask IncrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default) - { - lock (_lock) - { - if (!_runsByTenant.TryGetValue(tenantId, out var runs)) - { - runs = new HashSet(StringComparer.Ordinal); - _runsByTenant[tenantId] = runs; - } - - runs.Add(runId); - } - - return ValueTask.CompletedTask; - } - - public ValueTask DecrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default) - { - lock (_lock) - { - if (_runsByTenant.TryGetValue(tenantId, out var runs)) - { - runs.Remove(runId); - if (runs.Count == 0) - { - _runsByTenant.Remove(tenantId); - } - } - } - - return ValueTask.CompletedTask; - } - - /// - /// Gets all active runs for a tenant (for testing). - /// - public IReadOnlySet GetActiveRuns(string tenantId) - { - lock (_lock) - { - return _runsByTenant.TryGetValue(tenantId, out var runs) - ? new HashSet(runs) - : new HashSet(); - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/TenancyEnforcementOptions.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/TenancyEnforcementOptions.cs deleted file mode 100644 index 54aec5dd3..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/TenancyEnforcementOptions.cs +++ /dev/null @@ -1,153 +0,0 @@ -namespace StellaOps.TaskRunner.Core.Tenancy; - -/// -/// Configuration options for tenancy enforcement per TASKRUN-TEN-48-001. -/// -public sealed class TenancyEnforcementOptions -{ - /// - /// Whether tenancy enforcement is enabled. When true, all pack runs - /// must have valid tenant context. - /// - public bool Enabled { get; set; } = true; - - /// - /// Whether to require project ID in addition to tenant ID. - /// - public bool RequireProjectId { get; set; } = true; - - /// - /// Whether to enforce tenant-prefixed storage paths. - /// - public bool EnforceStoragePrefixes { get; set; } = true; - - /// - /// Whether to enforce egress policies for restricted tenants. - /// - public bool EnforceEgressPolicies { get; set; } = true; - - /// - /// Whether to propagate tenant context to step logs. - /// - public bool PropagateToLogs { get; set; } = true; - - /// - /// Whether to block runs for suspended tenants. - /// - public bool BlockSuspendedTenants { get; set; } = true; - - /// - /// Whether to validate tenant exists before starting run. - /// - public bool ValidateTenantExists { get; set; } = true; - - /// - /// Default maximum concurrent runs per tenant when not specified - /// in tenant restrictions. - /// - public int DefaultMaxConcurrentRuns { get; set; } = 10; - - /// - /// Default retention period in days for run artifacts when not specified - /// in tenant restrictions. - /// - public int DefaultRetentionDays { get; set; } = 30; - - /// - /// Storage path configuration for tenant scoping. - /// - public TenantStoragePathOptions Storage { get; set; } = new(); - - /// - /// Egress policy configuration. - /// - public TenantEgressPolicyOptions Egress { get; set; } = new(); -} - -/// -/// Storage path options for tenant scoping. -/// -public sealed class TenantStoragePathOptions -{ - /// - /// Path segment strategy for tenant prefixes. - /// - public TenantPathStrategy PathStrategy { get; set; } = TenantPathStrategy.Hierarchical; - - /// - /// Base path for run state storage. - /// - public string StateBasePath { get; set; } = "runs"; - - /// - /// Base path for run logs storage. - /// - public string LogsBasePath { get; set; } = "logs"; - - /// - /// Base path for run artifacts storage. - /// - public string ArtifactsBasePath { get; set; } = "artifacts"; - - /// - /// Base path for approval records storage. - /// - public string ApprovalsBasePath { get; set; } = "approvals"; - - /// - /// Base path for provenance records storage. - /// - public string ProvenanceBasePath { get; set; } = "provenance"; -} - -/// -/// Tenant path strategy for storage prefixes. -/// -public enum TenantPathStrategy -{ - /// - /// Hierarchical paths: {base}/{tenantId}/{projectId}/{runId} - /// - Hierarchical, - - /// - /// Flat paths with prefix: {base}/{tenantId}_{projectId}_{runId} - /// - Flat, - - /// - /// Hashed tenant prefixes for privacy: {base}/{hash(tenantId)}/{projectId}/{runId} - /// - Hashed -} - -/// -/// Egress policy options for tenant scoping. -/// -public sealed class TenantEgressPolicyOptions -{ - /// - /// Whether to allow egress by default when not restricted. - /// - public bool AllowByDefault { get; set; } = true; - - /// - /// Global egress allowlist applied to all tenants. - /// - public List GlobalAllowlist { get; set; } = []; - - /// - /// Global egress blocklist applied to all tenants. - /// - public List GlobalBlocklist { get; set; } = []; - - /// - /// Whether to log blocked egress attempts. - /// - public bool LogBlockedAttempts { get; set; } = true; - - /// - /// Whether to fail the run on blocked egress attempts. - /// - public bool FailOnBlockedAttempts { get; set; } = false; -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/TenantContext.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/TenantContext.cs deleted file mode 100644 index 64d501e56..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Tenancy/TenantContext.cs +++ /dev/null @@ -1,228 +0,0 @@ -using System.Collections.Immutable; - -namespace StellaOps.TaskRunner.Core.Tenancy; - -/// -/// Tenant context for pack runs per TASKRUN-TEN-48-001. -/// Provides required tenant/project context for every pack run, enabling -/// tenant-scoped storage prefixes and egress policy enforcement. -/// -public sealed record TenantContext -{ - /// - /// Creates a new tenant context. Both tenant and project IDs are required. - /// - public TenantContext( - string tenantId, - string projectId, - IReadOnlyDictionary? labels = null, - TenantRestrictions? restrictions = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - ArgumentException.ThrowIfNullOrWhiteSpace(projectId); - - TenantId = tenantId.Trim(); - ProjectId = projectId.Trim(); - Labels = labels?.ToImmutableDictionary(StringComparer.Ordinal) ?? ImmutableDictionary.Empty; - Restrictions = restrictions ?? TenantRestrictions.None; - } - - /// - /// Unique identifier for the tenant (organization/account). - /// - public string TenantId { get; } - - /// - /// Unique identifier for the project within the tenant. - /// - public string ProjectId { get; } - - /// - /// Optional labels for filtering and grouping. - /// - public ImmutableDictionary Labels { get; } - - /// - /// Restrictions applied to this tenant context. - /// - public TenantRestrictions Restrictions { get; } - - /// - /// Gets a storage-safe path prefix for this tenant context. - /// Format: {tenantId}/{projectId} - /// - public string StoragePrefix => $"{SanitizePathSegment(TenantId)}/{SanitizePathSegment(ProjectId)}"; - - /// - /// Gets a flat storage key prefix for this tenant context. - /// Format: {tenantId}_{projectId} - /// - public string FlatPrefix => $"{SanitizePathSegment(TenantId)}_{SanitizePathSegment(ProjectId)}"; - - /// - /// Creates a logging scope dictionary with tenant context. - /// - public IReadOnlyDictionary ToLoggingScope() => - new Dictionary - { - ["TenantId"] = TenantId, - ["ProjectId"] = ProjectId - }; - - private static string SanitizePathSegment(string value) - { - var result = value.Trim().ToLowerInvariant(); - foreach (var invalid in Path.GetInvalidFileNameChars()) - { - result = result.Replace(invalid, '_'); - } - - // Also replace path separators for flat prefixes - result = result.Replace('/', '_').Replace('\\', '_'); - return string.IsNullOrWhiteSpace(result) ? "unknown" : result; - } -} - -/// -/// Restrictions that can be applied to a tenant context. -/// -public sealed record TenantRestrictions -{ - public static TenantRestrictions None { get; } = new(); - - /// - /// Whether egress (outbound network) is blocked for this tenant. - /// - public bool EgressBlocked { get; init; } - - /// - /// Allowed egress domains when egress is restricted (not fully blocked). - /// Empty means all domains blocked when EgressBlocked is true. - /// - public ImmutableArray AllowedEgressDomains { get; init; } = []; - - /// - /// Whether the tenant is in read-only mode (no writes allowed). - /// - public bool ReadOnly { get; init; } - - /// - /// Whether the tenant is suspended (no operations allowed). - /// - public bool Suspended { get; init; } - - /// - /// Maximum concurrent pack runs allowed for this tenant. - /// Null means unlimited. - /// - public int? MaxConcurrentRuns { get; init; } - - /// - /// Maximum retention period for run artifacts in days. - /// Null means default retention applies. - /// - public int? MaxRetentionDays { get; init; } -} - -/// -/// Provider interface for tenant context resolution. -/// -public interface ITenantContextProvider -{ - /// - /// Gets the tenant context for a given tenant and project ID. - /// - ValueTask GetContextAsync( - string tenantId, - string projectId, - CancellationToken cancellationToken = default); - - /// - /// Validates that the tenant context is active and not suspended. - /// - ValueTask ValidateAsync( - TenantContext context, - CancellationToken cancellationToken = default); -} - -/// -/// Result of tenant validation. -/// -public sealed record TenantValidationResult -{ - public static TenantValidationResult Valid { get; } = new() { IsValid = true }; - - public static TenantValidationResult Invalid(string reason) => new() - { - IsValid = false, - Reason = reason - }; - - public static TenantValidationResult Suspended(string reason) => new() - { - IsValid = false, - IsSuspended = true, - Reason = reason - }; - - public bool IsValid { get; init; } - - public bool IsSuspended { get; init; } - - public string? Reason { get; init; } -} - -/// -/// In-memory implementation of tenant context provider for testing. -/// -public sealed class InMemoryTenantContextProvider : ITenantContextProvider -{ - private readonly Dictionary _contexts = new(StringComparer.Ordinal); - private readonly HashSet _suspendedTenants = new(StringComparer.Ordinal); - - public ValueTask GetContextAsync( - string tenantId, - string projectId, - CancellationToken cancellationToken = default) - { - var key = $"{tenantId}:{projectId}"; - return ValueTask.FromResult(_contexts.TryGetValue(key, out var context) ? context : null); - } - - public ValueTask ValidateAsync( - TenantContext context, - CancellationToken cancellationToken = default) - { - if (context.Restrictions.Suspended || _suspendedTenants.Contains(context.TenantId)) - { - return ValueTask.FromResult(TenantValidationResult.Suspended("Tenant is suspended.")); - } - - return ValueTask.FromResult(TenantValidationResult.Valid); - } - - /// - /// Registers a tenant context (for testing). - /// - public void Register(TenantContext context) - { - var key = $"{context.TenantId}:{context.ProjectId}"; - _contexts[key] = context; - } - - /// - /// Suspends a tenant (for testing). - /// - public void Suspend(string tenantId) - { - _suspendedTenants.Add(tenantId); - } - - /// - /// Unsuspends a tenant (for testing). - /// - public void Unsuspend(string tenantId) - { - _suspendedTenants.Remove(tenantId); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/AirGap/HttpAirGapStatusProvider.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/AirGap/HttpAirGapStatusProvider.cs deleted file mode 100644 index 537b82674..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/AirGap/HttpAirGapStatusProvider.cs +++ /dev/null @@ -1,238 +0,0 @@ - -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using StellaOps.TaskRunner.Core.AirGap; -using System.Net.Http.Json; -using System.Text.Json.Serialization; - -namespace StellaOps.TaskRunner.Infrastructure.AirGap; - -/// -/// HTTP client implementation for retrieving air-gap status from the AirGap controller. -/// -public sealed class HttpAirGapStatusProvider : IAirGapStatusProvider -{ - private readonly HttpClient _httpClient; - private readonly IOptions _options; - private readonly ILogger _logger; - - public HttpAirGapStatusProvider( - HttpClient httpClient, - IOptions options, - ILogger logger) - { - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - _options = options ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - public async Task GetStatusAsync( - string? tenantId = null, - CancellationToken cancellationToken = default) - { - var options = _options.Value; - var url = string.IsNullOrWhiteSpace(tenantId) - ? options.StatusEndpoint - : $"{options.StatusEndpoint}?tenantId={Uri.EscapeDataString(tenantId)}"; - - try - { - var response = await _httpClient.GetFromJsonAsync( - url, - cancellationToken).ConfigureAwait(false); - - if (response is null) - { - _logger.LogWarning("AirGap controller returned null response."); - return SealedModeStatus.Unavailable(); - } - - return MapToSealedModeStatus(response); - } - catch (HttpRequestException ex) - { - _logger.LogWarning(ex, "Failed to connect to AirGap controller at {Url}.", url); - - if (options.UseHeuristicFallback) - { - return await GetStatusFromHeuristicsAsync(cancellationToken).ConfigureAwait(false); - } - - return SealedModeStatus.Unavailable(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error getting air-gap status."); - return SealedModeStatus.Unavailable(); - } - } - - private static SealedModeStatus MapToSealedModeStatus(AirGapStatusDto dto) - { - TimeAnchorInfo? timeAnchor = null; - if (dto.TimeAnchor is not null) - { - timeAnchor = new TimeAnchorInfo( - dto.TimeAnchor.Timestamp, - dto.TimeAnchor.Signature, - dto.TimeAnchor.Valid, - dto.TimeAnchor.ExpiresAt); - } - - return new SealedModeStatus( - Sealed: dto.Sealed, - Mode: dto.Sealed ? "sealed" : "unsealed", - SealedAt: dto.SealedAt, - SealedBy: dto.SealedBy, - BundleVersion: dto.BundleVersion, - BundleDigest: dto.BundleDigest, - LastAdvisoryUpdate: dto.LastAdvisoryUpdate, - AdvisoryStalenessHours: dto.AdvisoryStalenessHours, - TimeAnchor: timeAnchor, - EgressBlocked: dto.EgressBlocked, - NetworkPolicy: dto.NetworkPolicy); - } - - private async Task GetStatusFromHeuristicsAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Using heuristic detection for sealed mode status."); - - var score = 0.0; - var weights = 0.0; - - // Check AIRGAP_MODE environment variable (high weight) - var airgapMode = Environment.GetEnvironmentVariable("AIRGAP_MODE"); - if (string.Equals(airgapMode, "sealed", StringComparison.OrdinalIgnoreCase)) - { - score += 0.3; - } - weights += 0.3; - - // Check for sealed file marker (medium weight) - var sealedMarkerPath = _options.Value.SealedMarkerPath; - if (!string.IsNullOrWhiteSpace(sealedMarkerPath) && File.Exists(sealedMarkerPath)) - { - score += 0.2; - } - weights += 0.2; - - // Check network connectivity (high weight) - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(2)); - - var testResponse = await _httpClient.GetAsync( - _options.Value.ConnectivityTestUrl, - cts.Token).ConfigureAwait(false); - - // If we can reach external network, likely not sealed - } - catch (Exception) - { - // Network blocked, likely sealed - score += 0.3; - } - weights += 0.3; - - // Check for local registry configuration (low weight) - var registryEnv = Environment.GetEnvironmentVariable("CONTAINER_REGISTRY"); - if (!string.IsNullOrWhiteSpace(registryEnv) && - (registryEnv.Contains("localhost", StringComparison.OrdinalIgnoreCase) || - registryEnv.Contains("127.0.0.1", StringComparison.Ordinal))) - { - score += 0.1; - } - weights += 0.1; - - // Check proxy settings (low weight) - var httpProxy = Environment.GetEnvironmentVariable("HTTP_PROXY") ?? - Environment.GetEnvironmentVariable("http_proxy"); - var noProxy = Environment.GetEnvironmentVariable("NO_PROXY") ?? - Environment.GetEnvironmentVariable("no_proxy"); - if (string.IsNullOrWhiteSpace(httpProxy) && !string.IsNullOrWhiteSpace(noProxy)) - { - score += 0.1; - } - weights += 0.1; - - var normalizedScore = weights > 0 ? score / weights : 0; - var threshold = _options.Value.HeuristicThreshold; - - var isSealed = normalizedScore >= threshold; - - _logger.LogInformation( - "Heuristic detection result: score={Score:F2}, threshold={Threshold:F2}, sealed={IsSealed}", - normalizedScore, - threshold, - isSealed); - - return new SealedModeStatus( - Sealed: isSealed, - Mode: isSealed ? "sealed-heuristic" : "unsealed-heuristic", - SealedAt: null, - SealedBy: null, - BundleVersion: null, - BundleDigest: null, - LastAdvisoryUpdate: null, - AdvisoryStalenessHours: 0, - TimeAnchor: null, - EgressBlocked: isSealed, - NetworkPolicy: isSealed ? "heuristic-detected" : null); - } - - private sealed record AirGapStatusDto( - [property: JsonPropertyName("sealed")] bool Sealed, - [property: JsonPropertyName("sealed_at")] DateTimeOffset? SealedAt, - [property: JsonPropertyName("sealed_by")] string? SealedBy, - [property: JsonPropertyName("bundle_version")] string? BundleVersion, - [property: JsonPropertyName("bundle_digest")] string? BundleDigest, - [property: JsonPropertyName("last_advisory_update")] DateTimeOffset? LastAdvisoryUpdate, - [property: JsonPropertyName("advisory_staleness_hours")] int AdvisoryStalenessHours, - [property: JsonPropertyName("time_anchor")] TimeAnchorDto? TimeAnchor, - [property: JsonPropertyName("egress_blocked")] bool EgressBlocked, - [property: JsonPropertyName("network_policy")] string? NetworkPolicy); - - private sealed record TimeAnchorDto( - [property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp, - [property: JsonPropertyName("signature")] string? Signature, - [property: JsonPropertyName("valid")] bool Valid, - [property: JsonPropertyName("expires_at")] DateTimeOffset? ExpiresAt); -} - -/// -/// Configuration options for the HTTP air-gap status provider. -/// -public sealed class AirGapStatusProviderOptions -{ - /// - /// Base URL of the AirGap controller. - /// - public string BaseUrl { get; set; } = "http://localhost:8080"; - - /// - /// Status endpoint path. - /// - public string StatusEndpoint { get; set; } = "/api/v1/airgap/status"; - - /// - /// Whether to use heuristic fallback when controller is unavailable. - /// - public bool UseHeuristicFallback { get; set; } = true; - - /// - /// Heuristic score threshold (0.0-1.0) to consider environment sealed. - /// - public double HeuristicThreshold { get; set; } = 0.7; - - /// - /// Path to the sealed mode marker file. - /// - public string? SealedMarkerPath { get; set; } = "/etc/stellaops/sealed"; - - /// - /// URL to test external connectivity. - /// - public string ConnectivityTestUrl { get; set; } = "https://api.stellaops.org/health"; -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/BundleIngestionStepExecutor.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/BundleIngestionStepExecutor.cs deleted file mode 100644 index 5c3d90b24..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/BundleIngestionStepExecutor.cs +++ /dev/null @@ -1,112 +0,0 @@ - -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using StellaOps.TaskRunner.Core.Configuration; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using System.Security.Cryptography; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -/// -/// Executes built-in bundle ingestion helpers: validates checksums and stages bundles to a deterministic destination. -/// -public sealed class BundleIngestionStepExecutor : IPackRunStepExecutor -{ - private const string BuiltInUses = "bundle.ingest"; - private readonly string stagingRoot; - private readonly ILogger logger; - - public BundleIngestionStepExecutor(IOptions options, ILogger logger) - { - ArgumentNullException.ThrowIfNull(options); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - stagingRoot = Path.Combine(options.Value.ArtifactsPath, "bundles"); - Directory.CreateDirectory(stagingRoot); - } - - public Task ExecuteAsync( - PackRunExecutionStep step, - IReadOnlyDictionary parameters, - CancellationToken cancellationToken) - { - // Non-bundle helpers are treated as no-op success for now. - if (!IsBundleIngestStep(step)) - { - return Task.FromResult(PackRunStepExecutionResult.Success()); - } - - var sourcePath = GetString(parameters, "path"); - if (string.IsNullOrWhiteSpace(sourcePath) || !File.Exists(sourcePath)) - { - return Task.FromResult(PackRunStepExecutionResult.Failure("Bundle path missing or not found.")); - } - - var checksum = GetString(parameters, "checksum") ?? GetString(parameters, "checksumSha256"); - if (string.IsNullOrWhiteSpace(checksum)) - { - return Task.FromResult(PackRunStepExecutionResult.Failure("Checksum is required for bundle ingestion.")); - } - - var actual = ComputeSha256(sourcePath); - if (!checksum.Equals(actual, StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult(PackRunStepExecutionResult.Failure($"Checksum mismatch: expected {checksum}, actual {actual}.")); - } - - var deterministicDir = Path.Combine(stagingRoot, checksum.ToLowerInvariant()); - var destination = GetString(parameters, "destinationPath") - ?? Path.Combine(deterministicDir, Path.GetFileName(sourcePath)); - - try - { - Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - File.Copy(sourcePath, destination, overwrite: true); - - // Persist deterministic metadata for downstream evidence - var metadataPath = Path.Combine(deterministicDir, "metadata.json"); - var metadata = new - { - source = Path.GetFullPath(sourcePath), - checksumSha256 = checksum.ToLowerInvariant(), - stagedPath = Path.GetFullPath(destination) - }; - var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions(JsonSerializerDefaults.Web) - { - WriteIndented = false - }); - File.WriteAllText(metadataPath, json); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to stage bundle to {Destination}.", destination); - return Task.FromResult(PackRunStepExecutionResult.Failure("Failed to stage bundle.")); - } - - return Task.FromResult(PackRunStepExecutionResult.Success()); - } - - private static string? GetString(IReadOnlyDictionary parameters, string key) - { - if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue) - { - return null; - } - - return jsonValue.TryGetValue(out var result) ? result : null; - } - - private static bool IsBundleIngestStep(PackRunExecutionStep step) - => !string.IsNullOrWhiteSpace(step.Uses) && - step.Kind == PackRunStepKind.Run && - step.Uses.Contains(BuiltInUses, StringComparison.OrdinalIgnoreCase); - - private static string ComputeSha256(string path) - { - using var stream = File.OpenRead(path); - var hash = SHA256.HashData(stream); - return Convert.ToHexString(hash).ToLowerInvariant(); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunApprovalStore.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunApprovalStore.cs deleted file mode 100644 index 198bab307..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunApprovalStore.cs +++ /dev/null @@ -1,119 +0,0 @@ - -using StellaOps.TaskRunner.Core.Execution; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -public sealed class FilePackRunApprovalStore : IPackRunApprovalStore -{ - private readonly string rootPath; - private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true - }; - - public FilePackRunApprovalStore(string rootPath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); - this.rootPath = rootPath; - Directory.CreateDirectory(rootPath); - } - - public Task SaveAsync(string runId, IReadOnlyList approvals, CancellationToken cancellationToken) - { - var path = GetFilePath(runId); - var json = SerializeApprovals(approvals); - File.WriteAllText(path, json); - return Task.CompletedTask; - } - - public Task> GetAsync(string runId, CancellationToken cancellationToken) - { - var path = GetFilePath(runId); - if (!File.Exists(path)) - { - return Task.FromResult((IReadOnlyList)Array.Empty()); - } - - var json = File.ReadAllText(path); - var approvals = DeserializeApprovals(json); - return Task.FromResult((IReadOnlyList)approvals); - } - - public async Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken) - { - var approvals = (await GetAsync(runId, cancellationToken).ConfigureAwait(false)).ToList(); - var index = approvals.FindIndex(existing => string.Equals(existing.ApprovalId, approval.ApprovalId, StringComparison.Ordinal)); - if (index < 0) - { - throw new InvalidOperationException($"Approval '{approval.ApprovalId}' not found for run '{runId}'."); - } - - approvals[index] = approval; - await SaveAsync(runId, approvals, cancellationToken).ConfigureAwait(false); - } - - private string GetFilePath(string runId) - { - var safeFile = $"{runId}.json"; - return Path.Combine(rootPath, safeFile); - } - - private string SerializeApprovals(IReadOnlyList approvals) - { - var array = new JsonArray(); - foreach (var approval in approvals) - { - var node = new JsonObject - { - ["approvalId"] = approval.ApprovalId, - ["status"] = approval.Status.ToString(), - ["requestedAt"] = approval.RequestedAt, - ["actorId"] = approval.ActorId, - ["completedAt"] = approval.CompletedAt, - ["summary"] = approval.Summary, - ["requiredGrants"] = new JsonArray(approval.RequiredGrants.Select(grant => (JsonNode)grant).ToArray()), - ["stepIds"] = new JsonArray(approval.StepIds.Select(step => (JsonNode)step).ToArray()), - ["messages"] = new JsonArray(approval.Messages.Select(message => (JsonNode)message).ToArray()), - ["reasonTemplate"] = approval.ReasonTemplate - }; - - array.Add(node); - } - - return array.ToJsonString(serializerOptions); - } - - private static IReadOnlyList DeserializeApprovals(string json) - { - var array = JsonNode.Parse(json)?.AsArray() ?? new JsonArray(); - var list = new List(array.Count); - foreach (var entry in array) - { - if (entry is not JsonObject obj) - { - continue; - } - - var requiredGrants = obj["requiredGrants"]?.AsArray()?.Select(node => node!.GetValue()).ToList() ?? new List(); - var stepIds = obj["stepIds"]?.AsArray()?.Select(node => node!.GetValue()).ToList() ?? new List(); - var messages = obj["messages"]?.AsArray()?.Select(node => node!.GetValue()).ToList() ?? new List(); - Enum.TryParse(obj["status"]?.GetValue(), ignoreCase: true, out PackRunApprovalStatus status); - - list.Add(new PackRunApprovalState( - obj["approvalId"]?.GetValue() ?? string.Empty, - requiredGrants, - stepIds, - messages, - obj["reasonTemplate"]?.GetValue(), - obj["requestedAt"]?.GetValue() ?? DateTimeOffset.UtcNow, - status, - obj["actorId"]?.GetValue(), - obj["completedAt"]?.GetValue(), - obj["summary"]?.GetValue())); - } - - return list; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunLogStore.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunLogStore.cs deleted file mode 100644 index 99c260037..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunLogStore.cs +++ /dev/null @@ -1,163 +0,0 @@ - -using StellaOps.TaskRunner.Core.Execution; -using System.Collections.Concurrent; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -/// -/// Persists pack run logs as newline-delimited JSON for deterministic replay and offline mirroring. -/// -public sealed class FilePackRunLogStore : IPackRunLogStore -{ - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = false - }; - - private readonly string rootPath; - private readonly ConcurrentDictionary fileLocks = new(StringComparer.Ordinal); - - public FilePackRunLogStore(string rootPath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); - - this.rootPath = Path.GetFullPath(rootPath); - Directory.CreateDirectory(this.rootPath); - } - - public async Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - ArgumentNullException.ThrowIfNull(entry); - - var path = GetPath(runId); - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - - var gate = fileLocks.GetOrAdd(path, _ => new SemaphoreSlim(1, 1)); - await gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - var document = PackRunLogEntryDocument.FromDomain(entry); - var json = JsonSerializer.Serialize(document, SerializerOptions); - await File.AppendAllTextAsync(path, json + Environment.NewLine, cancellationToken).ConfigureAwait(false); - } - finally - { - gate.Release(); - } - } - - public async IAsyncEnumerable ReadAsync( - string runId, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - var path = GetPath(runId); - if (!File.Exists(path)) - { - yield break; - } - - await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var reader = new StreamReader(stream, Encoding.UTF8); - - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - var line = await reader.ReadLineAsync().ConfigureAwait(false); - if (line is null) - { - yield break; - } - - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - PackRunLogEntryDocument? document = null; - try - { - document = JsonSerializer.Deserialize(line, SerializerOptions); - } - catch - { - // Skip malformed entries to avoid stopping the stream; diagnostics are captured via worker logs. - } - - if (document is null) - { - continue; - } - - yield return document.ToDomain(); - } - } - - public Task ExistsAsync(string runId, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - var path = GetPath(runId); - return Task.FromResult(File.Exists(path)); - } - - private string GetPath(string runId) - { - var safe = Sanitize(runId); - return Path.Combine(rootPath, $"{safe}.ndjson"); - } - - private static string Sanitize(string value) - { - var result = value.Trim(); - foreach (var invalid in Path.GetInvalidFileNameChars()) - { - result = result.Replace(invalid, '_'); - } - - return string.IsNullOrWhiteSpace(result) ? "run" : result; - } - - private sealed record PackRunLogEntryDocument( - DateTimeOffset Timestamp, - string Level, - string EventType, - string Message, - string? StepId, - Dictionary? Metadata) - { - public static PackRunLogEntryDocument FromDomain(PackRunLogEntry entry) - { - var metadata = entry.Metadata is null - ? null - : new Dictionary(entry.Metadata, StringComparer.Ordinal); - - return new PackRunLogEntryDocument( - entry.Timestamp, - entry.Level, - entry.EventType, - entry.Message, - entry.StepId, - metadata); - } - - public PackRunLogEntry ToDomain() - { - IReadOnlyDictionary? metadata = Metadata is null - ? null - : new Dictionary(Metadata, StringComparer.Ordinal); - - return new PackRunLogEntry( - Timestamp, - Level, - EventType, - Message, - StepId, - metadata); - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunStateStore.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunStateStore.cs deleted file mode 100644 index 5573baf82..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunStateStore.cs +++ /dev/null @@ -1,201 +0,0 @@ - -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using System.Text.Json; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -/// -/// File-system backed implementation of intended for development and air-gapped smoke tests. -/// -public sealed class FilePackRunStateStore : IPackRunStateStore -{ - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true - }; - - private readonly string rootPath; - private readonly SemaphoreSlim mutex = new(1, 1); - - public FilePackRunStateStore(string rootPath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); - - this.rootPath = Path.GetFullPath(rootPath); - Directory.CreateDirectory(this.rootPath); - } - - public async Task GetAsync(string runId, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - var path = GetPath(runId); - if (!File.Exists(path)) - { - return null; - } - - await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); - var document = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken) - .ConfigureAwait(false); - - return document?.ToDomain(); - } - - public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(state); - - var path = GetPath(state.RunId); - var document = StateDocument.FromDomain(state); - - Directory.CreateDirectory(rootPath); - - await mutex.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None); - await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken) - .ConfigureAwait(false); - } - finally - { - mutex.Release(); - } - } - - public async Task> ListAsync(CancellationToken cancellationToken) - { - if (!Directory.Exists(rootPath)) - { - return Array.Empty(); - } - - var states = new List(); - - var files = Directory.EnumerateFiles(rootPath, "*.json", SearchOption.TopDirectoryOnly) - .OrderBy(file => file, StringComparer.Ordinal); - - foreach (var file in files) - { - cancellationToken.ThrowIfCancellationRequested(); - - await using var stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.Read); - var document = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken) - .ConfigureAwait(false); - - if (document is not null) - { - states.Add(document.ToDomain()); - } - } - - return states; - } - - private string GetPath(string runId) - { - var safeName = SanitizeFileName(runId); - return Path.Combine(rootPath, $"{safeName}.json"); - } - - private static string SanitizeFileName(string value) - { - var result = value.Trim(); - foreach (var invalid in Path.GetInvalidFileNameChars()) - { - result = result.Replace(invalid, '_'); - } - - return result; - } - - private sealed record StateDocument( - string RunId, - string PlanHash, - TaskPackPlan Plan, - TaskPackPlanFailurePolicy FailurePolicy, - DateTimeOffset RequestedAt, - DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt, - IReadOnlyList Steps, - string? TenantId) - { - public static StateDocument FromDomain(PackRunState state) - { - var steps = state.Steps.Values - .OrderBy(step => step.StepId, StringComparer.Ordinal) - .Select(step => new StepDocument( - step.StepId, - step.Kind, - step.Enabled, - step.ContinueOnError, - step.MaxParallel, - step.ApprovalId, - step.GateMessage, - step.Status, - step.Attempts, - step.LastTransitionAt, - step.NextAttemptAt, - step.StatusReason)) - .ToList(); - - return new StateDocument( - state.RunId, - state.PlanHash, - state.Plan, - state.FailurePolicy, - state.RequestedAt, - state.CreatedAt, - state.UpdatedAt, - steps, - state.TenantId); - } - - public PackRunState ToDomain() - { - var steps = Steps.ToDictionary( - step => step.StepId, - step => new PackRunStepStateRecord( - step.StepId, - step.Kind, - step.Enabled, - step.ContinueOnError, - step.MaxParallel, - step.ApprovalId, - step.GateMessage, - step.Status, - step.Attempts, - step.LastTransitionAt, - step.NextAttemptAt, - step.StatusReason), - StringComparer.Ordinal); - - return new PackRunState( - RunId, - PlanHash, - Plan, - FailurePolicy, - RequestedAt, - CreatedAt, - UpdatedAt, - steps, - TenantId); - } - } - - private sealed record StepDocument( - string StepId, - PackRunStepKind Kind, - bool Enabled, - bool ContinueOnError, - int? MaxParallel, - string? ApprovalId, - string? GateMessage, - PackRunStepExecutionStatus Status, - int Attempts, - DateTimeOffset? LastTransitionAt, - DateTimeOffset? NextAttemptAt, - string? StatusReason); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunArtifactReader.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunArtifactReader.cs deleted file mode 100644 index cd7c03cef..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunArtifactReader.cs +++ /dev/null @@ -1,76 +0,0 @@ - -using StellaOps.TaskRunner.Core.Execution; -using System.Text.Json; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -public sealed class FilesystemPackRunArtifactReader : IPackRunArtifactReader -{ - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); - - private readonly string rootPath; - - public FilesystemPackRunArtifactReader(string rootPath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); - this.rootPath = Path.GetFullPath(rootPath); - } - - public async Task> ListAsync(string runId, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - var manifestPath = Path.Combine(rootPath, Sanitize(runId), "artifact-manifest.json"); - if (!File.Exists(manifestPath)) - { - return Array.Empty(); - } - - await using var stream = File.OpenRead(manifestPath); - var manifest = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken) - .ConfigureAwait(false); - - if (manifest is null || manifest.Outputs is null) - { - return Array.Empty(); - } - - return manifest.Outputs - .OrderBy(output => output.Name, StringComparer.Ordinal) - .Select(output => new PackRunArtifactRecord( - output.Name, - output.Type, - output.SourcePath, - output.StoredPath, - output.Status, - output.Notes, - manifest.UploadedAt, - output.ExpressionJson)) - .ToList(); - } - - private static string Sanitize(string value) - { - var safe = value.Trim(); - foreach (var invalid in Path.GetInvalidFileNameChars()) - { - safe = safe.Replace(invalid, '_'); - } - - return string.IsNullOrWhiteSpace(safe) ? "run" : safe; - } - - private sealed record ArtifactManifest( - string RunId, - DateTimeOffset UploadedAt, - List Outputs); - - private sealed record ArtifactRecord( - string Name, - string Type, - string? SourcePath, - string? StoredPath, - string Status, - string? Notes, - string? ExpressionJson); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunArtifactUploader.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunArtifactUploader.cs deleted file mode 100644 index 064c6dd8f..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunArtifactUploader.cs +++ /dev/null @@ -1,240 +0,0 @@ - -using Microsoft.Extensions.Logging; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -/// -/// Stores pack run artifacts on the local file system so they can be mirrored to the eventual remote store. -/// -public sealed class FilesystemPackRunArtifactUploader : IPackRunArtifactUploader -{ - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true - }; - - private readonly string rootPath; - private readonly ILogger logger; - private readonly TimeProvider timeProvider; - - public FilesystemPackRunArtifactUploader( - string rootPath, - TimeProvider? timeProvider, - ILogger logger) - { - ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); - - this.rootPath = Path.GetFullPath(rootPath); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.timeProvider = timeProvider ?? TimeProvider.System; - - Directory.CreateDirectory(this.rootPath); - } - - public async Task UploadAsync( - PackRunExecutionContext context, - PackRunState state, - IReadOnlyList outputs, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(state); - ArgumentNullException.ThrowIfNull(outputs); - - if (outputs.Count == 0) - { - return; - } - - var destinationRoot = Path.Combine(rootPath, SanitizeFileName(context.RunId)); - var filesRoot = Path.Combine(destinationRoot, "files"); - var expressionsRoot = Path.Combine(destinationRoot, "expressions"); - - Directory.CreateDirectory(destinationRoot); - - var manifest = new ArtifactManifest( - context.RunId, - timeProvider.GetUtcNow(), - new List(outputs.Count)); - - foreach (var output in outputs) - { - cancellationToken.ThrowIfCancellationRequested(); - - var record = await ProcessOutputAsync( - context, - output, - destinationRoot, - filesRoot, - expressionsRoot, - cancellationToken).ConfigureAwait(false); - - manifest.Outputs.Add(record); - } - - var manifestPath = Path.Combine(destinationRoot, "artifact-manifest.json"); - await using (var stream = File.Open(manifestPath, FileMode.Create, FileAccess.Write, FileShare.None)) - { - await JsonSerializer.SerializeAsync(stream, manifest, SerializerOptions, cancellationToken) - .ConfigureAwait(false); - } - - logger.LogInformation( - "Pack run {RunId} artifact manifest written to {Path} with {Count} output entries.", - context.RunId, - manifestPath, - manifest.Outputs.Count); - } - - private async Task ProcessOutputAsync( - PackRunExecutionContext context, - TaskPackPlanOutput output, - string destinationRoot, - string filesRoot, - string expressionsRoot, - CancellationToken cancellationToken) - { - var sourcePath = ResolveString(output.Path); - var expressionNode = ResolveExpression(output.Expression); - var status = "skipped"; - string? storedPath = null; - string? notes = null; - - if (IsFileOutput(output)) - { - if (string.IsNullOrWhiteSpace(sourcePath)) - { - status = "unresolved"; - notes = "Output path requires runtime value."; - } - else if (!File.Exists(sourcePath)) - { - status = "missing"; - notes = $"Source file '{sourcePath}' not found."; - logger.LogWarning( - "Pack run {RunId} output {Output} referenced missing file {Path}.", - context.RunId, - output.Name, - sourcePath); - } - else - { - Directory.CreateDirectory(filesRoot); - - var destinationPath = Path.Combine(filesRoot, DetermineDestinationFileName(output, sourcePath)); - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); - - await CopyFileAsync(sourcePath, destinationPath, cancellationToken).ConfigureAwait(false); - storedPath = GetRelativePath(destinationPath, destinationRoot); - status = "copied"; - - logger.LogInformation( - "Pack run {RunId} output {Output} copied to {Destination}.", - context.RunId, - output.Name, - destinationPath); - } - } - - if (expressionNode is not null) - { - Directory.CreateDirectory(expressionsRoot); - - var expressionPath = Path.Combine( - expressionsRoot, - $"{SanitizeFileName(output.Name)}.json"); - - var json = expressionNode.ToJsonString(SerializerOptions); - await File.WriteAllTextAsync(expressionPath, json, cancellationToken).ConfigureAwait(false); - - storedPath ??= GetRelativePath(expressionPath, destinationRoot); - status = status == "copied" ? "copied" : "materialized"; - } - - return new ArtifactRecord( - output.Name, - output.Type, - sourcePath, - storedPath, - status, - notes); - } - - private static async Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken) - { - await using var source = File.Open(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read); - await using var destination = File.Open(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None); - await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false); - } - - private static bool IsFileOutput(TaskPackPlanOutput output) - => string.Equals(output.Type, "file", StringComparison.OrdinalIgnoreCase); - - private static string DetermineDestinationFileName(TaskPackPlanOutput output, string sourcePath) - { - var extension = Path.GetExtension(sourcePath); - var baseName = SanitizeFileName(output.Name); - - if (!string.IsNullOrWhiteSpace(extension) && - !baseName.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) - { - return baseName + extension; - } - - return baseName; - } - - private static string? ResolveString(TaskPackPlanParameterValue? parameter) - { - if (parameter is null || parameter.RequiresRuntimeValue || parameter.Value is null) - { - return null; - } - - if (parameter.Value is JsonValue jsonValue && jsonValue.TryGetValue(out var value)) - { - return value; - } - - return null; - } - - private static JsonNode? ResolveExpression(TaskPackPlanParameterValue? parameter) - { - if (parameter is null || parameter.RequiresRuntimeValue) - { - return null; - } - - return parameter.Value; - } - - private static string SanitizeFileName(string value) - { - var result = value; - foreach (var invalid in Path.GetInvalidFileNameChars()) - { - result = result.Replace(invalid, '_'); - } - - return string.IsNullOrWhiteSpace(result) ? "output" : result; - } - - private static string GetRelativePath(string path, string root) - => Path.GetRelativePath(root, path) - .Replace('\\', '/'); - - private sealed record ArtifactManifest(string RunId, DateTimeOffset UploadedAt, List Outputs); - - private sealed record ArtifactRecord( - string Name, - string Type, - string? SourcePath, - string? StoredPath, - string Status, - string? Notes); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunDispatcher.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunDispatcher.cs deleted file mode 100644 index 232befb3f..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunDispatcher.cs +++ /dev/null @@ -1,148 +0,0 @@ - -using StellaOps.AirGap.Policy; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using StellaOps.TaskRunner.Core.TaskPacks; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -public sealed class FilesystemPackRunDispatcher : IPackRunJobDispatcher, IPackRunJobScheduler -{ - private readonly string queuePath; - private readonly string archivePath; - private readonly TaskPackManifestLoader manifestLoader = new(); - private readonly TaskPackPlanner planner; - private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web); - - public FilesystemPackRunDispatcher(string queuePath, string archivePath, IEgressPolicy? egressPolicy = null) - { - this.queuePath = queuePath ?? throw new ArgumentNullException(nameof(queuePath)); - this.archivePath = archivePath ?? throw new ArgumentNullException(nameof(archivePath)); - planner = new TaskPackPlanner(egressPolicy); - Directory.CreateDirectory(queuePath); - Directory.CreateDirectory(archivePath); - } - - public string QueuePath => queuePath; - - public async Task TryDequeueAsync(CancellationToken cancellationToken) - { - var files = Directory.GetFiles(queuePath, "*.json", SearchOption.TopDirectoryOnly) - .OrderBy(path => path, StringComparer.Ordinal) - .ToArray(); - - foreach (var file in files) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - var jobJson = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false); - var job = JsonSerializer.Deserialize(jobJson, serializerOptions); - if (job is null) - { - continue; - } - - TaskPackPlan? plan = job.Plan; - if (plan is null) - { - if (string.IsNullOrWhiteSpace(job.ManifestPath)) - { - continue; - } - - var manifestPath = ResolvePath(queuePath, job.ManifestPath); - var inputsPath = job.InputsPath is null ? null : ResolvePath(queuePath, job.InputsPath); - - var manifest = manifestLoader.Load(manifestPath); - var inputs = await LoadInputsAsync(inputsPath, cancellationToken).ConfigureAwait(false); - var planResult = planner.Plan(manifest, inputs); - if (!planResult.Success || planResult.Plan is null) - { - throw new InvalidOperationException($"Failed to plan pack for run {job.RunId}: {string.Join(';', planResult.Errors.Select(e => e.Message))}"); - } - - plan = planResult.Plan; - } - - var archiveFile = Path.Combine(archivePath, Path.GetFileName(file)); - File.Move(file, archiveFile, overwrite: true); - - var requestedAt = job.RequestedAt ?? DateTimeOffset.UtcNow; - var runId = string.IsNullOrWhiteSpace(job.RunId) ? Guid.NewGuid().ToString("n") : job.RunId; - return new PackRunExecutionContext(runId, plan, requestedAt); - } - catch (Exception ex) - { - var failedPath = file + ".failed"; - File.Move(file, failedPath, overwrite: true); - Console.Error.WriteLine($"Failed to dequeue job '{file}': {ex.Message}"); - } - } - - return null; - } - - public async Task ScheduleAsync(PackRunExecutionContext context, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - - var envelope = new JobEnvelope( - context.RunId, - ManifestPath: null, - InputsPath: null, - context.RequestedAt, - context.Plan); - - Directory.CreateDirectory(queuePath); - var safeRunId = string.IsNullOrWhiteSpace(context.RunId) ? Guid.NewGuid().ToString("n") : SanitizeFileName(context.RunId); - var fileName = $"{safeRunId}-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}.json"; - var path = Path.Combine(queuePath, fileName); - var json = JsonSerializer.Serialize(envelope, serializerOptions); - await File.WriteAllTextAsync(path, json, cancellationToken).ConfigureAwait(false); - } - - private static string ResolvePath(string root, string relative) - => Path.IsPathRooted(relative) ? relative : Path.Combine(root, relative); - - private static async Task> LoadInputsAsync(string? path, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) - { - return new Dictionary(StringComparer.Ordinal); - } - - var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false); - var node = JsonNode.Parse(json) as JsonObject; - if (node is null) - { - return new Dictionary(StringComparer.Ordinal); - } - - return node.ToDictionary( - pair => pair.Key, - pair => pair.Value, - StringComparer.Ordinal); - } - - private sealed record JobEnvelope( - string? RunId, - string? ManifestPath, - string? InputsPath, - DateTimeOffset? RequestedAt, - TaskPackPlan? Plan); - - private static string SanitizeFileName(string value) - { - var safe = value.Trim(); - foreach (var invalid in Path.GetInvalidFileNameChars()) - { - safe = safe.Replace(invalid, '_'); - } - - return safe; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunProvenanceWriter.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunProvenanceWriter.cs deleted file mode 100644 index 316002ff8..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunProvenanceWriter.cs +++ /dev/null @@ -1,57 +0,0 @@ - -using StellaOps.TaskRunner.Core.Execution; -using System.Text.Json; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -public sealed class FilesystemPackRunProvenanceWriter : IPackRunProvenanceWriter -{ - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = false - }; - - private readonly string rootPath; - private readonly TimeProvider timeProvider; - - public FilesystemPackRunProvenanceWriter(string rootPath, TimeProvider? timeProvider = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); - - this.rootPath = Path.GetFullPath(rootPath); - this.timeProvider = timeProvider ?? TimeProvider.System; - } - - public async Task WriteAsync(PackRunExecutionContext context, PackRunState state, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(state); - - var completedAt = timeProvider.GetUtcNow(); - var manifest = ProvenanceManifestFactory.Create(context, state, completedAt); - var manifestPath = GetPath(context.RunId); - - Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); - - await using var stream = File.Open(manifestPath, FileMode.Create, FileAccess.Write, FileShare.None); - await JsonSerializer.SerializeAsync(stream, manifest, SerializerOptions, cancellationToken).ConfigureAwait(false); - await stream.FlushAsync(cancellationToken).ConfigureAwait(false); - } - - private string GetPath(string runId) - { - var safe = Sanitize(runId); - return Path.Combine(rootPath, "provenance", $"{safe}.json"); - } - - private static string Sanitize(string value) - { - var result = value.Trim(); - foreach (var invalid in Path.GetInvalidFileNameChars()) - { - result = result.Replace(invalid, '_'); - } - - return result; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/HttpPackRunNotificationPublisher.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/HttpPackRunNotificationPublisher.cs deleted file mode 100644 index 83d15ac4e..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/HttpPackRunNotificationPublisher.cs +++ /dev/null @@ -1,74 +0,0 @@ - -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using StellaOps.TaskRunner.Core.Execution; -using System.Net.Http.Json; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -public sealed class HttpPackRunNotificationPublisher : IPackRunNotificationPublisher -{ - private readonly IHttpClientFactory httpClientFactory; - private readonly NotificationOptions options; - private readonly ILogger logger; - - public HttpPackRunNotificationPublisher( - IHttpClientFactory httpClientFactory, - IOptions options, - ILogger logger) - { - this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken) - { - if (options.ApprovalEndpoint is null) - { - logger.LogWarning("Approval endpoint not configured; skipping approval notification for run {RunId}.", runId); - return; - } - - var client = httpClientFactory.CreateClient("taskrunner-notifications"); - var payload = new - { - runId, - notification.ApprovalId, - notification.RequiredGrants, - notification.Messages, - notification.StepIds, - notification.ReasonTemplate - }; - - var response = await client.PostAsJsonAsync(options.ApprovalEndpoint, payload, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - } - - public async Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken) - { - if (options.PolicyEndpoint is null) - { - logger.LogDebug("Policy endpoint not configured; skipping policy notification for run {RunId} step {StepId}.", runId, notification.StepId); - return; - } - - var client = httpClientFactory.CreateClient("taskrunner-notifications"); - var payload = new - { - runId, - notification.StepId, - notification.Message, - Parameters = notification.Parameters.Select(parameter => new - { - parameter.Name, - parameter.RequiresRuntimeValue, - parameter.Expression, - parameter.Error - }) - }; - - var response = await client.PostAsJsonAsync(options.PolicyEndpoint, payload, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/InMemoryPackRunApprovalStore.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/InMemoryPackRunApprovalStore.cs deleted file mode 100644 index abaa257de..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/InMemoryPackRunApprovalStore.cs +++ /dev/null @@ -1,67 +0,0 @@ -using StellaOps.TaskRunner.Core.Execution; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -/// -/// In-memory approval state store for explicit non-production TaskRunner profiles. -/// -public sealed class InMemoryPackRunApprovalStore : IPackRunApprovalStore -{ - private readonly Dictionary> _approvals = new(StringComparer.Ordinal); - private readonly object _gate = new(); - - public Task SaveAsync(string runId, IReadOnlyList approvals, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - ArgumentNullException.ThrowIfNull(approvals); - cancellationToken.ThrowIfCancellationRequested(); - - lock (_gate) - { - _approvals[runId] = approvals.ToList(); - } - - return Task.CompletedTask; - } - - public Task> GetAsync(string runId, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - cancellationToken.ThrowIfCancellationRequested(); - - lock (_gate) - { - if (!_approvals.TryGetValue(runId, out var values)) - { - return Task.FromResult>(Array.Empty()); - } - - return Task.FromResult>(values.ToList()); - } - } - - public Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - ArgumentNullException.ThrowIfNull(approval); - cancellationToken.ThrowIfCancellationRequested(); - - lock (_gate) - { - if (!_approvals.TryGetValue(runId, out var values)) - { - throw new InvalidOperationException($"No approvals found for run '{runId}'."); - } - - var index = values.FindIndex(existing => string.Equals(existing.ApprovalId, approval.ApprovalId, StringComparison.Ordinal)); - if (index < 0) - { - throw new InvalidOperationException($"Approval '{approval.ApprovalId}' not found for run '{runId}'."); - } - - values[index] = approval; - } - - return Task.CompletedTask; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/InMemoryPackRunLogStore.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/InMemoryPackRunLogStore.cs deleted file mode 100644 index b292a0820..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/InMemoryPackRunLogStore.cs +++ /dev/null @@ -1,71 +0,0 @@ -using StellaOps.TaskRunner.Core.Execution; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -/// -/// In-memory append-only log store for explicit non-production TaskRunner profiles. -/// -public sealed class InMemoryPackRunLogStore : IPackRunLogStore -{ - private readonly Dictionary> _logs = new(StringComparer.Ordinal); - private readonly object _gate = new(); - - public Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - ArgumentNullException.ThrowIfNull(entry); - cancellationToken.ThrowIfCancellationRequested(); - - lock (_gate) - { - if (!_logs.TryGetValue(runId, out var entries)) - { - entries = new List(); - _logs[runId] = entries; - } - - entries.Add(entry); - } - - return Task.CompletedTask; - } - - public async IAsyncEnumerable ReadAsync( - string runId, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - PackRunLogEntry[] entries; - lock (_gate) - { - if (!_logs.TryGetValue(runId, out var values) || values.Count == 0) - { - yield break; - } - - entries = values - .OrderBy(entry => entry.Timestamp) - .ThenBy(entry => entry.EventType, StringComparer.Ordinal) - .ToArray(); - } - - foreach (var entry in entries) - { - cancellationToken.ThrowIfCancellationRequested(); - yield return entry; - await Task.Yield(); - } - } - - public Task ExistsAsync(string runId, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - cancellationToken.ThrowIfCancellationRequested(); - - lock (_gate) - { - return Task.FromResult(_logs.TryGetValue(runId, out var entries) && entries.Count > 0); - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/InMemoryPackRunStateStore.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/InMemoryPackRunStateStore.cs deleted file mode 100644 index 19d226572..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/InMemoryPackRunStateStore.cs +++ /dev/null @@ -1,51 +0,0 @@ -using StellaOps.TaskRunner.Core.Execution; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -/// -/// In-memory state store for explicit non-production TaskRunner profiles. -/// -public sealed class InMemoryPackRunStateStore : IPackRunStateStore -{ - private readonly Dictionary _states = new(StringComparer.Ordinal); - private readonly object _gate = new(); - - public Task GetAsync(string runId, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - cancellationToken.ThrowIfCancellationRequested(); - - lock (_gate) - { - _states.TryGetValue(runId, out var state); - return Task.FromResult(state); - } - } - - public Task SaveAsync(PackRunState state, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(state); - cancellationToken.ThrowIfCancellationRequested(); - - lock (_gate) - { - _states[state.RunId] = state; - } - - return Task.CompletedTask; - } - - public Task> ListAsync(CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - lock (_gate) - { - var ordered = _states.Values - .OrderBy(state => state.UpdatedAt) - .ThenBy(state => state.RunId, StringComparer.Ordinal) - .ToArray(); - return Task.FromResult>(ordered); - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/LoggingPackRunArtifactUploader.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/LoggingPackRunArtifactUploader.cs deleted file mode 100644 index 05773946e..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/LoggingPackRunArtifactUploader.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.Extensions.Logging; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -public sealed class LoggingPackRunArtifactUploader : IPackRunArtifactUploader -{ - private readonly ILogger _logger; - - public LoggingPackRunArtifactUploader(ILogger logger) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public Task UploadAsync( - PackRunExecutionContext context, - PackRunState state, - IReadOnlyList outputs, - CancellationToken cancellationToken) - { - if (outputs.Count == 0) - { - return Task.CompletedTask; - } - - foreach (var output in outputs) - { - var path = output.Path?.Value?.ToString() ?? "(dynamic)"; - _logger.LogInformation( - "Pack run {RunId} scheduled artifact upload for output {Output} (type={Type}, path={Path}).", - context.RunId, - output.Name, - output.Type, - path); - } - - return Task.CompletedTask; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/LoggingPackRunNotificationPublisher.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/LoggingPackRunNotificationPublisher.cs deleted file mode 100644 index 9026aca3c..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/LoggingPackRunNotificationPublisher.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.Extensions.Logging; -using StellaOps.TaskRunner.Core.Execution; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -public sealed class LoggingPackRunNotificationPublisher : IPackRunNotificationPublisher -{ - private readonly ILogger logger; - - public LoggingPackRunNotificationPublisher(ILogger logger) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken) - { - logger.LogInformation( - "Run {RunId}: approval {ApprovalId} requires grants {Grants}.", - runId, - notification.ApprovalId, - string.Join(",", notification.RequiredGrants)); - return Task.CompletedTask; - } - - public Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken) - { - logger.LogDebug( - "Run {RunId}: policy gate {StepId} pending (parameters: {Parameters}).", - runId, - notification.StepId, - string.Join(",", notification.Parameters.Select(p => p.Name))); - return Task.CompletedTask; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/NoopPackRunJobDispatcher.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/NoopPackRunJobDispatcher.cs deleted file mode 100644 index 54ecdcc81..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/NoopPackRunJobDispatcher.cs +++ /dev/null @@ -1,9 +0,0 @@ -using StellaOps.TaskRunner.Core.Execution; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -public sealed class NoopPackRunJobDispatcher : IPackRunJobDispatcher -{ - public Task TryDequeueAsync(CancellationToken cancellationToken) - => Task.FromResult(null); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/NoopPackRunStepExecutor.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/NoopPackRunStepExecutor.cs deleted file mode 100644 index 05591dd77..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/NoopPackRunStepExecutor.cs +++ /dev/null @@ -1,25 +0,0 @@ - -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using System.Text.Json.Nodes; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -public sealed class NoopPackRunStepExecutor : IPackRunStepExecutor -{ - public Task ExecuteAsync( - PackRunExecutionStep step, - IReadOnlyDictionary parameters, - CancellationToken cancellationToken) - { - if (parameters.TryGetValue("simulateFailure", out var value) && - value.Value is JsonValue jsonValue && - jsonValue.TryGetValue(out var failure) && - failure) - { - return Task.FromResult(new PackRunStepExecutionResult(false, "Simulated failure requested.")); - } - - return Task.FromResult(new PackRunStepExecutionResult(true)); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/NotificationOptions.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/NotificationOptions.cs deleted file mode 100644 index 4bd2c9272..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/NotificationOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -public sealed class NotificationOptions -{ - public Uri? ApprovalEndpoint { get; set; } - - public Uri? PolicyEndpoint { get; set; } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/PackRunApprovalDecisionService.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/PackRunApprovalDecisionService.cs deleted file mode 100644 index 0a19cfece..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/PackRunApprovalDecisionService.cs +++ /dev/null @@ -1,146 +0,0 @@ -using Microsoft.Extensions.Logging; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using System.Text.RegularExpressions; - -namespace StellaOps.TaskRunner.Infrastructure.Execution; - -public sealed class PackRunApprovalDecisionService -{ - private readonly IPackRunApprovalStore _approvalStore; - private readonly IPackRunStateStore _stateStore; - private readonly IPackRunJobScheduler _scheduler; - private readonly ILogger _logger; - - public PackRunApprovalDecisionService( - IPackRunApprovalStore approvalStore, - IPackRunStateStore stateStore, - IPackRunJobScheduler scheduler, - ILogger logger) - { - _approvalStore = approvalStore ?? throw new ArgumentNullException(nameof(approvalStore)); - _stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); - _scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task ApplyAsync( - PackRunApprovalDecisionRequest request, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentException.ThrowIfNullOrWhiteSpace(request.RunId); - ArgumentException.ThrowIfNullOrWhiteSpace(request.ApprovalId); - - var runId = request.RunId.Trim(); - var approvalId = request.ApprovalId.Trim(); - - if (!IsSha256Digest(request.PlanHash)) - { - _logger.LogWarning( - "Approval decision for run {RunId} rejected – plan hash format invalid (expected sha256:<64-hex>).", - runId); - return PackRunApprovalDecisionResult.PlanHashMismatch; - } - - var state = await _stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false); - if (state is null) - { - _logger.LogWarning("Approval decision for run {RunId} rejected – run state not found.", runId); - return PackRunApprovalDecisionResult.NotFound; - } - - var approvals = await _approvalStore.GetAsync(runId, cancellationToken).ConfigureAwait(false); - if (approvals.Count == 0) - { - _logger.LogWarning("Approval decision for run {RunId} rejected – approval state not found.", runId); - return PackRunApprovalDecisionResult.NotFound; - } - - if (!string.Equals(state.PlanHash, request.PlanHash, StringComparison.Ordinal)) - { - _logger.LogWarning( - "Approval decision for run {RunId} rejected – plan hash mismatch (expected {Expected}, got {Actual}).", - runId, - state.PlanHash, - request.PlanHash); - return PackRunApprovalDecisionResult.PlanHashMismatch; - } - - var requestedAt = state.RequestedAt != default ? state.RequestedAt : state.CreatedAt; - var coordinator = PackRunApprovalCoordinator.Restore(state.Plan, approvals, requestedAt); - - ApprovalActionResult actionResult; - var now = DateTimeOffset.UtcNow; - - switch (request.Decision) - { - case PackRunApprovalDecisionType.Approved: - actionResult = coordinator.Approve(approvalId, request.ActorId ?? "system", now, request.Summary); - break; - - case PackRunApprovalDecisionType.Rejected: - actionResult = coordinator.Reject(approvalId, request.ActorId ?? "system", now, request.Summary); - break; - - case PackRunApprovalDecisionType.Expired: - actionResult = coordinator.Expire(approvalId, now, request.Summary); - break; - - default: - throw new ArgumentOutOfRangeException(nameof(request.Decision), request.Decision, "Unsupported approval decision."); - } - - await _approvalStore.UpdateAsync(runId, actionResult.State, cancellationToken).ConfigureAwait(false); - - _logger.LogInformation( - "Applied approval decision {Decision} for run {RunId} (approval {ApprovalId}, actor={ActorId}).", - request.Decision, - runId, - approvalId, - request.ActorId ?? "(system)"); - - if (actionResult.ShouldResumeRun && request.Decision == PackRunApprovalDecisionType.Approved) - { - var context = new PackRunExecutionContext(runId, state.Plan, requestedAt); - await _scheduler.ScheduleAsync(context, cancellationToken).ConfigureAwait(false); - _logger.LogInformation("Scheduled run {RunId} for resume after approvals completed.", runId); - return PackRunApprovalDecisionResult.Resumed; - } - - return PackRunApprovalDecisionResult.Applied; - } - - private static bool IsSha256Digest(string value) - => !string.IsNullOrWhiteSpace(value) - && Sha256Pattern.IsMatch(value); - - private static readonly Regex Sha256Pattern = new( - "^sha256:[0-9a-f]{64}$", - RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); -} - -public sealed record PackRunApprovalDecisionRequest( - string RunId, - string ApprovalId, - string PlanHash, - PackRunApprovalDecisionType Decision, - string? ActorId, - string? Summary); - -public enum PackRunApprovalDecisionType -{ - Approved, - Rejected, - Expired -} - -public sealed record PackRunApprovalDecisionResult(string Status) -{ - public static PackRunApprovalDecisionResult NotFound { get; } = new("not_found"); - public static PackRunApprovalDecisionResult PlanHashMismatch { get; } = new("plan_hash_mismatch"); - public static PackRunApprovalDecisionResult Applied { get; } = new("applied"); - public static PackRunApprovalDecisionResult Resumed { get; } = new("resumed"); - - public bool ShouldResume => ReferenceEquals(this, Resumed); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/StellaOps.TaskRunner.Infrastructure.csproj b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/StellaOps.TaskRunner.Infrastructure.csproj deleted file mode 100644 index 9e0f9494f..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/StellaOps.TaskRunner.Infrastructure.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - net10.0 - enable - enable - preview - true - - diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/TASKS.md b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/TASKS.md deleted file mode 100644 index 756f2e509..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/TASKS.md +++ /dev/null @@ -1,9 +0,0 @@ -# StellaOps.TaskRunner.Infrastructure Task Board -This board mirrors active sprint tasks for this module. -Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`. - -| Task ID | Status | Notes | -| --- | --- | --- | -| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/StellaOps.TaskRunner.Infrastructure.md. | -| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | -| SPRINT-312-004 | DONE | Added explicit in-memory store implementations to keep non-production fallback deterministic and explicit. | diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Tenancy/TenantScopedPackRunLogStore.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Tenancy/TenantScopedPackRunLogStore.cs deleted file mode 100644 index 99c180c08..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Tenancy/TenantScopedPackRunLogStore.cs +++ /dev/null @@ -1,242 +0,0 @@ - -using Microsoft.Extensions.Logging; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Tenancy; -using System.Collections.Concurrent; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; - -namespace StellaOps.TaskRunner.Infrastructure.Tenancy; - -/// -/// Tenant-scoped pack run log store per TASKRUN-TEN-48-001. -/// Persists logs as NDJSON under tenant-prefixed paths with tenant context propagation. -/// -public sealed class TenantScopedPackRunLogStore : IPackRunLogStore -{ - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = false - }; - - private readonly ITenantScopedStoragePathResolver _pathResolver; - private readonly TenantContext _tenant; - private readonly ConcurrentDictionary _fileLocks = new(StringComparer.Ordinal); - private readonly ILogger _logger; - - public TenantScopedPackRunLogStore( - ITenantScopedStoragePathResolver pathResolver, - TenantContext tenant, - ILogger logger) - { - _pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver)); - _tenant = tenant ?? throw new ArgumentNullException(nameof(tenant)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - ArgumentNullException.ThrowIfNull(entry); - - var path = GetLogsPath(runId); - var directory = Path.GetDirectoryName(path); - if (directory is not null) - { - Directory.CreateDirectory(directory); - } - - var gate = _fileLocks.GetOrAdd(path, _ => new SemaphoreSlim(1, 1)); - await gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - // Enrich entry with tenant context - var enrichedEntry = EnrichWithTenantContext(entry); - var document = PackRunLogEntryDocument.FromDomain(enrichedEntry); - var json = JsonSerializer.Serialize(document, SerializerOptions); - await File.AppendAllTextAsync(path, json + Environment.NewLine, cancellationToken) - .ConfigureAwait(false); - - _logger.LogDebug( - "Appended log entry for run {RunId} in tenant {TenantId}.", - runId, - _tenant.TenantId); - } - finally - { - gate.Release(); - } - } - - public async IAsyncEnumerable ReadAsync( - string runId, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - var path = GetLogsPath(runId); - if (!File.Exists(path)) - { - _logger.LogDebug( - "No logs found for run {RunId} in tenant {TenantId}.", - runId, - _tenant.TenantId); - yield break; - } - - await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var reader = new StreamReader(stream, Encoding.UTF8); - - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); - if (line is null) - { - yield break; - } - - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - PackRunLogEntryDocument? document = null; - try - { - document = JsonSerializer.Deserialize(line, SerializerOptions); - } - catch - { - // Skip malformed entries - _logger.LogWarning("Skipping malformed log entry in run {RunId}.", runId); - } - - if (document is null) - { - continue; - } - - var entry = document.ToDomain(); - - // Verify tenant ownership from metadata - var tenantId = entry.Metadata?.GetValueOrDefault("TenantId"); - if (tenantId is not null && !string.Equals(tenantId, _tenant.TenantId, StringComparison.Ordinal)) - { - _logger.LogWarning( - "Log entry tenant mismatch: expected {ExpectedTenantId}, found {ActualTenantId} in run {RunId}.", - _tenant.TenantId, - tenantId, - runId); - continue; - } - - yield return entry; - } - } - - public Task ExistsAsync(string runId, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - var path = GetLogsPath(runId); - return Task.FromResult(File.Exists(path)); - } - - private string GetLogsPath(string runId) - { - var logsPath = _pathResolver.GetLogsPath(_tenant, runId); - return $"{logsPath}.ndjson"; - } - - private PackRunLogEntry EnrichWithTenantContext(PackRunLogEntry entry) - { - // Add tenant context to metadata - var metadata = entry.Metadata is not null - ? new Dictionary(entry.Metadata, StringComparer.Ordinal) - : new Dictionary(StringComparer.Ordinal); - - metadata["TenantId"] = _tenant.TenantId; - metadata["ProjectId"] = _tenant.ProjectId; - - return new PackRunLogEntry( - entry.Timestamp, - entry.Level, - entry.EventType, - entry.Message, - entry.StepId, - metadata); - } - - private sealed record PackRunLogEntryDocument( - DateTimeOffset Timestamp, - string Level, - string EventType, - string Message, - string? StepId, - Dictionary? Metadata) - { - public static PackRunLogEntryDocument FromDomain(PackRunLogEntry entry) - { - var metadata = entry.Metadata is null - ? null - : new Dictionary(entry.Metadata, StringComparer.Ordinal); - - return new PackRunLogEntryDocument( - entry.Timestamp, - entry.Level, - entry.EventType, - entry.Message, - entry.StepId, - metadata); - } - - public PackRunLogEntry ToDomain() - { - IReadOnlyDictionary? metadata = Metadata is null - ? null - : new Dictionary(Metadata, StringComparer.Ordinal); - - return new PackRunLogEntry( - Timestamp, - Level, - EventType, - Message, - StepId, - metadata); - } - } -} - -/// -/// Factory for creating tenant-scoped log stores. -/// -public interface ITenantScopedLogStoreFactory -{ - IPackRunLogStore Create(TenantContext tenant); -} - -/// -/// Default implementation of tenant-scoped log store factory. -/// -public sealed class TenantScopedLogStoreFactory : ITenantScopedLogStoreFactory -{ - private readonly ITenantScopedStoragePathResolver _pathResolver; - private readonly ILoggerFactory _loggerFactory; - - public TenantScopedLogStoreFactory( - ITenantScopedStoragePathResolver pathResolver, - ILoggerFactory loggerFactory) - { - _pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver)); - _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - } - - public IPackRunLogStore Create(TenantContext tenant) - { - ArgumentNullException.ThrowIfNull(tenant); - - var logger = _loggerFactory.CreateLogger(); - return new TenantScopedPackRunLogStore(_pathResolver, tenant, logger); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Tenancy/TenantScopedPackRunStateStore.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Tenancy/TenantScopedPackRunStateStore.cs deleted file mode 100644 index 61b2955c0..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Tenancy/TenantScopedPackRunStateStore.cs +++ /dev/null @@ -1,283 +0,0 @@ - -using Microsoft.Extensions.Logging; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using StellaOps.TaskRunner.Core.Tenancy; -using System.Text.Json; - -namespace StellaOps.TaskRunner.Infrastructure.Tenancy; - -/// -/// Tenant-scoped pack run state store per TASKRUN-TEN-48-001. -/// Ensures all state is stored under tenant-prefixed paths. -/// -public sealed class TenantScopedPackRunStateStore : IPackRunStateStore -{ - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true - }; - - private readonly ITenantScopedStoragePathResolver _pathResolver; - private readonly TenantContext _tenant; - private readonly SemaphoreSlim _mutex = new(1, 1); - private readonly ILogger _logger; - private readonly string _basePath; - - public TenantScopedPackRunStateStore( - ITenantScopedStoragePathResolver pathResolver, - TenantContext tenant, - ILogger logger) - { - _pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver)); - _tenant = tenant ?? throw new ArgumentNullException(nameof(tenant)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - // Use the tenant base path for listing operations - _basePath = _pathResolver.GetTenantBasePath(tenant); - Directory.CreateDirectory(_basePath); - } - - public async Task GetAsync(string runId, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(runId); - - var path = GetStatePath(runId); - if (!File.Exists(path)) - { - _logger.LogDebug( - "State not found for run {RunId} in tenant {TenantId}.", - runId, - _tenant.TenantId); - return null; - } - - await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); - var document = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken) - .ConfigureAwait(false); - - var state = document?.ToDomain(); - - // Validate tenant ownership - if (state is not null && !string.Equals(state.TenantId, _tenant.TenantId, StringComparison.Ordinal)) - { - _logger.LogWarning( - "State tenant mismatch: expected {ExpectedTenantId}, found {ActualTenantId} for run {RunId}.", - _tenant.TenantId, - state.TenantId, - runId); - return null; - } - - return state; - } - - public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(state); - - // Enforce tenant ownership - if (!string.Equals(state.TenantId, _tenant.TenantId, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Cannot save state for tenant {state.TenantId} in store scoped to tenant {_tenant.TenantId}."); - } - - var path = GetStatePath(state.RunId); - var directory = Path.GetDirectoryName(path); - if (directory is not null) - { - Directory.CreateDirectory(directory); - } - - var document = StateDocument.FromDomain(state); - - await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None); - await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken) - .ConfigureAwait(false); - - _logger.LogDebug( - "Saved state for run {RunId} in tenant {TenantId}.", - state.RunId, - _tenant.TenantId); - } - finally - { - _mutex.Release(); - } - } - - public async Task> ListAsync(CancellationToken cancellationToken) - { - var stateBasePath = Path.Combine(_basePath, "state"); - if (!Directory.Exists(stateBasePath)) - { - return Array.Empty(); - } - - var states = new List(); - - // Search recursively for state files in tenant-scoped directory - var files = Directory.EnumerateFiles(stateBasePath, "*.json", SearchOption.AllDirectories) - .OrderBy(file => file, StringComparer.Ordinal); - - foreach (var file in files) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - await using var stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.Read); - var document = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken) - .ConfigureAwait(false); - - if (document is not null) - { - var state = document.ToDomain(); - - // Only include states that belong to this tenant - if (string.Equals(state.TenantId, _tenant.TenantId, StringComparison.Ordinal)) - { - states.Add(state); - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to read state file {File}.", file); - } - } - - return states; - } - - private string GetStatePath(string runId) - { - var statePath = _pathResolver.GetStatePath(_tenant, runId); - return $"{statePath}.json"; - } - - private sealed record StateDocument( - string RunId, - string PlanHash, - TaskPackPlan Plan, - TaskPackPlanFailurePolicy FailurePolicy, - DateTimeOffset RequestedAt, - DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt, - IReadOnlyList Steps, - string? TenantId) - { - public static StateDocument FromDomain(PackRunState state) - { - var steps = state.Steps.Values - .OrderBy(step => step.StepId, StringComparer.Ordinal) - .Select(step => new StepDocument( - step.StepId, - step.Kind, - step.Enabled, - step.ContinueOnError, - step.MaxParallel, - step.ApprovalId, - step.GateMessage, - step.Status, - step.Attempts, - step.LastTransitionAt, - step.NextAttemptAt, - step.StatusReason)) - .ToList(); - - return new StateDocument( - state.RunId, - state.PlanHash, - state.Plan, - state.FailurePolicy, - state.RequestedAt, - state.CreatedAt, - state.UpdatedAt, - steps, - state.TenantId); - } - - public PackRunState ToDomain() - { - var steps = Steps.ToDictionary( - step => step.StepId, - step => new PackRunStepStateRecord( - step.StepId, - step.Kind, - step.Enabled, - step.ContinueOnError, - step.MaxParallel, - step.ApprovalId, - step.GateMessage, - step.Status, - step.Attempts, - step.LastTransitionAt, - step.NextAttemptAt, - step.StatusReason), - StringComparer.Ordinal); - - return new PackRunState( - RunId, - PlanHash, - Plan, - FailurePolicy, - RequestedAt, - CreatedAt, - UpdatedAt, - steps, - TenantId); - } - } - - private sealed record StepDocument( - string StepId, - PackRunStepKind Kind, - bool Enabled, - bool ContinueOnError, - int? MaxParallel, - string? ApprovalId, - string? GateMessage, - PackRunStepExecutionStatus Status, - int Attempts, - DateTimeOffset? LastTransitionAt, - DateTimeOffset? NextAttemptAt, - string? StatusReason); -} - -/// -/// Factory for creating tenant-scoped state stores. -/// -public interface ITenantScopedStateStoreFactory -{ - IPackRunStateStore Create(TenantContext tenant); -} - -/// -/// Default implementation of tenant-scoped state store factory. -/// -public sealed class TenantScopedStateStoreFactory : ITenantScopedStateStoreFactory -{ - private readonly ITenantScopedStoragePathResolver _pathResolver; - private readonly ILoggerFactory _loggerFactory; - - public TenantScopedStateStoreFactory( - ITenantScopedStoragePathResolver pathResolver, - ILoggerFactory loggerFactory) - { - _pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver)); - _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - } - - public IPackRunStateStore Create(TenantContext tenant) - { - ArgumentNullException.ThrowIfNull(tenant); - - var logger = _loggerFactory.CreateLogger(); - return new TenantScopedPackRunStateStore(_pathResolver, tenant, logger); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/ApiDeprecationTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/ApiDeprecationTests.cs deleted file mode 100644 index c017066e6..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/ApiDeprecationTests.cs +++ /dev/null @@ -1,183 +0,0 @@ -using System.Text.RegularExpressions; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using StellaOps.TaskRunner.WebService.Deprecation; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class ApiDeprecationTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public void DeprecatedEndpoint_PathPattern_MatchesExpected() - { - var endpoint = new DeprecatedEndpoint - { - PathPattern = "/v1/legacy/*", - DeprecatedAt = DateTimeOffset.UtcNow.AddDays(-30), - SunsetAt = DateTimeOffset.UtcNow.AddDays(60), - ReplacementPath = "/v2/new", - Message = "Use the v2 API" - }; - - Assert.Equal("/v1/legacy/*", endpoint.PathPattern); - Assert.NotNull(endpoint.DeprecatedAt); - Assert.NotNull(endpoint.SunsetAt); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void ApiDeprecationOptions_DefaultValues_AreCorrect() - { - var options = new ApiDeprecationOptions(); - - Assert.True(options.EmitDeprecationHeaders); - Assert.True(options.EmitSunsetHeaders); - Assert.NotNull(options.DeprecationPolicyUrl); - Assert.Empty(options.DeprecatedEndpoints); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task LoggingDeprecationNotificationService_GetUpcoming_FiltersCorrectly() - { - var now = DateTimeOffset.UtcNow; - var options = new ApiDeprecationOptions - { - DeprecatedEndpoints = - [ - new DeprecatedEndpoint - { - PathPattern = "/v1/soon/*", - SunsetAt = now.AddDays(30) // Within 90 days - }, - new DeprecatedEndpoint - { - PathPattern = "/v1/later/*", - SunsetAt = now.AddDays(180) // Beyond 90 days - }, - new DeprecatedEndpoint - { - PathPattern = "/v1/past/*", - SunsetAt = now.AddDays(-10) // Already passed - } - ] - }; - - var optionsMonitor = new OptionsMonitor(options); - var service = new LoggingDeprecationNotificationService( - NullLogger.Instance, - optionsMonitor); - - var upcoming = await service.GetUpcomingDeprecationsAsync(90, CancellationToken.None); - - Assert.Single(upcoming); - Assert.Equal("/v1/soon/*", upcoming[0].EndpointPath); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task LoggingDeprecationNotificationService_GetUpcoming_OrdersBySunsetDate() - { - var now = DateTimeOffset.UtcNow; - var options = new ApiDeprecationOptions - { - DeprecatedEndpoints = - [ - new DeprecatedEndpoint { PathPattern = "/v1/third/*", SunsetAt = now.AddDays(60) }, - new DeprecatedEndpoint { PathPattern = "/v1/first/*", SunsetAt = now.AddDays(10) }, - new DeprecatedEndpoint { PathPattern = "/v1/second/*", SunsetAt = now.AddDays(30) } - ] - }; - - var optionsMonitor = new OptionsMonitor(options); - var service = new LoggingDeprecationNotificationService( - NullLogger.Instance, - optionsMonitor); - - var upcoming = await service.GetUpcomingDeprecationsAsync(90, CancellationToken.None); - - Assert.Equal(3, upcoming.Count); - Assert.Equal("/v1/first/*", upcoming[0].EndpointPath); - Assert.Equal("/v1/second/*", upcoming[1].EndpointPath); - Assert.Equal("/v1/third/*", upcoming[2].EndpointPath); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void DeprecationInfo_DaysUntilSunset_CalculatesCorrectly() - { - var now = DateTimeOffset.UtcNow; - var sunsetDate = now.AddDays(45); - - var info = new DeprecationInfo( - "/v1/test/*", - now.AddDays(-30), - sunsetDate, - "/v2/test/*", - "https://docs.example.com/migration", - 45); - - Assert.Equal(45, info.DaysUntilSunset); - Assert.Equal("/v2/test/*", info.ReplacementPath); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void DeprecationNotification_RecordProperties_AreAccessible() - { - var notification = new DeprecationNotification( - "/v1/legacy/endpoint", - "/v2/new/endpoint", - DateTimeOffset.UtcNow.AddDays(90), - "This endpoint is deprecated", - "https://docs.example.com/deprecation", - ["consumer-1", "consumer-2"]); - - Assert.Equal("/v1/legacy/endpoint", notification.EndpointPath); - Assert.Equal("/v2/new/endpoint", notification.ReplacementPath); - Assert.NotNull(notification.SunsetDate); - Assert.Equal(2, notification.AffectedConsumerIds?.Count); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void PathPattern_WildcardToRegex_MatchesSingleSegment() - { - var pattern = "^" + Regex.Escape("/v1/packs/*") - .Replace("\\*\\*", ".*") - .Replace("\\*", "[^/]*") + "$"; - - Assert.Matches(pattern, "/v1/packs/foo"); - Assert.Matches(pattern, "/v1/packs/bar"); - Assert.DoesNotMatch(pattern, "/v1/packs/foo/bar"); // Single * shouldn't match / - Assert.DoesNotMatch(pattern, "/v2/packs/foo"); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void PathPattern_DoubleWildcard_MatchesMultipleSegments() - { - var pattern = "^" + Regex.Escape("/v1/legacy/**") - .Replace("\\*\\*", ".*") - .Replace("\\*", "[^/]*") + "$"; - - Assert.Matches(pattern, "/v1/legacy/foo"); - Assert.Matches(pattern, "/v1/legacy/foo/bar"); - Assert.Matches(pattern, "/v1/legacy/foo/bar/baz"); - Assert.DoesNotMatch(pattern, "/v2/legacy/foo"); - } - - private sealed class OptionsMonitor : IOptionsMonitor - { - public OptionsMonitor(ApiDeprecationOptions value) => CurrentValue = value; - - public ApiDeprecationOptions CurrentValue { get; } - - public ApiDeprecationOptions Get(string? name) => CurrentValue; - - public IDisposable? OnChange(Action listener) => null; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/BundleImportEvidenceTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/BundleImportEvidenceTests.cs deleted file mode 100644 index 47e3e3371..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/BundleImportEvidenceTests.cs +++ /dev/null @@ -1,358 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using StellaOps.TaskRunner.Core.Evidence; -using StellaOps.TaskRunner.Core.Events; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class BundleImportEvidenceTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public void BundleImportHashChain_Compute_CreatesDeterministicHash() - { - var input = new BundleImportInputManifest( - FormatVersion: "1.0.0", - BundleId: "test-bundle", - BundleVersion: "2025.10.0", - CreatedAt: DateTimeOffset.Parse("2025-12-06T00:00:00Z"), - CreatedBy: "test@example.com", - TotalSizeBytes: 1024, - ItemCount: 5, - ManifestSha256: "sha256:abc123", - Signature: null, - SignatureValid: null); - - var outputs = new List - { - new("file1.json", "sha256:aaa", 100, "application/json", DateTimeOffset.UtcNow, "item1"), - new("file2.json", "sha256:bbb", 200, "application/json", DateTimeOffset.UtcNow, "item2") - }; - - var transcript = new List - { - new(DateTimeOffset.UtcNow, "info", "import.started", "Import started", null) - }; - - var chain1 = BundleImportHashChain.Compute(input, outputs, transcript); - var chain2 = BundleImportHashChain.Compute(input, outputs, transcript); - - Assert.Equal(chain1.RootHash, chain2.RootHash); - Assert.Equal(chain1.InputsHash, chain2.InputsHash); - Assert.Equal(chain1.OutputsHash, chain2.OutputsHash); - Assert.StartsWith("sha256:", chain1.RootHash); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void BundleImportHashChain_Compute_DifferentInputsProduceDifferentHashes() - { - var input1 = new BundleImportInputManifest( - FormatVersion: "1.0.0", - BundleId: "bundle-1", - BundleVersion: "2025.10.0", - CreatedAt: DateTimeOffset.UtcNow, - CreatedBy: null, - TotalSizeBytes: 1024, - ItemCount: 5, - ManifestSha256: "sha256:abc123", - Signature: null, - SignatureValid: null); - - var input2 = new BundleImportInputManifest( - FormatVersion: "1.0.0", - BundleId: "bundle-2", - BundleVersion: "2025.10.0", - CreatedAt: DateTimeOffset.UtcNow, - CreatedBy: null, - TotalSizeBytes: 1024, - ItemCount: 5, - ManifestSha256: "sha256:def456", - Signature: null, - SignatureValid: null); - - var outputs = new List(); - var transcript = new List(); - - var chain1 = BundleImportHashChain.Compute(input1, outputs, transcript); - var chain2 = BundleImportHashChain.Compute(input2, outputs, transcript); - - Assert.NotEqual(chain1.RootHash, chain2.RootHash); - Assert.NotEqual(chain1.InputsHash, chain2.InputsHash); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task BundleImportEvidenceService_CaptureAsync_StoresEvidence() - { - var store = new InMemoryPackRunEvidenceStore(); - var service = new BundleImportEvidenceService( - store, - NullLogger.Instance); - - var evidence = CreateTestEvidence(); - - var result = await service.CaptureAsync(evidence, CancellationToken.None); - - Assert.True(result.Success); - Assert.NotNull(result.Snapshot); - Assert.NotNull(result.EvidencePointer); - Assert.Single(store.GetAll()); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task BundleImportEvidenceService_CaptureAsync_CreatesCorrectMaterials() - { - var store = new InMemoryPackRunEvidenceStore(); - var service = new BundleImportEvidenceService( - store, - NullLogger.Instance); - - var evidence = CreateTestEvidence(); - - var result = await service.CaptureAsync(evidence, CancellationToken.None); - - Assert.True(result.Success); - var snapshot = result.Snapshot!; - - // Should have: input manifest, 2 outputs, transcript, validation, hashchain = 6 materials - Assert.Equal(6, snapshot.Materials.Count); - Assert.Contains(snapshot.Materials, m => m.Section == "input"); - Assert.Contains(snapshot.Materials, m => m.Section == "output"); - Assert.Contains(snapshot.Materials, m => m.Section == "transcript"); - Assert.Contains(snapshot.Materials, m => m.Section == "validation"); - Assert.Contains(snapshot.Materials, m => m.Section == "hashchain"); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task BundleImportEvidenceService_CaptureAsync_SetsCorrectMetadata() - { - var store = new InMemoryPackRunEvidenceStore(); - var service = new BundleImportEvidenceService( - store, - NullLogger.Instance); - - var evidence = CreateTestEvidence(); - - var result = await service.CaptureAsync(evidence, CancellationToken.None); - - Assert.True(result.Success); - var snapshot = result.Snapshot!; - - Assert.Equal(evidence.JobId, snapshot.Metadata!["jobId"]); - Assert.Equal(evidence.Status.ToString(), snapshot.Metadata["status"]); - Assert.Equal(evidence.SourcePath, snapshot.Metadata["sourcePath"]); - Assert.Equal("2", snapshot.Metadata["outputCount"]); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task BundleImportEvidenceService_CaptureAsync_EmitsTimelineEvent() - { - var store = new InMemoryPackRunEvidenceStore(); - var timelineSink = new InMemoryPackRunTimelineEventSink(); - var emitter = new PackRunTimelineEventEmitter( - timelineSink, - TimeProvider.System, - NullLogger.Instance); - var service = new BundleImportEvidenceService( - store, - NullLogger.Instance, - emitter); - - var evidence = CreateTestEvidence(); - - var result = await service.CaptureAsync(evidence, CancellationToken.None); - - Assert.True(result.Success); - Assert.Single(timelineSink.GetEvents()); - var evt = timelineSink.GetEvents()[0]; - Assert.Equal("bundle.import.evidence_captured", evt.EventType); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task BundleImportEvidenceService_GetAsync_ReturnsEvidence() - { - var store = new InMemoryPackRunEvidenceStore(); - var service = new BundleImportEvidenceService( - store, - NullLogger.Instance); - - var evidence = CreateTestEvidence(); - await service.CaptureAsync(evidence, CancellationToken.None); - - var retrieved = await service.GetAsync(evidence.JobId, CancellationToken.None); - - Assert.NotNull(retrieved); - Assert.Equal(evidence.JobId, retrieved.JobId); - Assert.Equal(evidence.TenantId, retrieved.TenantId); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task BundleImportEvidenceService_GetAsync_ReturnsNullForMissingJob() - { - var store = new InMemoryPackRunEvidenceStore(); - var service = new BundleImportEvidenceService( - store, - NullLogger.Instance); - - var retrieved = await service.GetAsync("non-existent-job", CancellationToken.None); - - Assert.Null(retrieved); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task BundleImportEvidenceService_ExportToPortableBundleAsync_CreatesFile() - { - var store = new InMemoryPackRunEvidenceStore(); - var service = new BundleImportEvidenceService( - store, - NullLogger.Instance); - - var evidence = CreateTestEvidence(); - await service.CaptureAsync(evidence, CancellationToken.None); - - var outputPath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.json"); - try - { - var result = await service.ExportToPortableBundleAsync( - evidence.JobId, - outputPath, - CancellationToken.None); - - Assert.True(result.Success); - Assert.Equal(outputPath, result.OutputPath); - Assert.True(File.Exists(outputPath)); - Assert.True(result.SizeBytes > 0); - Assert.StartsWith("sha256:", result.BundleSha256); - } - finally - { - if (File.Exists(outputPath)) - { - File.Delete(outputPath); - } - } - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task BundleImportEvidenceService_ExportToPortableBundleAsync_FailsForMissingJob() - { - var store = new InMemoryPackRunEvidenceStore(); - var service = new BundleImportEvidenceService( - store, - NullLogger.Instance); - - var outputPath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.json"); - - var result = await service.ExportToPortableBundleAsync( - "non-existent-job", - outputPath, - CancellationToken.None); - - Assert.False(result.Success); - Assert.Contains("No evidence found", result.Error); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void BundleImportEvidence_RecordProperties_AreAccessible() - { - var evidence = CreateTestEvidence(); - - Assert.Equal("test-job-123", evidence.JobId); - Assert.Equal("tenant-1", evidence.TenantId); - Assert.Equal("/path/to/bundle.tar.gz", evidence.SourcePath); - Assert.Equal(BundleImportStatus.Completed, evidence.Status); - Assert.NotNull(evidence.InputManifest); - Assert.Equal(2, evidence.OutputFiles.Count); - Assert.Equal(2, evidence.Transcript.Count); - Assert.NotNull(evidence.ValidationResult); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void BundleImportValidationResult_RecordProperties_AreAccessible() - { - var result = new BundleImportValidationResult( - Valid: true, - ChecksumValid: true, - SignatureValid: true, - FormatValid: true, - Errors: null, - Warnings: ["Advisory data may be stale"]); - - Assert.True(result.Valid); - Assert.True(result.ChecksumValid); - Assert.True(result.SignatureValid); - Assert.True(result.FormatValid); - Assert.Null(result.Errors); - Assert.Single(result.Warnings!); - } - - private static BundleImportEvidence CreateTestEvidence() - { - var now = DateTimeOffset.UtcNow; - - var input = new BundleImportInputManifest( - FormatVersion: "1.0.0", - BundleId: "test-bundle-001", - BundleVersion: "2025.10.0", - CreatedAt: now.AddHours(-1), - CreatedBy: "bundle-builder@example.com", - TotalSizeBytes: 10240, - ItemCount: 5, - ManifestSha256: "sha256:abcdef1234567890", - Signature: "base64sig...", - SignatureValid: true); - - var outputs = new List - { - new("advisories/CVE-2025-0001.json", "sha256:output1hash", 512, "application/json", now, "item1"), - new("advisories/CVE-2025-0002.json", "sha256:output2hash", 1024, "application/json", now, "item2") - }; - - var transcript = new List - { - new(now.AddMinutes(-5), "info", "import.started", "Bundle import started", new Dictionary - { - ["sourcePath"] = "/path/to/bundle.tar.gz" - }), - new(now, "info", "import.completed", "Bundle import completed successfully", new Dictionary - { - ["itemsImported"] = "5" - }) - }; - - var validation = new BundleImportValidationResult( - Valid: true, - ChecksumValid: true, - SignatureValid: true, - FormatValid: true, - Errors: null, - Warnings: null); - - var hashChain = BundleImportHashChain.Compute(input, outputs, transcript); - - return new BundleImportEvidence( - JobId: "test-job-123", - TenantId: "tenant-1", - SourcePath: "/path/to/bundle.tar.gz", - StartedAt: now.AddMinutes(-5), - CompletedAt: now, - Status: BundleImportStatus.Completed, - ErrorMessage: null, - InitiatedBy: "admin@example.com", - InputManifest: input, - OutputFiles: outputs, - Transcript: transcript, - ValidationResult: validation, - HashChain: hashChain); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/BundleIngestionStepExecutorTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/BundleIngestionStepExecutorTests.cs deleted file mode 100644 index 2449bb1e0..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/BundleIngestionStepExecutorTests.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System.Text.Json.Nodes; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using StellaOps.TaskRunner.Core.Configuration; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using StellaOps.TaskRunner.Infrastructure.Execution; - - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class BundleIngestionStepExecutorTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ExecuteAsync_ValidBundle_CopiesAndSucceeds() - { - using var temp = new TempDirectory(); - var source = Path.Combine(temp.Path, "bundle.tgz"); - var ct = CancellationToken.None; - await File.WriteAllTextAsync(source, "bundle-data", ct); - var checksum = "b9c72134b48cdc15e7a47f2476a41612d2084b763bea0575f5600b22041db7dc"; // sha256 of "bundle-data" - - var options = Options.Create(new PackRunWorkerOptions { ArtifactsPath = temp.Path }); - var executor = new BundleIngestionStepExecutor(options, NullLogger.Instance); - - var step = CreateStep("builtin:bundle.ingest", new Dictionary - { - ["path"] = Value(source), - ["checksum"] = Value(checksum) - }); - - var result = await executor.ExecuteAsync(step, step.Parameters, ct); - - Assert.True(result.Succeeded); - var staged = Path.Combine(temp.Path, "bundles", checksum, "bundle.tgz"); - Assert.True(File.Exists(staged)); - Assert.Equal(await File.ReadAllBytesAsync(source, ct), await File.ReadAllBytesAsync(staged, ct)); - - var metadataPath = Path.Combine(temp.Path, "bundles", checksum, "metadata.json"); - Assert.True(File.Exists(metadataPath)); - var metadata = await File.ReadAllTextAsync(metadataPath, ct); - Assert.Contains(checksum, metadata, StringComparison.OrdinalIgnoreCase); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ExecuteAsync_ChecksumMismatch_Fails() - { - using var temp = new TempDirectory(); - var source = Path.Combine(temp.Path, "bundle.tgz"); - var ct = CancellationToken.None; - await File.WriteAllTextAsync(source, "bundle-data", ct); - - var options = Options.Create(new PackRunWorkerOptions { ArtifactsPath = temp.Path }); - var executor = new BundleIngestionStepExecutor(options, NullLogger.Instance); - - var step = CreateStep("builtin:bundle.ingest", new Dictionary - { - ["path"] = Value(source), - ["checksum"] = Value("deadbeef") - }); - - var result = await executor.ExecuteAsync(step, step.Parameters, ct); - - Assert.False(result.Succeeded); - Assert.Contains("Checksum mismatch", result.Error, StringComparison.OrdinalIgnoreCase); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ExecuteAsync_MissingChecksum_Fails() - { - using var temp = new TempDirectory(); - var source = Path.Combine(temp.Path, "bundle.tgz"); - var ct = CancellationToken.None; - await File.WriteAllTextAsync(source, "bundle-data", ct); - - var options = Options.Create(new PackRunWorkerOptions { ArtifactsPath = temp.Path }); - var executor = new BundleIngestionStepExecutor(options, NullLogger.Instance); - - var step = CreateStep("builtin:bundle.ingest", new Dictionary - { - ["path"] = Value(source) - }); - - var result = await executor.ExecuteAsync(step, step.Parameters, ct); - - Assert.False(result.Succeeded); - Assert.Contains("Checksum is required", result.Error, StringComparison.OrdinalIgnoreCase); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ExecuteAsync_UnknownUses_NoOpSuccess() - { - var ct = CancellationToken.None; - var executor = new BundleIngestionStepExecutor( - Options.Create(new PackRunWorkerOptions { ArtifactsPath = Path.GetTempPath() }), - NullLogger.Instance); - - var step = CreateStep("builtin:noop", new Dictionary()); - var result = await executor.ExecuteAsync(step, step.Parameters, ct); - - Assert.True(result.Succeeded); - } - - private static TaskPackPlanParameterValue Value(string literal) - => new(JsonValue.Create(literal), null, null, false); - - private static PackRunExecutionStep CreateStep(string uses, IReadOnlyDictionary parameters) - => new( - id: "ingest", - templateId: "ingest", - kind: PackRunStepKind.Run, - enabled: true, - uses: uses, - parameters: parameters, - approvalId: null, - gateMessage: null, - maxParallel: null, - continueOnError: false, - children: PackRunExecutionStep.EmptyChildren); - - private sealed class TempDirectory : IDisposable - { - public TempDirectory() - { - Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("n")); - Directory.CreateDirectory(Path); - } - - public string Path { get; } - - public void Dispose() - { - if (Directory.Exists(Path)) - { - Directory.Delete(Path, recursive: true); - } - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilePackRunLogStoreTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilePackRunLogStoreTests.cs deleted file mode 100644 index 2eb40afd1..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilePackRunLogStoreTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Infrastructure.Execution; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class FilePackRunLogStoreTests : IDisposable -{ - private readonly string rootPath; - - public FilePackRunLogStoreTests() - { - rootPath = Path.Combine(Path.GetTempPath(), "StellaOps_TaskRunnerTests", Guid.NewGuid().ToString("n")); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task AppendAndReadAsync_RoundTripsEntriesInOrder() - { - var store = new FilePackRunLogStore(rootPath); - var runId = "run-append-test"; - - var first = new PackRunLogEntry( - DateTimeOffset.UtcNow, - "info", - "run.created", - "Run created.", - StepId: null, - Metadata: new Dictionary(StringComparer.Ordinal) - { - ["planHash"] = "hash-1" - }); - - var second = new PackRunLogEntry( - DateTimeOffset.UtcNow.AddSeconds(1), - "info", - "step.started", - "Step started.", - StepId: "build", - Metadata: null); - - await store.AppendAsync(runId, first, CancellationToken.None); - await store.AppendAsync(runId, second, CancellationToken.None); - - var reloaded = new List(); - await foreach (var entry in store.ReadAsync(runId, CancellationToken.None)) - { - reloaded.Add(entry); - } - - Assert.Collection( - reloaded, - entry => - { - Assert.Equal("run.created", entry.EventType); - Assert.NotNull(entry.Metadata); - Assert.Equal("hash-1", entry.Metadata!["planHash"]); - }, - entry => - { - Assert.Equal("step.started", entry.EventType); - Assert.Equal("build", entry.StepId); - }); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ExistsAsync_ReturnsFalseWhenNoLogPresent() - { - var store = new FilePackRunLogStore(rootPath); - - var exists = await store.ExistsAsync("missing-run", CancellationToken.None); - - Assert.False(exists); - } - - public void Dispose() - { - try - { - if (Directory.Exists(rootPath)) - { - Directory.Delete(rootPath, recursive: true); - } - } - catch - { - // Ignore cleanup failures to keep tests deterministic. - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilePackRunStateStoreTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilePackRunStateStoreTests.cs deleted file mode 100644 index b9c7563f9..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilePackRunStateStoreTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Text.Json.Nodes; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using StellaOps.TaskRunner.Infrastructure.Execution; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class FilePackRunStateStoreTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task SaveAndGetAsync_RoundTripsState() - { - var directory = CreateTempDirectory(); - try - { - var store = new FilePackRunStateStore(directory); - var original = CreateState("run:primary"); - - await store.SaveAsync(original, CancellationToken.None); - - var reloaded = await store.GetAsync("run:primary", CancellationToken.None); - Assert.NotNull(reloaded); - Assert.Equal(original.RunId, reloaded!.RunId); - Assert.Equal(original.PlanHash, reloaded.PlanHash); - Assert.Equal(original.FailurePolicy, reloaded.FailurePolicy); - Assert.Equal(original.Steps.Count, reloaded.Steps.Count); - var step = Assert.Single(reloaded.Steps); - Assert.Equal("step-a", step.Key); - Assert.Equal(original.Steps["step-a"], step.Value); - } - finally - { - TryDelete(directory); - } - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ListAsync_ReturnsStatesInDeterministicOrder() - { - var directory = CreateTempDirectory(); - try - { - var store = new FilePackRunStateStore(directory); - var stateB = CreateState("run-b"); - var stateA = CreateState("run-a"); - - await store.SaveAsync(stateB, CancellationToken.None); - await store.SaveAsync(stateA, CancellationToken.None); - - var states = await store.ListAsync(CancellationToken.None); - - Assert.Collection(states, - first => Assert.Equal("run-a", first.RunId), - second => Assert.Equal("run-b", second.RunId)); - } - finally - { - TryDelete(directory); - } - } - - private static PackRunState CreateState(string runId) - { - var failurePolicy = new TaskPackPlanFailurePolicy(MaxAttempts: 3, BackoffSeconds: 30, ContinueOnError: false); - var metadata = new TaskPackPlanMetadata("sample", "1.0.0", null, Array.Empty()); - var parameters = new Dictionary(StringComparer.Ordinal); - var stepPlan = new TaskPackPlanStep( - Id: "step-a", - TemplateId: "run/image", - Name: "Run step", - Type: "run", - Enabled: true, - Uses: "builtin/run", - Parameters: parameters, - ApprovalId: null, - GateMessage: null, - Children: Array.Empty()); - var plan = new TaskPackPlan( - metadata, - new Dictionary(StringComparer.Ordinal), - new[] { stepPlan }, - "hash-123", - Array.Empty(), - Array.Empty(), - Array.Empty(), - failurePolicy); - var steps = new Dictionary(StringComparer.Ordinal) - { - ["step-a"] = new PackRunStepStateRecord( - StepId: "step-a", - Kind: PackRunStepKind.Run, - Enabled: true, - ContinueOnError: false, - MaxParallel: null, - ApprovalId: null, - GateMessage: null, - Status: PackRunStepExecutionStatus.Pending, - Attempts: 1, - LastTransitionAt: DateTimeOffset.UtcNow, - NextAttemptAt: null, - StatusReason: null) - }; - - var timestamp = DateTimeOffset.UtcNow; - return PackRunState.Create(runId, "hash-123", plan, failurePolicy, timestamp, steps, timestamp); - } - - private static string CreateTempDirectory() - { - var path = Path.Combine(Path.GetTempPath(), "stellaops-taskrunner-tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(path); - return path; - } - - private static void TryDelete(string directory) - { - try - { - if (Directory.Exists(directory)) - { - Directory.Delete(directory, recursive: true); - } - } - catch - { - // Swallow cleanup errors to avoid masking test assertions. - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilesystemPackRunArtifactReaderTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilesystemPackRunArtifactReaderTests.cs deleted file mode 100644 index 870cb7377..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilesystemPackRunArtifactReaderTests.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System.Text.Json; -using StellaOps.TaskRunner.Infrastructure.Execution; - - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class FilesystemPackRunArtifactReaderTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ListAsync_ReturnsEmpty_WhenManifestMissing() - { - using var temp = new TempDir(); - var ct = CancellationToken.None; - var reader = new FilesystemPackRunArtifactReader(temp.Path); - - var results = await reader.ListAsync("run-absent", ct); - - Assert.Empty(results); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ListAsync_ParsesManifestAndSortsByName() - { - using var temp = new TempDir(); - var runId = "run-1"; - var manifestPath = Path.Combine(temp.Path, "run-1", "artifact-manifest.json"); - Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); - var ct = CancellationToken.None; - - var manifest = new - { - RunId = runId, - UploadedAt = DateTimeOffset.UtcNow, - Outputs = new[] - { - new - { - Name = "b", - Type = "file", - SourcePath = (string?)"/tmp/source-b", - StoredPath = "files/b.txt", - Status = "copied", - Notes = (string?)"ok", - ExpressionJson = (string?)null - }, - new - { - Name = "a", - Type = "object", - SourcePath = (string?)null, - StoredPath = "expressions/a.json", - Status = "materialized", - Notes = (string?)null, - ExpressionJson = (string?)"{\"key\":\"value\"}" - } - } - }; - - var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web)); - await File.WriteAllTextAsync(manifestPath, json, ct); - - var reader = new FilesystemPackRunArtifactReader(temp.Path); - var results = await reader.ListAsync(runId, ct); - - Assert.Equal(2, results.Count); - Assert.Collection(results, - first => - { - Assert.Equal("a", first.Name); - Assert.Equal("object", first.Type); - Assert.Equal("expressions/a.json", first.StoredPath); - Assert.Equal("materialized", first.Status); - Assert.Equal("{\"key\":\"value\"}", first.ExpressionJson); - }, - second => - { - Assert.Equal("b", second.Name); - Assert.Equal("file", second.Type); - Assert.Equal("files/b.txt", second.StoredPath); - Assert.Equal("copied", second.Status); - Assert.Null(second.ExpressionJson); - }); - } -} - -internal sealed class TempDir : IDisposable -{ - public TempDir() - { - Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("n")); - Directory.CreateDirectory(Path); - } - - public string Path { get; } - - public void Dispose() - { - if (Directory.Exists(Path)) - { - Directory.Delete(Path, recursive: true); - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilesystemPackRunArtifactUploaderTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilesystemPackRunArtifactUploaderTests.cs deleted file mode 100644 index 39bf7f2e7..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilesystemPackRunArtifactUploaderTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using Microsoft.Extensions.Logging.Abstractions; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using StellaOps.TaskRunner.Infrastructure.Execution; -using Xunit; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class FilesystemPackRunArtifactUploaderTests : IDisposable -{ - private readonly string artifactsRoot; - - public FilesystemPackRunArtifactUploaderTests() - { - artifactsRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("n")); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task CopiesFileOutputs() - { - var sourceFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():n}.txt"); - await File.WriteAllTextAsync(sourceFile, "artifact-content", CancellationToken.None); - - var uploader = CreateUploader(); - var output = CreateFileOutput("bundle", sourceFile); - var context = CreateContext(); - var state = CreateState(context); - - await uploader.UploadAsync(context, state, new[] { output }, CancellationToken.None); - - var runPath = Path.Combine(artifactsRoot, context.RunId); - var filesDirectory = Path.Combine(runPath, "files"); - var copiedFiles = Directory.GetFiles(filesDirectory); - Assert.Single(copiedFiles); - Assert.Equal("bundle.txt", Path.GetFileName(copiedFiles[0])); - Assert.Equal("artifact-content", await File.ReadAllTextAsync(copiedFiles[0], CancellationToken.None)); - - var manifest = await ReadManifestAsync(runPath); - Assert.Single(manifest.Outputs); - Assert.Equal("copied", manifest.Outputs[0].Status); - Assert.Equal("files/bundle.txt", manifest.Outputs[0].StoredPath); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task RecordsMissingFilesWithoutThrowing() - { - var uploader = CreateUploader(); - var output = CreateFileOutput("missing", Path.Combine(Path.GetTempPath(), "does-not-exist.txt")); - var context = CreateContext(); - var state = CreateState(context); - - await uploader.UploadAsync(context, state, new[] { output }, CancellationToken.None); - - var manifest = await ReadManifestAsync(Path.Combine(artifactsRoot, context.RunId)); - Assert.Equal("missing", manifest.Outputs[0].Status); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task WritesExpressionOutputsAsJson() - { - var uploader = CreateUploader(); - var output = CreateExpressionOutput("metadata", JsonNode.Parse("""{"foo":"bar"}""")!); - var context = CreateContext(); - var state = CreateState(context); - - await uploader.UploadAsync(context, state, new[] { output }, CancellationToken.None); - - var expressionPath = Path.Combine(artifactsRoot, context.RunId, "expressions", "metadata.json"); - Assert.True(File.Exists(expressionPath)); - - var manifest = await ReadManifestAsync(Path.Combine(artifactsRoot, context.RunId)); - Assert.Equal("materialized", manifest.Outputs[0].Status); - Assert.Equal("expressions/metadata.json", manifest.Outputs[0].StoredPath); - } - - private FilesystemPackRunArtifactUploader CreateUploader() - => new(artifactsRoot, TimeProvider.System, NullLogger.Instance); - - private static TaskPackPlanOutput CreateFileOutput(string name, string path) - => new( - name, - Type: "file", - Path: new TaskPackPlanParameterValue(JsonValue.Create(path), null, null, false), - Expression: null); - - private static TaskPackPlanOutput CreateExpressionOutput(string name, JsonNode expression) - => new( - name, - Type: "object", - Path: null, - Expression: new TaskPackPlanParameterValue(expression, null, null, false)); - - private static PackRunExecutionContext CreateContext() - => new("run-" + Guid.NewGuid().ToString("n"), CreatePlan(), DateTimeOffset.UtcNow); - - private static PackRunState CreateState(PackRunExecutionContext context) - => PackRunState.Create( - runId: context.RunId, - planHash: context.Plan.Hash, - context.Plan, - failurePolicy: new TaskPackPlanFailurePolicy(1, 1, false), - requestedAt: DateTimeOffset.UtcNow, - steps: new Dictionary(StringComparer.Ordinal), - timestamp: DateTimeOffset.UtcNow); - - private static TaskPackPlan CreatePlan() - { - return new TaskPackPlan( - new TaskPackPlanMetadata("sample-pack", "1.0.0", null, Array.Empty()), - new Dictionary(StringComparer.Ordinal), - Array.Empty(), - hash: "hash", - approvals: Array.Empty(), - secrets: Array.Empty(), - outputs: Array.Empty(), - failurePolicy: new TaskPackPlanFailurePolicy(1, 1, false)); - } - - private static async Task ReadManifestAsync(string runPath) - { - var json = await File.ReadAllTextAsync(Path.Combine(runPath, "artifact-manifest.json"), CancellationToken.None); - return JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!; - } - - public void Dispose() - { - if (Directory.Exists(artifactsRoot)) - { - Directory.Delete(artifactsRoot, recursive: true); - } - } - - private sealed record ArtifactManifestModel(string RunId, DateTimeOffset UploadedAt, List Outputs); - - private sealed record ArtifactRecordModel(string Name, string Type, string? SourcePath, string? StoredPath, string Status, string? Notes); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilesystemPackRunDispatcherTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilesystemPackRunDispatcherTests.cs deleted file mode 100644 index 1cbe0e5ab..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/FilesystemPackRunDispatcherTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Text.Json; -using StellaOps.AirGap.Policy; -using StellaOps.TaskRunner.Infrastructure.Execution; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class FilesystemPackRunDispatcherTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task TryDequeueAsync_BlocksJob_WhenEgressPolicyDeniesDestination() - { - var root = Path.Combine(Path.GetTempPath(), "StellaOps_TaskRunnerTests", Guid.NewGuid().ToString("n")); - Directory.CreateDirectory(root); - - var cancellationToken = CancellationToken.None; - - var queuePath = Path.Combine(root, "queue"); - var archivePath = Path.Combine(root, "archive"); - Directory.CreateDirectory(queuePath); - Directory.CreateDirectory(archivePath); - - var manifestPath = Path.Combine(queuePath, "manifest.yaml"); - await File.WriteAllTextAsync(manifestPath, TestManifests.EgressBlocked, cancellationToken); - - var jobEnvelope = new - { - RunId = "run-egress-blocked", - ManifestPath = Path.GetFileName(manifestPath), - InputsPath = (string?)null, - RequestedAt = (DateTimeOffset?)null - }; - - var jobPath = Path.Combine(queuePath, "job.json"); - await File.WriteAllTextAsync(jobPath, JsonSerializer.Serialize(jobEnvelope), cancellationToken); - - var policy = new EgressPolicy(new EgressPolicyOptions - { - Mode = EgressPolicyMode.Sealed, - AllowLoopback = false, - AllowPrivateNetworks = false - }); - - try - { - var dispatcher = new FilesystemPackRunDispatcher(queuePath, archivePath, policy); - var result = await dispatcher.TryDequeueAsync(cancellationToken); - - Assert.Null(result); - Assert.False(File.Exists(jobPath)); - Assert.True(File.Exists(jobPath + ".failed")); - Assert.Empty(Directory.GetFiles(archivePath)); - } - finally - { - try - { - Directory.Delete(root, recursive: true); - } - catch - { - // Best-effort cleanup; ignore failures to avoid masking test results. - } - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/OpenApiMetadataFactoryTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/OpenApiMetadataFactoryTests.cs deleted file mode 100644 index 84b481c49..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/OpenApiMetadataFactoryTests.cs +++ /dev/null @@ -1,55 +0,0 @@ -using StellaOps.TaskRunner.WebService; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class OpenApiMetadataFactoryTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_ProducesExpectedDefaults() - { - var metadata = OpenApiMetadataFactory.Create(); - - Assert.Equal("/openapi", metadata.SpecUrl); - Assert.Equal(OpenApiMetadataFactory.ApiVersion, metadata.Version); - Assert.False(string.IsNullOrWhiteSpace(metadata.BuildVersion)); - Assert.StartsWith("W/\"", metadata.ETag); - Assert.EndsWith("\"", metadata.ETag); - Assert.StartsWith("sha256:", metadata.Signature); - var hashPart = metadata.Signature["sha256:".Length..]; - Assert.Equal(64, hashPart.Length); - Assert.True(hashPart.All(c => char.IsDigit(c) || (c >= 'a' && c <= 'f'))); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_AllowsOverrideUrl() - { - var metadata = OpenApiMetadataFactory.Create("/docs/openapi.json"); - - Assert.Equal("/docs/openapi.json", metadata.SpecUrl); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_SignatureIncludesAllComponents() - { - var metadata1 = OpenApiMetadataFactory.Create("/path1"); - var metadata2 = OpenApiMetadataFactory.Create("/path2"); - - // Different URLs should produce different signatures - Assert.NotEqual(metadata1.Signature, metadata2.Signature); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_ETagIsDeterministic() - { - var metadata1 = OpenApiMetadataFactory.Create(); - var metadata2 = OpenApiMetadataFactory.Create(); - - // Same inputs should produce same ETag - Assert.Equal(metadata1.ETag, metadata2.ETag); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunApprovalCoordinatorTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunApprovalCoordinatorTests.cs deleted file mode 100644 index 61958abaf..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunApprovalCoordinatorTests.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Text.Json.Nodes; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunApprovalCoordinatorTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_FromPlan_PopulatesApprovals() - { - var plan = BuildPlan(); - var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow); - - var approvals = coordinator.GetApprovals(); - Assert.Single(approvals); - Assert.Equal("security-review", approvals[0].ApprovalId); - Assert.Equal(PackRunApprovalStatus.Pending, approvals[0].Status); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Approve_AllowsResumeWhenLastApprovalCompletes() - { - var plan = BuildPlan(); - var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow); - - var result = coordinator.Approve("security-review", "approver-1", DateTimeOffset.UtcNow); - - Assert.True(result.ShouldResumeRun); - Assert.Equal(PackRunApprovalStatus.Approved, result.State.Status); - Assert.Equal("approver-1", result.State.ActorId); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Reject_DoesNotResumeAndMarksState() - { - var plan = BuildPlan(); - var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow); - - var result = coordinator.Reject("security-review", "approver-1", DateTimeOffset.UtcNow, "Not safe"); - - Assert.False(result.ShouldResumeRun); - Assert.Equal(PackRunApprovalStatus.Rejected, result.State.Status); - Assert.Equal("Not safe", result.State.Summary); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void BuildNotifications_UsesRequirements() - { - var plan = BuildPlan(); - var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow); - - var notifications = coordinator.BuildNotifications(plan); - Assert.Single(notifications); - var notification = notifications[0]; - Assert.Equal("security-review", notification.ApprovalId); - Assert.Contains("Packs.Approve", notification.RequiredGrants); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void BuildPolicyNotifications_ProducesGateMetadata() - { - var plan = BuildPolicyPlan(); - var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow); - - var notifications = coordinator.BuildPolicyNotifications(plan); - Assert.Single(notifications); - var hint = notifications[0]; - Assert.Equal("policy-check", hint.StepId); - var parameter = hint.Parameters.Single(p => p.Name == "threshold"); - Assert.False(parameter.RequiresRuntimeValue); - var runtimeParam = hint.Parameters.Single(p => p.Name == "evidenceRef"); - Assert.True(runtimeParam.RequiresRuntimeValue); - Assert.Equal("steps.prepare.outputs.evidence", runtimeParam.Expression); - } - - private static TaskPackPlan BuildPlan() - { - var manifest = TestManifests.Load(TestManifests.Sample); - var planner = new TaskPackPlanner(); - var inputs = new Dictionary - { - ["dryRun"] = JsonValue.Create(false) - }; - - return planner.Plan(manifest, inputs).Plan!; - } - - private static TaskPackPlan BuildPolicyPlan() - { - var manifest = TestManifests.Load(TestManifests.PolicyGate); - var planner = new TaskPackPlanner(); - return planner.Plan(manifest).Plan!; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunApprovalDecisionServiceTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunApprovalDecisionServiceTests.cs deleted file mode 100644 index 72d68ff90..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunApprovalDecisionServiceTests.cs +++ /dev/null @@ -1,292 +0,0 @@ -using System.Text.Json.Nodes; -using Microsoft.Extensions.Logging.Abstractions; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using StellaOps.TaskRunner.Infrastructure.Execution; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunApprovalDecisionServiceTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ApplyAsync_ApprovingLastGateSchedulesResume() - { - var plan = TestPlanFactory.CreatePlan(); - var state = TestPlanFactory.CreateState("run-1", plan); - var approval = new PackRunApprovalState( - "security-review", - new[] { "Packs.Approve" }, - new[] { "step-a" }, - Array.Empty(), - null, - DateTimeOffset.UtcNow.AddMinutes(-5), - PackRunApprovalStatus.Pending); - - var approvalStore = new InMemoryApprovalStore(new Dictionary> - { - ["run-1"] = new List { approval } - }); - var stateStore = new InMemoryStateStore(new Dictionary - { - ["run-1"] = state - }); - var scheduler = new RecordingScheduler(); - - var service = new PackRunApprovalDecisionService( - approvalStore, - stateStore, - scheduler, - NullLogger.Instance); - - var result = await service.ApplyAsync( - new PackRunApprovalDecisionRequest("run-1", "security-review", plan.Hash, PackRunApprovalDecisionType.Approved, "approver@example.com", "LGTM"), - CancellationToken.None); - - Assert.Equal("resumed", result.Status); - Assert.True(scheduler.ScheduledContexts.TryGetValue("run-1", out var context)); - Assert.Equal(plan.Hash, context!.Plan.Hash); - Assert.Equal(PackRunApprovalStatus.Approved, approvalStore.LastUpdated?.Status); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ApplyAsync_ReturnsNotFoundWhenStateMissing() - { - var approvalStore = new InMemoryApprovalStore(new Dictionary>()); - var stateStore = new InMemoryStateStore(new Dictionary()); - var scheduler = new RecordingScheduler(); - - var service = new PackRunApprovalDecisionService( - approvalStore, - stateStore, - scheduler, - NullLogger.Instance); - - var result = await service.ApplyAsync( - new PackRunApprovalDecisionRequest("missing", "approval", "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", PackRunApprovalDecisionType.Approved, "actor", null), - CancellationToken.None); - - Assert.Equal("not_found", result.Status); - Assert.False(scheduler.ScheduledContexts.Any()); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ApplyAsync_ReturnsPlanHashMismatchWhenIncorrect() - { - var plan = TestPlanFactory.CreatePlan(); - var state = TestPlanFactory.CreateState("run-1", plan); - var approval = new PackRunApprovalState( - "security-review", - new[] { "Packs.Approve" }, - new[] { "step-a" }, - Array.Empty(), - null, - DateTimeOffset.UtcNow.AddMinutes(-5), - PackRunApprovalStatus.Pending); - - var approvalStore = new InMemoryApprovalStore(new Dictionary> - { - ["run-1"] = new List { approval } - }); - var stateStore = new InMemoryStateStore(new Dictionary - { - ["run-1"] = state - }); - var scheduler = new RecordingScheduler(); - - var service = new PackRunApprovalDecisionService( - approvalStore, - stateStore, - scheduler, - NullLogger.Instance); - - var result = await service.ApplyAsync( - new PackRunApprovalDecisionRequest("run-1", "security-review", "wrong-hash", PackRunApprovalDecisionType.Approved, "actor", null), - CancellationToken.None); - - Assert.Equal("plan_hash_mismatch", result.Status); - Assert.False(scheduler.ScheduledContexts.Any()); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ApplyAsync_ReturnsPlanHashMismatchWhenFormatInvalid() - { - var plan = TestPlanFactory.CreatePlan(); - var state = TestPlanFactory.CreateState("run-1", plan); - var approval = new PackRunApprovalState( - "security-review", - new[] { "Packs.Approve" }, - new[] { "step-a" }, - Array.Empty(), - null, - DateTimeOffset.UtcNow.AddMinutes(-5), - PackRunApprovalStatus.Pending); - - var approvalStore = new InMemoryApprovalStore(new Dictionary> - { - ["run-1"] = new List { approval } - }); - var stateStore = new InMemoryStateStore(new Dictionary - { - ["run-1"] = state - }); - var scheduler = new RecordingScheduler(); - - var service = new PackRunApprovalDecisionService( - approvalStore, - stateStore, - scheduler, - NullLogger.Instance); - - var result = await service.ApplyAsync( - new PackRunApprovalDecisionRequest("run-1", "security-review", "not-a-digest", PackRunApprovalDecisionType.Approved, "actor", null), - CancellationToken.None); - - Assert.Equal("plan_hash_mismatch", result.Status); - Assert.False(scheduler.ScheduledContexts.Any()); - } - - private sealed class InMemoryApprovalStore : IPackRunApprovalStore - { - private readonly Dictionary> _approvals; - public PackRunApprovalState? LastUpdated { get; private set; } - - public InMemoryApprovalStore(IDictionary> seed) - { - _approvals = seed.ToDictionary( - pair => pair.Key, - pair => pair.Value.ToList(), - StringComparer.Ordinal); - } - - public Task SaveAsync(string runId, IReadOnlyList approvals, CancellationToken cancellationToken) - { - _approvals[runId] = approvals.ToList(); - return Task.CompletedTask; - } - - public Task> GetAsync(string runId, CancellationToken cancellationToken) - { - if (_approvals.TryGetValue(runId, out var existing)) - { - return Task.FromResult>(existing); - } - - return Task.FromResult>(Array.Empty()); - } - - public Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken) - { - if (_approvals.TryGetValue(runId, out var list)) - { - for (var i = 0; i < list.Count; i++) - { - if (string.Equals(list[i].ApprovalId, approval.ApprovalId, StringComparison.Ordinal)) - { - list[i] = approval; - LastUpdated = approval; - break; - } - } - } - - return Task.CompletedTask; - } - } - - private sealed class InMemoryStateStore : IPackRunStateStore - { - private readonly Dictionary _states; - - public InMemoryStateStore(IDictionary states) - { - _states = new Dictionary(states, StringComparer.Ordinal); - } - - public Task GetAsync(string runId, CancellationToken cancellationToken) - { - _states.TryGetValue(runId, out var state); - return Task.FromResult(state); - } - - public Task SaveAsync(PackRunState state, CancellationToken cancellationToken) - { - _states[state.RunId] = state; - return Task.CompletedTask; - } - - public Task> ListAsync(CancellationToken cancellationToken) - => Task.FromResult>(_states.Values.ToList()); - } - - private sealed class RecordingScheduler : IPackRunJobScheduler - { - public Dictionary ScheduledContexts { get; } = new(StringComparer.Ordinal); - - public Task ScheduleAsync(PackRunExecutionContext context, CancellationToken cancellationToken) - { - ScheduledContexts[context.RunId] = context; - return Task.CompletedTask; - } - } -} - -internal static class TestPlanFactory -{ - public static TaskPackPlan CreatePlan() - { - var metadata = new TaskPackPlanMetadata("sample", "1.0.0", null, Array.Empty()); - var parameters = new Dictionary(StringComparer.Ordinal); - var step = new TaskPackPlanStep( - Id: "step-a", - TemplateId: "run/image", - Name: "Run step", - Type: "run", - Enabled: true, - Uses: "builtin/run", - Parameters: parameters, - ApprovalId: "security-review", - GateMessage: null, - Children: Array.Empty()); - - return new TaskPackPlan( - metadata, - new Dictionary(StringComparer.Ordinal), - new[] { step }, - "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - new[] - { - new TaskPackPlanApproval("security-review", new[] { "Packs.Approve" }, null, null) - }, - Array.Empty(), - Array.Empty(), - new TaskPackPlanFailurePolicy(3, 30, false)); - } - - public static PackRunState CreateState(string runId, TaskPackPlan plan) - { - var timestamp = DateTimeOffset.UtcNow; - var steps = new Dictionary(StringComparer.Ordinal) - { - ["step-a"] = new PackRunStepStateRecord( - "step-a", - PackRunStepKind.GateApproval, - true, - false, - null, - "security-review", - null, - PackRunStepExecutionStatus.Pending, - 0, - null, - null, - null) - }; - - return PackRunState.Create(runId, plan.Hash, plan, plan.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy, timestamp, steps, timestamp); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunAttestationTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunAttestationTests.cs deleted file mode 100644 index 89b2222f1..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunAttestationTests.cs +++ /dev/null @@ -1,506 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using StellaOps.TaskRunner.Core.Attestation; -using StellaOps.TaskRunner.Core.Events; -using StellaOps.TaskRunner.Core.Evidence; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunAttestationTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task GenerateAsync_CreatesAttestationWithSubjects() - { - var store = new InMemoryPackRunAttestationStore(); - var signer = new StubPackRunAttestationSigner(); - var service = new PackRunAttestationService( - store, - NullLogger.Instance, - signer); - - var subjects = new List - { - new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" }), - new("artifact/sbom.json", new Dictionary { ["sha256"] = "def456" }) - }; - - var request = new PackRunAttestationRequest( - RunId: "run-001", - TenantId: "tenant-1", - PlanHash: "sha256:plan123", - Subjects: subjects, - EvidenceSnapshotId: Guid.NewGuid(), - StartedAt: DateTimeOffset.UtcNow.AddMinutes(-5), - CompletedAt: DateTimeOffset.UtcNow, - BuilderId: null, - ExternalParameters: null, - ResolvedDependencies: null, - Metadata: null); - - var result = await service.GenerateAsync(request, CancellationToken.None); - - Assert.True(result.Success); - Assert.NotNull(result.Attestation); - Assert.Equal(PackRunAttestationStatus.Signed, result.Attestation.Status); - Assert.Equal(2, result.Attestation.Subjects.Count); - Assert.NotNull(result.Attestation.Envelope); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task GenerateAsync_WithoutSigner_CreatesPendingAttestation() - { - var store = new InMemoryPackRunAttestationStore(); - var service = new PackRunAttestationService( - store, - NullLogger.Instance); - - var subjects = new List - { - new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" }) - }; - - var request = new PackRunAttestationRequest( - RunId: "run-002", - TenantId: "tenant-1", - PlanHash: "sha256:plan123", - Subjects: subjects, - EvidenceSnapshotId: null, - StartedAt: DateTimeOffset.UtcNow, - CompletedAt: null, - BuilderId: null, - ExternalParameters: null, - ResolvedDependencies: null, - Metadata: null); - - var result = await service.GenerateAsync(request, CancellationToken.None); - - Assert.True(result.Success); - Assert.NotNull(result.Attestation); - Assert.Equal(PackRunAttestationStatus.Pending, result.Attestation.Status); - Assert.Null(result.Attestation.Envelope); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task GenerateAsync_EmitsTimelineEvent() - { - var store = new InMemoryPackRunAttestationStore(); - var signer = new StubPackRunAttestationSigner(); - var timelineSink = new InMemoryPackRunTimelineEventSink(); - var emitter = new PackRunTimelineEventEmitter( - timelineSink, - TimeProvider.System, - NullLogger.Instance); - var service = new PackRunAttestationService( - store, - NullLogger.Instance, - signer, - emitter); - - var request = new PackRunAttestationRequest( - RunId: "run-003", - TenantId: "tenant-1", - PlanHash: "sha256:plan123", - Subjects: [new("artifact/test.json", new Dictionary { ["sha256"] = "abc" })], - EvidenceSnapshotId: null, - StartedAt: DateTimeOffset.UtcNow, - CompletedAt: DateTimeOffset.UtcNow, - BuilderId: null, - ExternalParameters: null, - ResolvedDependencies: null, - Metadata: null); - - await service.GenerateAsync(request, CancellationToken.None); - - Assert.Single(timelineSink.GetEvents()); - var evt = timelineSink.GetEvents()[0]; - Assert.Equal(PackRunAttestationEventTypes.AttestationCreated, evt.EventType); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task VerifyAsync_ValidatesSubjectsMatch() - { - var store = new InMemoryPackRunAttestationStore(); - var signer = new StubPackRunAttestationSigner(); - var service = new PackRunAttestationService( - store, - NullLogger.Instance, - signer); - - var subjects = new List - { - new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" }) - }; - - var request = new PackRunAttestationRequest( - RunId: "run-004", - TenantId: "tenant-1", - PlanHash: "sha256:plan123", - Subjects: subjects, - EvidenceSnapshotId: null, - StartedAt: DateTimeOffset.UtcNow, - CompletedAt: DateTimeOffset.UtcNow, - BuilderId: null, - ExternalParameters: null, - ResolvedDependencies: null, - Metadata: null); - - var genResult = await service.GenerateAsync(request, CancellationToken.None); - Assert.NotNull(genResult.Attestation); - - var verifyResult = await service.VerifyAsync( - new PackRunAttestationVerificationRequest( - AttestationId: genResult.Attestation.AttestationId, - ExpectedSubjects: subjects, - VerifySignature: true, - VerifySubjects: true, - CheckRevocation: true), - CancellationToken.None); - - Assert.True(verifyResult.Valid); - Assert.Equal(PackRunSignatureVerificationStatus.Valid, verifyResult.SignatureStatus); - Assert.Equal(PackRunSubjectVerificationStatus.Match, verifyResult.SubjectStatus); - Assert.Equal(PackRunRevocationStatus.NotRevoked, verifyResult.RevocationStatus); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task VerifyAsync_DetectsMismatchedSubjects() - { - var store = new InMemoryPackRunAttestationStore(); - var signer = new StubPackRunAttestationSigner(); - var service = new PackRunAttestationService( - store, - NullLogger.Instance, - signer); - - var subjects = new List - { - new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" }) - }; - - var request = new PackRunAttestationRequest( - RunId: "run-005", - TenantId: "tenant-1", - PlanHash: "sha256:plan123", - Subjects: subjects, - EvidenceSnapshotId: null, - StartedAt: DateTimeOffset.UtcNow, - CompletedAt: DateTimeOffset.UtcNow, - BuilderId: null, - ExternalParameters: null, - ResolvedDependencies: null, - Metadata: null); - - var genResult = await service.GenerateAsync(request, CancellationToken.None); - Assert.NotNull(genResult.Attestation); - - // Verify with different expected subjects - var differentSubjects = new List - { - new("artifact/different.tar.gz", new Dictionary { ["sha256"] = "xyz789" }) - }; - - var verifyResult = await service.VerifyAsync( - new PackRunAttestationVerificationRequest( - AttestationId: genResult.Attestation.AttestationId, - ExpectedSubjects: differentSubjects, - VerifySignature: false, - VerifySubjects: true, - CheckRevocation: false), - CancellationToken.None); - - Assert.False(verifyResult.Valid); - Assert.Equal(PackRunSubjectVerificationStatus.Missing, verifyResult.SubjectStatus); - Assert.NotNull(verifyResult.Errors); - Assert.Contains(verifyResult.Errors, e => e.Contains("Missing subjects")); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task VerifyAsync_DetectsRevokedAttestation() - { - var store = new InMemoryPackRunAttestationStore(); - var signer = new StubPackRunAttestationSigner(); - var service = new PackRunAttestationService( - store, - NullLogger.Instance, - signer); - - var subjects = new List - { - new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" }) - }; - - var request = new PackRunAttestationRequest( - RunId: "run-006", - TenantId: "tenant-1", - PlanHash: "sha256:plan123", - Subjects: subjects, - EvidenceSnapshotId: null, - StartedAt: DateTimeOffset.UtcNow, - CompletedAt: DateTimeOffset.UtcNow, - BuilderId: null, - ExternalParameters: null, - ResolvedDependencies: null, - Metadata: null); - - var genResult = await service.GenerateAsync(request, CancellationToken.None); - Assert.NotNull(genResult.Attestation); - - // Revoke the attestation - await store.UpdateStatusAsync( - genResult.Attestation.AttestationId, - PackRunAttestationStatus.Revoked, - "Compromised key", - CancellationToken.None); - - var verifyResult = await service.VerifyAsync( - new PackRunAttestationVerificationRequest( - AttestationId: genResult.Attestation.AttestationId, - ExpectedSubjects: null, - VerifySignature: false, - VerifySubjects: false, - CheckRevocation: true), - CancellationToken.None); - - Assert.False(verifyResult.Valid); - Assert.Equal(PackRunRevocationStatus.Revoked, verifyResult.RevocationStatus); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task VerifyAsync_ReturnsErrorForNonExistentAttestation() - { - var store = new InMemoryPackRunAttestationStore(); - var service = new PackRunAttestationService( - store, - NullLogger.Instance); - - var verifyResult = await service.VerifyAsync( - new PackRunAttestationVerificationRequest( - AttestationId: Guid.NewGuid(), - ExpectedSubjects: null, - VerifySignature: false, - VerifySubjects: false, - CheckRevocation: false), - CancellationToken.None); - - Assert.False(verifyResult.Valid); - Assert.NotNull(verifyResult.Errors); - Assert.Contains(verifyResult.Errors, e => e.Contains("not found")); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ListByRunAsync_ReturnsAttestationsForRun() - { - var store = new InMemoryPackRunAttestationStore(); - var signer = new StubPackRunAttestationSigner(); - var service = new PackRunAttestationService( - store, - NullLogger.Instance, - signer); - - // Create two attestations for the same run - for (var i = 0; i < 2; i++) - { - var request = new PackRunAttestationRequest( - RunId: "run-007", - TenantId: "tenant-1", - PlanHash: "sha256:plan123", - Subjects: [new($"artifact/output{i}.tar.gz", new Dictionary { ["sha256"] = $"hash{i}" })], - EvidenceSnapshotId: null, - StartedAt: DateTimeOffset.UtcNow, - CompletedAt: DateTimeOffset.UtcNow, - BuilderId: null, - ExternalParameters: null, - ResolvedDependencies: null, - Metadata: null); - - await service.GenerateAsync(request, CancellationToken.None); - } - - var attestations = await service.ListByRunAsync("tenant-1", "run-007", CancellationToken.None); - - Assert.Equal(2, attestations.Count); - Assert.All(attestations, a => Assert.Equal("run-007", a.RunId)); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task GetEnvelopeAsync_ReturnsEnvelopeForSignedAttestation() - { - var store = new InMemoryPackRunAttestationStore(); - var signer = new StubPackRunAttestationSigner(); - var service = new PackRunAttestationService( - store, - NullLogger.Instance, - signer); - - var request = new PackRunAttestationRequest( - RunId: "run-008", - TenantId: "tenant-1", - PlanHash: "sha256:plan123", - Subjects: [new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" })], - EvidenceSnapshotId: null, - StartedAt: DateTimeOffset.UtcNow, - CompletedAt: DateTimeOffset.UtcNow, - BuilderId: null, - ExternalParameters: null, - ResolvedDependencies: null, - Metadata: null); - - var genResult = await service.GenerateAsync(request, CancellationToken.None); - Assert.NotNull(genResult.Attestation); - - var envelope = await service.GetEnvelopeAsync(genResult.Attestation.AttestationId, CancellationToken.None); - - Assert.NotNull(envelope); - Assert.Equal(PackRunDsseEnvelope.InTotoPayloadType, envelope.PayloadType); - Assert.Single(envelope.Signatures); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void PackRunAttestationSubject_FromArtifact_ParsesSha256Prefix() - { - var artifact = new PackRunArtifactReference( - Name: "output.tar.gz", - Sha256: "sha256:abcdef123456", - SizeBytes: 1024, - MediaType: "application/gzip"); - - var subject = PackRunAttestationSubject.FromArtifact(artifact); - - Assert.Equal("output.tar.gz", subject.Name); - Assert.Equal("abcdef123456", subject.Digest["sha256"]); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void PackRunAttestation_ComputeStatementDigest_IsDeterministic() - { - var subjects = new List - { - new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" }) - }; - - var attestation = new PackRunAttestation( - AttestationId: Guid.NewGuid(), - TenantId: "tenant-1", - RunId: "run-001", - PlanHash: "sha256:plan123", - CreatedAt: DateTimeOffset.Parse("2025-12-06T00:00:00Z"), - Subjects: subjects, - PredicateType: PredicateTypes.PackRunProvenance, - PredicateJson: "{\"test\":true}", - Envelope: null, - Status: PackRunAttestationStatus.Pending, - Error: null, - EvidenceSnapshotId: null, - Metadata: null); - - var digest1 = attestation.ComputeStatementDigest(); - var digest2 = attestation.ComputeStatementDigest(); - - Assert.Equal(digest1, digest2); - Assert.StartsWith("sha256:", digest1); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void PackRunDsseEnvelope_ComputeDigest_IsDeterministic() - { - var envelope = new PackRunDsseEnvelope( - PayloadType: PackRunDsseEnvelope.InTotoPayloadType, - Payload: Convert.ToBase64String([1, 2, 3]), - Signatures: [new PackRunDsseSignature("key-001", "sig123")]); - - var digest1 = envelope.ComputeDigest(); - var digest2 = envelope.ComputeDigest(); - - Assert.Equal(digest1, digest2); - Assert.StartsWith("sha256:", digest1); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task GenerateAsync_WithExternalParameters_IncludesInPredicate() - { - var store = new InMemoryPackRunAttestationStore(); - var signer = new StubPackRunAttestationSigner(); - var service = new PackRunAttestationService( - store, - NullLogger.Instance, - signer); - - var externalParams = new Dictionary - { - ["manifestUrl"] = "https://registry.example.com/pack/v1", - ["version"] = "1.0.0" - }; - - var request = new PackRunAttestationRequest( - RunId: "run-009", - TenantId: "tenant-1", - PlanHash: "sha256:plan123", - Subjects: [new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc" })], - EvidenceSnapshotId: null, - StartedAt: DateTimeOffset.UtcNow, - CompletedAt: DateTimeOffset.UtcNow, - BuilderId: "https://stellaops.io/task-runner/custom", - ExternalParameters: externalParams, - ResolvedDependencies: null, - Metadata: null); - - var result = await service.GenerateAsync(request, CancellationToken.None); - - Assert.True(result.Success); - Assert.NotNull(result.Attestation); - Assert.Contains("manifestUrl", result.Attestation.PredicateJson); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task GenerateAsync_WithResolvedDependencies_IncludesInPredicate() - { - var store = new InMemoryPackRunAttestationStore(); - var signer = new StubPackRunAttestationSigner(); - var service = new PackRunAttestationService( - store, - NullLogger.Instance, - signer); - - var dependencies = new List - { - new("https://registry.example.com/tool/scanner:v1", - new Dictionary { ["sha256"] = "scanner123" }, - "scanner", - "application/vnd.oci.image.index.v1+json") - }; - - var request = new PackRunAttestationRequest( - RunId: "run-010", - TenantId: "tenant-1", - PlanHash: "sha256:plan123", - Subjects: [new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc" })], - EvidenceSnapshotId: null, - StartedAt: DateTimeOffset.UtcNow, - CompletedAt: DateTimeOffset.UtcNow, - BuilderId: null, - ExternalParameters: null, - ResolvedDependencies: dependencies, - Metadata: null); - - var result = await service.GenerateAsync(request, CancellationToken.None); - - Assert.True(result.Success); - Assert.NotNull(result.Attestation); - Assert.Contains("resolvedDependencies", result.Attestation.PredicateJson); - Assert.Contains("scanner", result.Attestation.PredicateJson); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunEvidenceSnapshotTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunEvidenceSnapshotTests.cs deleted file mode 100644 index d14c58fa7..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunEvidenceSnapshotTests.cs +++ /dev/null @@ -1,740 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using StellaOps.TaskRunner.Core.Events; -using StellaOps.TaskRunner.Core.Evidence; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Execution.Simulation; -using StellaOps.TaskRunner.Core.Planning; -using Xunit; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -/// -/// Tests for pack run evidence snapshot domain model, store, redaction guard, and service. -/// Per TASKRUN-OBS-53-001. -/// -public sealed class PackRunEvidenceSnapshotTests -{ - private const string TestTenantId = "test-tenant"; - private const string TestRunId = "run-12345"; - private const string TestPlanHash = "sha256:abc123def456789012345678901234567890123456789012345678901234"; - private const string TestStepId = "plan-step"; - - #region PackRunEvidenceSnapshot Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_WithMaterials_ComputesMerkleRoot() - { - // Arrange - var materials = new List - { - PackRunEvidenceMaterial.FromString("transcript", "step-001.json", "{\"stepId\":\"step-001\"}"), - PackRunEvidenceMaterial.FromString("transcript", "step-002.json", "{\"stepId\":\"step-002\"}") - }; - - // Act - var snapshot = PackRunEvidenceSnapshot.Create( - TestTenantId, - TestRunId, - TestPlanHash, - PackRunEvidenceSnapshotKind.RunCompletion, - materials); - - // Assert - Assert.NotEqual(Guid.Empty, snapshot.SnapshotId); - Assert.Equal(TestTenantId, snapshot.TenantId); - Assert.Equal(TestRunId, snapshot.RunId); - Assert.Equal(TestPlanHash, snapshot.PlanHash); - Assert.Equal(PackRunEvidenceSnapshotKind.RunCompletion, snapshot.Kind); - Assert.Equal(2, snapshot.Materials.Count); - Assert.StartsWith("sha256:", snapshot.RootHash); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_WithEmptyMaterials_ReturnsZeroHash() - { - // Act - var snapshot = PackRunEvidenceSnapshot.Create( - TestTenantId, - TestRunId, - TestPlanHash, - PackRunEvidenceSnapshotKind.RunCompletion, - new List()); - - // Assert - Assert.Equal("sha256:" + new string('0', 64), snapshot.RootHash); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_WithMetadata_StoresMetadata() - { - // Arrange - var metadata = new Dictionary - { - ["key1"] = "value1", - ["key2"] = "value2" - }; - - // Act - var snapshot = PackRunEvidenceSnapshot.Create( - TestTenantId, - TestRunId, - TestPlanHash, - PackRunEvidenceSnapshotKind.StepExecution, - new List(), - metadata); - - // Assert - Assert.NotNull(snapshot.Metadata); - Assert.Equal("value1", snapshot.Metadata["key1"]); - Assert.Equal("value2", snapshot.Metadata["key2"]); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_SameMaterials_ProducesDeterministicHash() - { - // Arrange - var materials = new List - { - PackRunEvidenceMaterial.FromString("transcript", "step-001.json", "{\"data\":\"test\"}") - }; - - // Act - var snapshot1 = PackRunEvidenceSnapshot.Create( - TestTenantId, TestRunId, TestPlanHash, - PackRunEvidenceSnapshotKind.StepExecution, materials); - - var snapshot2 = PackRunEvidenceSnapshot.Create( - TestTenantId, TestRunId, TestPlanHash, - PackRunEvidenceSnapshotKind.StepExecution, materials); - - // Assert - Assert.Equal(snapshot1.RootHash, snapshot2.RootHash); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_MaterialOrderDoesNotAffectHash() - { - // Arrange - materials in different order - var materials1 = new List - { - PackRunEvidenceMaterial.FromString("transcript", "a.json", "{}"), - PackRunEvidenceMaterial.FromString("transcript", "b.json", "{}") - }; - - var materials2 = new List - { - PackRunEvidenceMaterial.FromString("transcript", "b.json", "{}"), - PackRunEvidenceMaterial.FromString("transcript", "a.json", "{}") - }; - - // Act - var snapshot1 = PackRunEvidenceSnapshot.Create( - TestTenantId, TestRunId, TestPlanHash, - PackRunEvidenceSnapshotKind.RunCompletion, materials1); - - var snapshot2 = PackRunEvidenceSnapshot.Create( - TestTenantId, TestRunId, TestPlanHash, - PackRunEvidenceSnapshotKind.RunCompletion, materials2); - - // Assert - hash should be same due to canonical ordering - Assert.Equal(snapshot1.RootHash, snapshot2.RootHash); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void ToJson_AndFromJson_RoundTrips() - { - // Arrange - var materials = new List - { - PackRunEvidenceMaterial.FromString("test", "file.txt", "content") - }; - var snapshot = PackRunEvidenceSnapshot.Create( - TestTenantId, TestRunId, TestPlanHash, - PackRunEvidenceSnapshotKind.RunCompletion, materials); - - // Act - var json = snapshot.ToJson(); - var restored = PackRunEvidenceSnapshot.FromJson(json); - - // Assert - Assert.NotNull(restored); - Assert.Equal(snapshot.SnapshotId, restored.SnapshotId); - Assert.Equal(snapshot.RootHash, restored.RootHash); - Assert.Equal(snapshot.TenantId, restored.TenantId); - } - - #endregion - - #region PackRunEvidenceMaterial Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void FromString_ComputesSha256Hash() - { - // Act - var material = PackRunEvidenceMaterial.FromString( - "transcript", "output.txt", "Hello, World!"); - - // Assert - Assert.Equal("transcript", material.Section); - Assert.Equal("output.txt", material.Path); - Assert.StartsWith("sha256:", material.Sha256); - Assert.Equal("text/plain", material.MediaType); - Assert.Equal(13, material.SizeBytes); // "Hello, World!" is 13 bytes - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void FromJson_ComputesSha256Hash() - { - // Arrange - var obj = new { stepId = "step-001", status = "completed" }; - - // Act - var material = PackRunEvidenceMaterial.FromJson("transcript", "step.json", obj); - - // Assert - Assert.Equal("transcript", material.Section); - Assert.Equal("step.json", material.Path); - Assert.StartsWith("sha256:", material.Sha256); - Assert.Equal("application/json", material.MediaType); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void FromContent_WithAttributes_StoresAttributes() - { - // Arrange - var attributes = new Dictionary { ["stepId"] = "step-001" }; - - // Act - var material = PackRunEvidenceMaterial.FromContent( - "artifact", "output.bin", new byte[] { 1, 2, 3 }, - "application/octet-stream", attributes); - - // Assert - Assert.NotNull(material.Attributes); - Assert.Equal("step-001", material.Attributes["stepId"]); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void CanonicalPath_CombinesSectionAndPath() - { - // Act - var material = PackRunEvidenceMaterial.FromString("transcript", "step-001.json", "{}"); - - // Assert - Assert.Equal("transcript/step-001.json", material.CanonicalPath); - } - - #endregion - - #region InMemoryPackRunEvidenceStore Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Store_AndGet_ReturnsSnapshot() - { - // Arrange - var store = new InMemoryPackRunEvidenceStore(); - var snapshot = PackRunEvidenceSnapshot.Create( - TestTenantId, TestRunId, TestPlanHash, - PackRunEvidenceSnapshotKind.RunCompletion, - new List()); - - // Act - await store.StoreAsync(snapshot, CancellationToken.None); - var retrieved = await store.GetAsync(snapshot.SnapshotId, CancellationToken.None); - - // Assert - Assert.NotNull(retrieved); - Assert.Equal(snapshot.SnapshotId, retrieved.SnapshotId); - Assert.Equal(snapshot.RootHash, retrieved.RootHash); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Get_NonExistent_ReturnsNull() - { - // Arrange - var store = new InMemoryPackRunEvidenceStore(); - - // Act - var result = await store.GetAsync(Guid.NewGuid(), CancellationToken.None); - - // Assert - Assert.Null(result); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ListByRun_ReturnsMatchingSnapshots() - { - // Arrange - var store = new InMemoryPackRunEvidenceStore(); - var snapshot1 = PackRunEvidenceSnapshot.Create( - TestTenantId, TestRunId, TestPlanHash, - PackRunEvidenceSnapshotKind.StepExecution, - new List()); - var snapshot2 = PackRunEvidenceSnapshot.Create( - TestTenantId, TestRunId, TestPlanHash, - PackRunEvidenceSnapshotKind.ApprovalDecision, - new List()); - var otherRunSnapshot = PackRunEvidenceSnapshot.Create( - TestTenantId, "other-run", TestPlanHash, - PackRunEvidenceSnapshotKind.StepExecution, - new List()); - - await store.StoreAsync(snapshot1, CancellationToken.None); - await store.StoreAsync(snapshot2, CancellationToken.None); - await store.StoreAsync(otherRunSnapshot, CancellationToken.None); - - // Act - var results = await store.ListByRunAsync(TestTenantId, TestRunId, CancellationToken.None); - - // Assert - Assert.Equal(2, results.Count); - Assert.All(results, s => Assert.Equal(TestRunId, s.RunId)); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ListByKind_ReturnsMatchingSnapshots() - { - // Arrange - var store = new InMemoryPackRunEvidenceStore(); - var stepSnapshot1 = PackRunEvidenceSnapshot.Create( - TestTenantId, TestRunId, TestPlanHash, - PackRunEvidenceSnapshotKind.StepExecution, - new List()); - var stepSnapshot2 = PackRunEvidenceSnapshot.Create( - TestTenantId, TestRunId, TestPlanHash, - PackRunEvidenceSnapshotKind.StepExecution, - new List()); - var approvalSnapshot = PackRunEvidenceSnapshot.Create( - TestTenantId, TestRunId, TestPlanHash, - PackRunEvidenceSnapshotKind.ApprovalDecision, - new List()); - - await store.StoreAsync(stepSnapshot1, CancellationToken.None); - await store.StoreAsync(stepSnapshot2, CancellationToken.None); - await store.StoreAsync(approvalSnapshot, CancellationToken.None); - - // Act - var results = await store.ListByKindAsync( - TestTenantId, TestRunId, - PackRunEvidenceSnapshotKind.StepExecution, - CancellationToken.None); - - // Assert - Assert.Equal(2, results.Count); - Assert.All(results, s => Assert.Equal(PackRunEvidenceSnapshotKind.StepExecution, s.Kind)); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Verify_ValidSnapshot_ReturnsValid() - { - // Arrange - var store = new InMemoryPackRunEvidenceStore(); - var materials = new List - { - PackRunEvidenceMaterial.FromString("test", "file.txt", "content") - }; - var snapshot = PackRunEvidenceSnapshot.Create( - TestTenantId, TestRunId, TestPlanHash, - PackRunEvidenceSnapshotKind.RunCompletion, materials); - - await store.StoreAsync(snapshot, CancellationToken.None); - - // Act - var result = await store.VerifyAsync(snapshot.SnapshotId, CancellationToken.None); - - // Assert - Assert.True(result.Valid); - Assert.Equal(snapshot.RootHash, result.ExpectedHash); - Assert.Equal(snapshot.RootHash, result.ComputedHash); - Assert.Null(result.Error); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Verify_NonExistent_ReturnsInvalid() - { - // Arrange - var store = new InMemoryPackRunEvidenceStore(); - - // Act - var result = await store.VerifyAsync(Guid.NewGuid(), CancellationToken.None); - - // Assert - Assert.False(result.Valid); - Assert.Equal("Snapshot not found", result.Error); - } - - #endregion - - #region PackRunRedactionGuard Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void RedactTranscript_RedactsSensitiveOutput() - { - // Arrange - var guard = new PackRunRedactionGuard(); - var transcript = new PackRunStepTranscript( - StepId: TestStepId, - Kind: "shell", - StartedAt: DateTimeOffset.UtcNow, - EndedAt: DateTimeOffset.UtcNow, - Status: "completed", - Attempt: 1, - DurationMs: 100, - Output: "Connecting with Bearer eyJhbGciOiJIUzI1NiJ9.token", - Error: null, - EnvironmentDigest: null, - Artifacts: null); - - // Act - var redacted = guard.RedactTranscript(transcript); - - // Assert - Assert.DoesNotContain("eyJhbGciOiJIUzI1NiJ9", redacted.Output); - Assert.Contains("[REDACTED", redacted.Output); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void RedactTranscript_PreservesNonSensitiveOutput() - { - // Arrange - var guard = new PackRunRedactionGuard(); - var transcript = new PackRunStepTranscript( - StepId: TestStepId, - Kind: "shell", - StartedAt: DateTimeOffset.UtcNow, - EndedAt: DateTimeOffset.UtcNow, - Status: "completed", - Attempt: 1, - DurationMs: 100, - Output: "Build completed successfully", - Error: null, - EnvironmentDigest: null, - Artifacts: null); - - // Act - var redacted = guard.RedactTranscript(transcript); - - // Assert - Assert.Equal("Build completed successfully", redacted.Output); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void RedactIdentity_RedactsEmail() - { - // Arrange - var guard = new PackRunRedactionGuard(); - - // Act - var redacted = guard.RedactIdentity("john.doe@example.com"); - - // Assert - Assert.DoesNotContain("john.doe", redacted); - Assert.DoesNotContain("example.com", redacted); - Assert.Contains("[", redacted); // Contains redaction markers - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void RedactIdentity_HashesNonEmailIdentity() - { - // Arrange - var guard = new PackRunRedactionGuard(); - - // Act - var redacted = guard.RedactIdentity("admin-user-12345"); - - // Assert - Assert.StartsWith("[USER:", redacted); - Assert.EndsWith("]", redacted); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void RedactApproval_RedactsApproverAndComments() - { - // Arrange - var guard = new PackRunRedactionGuard(); - var approval = new PackRunApprovalEvidence( - ApprovalId: "approval-001", - Approver: "jane.doe@example.com", - ApprovedAt: DateTimeOffset.UtcNow, - Decision: "approved", - RequiredGrants: new[] { "deploy:production" }, - GrantedBy: new[] { "team-lead@example.com" }, - Comments: "Approved. Use token=abc123xyz for deployment."); - - // Act - var redacted = guard.RedactApproval(approval); - - // Assert - Assert.DoesNotContain("jane.doe", redacted.Approver); - Assert.DoesNotContain("team-lead", redacted.GrantedBy![0]); - Assert.Contains("[REDACTED", redacted.Comments); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void RedactValue_ReturnsHashedValue() - { - // Arrange - var guard = new PackRunRedactionGuard(); - - // Act - var redacted = guard.RedactValue("super-secret-value"); - - // Assert - Assert.StartsWith("[HASH:", redacted); - Assert.EndsWith("]", redacted); - Assert.DoesNotContain("super-secret-value", redacted); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void NoOpRedactionGuard_PreservesAllData() - { - // Arrange - var guard = NoOpPackRunRedactionGuard.Instance; - var transcript = new PackRunStepTranscript( - StepId: TestStepId, - Kind: "shell", - StartedAt: DateTimeOffset.UtcNow, - EndedAt: DateTimeOffset.UtcNow, - Status: "completed", - Attempt: 1, - DurationMs: 100, - Output: "Bearer secret-token-12345", - Error: null, - EnvironmentDigest: null, - Artifacts: null); - - // Act - var result = guard.RedactTranscript(transcript); - - // Assert - Assert.Same(transcript, result); - Assert.Equal("Bearer secret-token-12345", result.Output); - } - - #endregion - - #region PackRunEvidenceSnapshotService Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task CaptureRunCompletion_StoresSnapshot() - { - // Arrange - var store = new InMemoryPackRunEvidenceStore(); - var sink = new InMemoryPackRunTimelineEventSink(); - var emitter = new PackRunTimelineEventEmitter( - sink, TimeProvider.System, NullLogger.Instance); - var service = new PackRunEvidenceSnapshotService( - store, - new PackRunRedactionGuard(), - NullLogger.Instance, - emitter); - - var state = CreateTestPackRunState(); - - // Act - var result = await service.CaptureRunCompletionAsync( - TestTenantId, TestRunId, TestPlanHash, state, - cancellationToken: CancellationToken.None); - - // Assert - Assert.True(result.Success); - Assert.NotNull(result.Snapshot); - Assert.NotNull(result.EvidencePointer); - Assert.Equal(PackRunEvidenceSnapshotKind.RunCompletion, result.Snapshot.Kind); - Assert.Single(store.GetAll()); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task CaptureRunCompletion_WithTranscripts_IncludesRedactedTranscripts() - { - // Arrange - var store = new InMemoryPackRunEvidenceStore(); - var service = new PackRunEvidenceSnapshotService( - store, - new PackRunRedactionGuard(), - NullLogger.Instance); - - var state = CreateTestPackRunState(); - var transcripts = new List - { - new(TestStepId, "shell", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, - "completed", 1, 100, "Bearer token123", null, null, null) - }; - - // Act - var result = await service.CaptureRunCompletionAsync( - TestTenantId, TestRunId, TestPlanHash, state, - transcripts: transcripts, - cancellationToken: CancellationToken.None); - - // Assert - Assert.True(result.Success); - var transcriptMaterial = result.Snapshot!.Materials - .FirstOrDefault(m => m.Section == "transcript"); - Assert.NotNull(transcriptMaterial); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task CaptureStepExecution_CapturesTranscript() - { - // Arrange - var store = new InMemoryPackRunEvidenceStore(); - var service = new PackRunEvidenceSnapshotService( - store, - new PackRunRedactionGuard(), - NullLogger.Instance); - - var transcript = new PackRunStepTranscript( - TestStepId, "shell", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, - "completed", 1, 150, "Build output", null, null, null); - - // Act - var result = await service.CaptureStepExecutionAsync( - TestTenantId, TestRunId, TestPlanHash, transcript, - CancellationToken.None); - - // Assert - Assert.True(result.Success); - Assert.Equal(PackRunEvidenceSnapshotKind.StepExecution, result.Snapshot!.Kind); - Assert.Contains(result.Snapshot.Materials, m => m.Section == "transcript"); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task CaptureApprovalDecision_CapturesApproval() - { - // Arrange - var store = new InMemoryPackRunEvidenceStore(); - var service = new PackRunEvidenceSnapshotService( - store, - new PackRunRedactionGuard(), - NullLogger.Instance); - - var approval = new PackRunApprovalEvidence( - "approval-001", - "approver@example.com", - DateTimeOffset.UtcNow, - "approved", - new[] { "deploy:prod" }, - null, - "LGTM"); - - // Act - var result = await service.CaptureApprovalDecisionAsync( - TestTenantId, TestRunId, TestPlanHash, approval, - CancellationToken.None); - - // Assert - Assert.True(result.Success); - Assert.Equal(PackRunEvidenceSnapshotKind.ApprovalDecision, result.Snapshot!.Kind); - Assert.Contains(result.Snapshot.Materials, m => m.Section == "approval"); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task CapturePolicyEvaluation_CapturesEvaluation() - { - // Arrange - var store = new InMemoryPackRunEvidenceStore(); - var service = new PackRunEvidenceSnapshotService( - store, - new PackRunRedactionGuard(), - NullLogger.Instance); - - var evaluation = new PackRunPolicyEvidence( - "require-approval", - "1.0.0", - "pass", - DateTimeOffset.UtcNow, - 5.5, - new[] { "rule-1", "rule-2" }, - "sha256:policy123"); - - // Act - var result = await service.CapturePolicyEvaluationAsync( - TestTenantId, TestRunId, TestPlanHash, evaluation, - CancellationToken.None); - - // Assert - Assert.True(result.Success); - Assert.Equal(PackRunEvidenceSnapshotKind.PolicyEvaluation, result.Snapshot!.Kind); - Assert.Contains(result.Snapshot.Materials, m => m.Section == "policy"); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task CaptureRunCompletion_EmitsTimelineEvent() - { - // Arrange - var store = new InMemoryPackRunEvidenceStore(); - var sink = new InMemoryPackRunTimelineEventSink(); - var emitter = new PackRunTimelineEventEmitter( - sink, TimeProvider.System, NullLogger.Instance); - var service = new PackRunEvidenceSnapshotService( - store, - new PackRunRedactionGuard(), - NullLogger.Instance, - emitter); - - var state = CreateTestPackRunState(); - - // Act - await service.CaptureRunCompletionAsync( - TestTenantId, TestRunId, TestPlanHash, state, - cancellationToken: CancellationToken.None); - - // Assert - var events = sink.GetEvents(); - Assert.Single(events); - Assert.Equal("pack.evidence.captured", events[0].EventType); - } - - #endregion - - #region Helper Methods - - private static PackRunState CreateTestPackRunState() - { - var manifest = TestManifests.Load(TestManifests.Sample); - var planner = new TaskPackPlanner(); - var planResult = planner.Plan(manifest); - var plan = planResult.Plan!; - - var context = new PackRunExecutionContext(TestRunId, plan, DateTimeOffset.UtcNow); - var graphBuilder = new PackRunExecutionGraphBuilder(); - var graph = graphBuilder.Build(plan); - var simulationEngine = new PackRunSimulationEngine(); - - var timestamp = DateTimeOffset.UtcNow; - return PackRunStateFactory.CreateInitialState(context, graph, simulationEngine, timestamp); - } - - #endregion -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunExecutionGraphBuilderTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunExecutionGraphBuilderTests.cs deleted file mode 100644 index 4676efa8e..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunExecutionGraphBuilderTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Text.Json.Nodes; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunExecutionGraphBuilderTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Build_GeneratesParallelMetadata() - { - var manifest = TestManifests.Load(TestManifests.Parallel); - var planner = new TaskPackPlanner(); - - var result = planner.Plan(manifest); - Assert.True(result.Success); - var plan = result.Plan!; - - var builder = new PackRunExecutionGraphBuilder(); - var graph = builder.Build(plan); - - Assert.Equal(2, graph.FailurePolicy.MaxAttempts); - Assert.Equal(10, graph.FailurePolicy.BackoffSeconds); - - var parallel = Assert.Single(graph.Steps); - Assert.Equal(PackRunStepKind.Parallel, parallel.Kind); - Assert.True(parallel.Enabled); - Assert.Equal(2, parallel.MaxParallel); - Assert.True(parallel.ContinueOnError); - Assert.Equal(2, parallel.Children.Count); - Assert.All(parallel.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind)); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Build_PreservesMapIterationsAndDisabledSteps() - { - var planner = new TaskPackPlanner(); - var builder = new PackRunExecutionGraphBuilder(); - - // Map iterations - var mapManifest = TestManifests.Load(TestManifests.Map); - var inputs = new Dictionary - { - ["targets"] = new JsonArray("alpha", "beta", "gamma") - }; - - var mapPlan = planner.Plan(mapManifest, inputs).Plan!; - var mapGraph = builder.Build(mapPlan); - - var mapStep = Assert.Single(mapGraph.Steps); - Assert.Equal(PackRunStepKind.Map, mapStep.Kind); - Assert.Equal(3, mapStep.Children.Count); - Assert.All(mapStep.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind)); - - // Disabled conditional step - var conditionalManifest = TestManifests.Load(TestManifests.Sample); - var conditionalInputs = new Dictionary - { - ["dryRun"] = JsonValue.Create(true) - }; - - var conditionalPlan = planner.Plan(conditionalManifest, conditionalInputs).Plan!; - var conditionalGraph = builder.Build(conditionalPlan); - - var applyStep = conditionalGraph.Steps.Single(step => step.Id == "apply-step"); - Assert.False(applyStep.Enabled); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunGateStateUpdaterTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunGateStateUpdaterTests.cs deleted file mode 100644 index 49fb17a40..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunGateStateUpdaterTests.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System; -using System.Collections.Generic; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunGateStateUpdaterTests -{ - private static readonly DateTimeOffset RequestedAt = DateTimeOffset.UnixEpoch; - private static readonly DateTimeOffset UpdateTimestamp = DateTimeOffset.UnixEpoch.AddMinutes(5); - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Apply_ApprovedGate_ClearsReasonAndSucceeds() - { - var plan = BuildApprovalPlan(); - var graph = new PackRunExecutionGraphBuilder().Build(plan); - var state = CreateInitialState(plan, graph); - var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt); - coordinator.Approve("security-review", "approver-1", UpdateTimestamp); - - var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp); - - Assert.False(result.HasBlockingFailure); - Assert.Equal(UpdateTimestamp, result.State.UpdatedAt); - - var gate = result.State.Steps["approval"]; - Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status); - Assert.Null(gate.StatusReason); - Assert.Equal(UpdateTimestamp, gate.LastTransitionAt); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Apply_RejectedGate_FlagsFailure() - { - var plan = BuildApprovalPlan(); - var graph = new PackRunExecutionGraphBuilder().Build(plan); - var state = CreateInitialState(plan, graph); - var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt); - coordinator.Reject("security-review", "approver-1", UpdateTimestamp, "not-safe"); - - var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp); - - Assert.True(result.HasBlockingFailure); - Assert.Equal(UpdateTimestamp, result.State.UpdatedAt); - - var gate = result.State.Steps["approval"]; - Assert.Equal(PackRunStepExecutionStatus.Failed, gate.Status); - Assert.StartsWith("approval-rejected", gate.StatusReason, StringComparison.Ordinal); - Assert.Equal(UpdateTimestamp, gate.LastTransitionAt); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Apply_PolicyGate_ClearsPendingReason() - { - var plan = BuildPolicyPlan(); - var graph = new PackRunExecutionGraphBuilder().Build(plan); - var state = CreateInitialState(plan, graph); - var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt); - - var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp); - - Assert.False(result.HasBlockingFailure); - - var gate = result.State.Steps["policy-check"]; - Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status); - Assert.Null(gate.StatusReason); - Assert.Equal(UpdateTimestamp, gate.LastTransitionAt); - - var prepare = result.State.Steps["prepare"]; - Assert.Equal(PackRunStepExecutionStatus.Pending, prepare.Status); - Assert.Null(prepare.StatusReason); - } - - private static TaskPackPlan BuildApprovalPlan() - { - var manifest = TestManifests.Load(TestManifests.Sample); - var planner = new TaskPackPlanner(); - var inputs = new Dictionary - { - ["dryRun"] = System.Text.Json.Nodes.JsonValue.Create(false) - }; - - return planner.Plan(manifest, inputs).Plan!; - } - - private static TaskPackPlan BuildPolicyPlan() - { - var manifest = TestManifests.Load(TestManifests.PolicyGate); - var planner = new TaskPackPlanner(); - return planner.Plan(manifest).Plan!; - } - - private static PackRunState CreateInitialState(TaskPackPlan plan, PackRunExecutionGraph graph) - { - var steps = new Dictionary(StringComparer.Ordinal); - - foreach (var step in EnumerateSteps(graph.Steps)) - { - var status = PackRunStepExecutionStatus.Pending; - string? reason = null; - - if (!step.Enabled) - { - status = PackRunStepExecutionStatus.Skipped; - reason = "disabled"; - } - else if (step.Kind == PackRunStepKind.GateApproval) - { - reason = "requires-approval"; - } - else if (step.Kind == PackRunStepKind.GatePolicy) - { - reason = "requires-policy"; - } - - steps[step.Id] = new PackRunStepStateRecord( - step.Id, - step.Kind, - step.Enabled, - step.ContinueOnError, - step.MaxParallel, - step.ApprovalId, - step.GateMessage, - status, - Attempts: 0, - LastTransitionAt: null, - NextAttemptAt: null, - StatusReason: reason); - } - - return PackRunState.Create("run-1", plan.Hash, plan, graph.FailurePolicy, RequestedAt, steps, RequestedAt); - } - - private static IEnumerable EnumerateSteps(IReadOnlyList steps) - { - foreach (var step in steps) - { - yield return step; - - if (step.Children.Count > 0) - { - foreach (var child in EnumerateSteps(step.Children)) - { - yield return child; - } - } - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunIncidentModeTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunIncidentModeTests.cs deleted file mode 100644 index d5d5d9ee8..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunIncidentModeTests.cs +++ /dev/null @@ -1,413 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Time.Testing; -using StellaOps.TaskRunner.Core.Events; -using StellaOps.TaskRunner.Core.IncidentMode; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunIncidentModeTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ActivateAsync_ActivatesIncidentModeSuccessfully() - { - var store = new InMemoryPackRunIncidentModeStore(); - var service = new PackRunIncidentModeService( - store, - NullLogger.Instance); - - var request = new IncidentModeActivationRequest( - RunId: "run-001", - TenantId: "tenant-1", - Level: IncidentEscalationLevel.Medium, - Source: IncidentModeSource.Manual, - Reason: "Debugging production issue", - DurationMinutes: 60, - RequestedBy: "admin@example.com"); - - var result = await service.ActivateAsync(request, CancellationToken.None); - - Assert.True(result.Success); - Assert.True(result.Status.Active); - Assert.Equal(IncidentEscalationLevel.Medium, result.Status.Level); - Assert.Equal(IncidentModeSource.Manual, result.Status.Source); - Assert.NotNull(result.Status.ActivatedAt); - Assert.NotNull(result.Status.ExpiresAt); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ActivateAsync_WithoutDuration_CreatesIndefiniteIncidentMode() - { - var store = new InMemoryPackRunIncidentModeStore(); - var service = new PackRunIncidentModeService( - store, - NullLogger.Instance); - - var request = new IncidentModeActivationRequest( - RunId: "run-002", - TenantId: "tenant-1", - Level: IncidentEscalationLevel.High, - Source: IncidentModeSource.Manual, - Reason: "Critical investigation", - DurationMinutes: null, - RequestedBy: null); - - var result = await service.ActivateAsync(request, CancellationToken.None); - - Assert.True(result.Success); - Assert.Null(result.Status.ExpiresAt); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ActivateAsync_EmitsTimelineEvent() - { - var store = new InMemoryPackRunIncidentModeStore(); - var timelineSink = new InMemoryPackRunTimelineEventSink(); - var emitter = new PackRunTimelineEventEmitter( - timelineSink, - TimeProvider.System, - NullLogger.Instance); - var service = new PackRunIncidentModeService( - store, - NullLogger.Instance, - null, - emitter); - - var request = new IncidentModeActivationRequest( - RunId: "run-003", - TenantId: "tenant-1", - Level: IncidentEscalationLevel.Low, - Source: IncidentModeSource.Manual, - Reason: "Test", - DurationMinutes: 30, - RequestedBy: null); - - await service.ActivateAsync(request, CancellationToken.None); - - Assert.Single(timelineSink.GetEvents()); - var evt = timelineSink.GetEvents()[0]; - Assert.Equal(PackRunIncidentEventTypes.IncidentModeActivated, evt.EventType); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task DeactivateAsync_DeactivatesIncidentMode() - { - var store = new InMemoryPackRunIncidentModeStore(); - var service = new PackRunIncidentModeService( - store, - NullLogger.Instance); - - // First activate - var activateRequest = new IncidentModeActivationRequest( - RunId: "run-004", - TenantId: "tenant-1", - Level: IncidentEscalationLevel.Medium, - Source: IncidentModeSource.Manual, - Reason: "Test", - DurationMinutes: null, - RequestedBy: null); - - await service.ActivateAsync(activateRequest, CancellationToken.None); - - // Then deactivate - var result = await service.DeactivateAsync("run-004", "Issue resolved", CancellationToken.None); - - Assert.True(result.Success); - Assert.False(result.Status.Active); - - var status = await service.GetStatusAsync("run-004", CancellationToken.None); - Assert.False(status.Active); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task GetStatusAsync_ReturnsInactiveForUnknownRun() - { - var store = new InMemoryPackRunIncidentModeStore(); - var service = new PackRunIncidentModeService( - store, - NullLogger.Instance); - - var status = await service.GetStatusAsync("unknown-run", CancellationToken.None); - - Assert.False(status.Active); - Assert.Equal(IncidentEscalationLevel.None, status.Level); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task GetStatusAsync_AutoDeactivatesExpiredIncidentMode() - { - var store = new InMemoryPackRunIncidentModeStore(); - var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); - var service = new PackRunIncidentModeService( - store, - NullLogger.Instance, - fakeTime); - - var request = new IncidentModeActivationRequest( - RunId: "run-005", - TenantId: "tenant-1", - Level: IncidentEscalationLevel.Medium, - Source: IncidentModeSource.Manual, - Reason: "Test", - DurationMinutes: 30, - RequestedBy: null); - - await service.ActivateAsync(request, CancellationToken.None); - - // Advance time past expiration - fakeTime.Advance(TimeSpan.FromMinutes(31)); - - var status = await service.GetStatusAsync("run-005", CancellationToken.None); - - Assert.False(status.Active); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task HandleSloBreachAsync_ActivatesIncidentModeFromBreach() - { - var store = new InMemoryPackRunIncidentModeStore(); - var service = new PackRunIncidentModeService( - store, - NullLogger.Instance); - - var breach = new SloBreachNotification( - BreachId: "breach-001", - SloName: "error_rate_5m", - Severity: "HIGH", - OccurredAt: DateTimeOffset.UtcNow, - CurrentValue: 15.5, - Threshold: 5.0, - Target: 1.0, - ResourceId: "run-006", - TenantId: "tenant-1", - Context: new Dictionary { ["step"] = "scan" }); - - var result = await service.HandleSloBreachAsync(breach, CancellationToken.None); - - Assert.True(result.Success); - Assert.True(result.Status.Active); - Assert.Equal(IncidentEscalationLevel.High, result.Status.Level); - Assert.Equal(IncidentModeSource.SloBreach, result.Status.Source); - Assert.Contains("error_rate_5m", result.Status.ActivationReason!); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task HandleSloBreachAsync_MapsSeverityToLevel() - { - var store = new InMemoryPackRunIncidentModeStore(); - var service = new PackRunIncidentModeService( - store, - NullLogger.Instance); - - var severityToLevel = new Dictionary - { - ["CRITICAL"] = IncidentEscalationLevel.Critical, - ["HIGH"] = IncidentEscalationLevel.High, - ["MEDIUM"] = IncidentEscalationLevel.Medium, - ["LOW"] = IncidentEscalationLevel.Low - }; - - var runIndex = 0; - foreach (var (severity, expectedLevel) in severityToLevel) - { - var breach = new SloBreachNotification( - BreachId: $"breach-{runIndex}", - SloName: "test_slo", - Severity: severity, - OccurredAt: DateTimeOffset.UtcNow, - CurrentValue: 10.0, - Threshold: 5.0, - Target: 1.0, - ResourceId: $"run-severity-{runIndex++}", - TenantId: "tenant-1", - Context: null); - - var result = await service.HandleSloBreachAsync(breach, CancellationToken.None); - - Assert.True(result.Success); - Assert.Equal(expectedLevel, result.Status.Level); - } - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task HandleSloBreachAsync_ReturnsErrorForMissingResourceId() - { - var store = new InMemoryPackRunIncidentModeStore(); - var service = new PackRunIncidentModeService( - store, - NullLogger.Instance); - - var breach = new SloBreachNotification( - BreachId: "breach-no-resource", - SloName: "test_slo", - Severity: "HIGH", - OccurredAt: DateTimeOffset.UtcNow, - CurrentValue: 10.0, - Threshold: 5.0, - Target: 1.0, - ResourceId: null, - TenantId: "tenant-1", - Context: null); - - var result = await service.HandleSloBreachAsync(breach, CancellationToken.None); - - Assert.False(result.Success); - Assert.Contains("No resource ID", result.Error); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EscalateAsync_IncreasesEscalationLevel() - { - var store = new InMemoryPackRunIncidentModeStore(); - var service = new PackRunIncidentModeService( - store, - NullLogger.Instance); - - // First activate at Low level - var activateRequest = new IncidentModeActivationRequest( - RunId: "run-escalate", - TenantId: "tenant-1", - Level: IncidentEscalationLevel.Low, - Source: IncidentModeSource.Manual, - Reason: "Initial activation", - DurationMinutes: null, - RequestedBy: null); - - await service.ActivateAsync(activateRequest, CancellationToken.None); - - // Escalate to High - var result = await service.EscalateAsync( - "run-escalate", - IncidentEscalationLevel.High, - "Issue is more severe than expected", - CancellationToken.None); - - Assert.True(result.Success); - Assert.Equal(IncidentEscalationLevel.High, result.Status.Level); - Assert.Contains("Escalated", result.Status.ActivationReason); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EscalateAsync_FailsWhenNotInIncidentMode() - { - var store = new InMemoryPackRunIncidentModeStore(); - var service = new PackRunIncidentModeService( - store, - NullLogger.Instance); - - var result = await service.EscalateAsync( - "unknown-run", - IncidentEscalationLevel.High, - null, - CancellationToken.None); - - Assert.False(result.Success); - Assert.Contains("not active", result.Error); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EscalateAsync_FailsWhenNewLevelIsLowerOrEqual() - { - var store = new InMemoryPackRunIncidentModeStore(); - var service = new PackRunIncidentModeService( - store, - NullLogger.Instance); - - var activateRequest = new IncidentModeActivationRequest( - RunId: "run-no-deescalate", - TenantId: "tenant-1", - Level: IncidentEscalationLevel.High, - Source: IncidentModeSource.Manual, - Reason: "Test", - DurationMinutes: null, - RequestedBy: null); - - await service.ActivateAsync(activateRequest, CancellationToken.None); - - var result = await service.EscalateAsync( - "run-no-deescalate", - IncidentEscalationLevel.Medium, // Lower than High - null, - CancellationToken.None); - - Assert.False(result.Success); - Assert.Contains("Cannot escalate", result.Error); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void GetSettingsForLevel_ReturnsCorrectSettings() - { - var store = new InMemoryPackRunIncidentModeStore(); - var service = new PackRunIncidentModeService( - store, - NullLogger.Instance); - - // Test None level - var noneSettings = service.GetSettingsForLevel(IncidentEscalationLevel.None); - Assert.False(noneSettings.TelemetrySettings.EnhancedTelemetryActive); - Assert.False(noneSettings.DebugCaptureSettings.CaptureActive); - - // Test Critical level - var criticalSettings = service.GetSettingsForLevel(IncidentEscalationLevel.Critical); - Assert.True(criticalSettings.TelemetrySettings.EnhancedTelemetryActive); - Assert.Equal(IncidentLogVerbosity.Debug, criticalSettings.TelemetrySettings.LogVerbosity); - Assert.Equal(1.0, criticalSettings.TelemetrySettings.TraceSamplingRate); - Assert.True(criticalSettings.DebugCaptureSettings.CaptureActive); - Assert.True(criticalSettings.DebugCaptureSettings.CaptureHeapDumps); - Assert.Equal(365, criticalSettings.RetentionPolicy.LogRetentionDays); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void PackRunIncidentModeStatus_Inactive_ReturnsDefaultValues() - { - var inactive = PackRunIncidentModeStatus.Inactive(); - - Assert.False(inactive.Active); - Assert.Equal(IncidentEscalationLevel.None, inactive.Level); - Assert.Null(inactive.ActivatedAt); - Assert.Null(inactive.ActivationReason); - Assert.Equal(IncidentModeSource.None, inactive.Source); - Assert.False(inactive.RetentionPolicy.ExtendedRetentionActive); - Assert.False(inactive.TelemetrySettings.EnhancedTelemetryActive); - Assert.False(inactive.DebugCaptureSettings.CaptureActive); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void IncidentRetentionPolicy_Extended_HasLongerRetention() - { - var defaultPolicy = IncidentRetentionPolicy.Default(); - var extendedPolicy = IncidentRetentionPolicy.Extended(); - - Assert.True(extendedPolicy.ExtendedRetentionActive); - Assert.True(extendedPolicy.LogRetentionDays > defaultPolicy.LogRetentionDays); - Assert.True(extendedPolicy.ArtifactRetentionDays > defaultPolicy.ArtifactRetentionDays); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void IncidentTelemetrySettings_Enhanced_HasHigherSampling() - { - var defaultSettings = IncidentTelemetrySettings.Default(); - var enhancedSettings = IncidentTelemetrySettings.Enhanced(); - - Assert.True(enhancedSettings.EnhancedTelemetryActive); - Assert.True(enhancedSettings.TraceSamplingRate > defaultSettings.TraceSamplingRate); - Assert.True(enhancedSettings.CaptureEnvironment); - Assert.True(enhancedSettings.CaptureStepIo); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunProcessorTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunProcessorTests.cs deleted file mode 100644 index d90b557c7..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunProcessorTests.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; -using System.Text.Json.Nodes; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunProcessorTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ProcessNewRunAsync_PersistsApprovalsAndPublishesNotifications() - { - var manifest = TestManifests.Load(TestManifests.Sample); - var planner = new TaskPackPlanner(); - var plan = planner.Plan(manifest, new Dictionary { ["dryRun"] = JsonValue.Create(false) }).Plan!; - var context = new PackRunExecutionContext("run-123", plan, DateTimeOffset.UtcNow); - - var store = new TestApprovalStore(); - var publisher = new TestNotificationPublisher(); - var processor = new PackRunProcessor(store, publisher, NullLogger.Instance); - - var result = await processor.ProcessNewRunAsync(context, CancellationToken.None); - - Assert.False(result.ShouldResumeImmediately); - var saved = Assert.Single(store.Saved); - Assert.Equal("security-review", saved.ApprovalId); - Assert.Single(publisher.Approvals); - Assert.Empty(publisher.Policies); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ProcessNewRunAsync_NoApprovals_ResumesImmediately() - { - var manifest = TestManifests.Load(TestManifests.Output); - var planner = new TaskPackPlanner(); - var plan = planner.Plan(manifest).Plan!; - var context = new PackRunExecutionContext("run-456", plan, DateTimeOffset.UtcNow); - - var store = new TestApprovalStore(); - var publisher = new TestNotificationPublisher(); - var processor = new PackRunProcessor(store, publisher, NullLogger.Instance); - - var result = await processor.ProcessNewRunAsync(context, CancellationToken.None); - - Assert.True(result.ShouldResumeImmediately); - Assert.Empty(store.Saved); - Assert.Empty(publisher.Approvals); - } - - private sealed class TestApprovalStore : IPackRunApprovalStore - { - public List Saved { get; } = new(); - - public Task> GetAsync(string runId, CancellationToken cancellationToken) - => Task.FromResult((IReadOnlyList)Saved); - - public Task SaveAsync(string runId, IReadOnlyList approvals, CancellationToken cancellationToken) - { - Saved.Clear(); - Saved.AddRange(approvals); - return Task.CompletedTask; - } - - public Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken) - => Task.CompletedTask; - } - - private sealed class TestNotificationPublisher : IPackRunNotificationPublisher - { - public List Approvals { get; } = new(); - public List Policies { get; } = new(); - - public Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken) - { - Approvals.Add(notification); - return Task.CompletedTask; - } - - public Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken) - { - Policies.Add(notification); - return Task.CompletedTask; - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunProvenanceWriterTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunProvenanceWriterTests.cs deleted file mode 100644 index 32850f134..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunProvenanceWriterTests.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Execution.Simulation; -using StellaOps.TaskRunner.Core.Planning; -using StellaOps.TaskRunner.Core.TaskPacks; -using StellaOps.TaskRunner.Infrastructure.Execution; -using Xunit; - - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunProvenanceWriterTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Filesystem_writer_emits_manifest() - { - var (context, state) = CreateRunState(); - var completedAt = new DateTimeOffset(2025, 11, 30, 12, 30, 0, TimeSpan.Zero); - var temp = Directory.CreateTempSubdirectory(); - try - { - var ct = CancellationToken.None; - var writer = new FilesystemPackRunProvenanceWriter(temp.FullName, new FixedTimeProvider(completedAt)); - await writer.WriteAsync(context, state, ct); - - var path = Path.Combine(temp.FullName, "provenance", "run-test.json"); - Assert.True(File.Exists(path)); - - using var document = JsonDocument.Parse(await File.ReadAllTextAsync(path, ct)); - var root = document.RootElement; - Assert.Equal("run-test", root.GetProperty("runId").GetString()); - Assert.Equal("tenant-alpha", root.GetProperty("tenantId").GetString()); - Assert.Equal(context.Plan.Hash, root.GetProperty("planHash").GetString()); - Assert.Equal(completedAt, root.GetProperty("completedAt").GetDateTimeOffset()); - } - finally - { - temp.Delete(recursive: true); - } - } - - private static (PackRunExecutionContext Context, PackRunState State) CreateRunState() - { - var loader = new TaskPackManifestLoader(); - var planner = new TaskPackPlanner(); - var manifest = loader.Deserialize(TestManifests.Sample); - var plan = planner.Plan(manifest, new Dictionary()).Plan ?? throw new InvalidOperationException("Plan generation failed."); - - var graphBuilder = new PackRunExecutionGraphBuilder(); - var simulationEngine = new PackRunSimulationEngine(); - var graph = graphBuilder.Build(plan); - - var requestedAt = new DateTimeOffset(2025, 11, 30, 10, 0, 0, TimeSpan.Zero); - var context = new PackRunExecutionContext("run-test", plan, requestedAt, "tenant-alpha"); - var state = PackRunStateFactory.CreateInitialState(context, graph, simulationEngine, requestedAt); - return (context, state); - } - - private sealed class FixedTimeProvider : TimeProvider - { - private readonly DateTimeOffset now; - - public FixedTimeProvider(DateTimeOffset now) - { - this.now = now; - } - - public override DateTimeOffset GetUtcNow() => now; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunSimulationEngineTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunSimulationEngineTests.cs deleted file mode 100644 index a0b8d2930..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunSimulationEngineTests.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System.Text.Json.Nodes; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Execution.Simulation; -using StellaOps.TaskRunner.Core.Planning; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunSimulationEngineTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Simulate_IdentifiesGateStatuses() - { - var manifest = TestManifests.Load(TestManifests.PolicyGate); - var planner = new TaskPackPlanner(); - var plan = planner.Plan(manifest).Plan!; - - var engine = new PackRunSimulationEngine(); - var result = engine.Simulate(plan); - - var gate = result.Steps.Single(step => step.Kind == PackRunStepKind.GatePolicy); - Assert.Equal(PackRunSimulationStatus.RequiresPolicy, gate.Status); - - var run = result.Steps.Single(step => step.Kind == PackRunStepKind.Run); - Assert.Equal(PackRunSimulationStatus.Pending, run.Status); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Simulate_MarksDisabledStepsAndOutputs() - { - var manifest = TestManifests.Load(TestManifests.Sample); - var planner = new TaskPackPlanner(); - var inputs = new Dictionary - { - ["dryRun"] = JsonValue.Create(true) - }; - - var plan = planner.Plan(manifest, inputs).Plan!; - - var engine = new PackRunSimulationEngine(); - var result = engine.Simulate(plan); - - var applyStep = result.Steps.Single(step => step.Id == "apply-step"); - Assert.Equal(PackRunSimulationStatus.Skipped, applyStep.Status); - - Assert.Empty(result.Outputs); - Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.MaxAttempts, result.FailurePolicy.MaxAttempts); - Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.BackoffSeconds, result.FailurePolicy.BackoffSeconds); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Simulate_ProjectsOutputsAndRuntimeFlags() - { - var manifest = TestManifests.Load(TestManifests.Output); - var planner = new TaskPackPlanner(); - var plan = planner.Plan(manifest).Plan!; - - var engine = new PackRunSimulationEngine(); - var result = engine.Simulate(plan); - - var step = Assert.Single(result.Steps); - Assert.Equal(PackRunStepKind.Run, step.Kind); - - Assert.Collection(result.Outputs, - bundle => - { - Assert.Equal("bundlePath", bundle.Name); - Assert.False(bundle.RequiresRuntimeValue); - }, - evidence => - { - Assert.Equal("evidenceModel", evidence.Name); - Assert.True(evidence.RequiresRuntimeValue); - }); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Simulate_LoopStep_SetsWillIterateStatus() - { - var manifest = TestManifests.Load(TestManifests.Loop); - var planner = new TaskPackPlanner(); - var inputs = new Dictionary - { - ["targets"] = new JsonArray { "a", "b", "c" } - }; - var result = planner.Plan(manifest, inputs); - Assert.Empty(result.Errors); - Assert.NotNull(result.Plan); - - var engine = new PackRunSimulationEngine(); - var simResult = engine.Simulate(result.Plan); - - var loopStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Loop); - Assert.Equal(PackRunSimulationStatus.WillIterate, loopStep.Status); - Assert.Equal("process-loop", loopStep.Id); - Assert.NotNull(loopStep.LoopInfo); - Assert.Equal("target", loopStep.LoopInfo.Iterator); - Assert.Equal("idx", loopStep.LoopInfo.Index); - Assert.Equal(100, loopStep.LoopInfo.MaxIterations); - Assert.Equal("collect", loopStep.LoopInfo.AggregationMode); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Simulate_ConditionalStep_SetsWillBranchStatus() - { - var manifest = TestManifests.Load(TestManifests.Conditional); - var planner = new TaskPackPlanner(); - var inputs = new Dictionary - { - ["environment"] = JsonValue.Create("production") - }; - var result = planner.Plan(manifest, inputs); - Assert.Empty(result.Errors); - Assert.NotNull(result.Plan); - - var engine = new PackRunSimulationEngine(); - var simResult = engine.Simulate(result.Plan); - - var conditionalStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Conditional); - Assert.Equal(PackRunSimulationStatus.WillBranch, conditionalStep.Status); - Assert.Equal("env-branch", conditionalStep.Id); - Assert.NotNull(conditionalStep.ConditionalInfo); - Assert.Equal(2, conditionalStep.ConditionalInfo.Branches.Count); - Assert.True(conditionalStep.ConditionalInfo.OutputUnion); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Simulate_PolicyGateStep_HasPolicyInfo() - { - var manifest = TestManifests.Load(TestManifests.PolicyGate); - var planner = new TaskPackPlanner(); - var plan = planner.Plan(manifest).Plan!; - - var engine = new PackRunSimulationEngine(); - var result = engine.Simulate(plan); - - var policyStep = result.Steps.Single(s => s.Kind == PackRunStepKind.GatePolicy); - Assert.Equal(PackRunSimulationStatus.RequiresPolicy, policyStep.Status); - Assert.NotNull(policyStep.PolicyInfo); - Assert.Equal("security-hold", policyStep.PolicyInfo.PolicyId); - Assert.Equal("abort", policyStep.PolicyInfo.FailureAction); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunStateFactoryTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunStateFactoryTests.cs deleted file mode 100644 index e8db99365..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunStateFactoryTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Execution.Simulation; -using StellaOps.TaskRunner.Core.Planning; -using StellaOps.TaskRunner.Core.TaskPacks; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunStateFactoryTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public void CreateInitialState_AssignsGateReasons() - { - var manifest = TestManifests.Load(TestManifests.Sample); - var planner = new TaskPackPlanner(); - var planResult = planner.Plan(manifest); - - Assert.True(planResult.Success); - Assert.NotNull(planResult.Plan); - var plan = planResult.Plan!; - - var context = new PackRunExecutionContext("run-state-factory", plan, DateTimeOffset.UtcNow); - var graphBuilder = new PackRunExecutionGraphBuilder(); - var graph = graphBuilder.Build(plan); - var simulationEngine = new PackRunSimulationEngine(); - - var timestamp = DateTimeOffset.UtcNow; - var state = PackRunStateFactory.CreateInitialState(context, graph, simulationEngine, timestamp); - - Assert.Equal("run-state-factory", state.RunId); - Assert.Equal(plan.Hash, state.PlanHash); - - Assert.True(state.Steps.TryGetValue("approval", out var approvalStep)); - Assert.Equal(PackRunStepExecutionStatus.Pending, approvalStep.Status); - Assert.Equal("requires-approval", approvalStep.StatusReason); - - Assert.True(state.Steps.TryGetValue("plan-step", out var planStep)); - Assert.Equal(PackRunStepExecutionStatus.Pending, planStep.Status); - Assert.Null(planStep.StatusReason); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunStepStateMachineTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunStepStateMachineTests.cs deleted file mode 100644 index 402392140..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunStepStateMachineTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Planning; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class PackRunStepStateMachineTests -{ - private static readonly TaskPackPlanFailurePolicy RetryTwicePolicy = new(MaxAttempts: 3, BackoffSeconds: 5, ContinueOnError: false); - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Start_FromPending_SetsRunning() - { - var state = PackRunStepStateMachine.Create(); - var started = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch); - - Assert.Equal(PackRunStepExecutionStatus.Running, started.Status); - Assert.Equal(0, started.Attempts); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void CompleteSuccess_IncrementsAttempts() - { - var state = PackRunStepStateMachine.Create(); - var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch); - var completed = PackRunStepStateMachine.CompleteSuccess(running, DateTimeOffset.UnixEpoch.AddSeconds(1)); - - Assert.Equal(PackRunStepExecutionStatus.Succeeded, completed.Status); - Assert.Equal(1, completed.Attempts); - Assert.Null(completed.NextAttemptAt); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void RegisterFailure_SchedulesRetryUntilMaxAttempts() - { - var state = PackRunStepStateMachine.Create(); - var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch); - - var firstFailure = PackRunStepStateMachine.RegisterFailure(running, DateTimeOffset.UnixEpoch.AddSeconds(2), RetryTwicePolicy); - Assert.Equal(PackRunStepFailureOutcome.Retry, firstFailure.Outcome); - Assert.Equal(PackRunStepExecutionStatus.Pending, firstFailure.State.Status); - Assert.Equal(1, firstFailure.State.Attempts); - Assert.Equal(DateTimeOffset.UnixEpoch.AddSeconds(7), firstFailure.State.NextAttemptAt); - - var restarted = PackRunStepStateMachine.Start(firstFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(7)); - var secondFailure = PackRunStepStateMachine.RegisterFailure(restarted, DateTimeOffset.UnixEpoch.AddSeconds(9), RetryTwicePolicy); - Assert.Equal(PackRunStepFailureOutcome.Retry, secondFailure.Outcome); - Assert.Equal(2, secondFailure.State.Attempts); - - var finalStart = PackRunStepStateMachine.Start(secondFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(9 + RetryTwicePolicy.BackoffSeconds)); - var terminalFailure = PackRunStepStateMachine.RegisterFailure(finalStart, DateTimeOffset.UnixEpoch.AddSeconds(20), RetryTwicePolicy); - Assert.Equal(PackRunStepFailureOutcome.Abort, terminalFailure.Outcome); - Assert.Equal(PackRunStepExecutionStatus.Failed, terminalFailure.State.Status); - Assert.Equal(3, terminalFailure.State.Attempts); - Assert.Null(terminalFailure.State.NextAttemptAt); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Skip_FromPending_SetsSkipped() - { - var state = PackRunStepStateMachine.Create(); - var skipped = PackRunStepStateMachine.Skip(state, DateTimeOffset.UnixEpoch.AddHours(1)); - - Assert.Equal(PackRunStepExecutionStatus.Skipped, skipped.Status); - Assert.Equal(0, skipped.Attempts); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunTimelineEventTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunTimelineEventTests.cs deleted file mode 100644 index 6006f5f59..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunTimelineEventTests.cs +++ /dev/null @@ -1,746 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using StellaOps.TaskRunner.Core.Events; -using Xunit; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -/// -/// Tests for pack run timeline event domain model, emitter, and sink. -/// Per TASKRUN-OBS-52-001. -/// -public sealed class PackRunTimelineEventTests -{ - private const string TestTenantId = "test-tenant"; - private const string TestRunId = "run-12345"; - private const string TestPlanHash = "sha256:abc123"; - private const string TestStepId = "step-001"; - private const string TestProjectId = "project-xyz"; - - #region Domain Model Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_WithRequiredFields_GeneratesValidEvent() - { - // Arrange - var occurredAt = DateTimeOffset.UtcNow; - - // Act - var evt = PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.PackStarted, - source: "taskrunner-worker", - occurredAt: occurredAt, - runId: TestRunId, - planHash: TestPlanHash); - - // Assert - Assert.NotEqual(Guid.Empty, evt.EventId); - Assert.Equal(TestTenantId, evt.TenantId); - Assert.Equal(PackRunEventTypes.PackStarted, evt.EventType); - Assert.Equal("taskrunner-worker", evt.Source); - Assert.Equal(occurredAt, evt.OccurredAt); - Assert.Equal(TestRunId, evt.RunId); - Assert.Equal(TestPlanHash, evt.PlanHash); - Assert.Null(evt.ReceivedAt); - Assert.Null(evt.EventSeq); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_WithPayload_ComputesHashAndNormalizes() - { - // Arrange - var payload = new { stepId = "step-001", attempt = 1 }; - - // Act - var evt = PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.StepStarted, - source: "taskrunner-worker", - occurredAt: DateTimeOffset.UtcNow, - runId: TestRunId, - planHash: TestPlanHash, - payload: payload); - - // Assert - Assert.NotNull(evt.RawPayloadJson); - Assert.NotNull(evt.NormalizedPayloadJson); - Assert.NotNull(evt.PayloadHash); - Assert.StartsWith("sha256:", evt.PayloadHash); - Assert.Equal(64 + 7, evt.PayloadHash.Length); // sha256: prefix + 64 hex chars - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_WithStepId_SetsStepId() - { - // Act - var evt = PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.StepCompleted, - source: "taskrunner-worker", - occurredAt: DateTimeOffset.UtcNow, - runId: TestRunId, - planHash: TestPlanHash, - stepId: TestStepId); - - // Assert - Assert.Equal(TestStepId, evt.StepId); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Create_WithEvidencePointer_SetsPointer() - { - // Arrange - var evidence = PackRunEvidencePointer.Bundle(Guid.NewGuid(), "sha256:def456"); - - // Act - var evt = PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.PackCompleted, - source: "taskrunner-worker", - occurredAt: DateTimeOffset.UtcNow, - runId: TestRunId, - planHash: TestPlanHash, - evidencePointer: evidence); - - // Assert - Assert.NotNull(evt.EvidencePointer); - Assert.Equal(PackRunEvidencePointerType.Bundle, evt.EvidencePointer.Type); - Assert.Equal("sha256:def456", evt.EvidencePointer.BundleDigest); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void WithReceivedAt_CreatesCopyWithTimestamp() - { - // Arrange - var evt = PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.PackStarted, - source: "taskrunner-worker", - occurredAt: DateTimeOffset.UtcNow, - runId: TestRunId, - planHash: TestPlanHash); - - var receivedAt = DateTimeOffset.UtcNow.AddSeconds(1); - - // Act - var updated = evt.WithReceivedAt(receivedAt); - - // Assert - Assert.Null(evt.ReceivedAt); - Assert.Equal(receivedAt, updated.ReceivedAt); - Assert.Equal(evt.EventId, updated.EventId); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void WithSequence_CreatesCopyWithSequence() - { - // Arrange - var evt = PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.PackStarted, - source: "taskrunner-worker", - occurredAt: DateTimeOffset.UtcNow, - runId: TestRunId, - planHash: TestPlanHash); - - // Act - var updated = evt.WithSequence(42); - - // Assert - Assert.Null(evt.EventSeq); - Assert.Equal(42, updated.EventSeq); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void ToJson_SerializesEvent() - { - // Arrange - var evt = PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.StepCompleted, - source: "taskrunner-worker", - occurredAt: DateTimeOffset.UtcNow, - runId: TestRunId, - planHash: TestPlanHash, - stepId: TestStepId); - - // Act - var json = evt.ToJson(); - - // Assert - Assert.Contains("\"tenantId\"", json); - Assert.Contains("\"eventType\"", json); - Assert.Contains("pack.step.completed", json); - Assert.Contains(TestStepId, json); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void FromJson_DeserializesEvent() - { - // Arrange - var original = PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.StepCompleted, - source: "taskrunner-worker", - occurredAt: DateTimeOffset.UtcNow, - runId: TestRunId, - planHash: TestPlanHash, - stepId: TestStepId); - var json = original.ToJson(); - - // Act - var deserialized = PackRunTimelineEvent.FromJson(json); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.EventId, deserialized.EventId); - Assert.Equal(original.TenantId, deserialized.TenantId); - Assert.Equal(original.EventType, deserialized.EventType); - Assert.Equal(original.RunId, deserialized.RunId); - Assert.Equal(original.StepId, deserialized.StepId); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void GenerateIdempotencyKey_ReturnsConsistentKey() - { - // Arrange - var evt = PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.PackStarted, - source: "taskrunner-worker", - occurredAt: DateTimeOffset.UtcNow, - runId: TestRunId, - planHash: TestPlanHash); - - // Act - var key1 = evt.GenerateIdempotencyKey(); - var key2 = evt.GenerateIdempotencyKey(); - - // Assert - Assert.Equal(key1, key2); - Assert.Contains(TestTenantId, key1); - Assert.Contains(PackRunEventTypes.PackStarted, key1); - } - - #endregion - - #region Event Types Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void PackRunEventTypes_HasExpectedValues() - { - Assert.Equal("pack.started", PackRunEventTypes.PackStarted); - Assert.Equal("pack.completed", PackRunEventTypes.PackCompleted); - Assert.Equal("pack.failed", PackRunEventTypes.PackFailed); - Assert.Equal("pack.step.started", PackRunEventTypes.StepStarted); - Assert.Equal("pack.step.completed", PackRunEventTypes.StepCompleted); - Assert.Equal("pack.step.failed", PackRunEventTypes.StepFailed); - } - - [Trait("Category", TestCategories.Unit)] - [Theory] - [InlineData("pack.started", true)] - [InlineData("pack.step.completed", true)] - [InlineData("scan.completed", false)] - [InlineData("job.started", false)] - public void IsPackRunEvent_ReturnsCorrectly(string eventType, bool expected) - { - Assert.Equal(expected, PackRunEventTypes.IsPackRunEvent(eventType)); - } - - #endregion - - #region Evidence Pointer Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void EvidencePointer_Bundle_CreatesCorrectType() - { - var bundleId = Guid.NewGuid(); - var pointer = PackRunEvidencePointer.Bundle(bundleId, "sha256:abc"); - - Assert.Equal(PackRunEvidencePointerType.Bundle, pointer.Type); - Assert.Equal(bundleId, pointer.BundleId); - Assert.Equal("sha256:abc", pointer.BundleDigest); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void EvidencePointer_Attestation_CreatesCorrectType() - { - var pointer = PackRunEvidencePointer.Attestation("subject:uri", "sha256:abc"); - - Assert.Equal(PackRunEvidencePointerType.Attestation, pointer.Type); - Assert.Equal("subject:uri", pointer.AttestationSubject); - Assert.Equal("sha256:abc", pointer.AttestationDigest); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void EvidencePointer_Manifest_CreatesCorrectType() - { - var pointer = PackRunEvidencePointer.Manifest("https://example.com/manifest", "/locker/path"); - - Assert.Equal(PackRunEvidencePointerType.Manifest, pointer.Type); - Assert.Equal("https://example.com/manifest", pointer.ManifestUri); - Assert.Equal("/locker/path", pointer.LockerPath); - } - - #endregion - - #region In-Memory Sink Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task InMemorySink_WriteAsync_StoresEvent() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - var evt = PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.PackStarted, - source: "taskrunner-worker", - occurredAt: DateTimeOffset.UtcNow, - runId: TestRunId, - planHash: TestPlanHash); - - // Act - var result = await sink.WriteAsync(evt, CancellationToken.None); - - // Assert - Assert.True(result.Success); - Assert.NotNull(result.Sequence); - Assert.False(result.Deduplicated); - Assert.Single(sink.GetEvents()); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task InMemorySink_WriteAsync_Deduplicates() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - var evt = PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.PackStarted, - source: "taskrunner-worker", - occurredAt: DateTimeOffset.UtcNow, - runId: TestRunId, - planHash: TestPlanHash); - var ct = CancellationToken.None; - - // Act - await sink.WriteAsync(evt, ct); - var result = await sink.WriteAsync(evt, ct); - - // Assert - Assert.True(result.Success); - Assert.True(result.Deduplicated); - Assert.Single(sink.GetEvents()); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task InMemorySink_AssignsMonotonicSequence() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - var ct = CancellationToken.None; - - // Act - var evt1 = PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.PackStarted, - source: "test", - occurredAt: DateTimeOffset.UtcNow, - runId: "run-1", - planHash: TestPlanHash); - - var evt2 = PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.StepStarted, - source: "test", - occurredAt: DateTimeOffset.UtcNow, - runId: "run-1", - planHash: TestPlanHash); - - var result1 = await sink.WriteAsync(evt1, ct); - var result2 = await sink.WriteAsync(evt2, ct); - - // Assert - Assert.Equal(1, result1.Sequence); - Assert.Equal(2, result2.Sequence); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task InMemorySink_WriteBatchAsync_StoresMultiple() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - var events = Enumerable.Range(0, 3).Select(i => - PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.StepStarted, - source: "test", - occurredAt: DateTimeOffset.UtcNow, - runId: TestRunId, - planHash: TestPlanHash, - stepId: $"step-{i}")).ToList(); - - // Act - var result = await sink.WriteBatchAsync(events, CancellationToken.None); - - // Assert - Assert.Equal(3, result.Written); - Assert.Equal(0, result.Deduplicated); - Assert.Equal(3, sink.Count); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task InMemorySink_GetEventsForRun_FiltersCorrectly() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - var ct = CancellationToken.None; - - await sink.WriteAsync(PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.PackStarted, - source: "test", - occurredAt: DateTimeOffset.UtcNow, - runId: "run-1", - planHash: TestPlanHash), ct); - - await sink.WriteAsync(PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.PackStarted, - source: "test", - occurredAt: DateTimeOffset.UtcNow, - runId: "run-2", - planHash: TestPlanHash), ct); - - // Act - var run1Events = sink.GetEventsForRun("run-1"); - var run2Events = sink.GetEventsForRun("run-2"); - - // Assert - Assert.Single(run1Events); - Assert.Single(run2Events); - Assert.Equal("run-1", run1Events[0].RunId); - Assert.Equal("run-2", run2Events[0].RunId); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task InMemorySink_Clear_RemovesAll() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - await sink.WriteAsync(PackRunTimelineEvent.Create( - tenantId: TestTenantId, - eventType: PackRunEventTypes.PackStarted, - source: "test", - occurredAt: DateTimeOffset.UtcNow, - runId: TestRunId, - planHash: TestPlanHash), CancellationToken.None); - - // Act - sink.Clear(); - - // Assert - Assert.Empty(sink.GetEvents()); - } - - #endregion - - #region Emitter Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Emitter_EmitPackStartedAsync_CreatesEvent() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); - var emitter = new PackRunTimelineEventEmitter( - sink, - timeProvider, - NullLogger.Instance); - - // Act - var result = await emitter.EmitPackStartedAsync( - TestTenantId, - TestRunId, - TestPlanHash, - projectId: TestProjectId, - cancellationToken: CancellationToken.None); - - // Assert - Assert.True(result.Success); - Assert.False(result.Deduplicated); - Assert.Equal(PackRunEventTypes.PackStarted, result.Event.EventType); - Assert.Equal(TestRunId, result.Event.RunId); - Assert.Single(sink.GetEvents()); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Emitter_EmitPackCompletedAsync_CreatesEvent() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); - var emitter = new PackRunTimelineEventEmitter( - sink, - timeProvider, - NullLogger.Instance); - - // Act - var result = await emitter.EmitPackCompletedAsync( - TestTenantId, - TestRunId, - TestPlanHash, - cancellationToken: CancellationToken.None); - - // Assert - Assert.True(result.Success); - Assert.Equal(PackRunEventTypes.PackCompleted, result.Event.EventType); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Emitter_EmitPackFailedAsync_CreatesEventWithError() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); - var emitter = new PackRunTimelineEventEmitter( - sink, - timeProvider, - NullLogger.Instance); - - // Act - var result = await emitter.EmitPackFailedAsync( - TestTenantId, - TestRunId, - TestPlanHash, - failureReason: "Step step-001 failed", - cancellationToken: CancellationToken.None); - - // Assert - Assert.True(result.Success); - Assert.Equal(PackRunEventTypes.PackFailed, result.Event.EventType); - Assert.Equal(PackRunEventSeverity.Error, result.Event.Severity); - Assert.Contains("failureReason", result.Event.Attributes!.Keys); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Emitter_EmitStepStartedAsync_IncludesAttempt() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); - var emitter = new PackRunTimelineEventEmitter( - sink, - timeProvider, - NullLogger.Instance); - - // Act - var result = await emitter.EmitStepStartedAsync( - TestTenantId, - TestRunId, - TestPlanHash, - TestStepId, - attempt: 2, - cancellationToken: CancellationToken.None); - - // Assert - Assert.True(result.Success); - Assert.Equal(PackRunEventTypes.StepStarted, result.Event.EventType); - Assert.Equal(TestStepId, result.Event.StepId); - Assert.Equal("2", result.Event.Attributes!["attempt"]); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Emitter_EmitStepCompletedAsync_IncludesDuration() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); - var emitter = new PackRunTimelineEventEmitter( - sink, - timeProvider, - NullLogger.Instance); - - // Act - var result = await emitter.EmitStepCompletedAsync( - TestTenantId, - TestRunId, - TestPlanHash, - TestStepId, - attempt: 1, - durationMs: 123.45, - cancellationToken: CancellationToken.None); - - // Assert - Assert.True(result.Success); - Assert.Equal(PackRunEventTypes.StepCompleted, result.Event.EventType); - Assert.Contains("durationMs", result.Event.Attributes!.Keys); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Emitter_EmitStepFailedAsync_IncludesError() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); - var emitter = new PackRunTimelineEventEmitter( - sink, - timeProvider, - NullLogger.Instance); - - // Act - var result = await emitter.EmitStepFailedAsync( - TestTenantId, - TestRunId, - TestPlanHash, - TestStepId, - attempt: 3, - error: "Connection timeout", - cancellationToken: CancellationToken.None); - - // Assert - Assert.True(result.Success); - Assert.Equal(PackRunEventTypes.StepFailed, result.Event.EventType); - Assert.Equal(PackRunEventSeverity.Error, result.Event.Severity); - Assert.Equal("Connection timeout", result.Event.Attributes!["error"]); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Emitter_EmitBatchAsync_OrdersEventsDeterministically() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); - var emitter = new PackRunTimelineEventEmitter( - sink, - timeProvider, - NullLogger.Instance); - - var now = DateTimeOffset.UtcNow; - var events = new[] - { - PackRunTimelineEvent.Create(TestTenantId, PackRunEventTypes.StepStarted, "test", now.AddSeconds(2), TestRunId, TestPlanHash), - PackRunTimelineEvent.Create(TestTenantId, PackRunEventTypes.PackStarted, "test", now, TestRunId, TestPlanHash), - PackRunTimelineEvent.Create(TestTenantId, PackRunEventTypes.StepCompleted, "test", now.AddSeconds(1), TestRunId, TestPlanHash), - }; - - // Act - var result = await emitter.EmitBatchAsync(events, CancellationToken.None); - - // Assert - Assert.Equal(3, result.Emitted); - Assert.Equal(0, result.Deduplicated); - - var stored = sink.GetEvents(); - Assert.Equal(PackRunEventTypes.PackStarted, stored[0].EventType); - Assert.Equal(PackRunEventTypes.StepCompleted, stored[1].EventType); - Assert.Equal(PackRunEventTypes.StepStarted, stored[2].EventType); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Emitter_EmitBatchAsync_HandlesDuplicates() - { - // Arrange - var sink = new InMemoryPackRunTimelineEventSink(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); - var emitter = new PackRunTimelineEventEmitter( - sink, - timeProvider, - NullLogger.Instance); - var ct = CancellationToken.None; - - var evt = PackRunTimelineEvent.Create( - TestTenantId, - PackRunEventTypes.PackStarted, - "test", - DateTimeOffset.UtcNow, - TestRunId, - TestPlanHash); - - // Emit once directly - await sink.WriteAsync(evt, ct); - - // Act - emit batch with same event - var result = await emitter.EmitBatchAsync([evt], ct); - - // Assert - Assert.Equal(0, result.Emitted); - Assert.Equal(1, result.Deduplicated); - Assert.Single(sink.GetEvents()); // Only one event stored - } - - #endregion - - #region Null Sink Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task NullSink_WriteAsync_ReturnsSuccess() - { - // Arrange - var sink = NullPackRunTimelineEventSink.Instance; - var evt = PackRunTimelineEvent.Create( - TestTenantId, - PackRunEventTypes.PackStarted, - "test", - DateTimeOffset.UtcNow, - TestRunId, - TestPlanHash); - - // Act - var result = await sink.WriteAsync(evt, CancellationToken.None); - - // Assert - Assert.True(result.Success); - Assert.False(result.Deduplicated); - Assert.Null(result.Sequence); - } - - #endregion -} - -/// -/// Fake time provider for testing. -/// -internal sealed class FakeTimeProvider : TimeProvider -{ - private DateTimeOffset _utcNow; - - public FakeTimeProvider(DateTimeOffset utcNow) - { - _utcNow = utcNow; - } - - public override DateTimeOffset GetUtcNow() => _utcNow; - - public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/SealedInstallEnforcerTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/SealedInstallEnforcerTests.cs deleted file mode 100644 index 2e820f1ef..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/SealedInstallEnforcerTests.cs +++ /dev/null @@ -1,405 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using StellaOps.TaskRunner.Core.AirGap; -using StellaOps.TaskRunner.Core.TaskPacks; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class SealedInstallEnforcerTests -{ - private static TaskPackManifest CreateManifest(bool sealedInstall, SealedRequirements? requirements = null) - { - return new TaskPackManifest - { - ApiVersion = "taskrunner/v1", - Kind = "TaskPack", - Metadata = new TaskPackMetadata - { - Name = "test-pack", - Version = "1.0.0" - }, - Spec = new TaskPackSpec - { - SealedInstall = sealedInstall, - SealedRequirements = requirements - } - }; - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EnforceAsync_WhenPackDoesNotRequireSealedInstall_ReturnsAllowed() - { - var statusProvider = new MockAirGapStatusProvider(SealedModeStatus.Unsealed()); - var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true }); - var enforcer = new SealedInstallEnforcer( - statusProvider, - options, - NullLogger.Instance); - - var manifest = CreateManifest(sealedInstall: false); - - var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None); - - Assert.True(result.Allowed); - Assert.Equal("Pack does not require sealed install", result.Message); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EnforceAsync_WhenEnforcementDisabled_ReturnsAllowed() - { - var statusProvider = new MockAirGapStatusProvider(SealedModeStatus.Unsealed()); - var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = false }); - var enforcer = new SealedInstallEnforcer( - statusProvider, - options, - NullLogger.Instance); - - var manifest = CreateManifest(sealedInstall: true); - - var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None); - - Assert.True(result.Allowed); - Assert.Equal("Enforcement disabled", result.Message); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EnforceAsync_WhenSealedRequiredButEnvironmentNotSealed_ReturnsDenied() - { - var statusProvider = new MockAirGapStatusProvider(SealedModeStatus.Unsealed()); - var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true }); - var enforcer = new SealedInstallEnforcer( - statusProvider, - options, - NullLogger.Instance); - - var manifest = CreateManifest(sealedInstall: true); - - var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None); - - Assert.False(result.Allowed); - Assert.Equal(SealedInstallErrorCodes.SealedInstallViolation, result.ErrorCode); - Assert.NotNull(result.Violation); - Assert.True(result.Violation.RequiredSealed); - Assert.False(result.Violation.ActualSealed); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EnforceAsync_WhenSealedRequiredAndEnvironmentSealed_ReturnsAllowed() - { - var status = new SealedModeStatus( - Sealed: true, - Mode: "sealed", - SealedAt: DateTimeOffset.UtcNow.AddDays(-1), - SealedBy: "admin@test.com", - BundleVersion: "2025.10.0", - BundleDigest: "sha256:abc123", - LastAdvisoryUpdate: DateTimeOffset.UtcNow.AddHours(-12), - AdvisoryStalenessHours: 12, - TimeAnchor: new TimeAnchorInfo( - DateTimeOffset.UtcNow.AddHours(-1), - "base64signature", - Valid: true, - ExpiresAt: DateTimeOffset.UtcNow.AddDays(30)), - EgressBlocked: true, - NetworkPolicy: "deny-all"); - - var statusProvider = new MockAirGapStatusProvider(status); - var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true }); - var enforcer = new SealedInstallEnforcer( - statusProvider, - options, - NullLogger.Instance); - - var manifest = CreateManifest(sealedInstall: true); - - var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None); - - Assert.True(result.Allowed); - Assert.Equal("Sealed install requirements satisfied", result.Message); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EnforceAsync_WhenBundleVersionBelowMinimum_ReturnsDenied() - { - var status = new SealedModeStatus( - Sealed: true, - Mode: "sealed", - SealedAt: DateTimeOffset.UtcNow, - SealedBy: null, - BundleVersion: "2024.5.0", - BundleDigest: null, - LastAdvisoryUpdate: DateTimeOffset.UtcNow, - AdvisoryStalenessHours: 0, - TimeAnchor: new TimeAnchorInfo(DateTimeOffset.UtcNow, null, true, DateTimeOffset.UtcNow.AddDays(30)), - EgressBlocked: true, - NetworkPolicy: null); - - var statusProvider = new MockAirGapStatusProvider(status); - var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true }); - var enforcer = new SealedInstallEnforcer( - statusProvider, - options, - NullLogger.Instance); - - var requirements = new SealedRequirements( - MinBundleVersion: "2025.10.0", - MaxAdvisoryStalenessHours: 168, - RequireTimeAnchor: true, - AllowedOfflineDurationHours: 720, - RequireSignatureVerification: true); - - var manifest = CreateManifest(sealedInstall: true, requirements); - - var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None); - - Assert.False(result.Allowed); - Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode); - Assert.NotNull(result.RequirementViolations); - Assert.Single(result.RequirementViolations); - Assert.Equal("min_bundle_version", result.RequirementViolations[0].Requirement); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EnforceAsync_WhenAdvisoryTooStale_ReturnsDenied() - { - var status = new SealedModeStatus( - Sealed: true, - Mode: "sealed", - SealedAt: DateTimeOffset.UtcNow, - SealedBy: null, - BundleVersion: "2025.10.0", - BundleDigest: null, - LastAdvisoryUpdate: DateTimeOffset.UtcNow.AddHours(-200), - AdvisoryStalenessHours: 200, - TimeAnchor: new TimeAnchorInfo(DateTimeOffset.UtcNow, null, true, DateTimeOffset.UtcNow.AddDays(30)), - EgressBlocked: true, - NetworkPolicy: null); - - var statusProvider = new MockAirGapStatusProvider(status); - var options = Options.Create(new SealedInstallEnforcementOptions - { - Enabled = true, - DenyOnStaleness = true, - StalenessGracePeriodHours = 0 - }); - var enforcer = new SealedInstallEnforcer( - statusProvider, - options, - NullLogger.Instance); - - var requirements = new SealedRequirements( - MinBundleVersion: null, - MaxAdvisoryStalenessHours: 168, - RequireTimeAnchor: false, - AllowedOfflineDurationHours: 720, - RequireSignatureVerification: false); - - var manifest = CreateManifest(sealedInstall: true, requirements); - - var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None); - - Assert.False(result.Allowed); - Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode); - Assert.NotNull(result.RequirementViolations); - Assert.Single(result.RequirementViolations); - Assert.Equal("max_advisory_staleness_hours", result.RequirementViolations[0].Requirement); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EnforceAsync_WhenTimeAnchorMissing_ReturnsDenied() - { - var status = new SealedModeStatus( - Sealed: true, - Mode: "sealed", - SealedAt: DateTimeOffset.UtcNow, - SealedBy: null, - BundleVersion: "2025.10.0", - BundleDigest: null, - LastAdvisoryUpdate: DateTimeOffset.UtcNow, - AdvisoryStalenessHours: 0, - TimeAnchor: null, // No time anchor - EgressBlocked: true, - NetworkPolicy: null); - - var statusProvider = new MockAirGapStatusProvider(status); - var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true }); - var enforcer = new SealedInstallEnforcer( - statusProvider, - options, - NullLogger.Instance); - - var requirements = new SealedRequirements( - MinBundleVersion: null, - MaxAdvisoryStalenessHours: 168, - RequireTimeAnchor: true, - AllowedOfflineDurationHours: 720, - RequireSignatureVerification: false); - - var manifest = CreateManifest(sealedInstall: true, requirements); - - var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None); - - Assert.False(result.Allowed); - Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode); - Assert.NotNull(result.RequirementViolations); - Assert.Single(result.RequirementViolations); - Assert.Equal("require_time_anchor", result.RequirementViolations[0].Requirement); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EnforceAsync_WhenTimeAnchorInvalid_ReturnsDenied() - { - var status = new SealedModeStatus( - Sealed: true, - Mode: "sealed", - SealedAt: DateTimeOffset.UtcNow, - SealedBy: null, - BundleVersion: "2025.10.0", - BundleDigest: null, - LastAdvisoryUpdate: DateTimeOffset.UtcNow, - AdvisoryStalenessHours: 0, - TimeAnchor: new TimeAnchorInfo(DateTimeOffset.UtcNow, null, Valid: false, null), - EgressBlocked: true, - NetworkPolicy: null); - - var statusProvider = new MockAirGapStatusProvider(status); - var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true }); - var enforcer = new SealedInstallEnforcer( - statusProvider, - options, - NullLogger.Instance); - - var requirements = new SealedRequirements( - MinBundleVersion: null, - MaxAdvisoryStalenessHours: 168, - RequireTimeAnchor: true, - AllowedOfflineDurationHours: 720, - RequireSignatureVerification: false); - - var manifest = CreateManifest(sealedInstall: true, requirements); - - var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None); - - Assert.False(result.Allowed); - Assert.Equal(SealedInstallErrorCodes.SealedRequirementsViolation, result.ErrorCode); - Assert.NotNull(result.RequirementViolations); - Assert.Contains(result.RequirementViolations, v => v.Requirement == "require_time_anchor"); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EnforceAsync_WhenStatusProviderFails_ReturnsDenied() - { - var statusProvider = new FailingAirGapStatusProvider(); - var options = Options.Create(new SealedInstallEnforcementOptions { Enabled = true }); - var enforcer = new SealedInstallEnforcer( - statusProvider, - options, - NullLogger.Instance); - - var manifest = CreateManifest(sealedInstall: true); - - var result = await enforcer.EnforceAsync(manifest, cancellationToken: CancellationToken.None); - - Assert.False(result.Allowed); - Assert.Equal(SealedInstallErrorCodes.SealedInstallViolation, result.ErrorCode); - Assert.Contains("Failed to verify", result.Message); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void SealedModeStatus_Unsealed_ReturnsCorrectDefaults() - { - var status = SealedModeStatus.Unsealed(); - - Assert.False(status.Sealed); - Assert.Equal("unsealed", status.Mode); - Assert.Null(status.SealedAt); - Assert.Null(status.BundleVersion); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void SealedModeStatus_Unavailable_ReturnsCorrectDefaults() - { - var status = SealedModeStatus.Unavailable(); - - Assert.False(status.Sealed); - Assert.Equal("unavailable", status.Mode); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void SealedRequirements_Default_HasExpectedValues() - { - var defaults = SealedRequirements.Default; - - Assert.Null(defaults.MinBundleVersion); - Assert.Equal(168, defaults.MaxAdvisoryStalenessHours); - Assert.True(defaults.RequireTimeAnchor); - Assert.Equal(720, defaults.AllowedOfflineDurationHours); - Assert.True(defaults.RequireSignatureVerification); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void EnforcementResult_CreateAllowed_SetsProperties() - { - var result = SealedInstallEnforcementResult.CreateAllowed("Test message"); - - Assert.True(result.Allowed); - Assert.Null(result.ErrorCode); - Assert.Equal("Test message", result.Message); - Assert.Null(result.Violation); - Assert.Null(result.RequirementViolations); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void EnforcementResult_CreateDenied_SetsProperties() - { - var violation = new SealedInstallViolation("pack-1", "1.0.0", true, false, "Seal the environment"); - var result = SealedInstallEnforcementResult.CreateDenied( - SealedInstallErrorCodes.SealedInstallViolation, - "Denied message", - violation); - - Assert.False(result.Allowed); - Assert.Equal(SealedInstallErrorCodes.SealedInstallViolation, result.ErrorCode); - Assert.Equal("Denied message", result.Message); - Assert.NotNull(result.Violation); - Assert.Equal("pack-1", result.Violation.PackId); - } - - private sealed class MockAirGapStatusProvider : IAirGapStatusProvider - { - private readonly SealedModeStatus _status; - - public MockAirGapStatusProvider(SealedModeStatus status) - { - _status = status; - } - - public Task GetStatusAsync(string? tenantId = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(_status); - } - } - - private sealed class FailingAirGapStatusProvider : IAirGapStatusProvider - { - public Task GetStatusAsync(string? tenantId = null, CancellationToken cancellationToken = default) - { - throw new HttpRequestException("Connection refused"); - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/StellaOps.TaskRunner.Tests.csproj b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/StellaOps.TaskRunner.Tests.csproj deleted file mode 100644 index a2419c61c..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/StellaOps.TaskRunner.Tests.csproj +++ /dev/null @@ -1,41 +0,0 @@ - - - - true - net10.0 - enable - enable - preview - false - false - Exe - xUnit1051 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TASKS.md b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TASKS.md deleted file mode 100644 index e301a4f30..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TASKS.md +++ /dev/null @@ -1,9 +0,0 @@ -# StellaOps.TaskRunner.Tests Task Board -This board mirrors active sprint tasks for this module. -Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`. - -| Task ID | Status | Notes | -| --- | --- | --- | -| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/StellaOps.TaskRunner.Tests.md. | -| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | -| SPRINT-20260305-002 | DONE | Added `TaskRunnerStartupContractTests` covering postgres non-dev fail-fast and object-store driver contract (`seed-fs` only, rustfs/unknown rejected). | diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskPackPlannerTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskPackPlannerTests.cs deleted file mode 100644 index dec464fa3..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskPackPlannerTests.cs +++ /dev/null @@ -1,273 +0,0 @@ -using System; -using System.Linq; -using System.Text.Json.Nodes; -using StellaOps.AirGap.Policy; -using StellaOps.TaskRunner.Core.Planning; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class TaskPackPlannerTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Plan_WithSequentialSteps_ComputesDeterministicHash() - { - var manifest = TestManifests.Load(TestManifests.Sample); - var planner = new TaskPackPlanner(); - - var inputs = new Dictionary - { - ["dryRun"] = JsonValue.Create(false) - }; - - var resultA = planner.Plan(manifest, inputs); - Assert.True(resultA.Success); - var plan = resultA.Plan!; - Assert.Equal(3, plan.Steps.Count); - Assert.Equal("plan-step", plan.Steps[0].Id); - Assert.Equal("plan-step", plan.Steps[0].TemplateId); - Assert.Equal("run", plan.Steps[0].Type); - Assert.Equal("gate.approval", plan.Steps[1].Type); - Assert.Equal("security-review", plan.Steps[1].ApprovalId); - Assert.Equal("run", plan.Steps[2].Type); - Assert.True(plan.Steps[2].Enabled); - Assert.Single(plan.Approvals); - Assert.Equal("security-review", plan.Approvals[0].Id); - Assert.False(string.IsNullOrWhiteSpace(plan.Hash)); - - var resultB = planner.Plan(manifest, inputs); - Assert.True(resultB.Success); - Assert.Equal(plan.Hash, resultB.Plan!.Hash); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void PlanHash_IsPrefixedSha256Digest() - { - var manifest = TestManifests.Load(TestManifests.Sample); - var planner = new TaskPackPlanner(); - - var result = planner.Plan(manifest); - Assert.True(result.Success); - var hash = result.Plan!.Hash; - Assert.StartsWith("sha256:", hash, StringComparison.Ordinal); - Assert.Equal(71, hash.Length); // "sha256:" + 64 hex characters - var hex = hash.Substring("sha256:".Length); - Assert.True(hex.All(c => Uri.IsHexDigit(c)), "Hash contains non-hex characters."); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Plan_WhenConditionEvaluatesFalse_DisablesStep() - { - var manifest = TestManifests.Load(TestManifests.Sample); - var planner = new TaskPackPlanner(); - - var inputs = new Dictionary - { - ["dryRun"] = JsonValue.Create(true) - }; - - var result = planner.Plan(manifest, inputs); - Assert.True(result.Success); - Assert.False(result.Plan!.Steps[2].Enabled); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Plan_WithStepReferences_MarksParametersAsRuntime() - { - var manifest = TestManifests.Load(TestManifests.StepReference); - var planner = new TaskPackPlanner(); - - var result = planner.Plan(manifest); - Assert.True(result.Success); - var plan = result.Plan!; - Assert.Equal(2, plan.Steps.Count); - var referenceParameters = plan.Steps[1].Parameters!; - Assert.True(referenceParameters["sourceSummary"].RequiresRuntimeValue); - Assert.Equal("steps.prepare.outputs.summary", referenceParameters["sourceSummary"].Expression); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Plan_WithMapStep_ExpandsIterations() - { - var manifest = TestManifests.Load(TestManifests.Map); - var planner = new TaskPackPlanner(); - - var inputs = new Dictionary - { - ["targets"] = new JsonArray("alpha", "beta", "gamma") - }; - - var result = planner.Plan(manifest, inputs); - Assert.True(result.Success); - var plan = result.Plan!; - var mapStep = plan.Steps.Single(s => s.Type == "map"); - Assert.Equal(3, mapStep.Children!.Count); - Assert.All(mapStep.Children!, child => Assert.Equal("echo-step", child.TemplateId)); - Assert.Equal(3, mapStep.Parameters!["iterationCount"].Value!.GetValue()); - Assert.Equal("alpha", mapStep.Children![0].Parameters!["item"].Value!.GetValue()); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void CollectApprovalRequirements_GroupsGates() - { - var manifest = TestManifests.Load(TestManifests.Sample); - var planner = new TaskPackPlanner(); - - var plan = planner.Plan(manifest).Plan!; - var requirements = TaskPackPlanInsights.CollectApprovalRequirements(plan); - Assert.Single(requirements); - var requirement = requirements[0]; - Assert.Equal("security-review", requirement.ApprovalId); - Assert.Contains("Packs.Approve", requirement.Grants); - Assert.Equal(plan.Steps[1].Id, requirement.StepIds.Single()); - - var notifications = TaskPackPlanInsights.CollectNotificationHints(plan); - Assert.Contains(notifications, hint => hint.Type == "approval-request" && hint.StepId == plan.Steps[1].Id); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Plan_WithSecretReference_RecordsSecretMetadata() - { - var manifest = TestManifests.Load(TestManifests.Secret); - var planner = new TaskPackPlanner(); - - var result = planner.Plan(manifest); - Assert.True(result.Success); - var plan = result.Plan!; - Assert.Single(plan.Secrets); - Assert.Equal("apiKey", plan.Secrets[0].Name); - var param = plan.Steps[0].Parameters!["token"]; - Assert.True(param.RequiresRuntimeValue); - Assert.Equal("secrets.apiKey", param.Expression); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Plan_WithOutputs_ProjectsResolvedValues() - { - var manifest = TestManifests.Load(TestManifests.Output); - var planner = new TaskPackPlanner(); - - var result = planner.Plan(manifest); - Assert.True(result.Success); - var plan = result.Plan!; - Assert.Equal(2, plan.Outputs.Count); - - var bundle = plan.Outputs.First(o => o.Name == "bundlePath"); - Assert.NotNull(bundle.Path); - Assert.False(bundle.Path!.RequiresRuntimeValue); - Assert.Equal("artifacts/report.txt", bundle.Path.Value!.GetValue()); - - var evidence = plan.Outputs.First(o => o.Name == "evidenceModel"); - Assert.NotNull(evidence.Expression); - Assert.True(evidence.Expression!.RequiresRuntimeValue); - Assert.Equal("steps.generate.outputs.evidence", evidence.Expression.Expression); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Plan_WithFailurePolicy_PopulatesPlanFailure() - { - var manifest = TestManifests.Load(TestManifests.FailurePolicy); - var planner = new TaskPackPlanner(); - - var result = planner.Plan(manifest); - Assert.True(result.Success); - var plan = result.Plan!; - Assert.NotNull(plan.FailurePolicy); - Assert.Equal(4, plan.FailurePolicy!.MaxAttempts); - Assert.Equal(30, plan.FailurePolicy.BackoffSeconds); - Assert.False(plan.FailurePolicy.ContinueOnError); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void PolicyGateHints_IncludeRuntimeMetadata() - { - var manifest = TestManifests.Load(TestManifests.PolicyGate); - var planner = new TaskPackPlanner(); - - var plan = planner.Plan(manifest).Plan!; - var hints = TaskPackPlanInsights.CollectPolicyGateHints(plan); - Assert.Single(hints); - var hint = hints[0]; - Assert.Equal("policy-check", hint.StepId); - var threshold = hint.Parameters.Single(p => p.Name == "threshold"); - Assert.False(threshold.RequiresRuntimeValue); - Assert.Null(threshold.Expression); - var evidence = hint.Parameters.Single(p => p.Name == "evidenceRef"); - Assert.True(evidence.RequiresRuntimeValue); - Assert.Equal("steps.prepare.outputs.evidence", evidence.Expression); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Plan_SealedMode_BlocksUndeclaredEgress() - { - var manifest = TestManifests.Load(TestManifests.EgressBlocked); - var options = new EgressPolicyOptions - { - Mode = EgressPolicyMode.Sealed - }; - var planner = new TaskPackPlanner(new EgressPolicy(options)); - - var result = planner.Plan(manifest); - - Assert.False(result.Success); - Assert.Contains(result.Errors, error => error.Message.Contains("example.com", StringComparison.OrdinalIgnoreCase)); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Plan_WhenRequiredInputMissing_ReturnsError() - { - var manifest = TestManifests.Load(TestManifests.RequiredInput); - var planner = new TaskPackPlanner(); - - var result = planner.Plan(manifest); - Assert.False(result.Success); - Assert.NotEmpty(result.Errors); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Plan_SealedMode_AllowsDeclaredEgress() - { - var manifest = TestManifests.Load(TestManifests.EgressAllowed); - var options = new EgressPolicyOptions - { - Mode = EgressPolicyMode.Sealed - }; - options.AddAllowRule("mirror.internal", 443, EgressTransport.Https); - - var planner = new TaskPackPlanner(new EgressPolicy(options)); - - var result = planner.Plan(manifest); - - Assert.True(result.Success); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void Plan_SealedMode_RuntimeUrlWithoutDeclaration_ReturnsError() - { - var manifest = TestManifests.Load(TestManifests.EgressRuntime); - var options = new EgressPolicyOptions - { - Mode = EgressPolicyMode.Sealed - }; - var planner = new TaskPackPlanner(new EgressPolicy(options)); - - var result = planner.Plan(manifest); - - Assert.False(result.Success); - Assert.Contains(result.Errors, error => error.Path.StartsWith("spec.steps[0]", StringComparison.Ordinal)); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerClientTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerClientTests.cs deleted file mode 100644 index 054ff0a52..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerClientTests.cs +++ /dev/null @@ -1,256 +0,0 @@ -using System.Text; -using StellaOps.TaskRunner.Client.Models; -using StellaOps.TaskRunner.Client.Streaming; -using StellaOps.TaskRunner.Client.Pagination; -using StellaOps.TaskRunner.Client.Lifecycle; - - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -public sealed class TaskRunnerClientTests -{ - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task StreamingLogReader_ParsesNdjsonLines() - { - var ct = CancellationToken.None; - var ndjson = """ - {"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Starting","traceId":"abc123"} - {"timestamp":"2025-01-01T00:00:01Z","level":"error","stepId":"step-1","message":"Failed","traceId":"abc123"} - """; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson)); - - var entries = await StreamingLogReader.CollectAsync(stream, ct); - - Assert.Equal(2, entries.Count); - Assert.Equal("info", entries[0].Level); - Assert.Equal("error", entries[1].Level); - Assert.Equal("step-1", entries[0].StepId); - Assert.Equal("Starting", entries[0].Message); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task StreamingLogReader_SkipsEmptyLines() - { - var ct = CancellationToken.None; - var ndjson = """ - {"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Test","traceId":"abc123"} - - {"timestamp":"2025-01-01T00:00:01Z","level":"info","stepId":"step-2","message":"Test2","traceId":"abc123"} - """; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson)); - - var entries = await StreamingLogReader.CollectAsync(stream, ct); - - Assert.Equal(2, entries.Count); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task StreamingLogReader_SkipsMalformedLines() - { - var ct = CancellationToken.None; - var ndjson = """ - {"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Valid","traceId":"abc123"} - not valid json - {"timestamp":"2025-01-01T00:00:01Z","level":"info","stepId":"step-2","message":"AlsoValid","traceId":"abc123"} - """; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson)); - - var entries = await StreamingLogReader.CollectAsync(stream, ct); - - Assert.Equal(2, entries.Count); - Assert.Equal("Valid", entries[0].Message); - Assert.Equal("AlsoValid", entries[1].Message); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task StreamingLogReader_FilterByLevel_FiltersCorrectly() - { - var ct = CancellationToken.None; - var entries = new List - { - new(DateTimeOffset.UtcNow, "info", "step-1", "Info message", "trace1"), - new(DateTimeOffset.UtcNow, "error", "step-1", "Error message", "trace1"), - new(DateTimeOffset.UtcNow, "warning", "step-1", "Warning message", "trace1"), - }; - - var levels = new HashSet(StringComparer.OrdinalIgnoreCase) { "error", "warning" }; - var filtered = new List(); - - await foreach (var entry in StreamingLogReader.FilterByLevelAsync(entries.ToAsyncEnumerable(), levels, ct)) - { - filtered.Add(entry); - } - - Assert.Equal(2, filtered.Count); - Assert.DoesNotContain(filtered, e => e.Level == "info"); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task StreamingLogReader_GroupByStep_GroupsCorrectly() - { - var ct = CancellationToken.None; - var entries = new List - { - new(DateTimeOffset.UtcNow, "info", "step-1", "Message 1", "trace1"), - new(DateTimeOffset.UtcNow, "info", "step-2", "Message 2", "trace1"), - new(DateTimeOffset.UtcNow, "info", "step-1", "Message 3", "trace1"), - new(DateTimeOffset.UtcNow, "info", null, "Global message", "trace1"), - }; - - var groups = await StreamingLogReader.GroupByStepAsync(entries.ToAsyncEnumerable(), ct); - - Assert.Equal(3, groups.Count); - Assert.Equal(2, groups["step-1"].Count); - Assert.Single(groups["step-2"]); - Assert.Single(groups["(global)"]); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Paginator_IteratesAllPages() - { - var ct = CancellationToken.None; - var allItems = Enumerable.Range(1, 25).ToList(); - var pageSize = 10; - var fetchCalls = 0; - - var paginator = new Paginator( - async (offset, limit, token) => - { - fetchCalls++; - var items = allItems.Skip(offset).Take(limit).ToList(); - var hasMore = offset + items.Count < allItems.Count; - return new PagedResponse(items, allItems.Count, hasMore); - }, - pageSize); - - var collected = await paginator.CollectAsync(ct); - - Assert.Equal(25, collected.Count); - Assert.Equal(3, fetchCalls); // 10, 10, 5 items - Assert.Equal(allItems, collected); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task Paginator_GetPage_ReturnsCorrectPage() - { - var ct = CancellationToken.None; - var allItems = Enumerable.Range(1, 25).ToList(); - var pageSize = 10; - - var paginator = new Paginator( - async (offset, limit, token) => - { - var items = allItems.Skip(offset).Take(limit).ToList(); - var hasMore = offset + items.Count < allItems.Count; - return new PagedResponse(items, allItems.Count, hasMore); - }, - pageSize); - - var page2 = await paginator.GetPageAsync(2, ct); - - Assert.Equal(10, page2.Items.Count); - Assert.Equal(11, page2.Items[0]); // Items 11-20 - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task PaginatorExtensions_TakeAsync_TakesCorrectNumber() - { - var ct = CancellationToken.None; - var items = Enumerable.Range(1, 100).ToAsyncEnumerable(); - - var taken = new List(); - await foreach (var item in items.TakeAsync(5, ct)) - { - taken.Add(item); - } - - Assert.Equal(5, taken.Count); - Assert.Equal(new[] { 1, 2, 3, 4, 5 }, taken); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task PaginatorExtensions_SkipAsync_SkipsCorrectNumber() - { - var ct = CancellationToken.None; - var items = Enumerable.Range(1, 10).ToAsyncEnumerable(); - - var skipped = new List(); - await foreach (var item in items.SkipAsync(5, ct)) - { - skipped.Add(item); - } - - Assert.Equal(5, skipped.Count); - Assert.Equal(new[] { 6, 7, 8, 9, 10 }, skipped); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void PackRunLifecycleHelper_TerminalStatuses_IncludesExpectedStatuses() - { - Assert.Contains("completed", PackRunLifecycleHelper.TerminalStatuses); - Assert.Contains("failed", PackRunLifecycleHelper.TerminalStatuses); - Assert.Contains("cancelled", PackRunLifecycleHelper.TerminalStatuses); - Assert.Contains("rejected", PackRunLifecycleHelper.TerminalStatuses); - Assert.DoesNotContain("running", PackRunLifecycleHelper.TerminalStatuses); - Assert.DoesNotContain("pending", PackRunLifecycleHelper.TerminalStatuses); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void PackRunModels_CreatePackRunRequest_SerializesCorrectly() - { - var request = new CreatePackRunRequest( - "my-pack", - "1.0.0", - new Dictionary { ["key"] = "value" }, - "tenant-1", - "corr-123"); - - Assert.Equal("my-pack", request.PackId); - Assert.Equal("1.0.0", request.PackVersion); - Assert.NotNull(request.Inputs); - Assert.Equal("value", request.Inputs["key"]); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void PackRunModels_SimulatedStep_HasCorrectProperties() - { - var loopInfo = new LoopInfo("{{ inputs.items }}", "item", 100); - var step = new SimulatedStep( - "step-1", - "loop", - "WillIterate", - loopInfo, - null, - null); - - Assert.Equal("step-1", step.StepId); - Assert.Equal("loop", step.Kind); - Assert.NotNull(step.LoopInfo); - Assert.Equal("{{ inputs.items }}", step.LoopInfo.ItemsExpression); - } -} - -internal static class AsyncEnumerableExtensions -{ - public static async IAsyncEnumerable ToAsyncEnumerable(this IEnumerable source) - { - foreach (var item in source) - { - yield return item; - } - await Task.CompletedTask; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerStartupContractTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerStartupContractTests.cs deleted file mode 100644 index 43f795255..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerStartupContractTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Net; -using Microsoft.AspNetCore.Mvc.Testing; - -namespace StellaOps.TaskRunner.Tests; - -[Collection(TaskRunnerStartupEnvironmentCollection.Name)] -public sealed class TaskRunnerStartupContractTests -{ - [Fact] - public void Startup_FailsWithoutPostgresConnectionString_InProduction() - { - using var environment = TaskRunnerStartupEnvironmentScope.ProductionPostgresWithoutConnection(); - using var factory = new WebApplicationFactory(); - - var exception = Assert.ThrowsAny(() => - { - using var client = factory.CreateClient(); - }); - - Assert.Contains( - "TaskRunner requires PostgreSQL connection settings in non-development mode.", - exception.ToString(), - StringComparison.Ordinal); - } - - [Fact] - public void Startup_RejectsRustFsObjectStoreDriver() - { - using var environment = TaskRunnerStartupEnvironmentScope.ProductionWithObjectStoreDriver("rustfs"); - using var factory = new WebApplicationFactory(); - - var exception = Assert.ThrowsAny(() => - { - using var client = factory.CreateClient(); - }); - - Assert.Contains( - "RustFS object store is configured for TaskRunner, but no RustFS adapter is implemented. Use seed-fs.", - exception.ToString(), - StringComparison.Ordinal); - } - - [Fact] - public void Startup_RejectsUnsupportedObjectStoreDriver() - { - using var environment = TaskRunnerStartupEnvironmentScope.ProductionWithObjectStoreDriver("unknown-store"); - using var factory = new WebApplicationFactory(); - - var exception = Assert.ThrowsAny(() => - { - using var client = factory.CreateClient(); - }); - - Assert.Contains( - "Unsupported object store driver 'unknown-store' for TaskRunner. Allowed values: seed-fs.", - exception.ToString(), - StringComparison.Ordinal); - } - - [Fact] - public async Task Startup_AllowsSeedFsObjectStoreDriver() - { - using var environment = TaskRunnerStartupEnvironmentScope.TestingInMemorySeedFs(); - using var factory = new WebApplicationFactory(); - - using var client = factory.CreateClient(); - var response = await client.GetAsync("/.well-known/openapi", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerStartupEnvironmentScope.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerStartupEnvironmentScope.cs deleted file mode 100644 index 344c21754..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerStartupEnvironmentScope.cs +++ /dev/null @@ -1,96 +0,0 @@ -namespace StellaOps.TaskRunner.Tests; - -[CollectionDefinition(Name, DisableParallelization = true)] -public sealed class TaskRunnerStartupEnvironmentCollection -{ - public const string Name = "TaskRunnerStartupEnvironment"; -} - -internal sealed class TaskRunnerStartupEnvironmentScope : IDisposable -{ - private static readonly string[] ManagedKeys = - [ - "DOTNET_ENVIRONMENT", - "ASPNETCORE_ENVIRONMENT", - "STORAGE__DRIVER", - "TASKRUNNER__STORAGE__DRIVER", - "STORAGE__OBJECTSTORE__DRIVER", - "TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER", - "STORAGE__POSTGRES__CONNECTIONSTRING", - "TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING", - "CONNECTIONSTRINGS__TASKRUNNER", - "CONNECTIONSTRINGS__DEFAULT" - ]; - - private readonly Dictionary _originalValues = new(StringComparer.Ordinal); - - private TaskRunnerStartupEnvironmentScope() - { - foreach (var key in ManagedKeys) - { - _originalValues[key] = Environment.GetEnvironmentVariable(key); - } - } - - public static TaskRunnerStartupEnvironmentScope ProductionPostgresWithoutConnection() - { - var scope = new TaskRunnerStartupEnvironmentScope(); - scope.Set("DOTNET_ENVIRONMENT", "Production"); - scope.Set("ASPNETCORE_ENVIRONMENT", "Production"); - scope.Set("STORAGE__DRIVER", "postgres"); - scope.Set("TASKRUNNER__STORAGE__DRIVER", "postgres"); - scope.Set("TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); - scope.Set("STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); - scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", null); - scope.Set("TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING", null); - scope.Set("CONNECTIONSTRINGS__TASKRUNNER", null); - scope.Set("CONNECTIONSTRINGS__DEFAULT", null); - return scope; - } - - public static TaskRunnerStartupEnvironmentScope ProductionWithObjectStoreDriver(string objectStoreDriver) - { - var scope = new TaskRunnerStartupEnvironmentScope(); - scope.Set("DOTNET_ENVIRONMENT", "Production"); - scope.Set("ASPNETCORE_ENVIRONMENT", "Production"); - scope.Set("STORAGE__DRIVER", "postgres"); - scope.Set("TASKRUNNER__STORAGE__DRIVER", "postgres"); - scope.Set("TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER", objectStoreDriver); - scope.Set("STORAGE__OBJECTSTORE__DRIVER", objectStoreDriver); - var connectionString = "Host=localhost;Database=stellaops_taskrunner;Username=stellaops;Password=stellaops"; - scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", connectionString); - scope.Set("TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING", connectionString); - scope.Set("CONNECTIONSTRINGS__TASKRUNNER", connectionString); - scope.Set("CONNECTIONSTRINGS__DEFAULT", connectionString); - return scope; - } - - public static TaskRunnerStartupEnvironmentScope TestingInMemorySeedFs() - { - var scope = new TaskRunnerStartupEnvironmentScope(); - scope.Set("DOTNET_ENVIRONMENT", "Testing"); - scope.Set("ASPNETCORE_ENVIRONMENT", "Testing"); - scope.Set("STORAGE__DRIVER", "inmemory"); - scope.Set("TASKRUNNER__STORAGE__DRIVER", "inmemory"); - scope.Set("TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); - scope.Set("STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); - scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", null); - scope.Set("TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING", null); - scope.Set("CONNECTIONSTRINGS__TASKRUNNER", null); - scope.Set("CONNECTIONSTRINGS__DEFAULT", null); - return scope; - } - - public void Dispose() - { - foreach (var entry in _originalValues) - { - Environment.SetEnvironmentVariable(entry.Key, entry.Value); - } - } - - private void Set(string key, string? value) - { - Environment.SetEnvironmentVariable(key, value); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TenantEnforcementTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TenantEnforcementTests.cs deleted file mode 100644 index 98a5ea738..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TenantEnforcementTests.cs +++ /dev/null @@ -1,561 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using StellaOps.TaskRunner.Core.Tenancy; -using StellaOps.TaskRunner.Infrastructure.Tenancy; - -using StellaOps.TestKit; -namespace StellaOps.TaskRunner.Tests; - -/// -/// Tests for tenant enforcement per TASKRUN-TEN-48-001. -/// -public sealed class TenantEnforcementTests -{ - #region TenantContext Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void TenantContext_RequiresTenantId() - { - Assert.ThrowsAny(() => - new TenantContext(null!, "project-1")); - - Assert.ThrowsAny(() => - new TenantContext("", "project-1")); - - Assert.ThrowsAny(() => - new TenantContext(" ", "project-1")); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void TenantContext_RequiresProjectId() - { - Assert.ThrowsAny(() => - new TenantContext("tenant-1", null!)); - - Assert.ThrowsAny(() => - new TenantContext("tenant-1", "")); - - Assert.ThrowsAny(() => - new TenantContext("tenant-1", " ")); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void TenantContext_TrimsIds() - { - var context = new TenantContext(" tenant-1 ", " project-1 "); - - Assert.Equal("tenant-1", context.TenantId); - Assert.Equal("project-1", context.ProjectId); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void TenantContext_GeneratesStoragePrefix() - { - var context = new TenantContext("Tenant-1", "Project-1"); - - Assert.Equal("tenant-1/project-1", context.StoragePrefix); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void TenantContext_GeneratesFlatPrefix() - { - var context = new TenantContext("Tenant-1", "Project-1"); - - Assert.Equal("tenant-1_project-1", context.FlatPrefix); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void TenantContext_GeneratesLoggingScope() - { - var context = new TenantContext("tenant-1", "project-1"); - var scope = context.ToLoggingScope(); - - Assert.Equal("tenant-1", scope["TenantId"]); - Assert.Equal("project-1", scope["ProjectId"]); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void TenantContext_DefaultRestrictionsAreNone() - { - var context = new TenantContext("tenant-1", "project-1"); - - Assert.False(context.Restrictions.EgressBlocked); - Assert.False(context.Restrictions.ReadOnly); - Assert.False(context.Restrictions.Suspended); - Assert.Null(context.Restrictions.MaxConcurrentRuns); - } - - #endregion - - #region StoragePathResolver Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void StoragePathResolver_HierarchicalPaths() - { - var options = new TenantStoragePathOptions - { - PathStrategy = TenantPathStrategy.Hierarchical, - StateBasePath = "state", - LogsBasePath = "logs" - }; - - var resolver = new TenantScopedStoragePathResolver(options, "/data"); - var tenant = new TenantContext("tenant-1", "project-1"); - - var statePath = resolver.GetStatePath(tenant, "run-123"); - var logsPath = resolver.GetLogsPath(tenant, "run-123"); - - Assert.Contains("state", statePath); - Assert.Contains("tenant-1", statePath); - Assert.Contains("project-1", statePath); - Assert.Contains("run-123", statePath); - - Assert.Contains("logs", logsPath); - Assert.Contains("tenant-1", logsPath); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void StoragePathResolver_FlatPaths() - { - var options = new TenantStoragePathOptions - { - PathStrategy = TenantPathStrategy.Flat, - StateBasePath = "state" - }; - - var resolver = new TenantScopedStoragePathResolver(options, "/data"); - var tenant = new TenantContext("tenant-1", "project-1"); - - var statePath = resolver.GetStatePath(tenant, "run-123"); - - Assert.Contains("tenant-1_project-1_run-123", statePath); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void StoragePathResolver_HashedPaths() - { - var options = new TenantStoragePathOptions - { - PathStrategy = TenantPathStrategy.Hashed - }; - - var resolver = new TenantScopedStoragePathResolver(options, "/data"); - var tenant = new TenantContext("tenant-1", "project-1"); - - var basePath = resolver.GetTenantBasePath(tenant); - - // Should contain a hash (hex characters) - Assert.DoesNotContain("tenant-1", basePath); - Assert.Contains("project-1", basePath); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public void StoragePathResolver_ValidatesPathOwnership() - { - var options = new TenantStoragePathOptions - { - PathStrategy = TenantPathStrategy.Hierarchical - }; - - // Use temp path for cross-platform compatibility - var basePath = Path.Combine(Path.GetTempPath(), "tenant-test-" + Guid.NewGuid().ToString("N")[..8]); - var resolver = new TenantScopedStoragePathResolver(options, basePath); - var tenant1 = new TenantContext("tenant-1", "project-1"); - var tenant2 = new TenantContext("tenant-2", "project-1"); - - var tenant1Path = resolver.GetStatePath(tenant1, "run-123"); - var tenant2Path = resolver.GetStatePath(tenant2, "run-123"); - - Assert.True(resolver.ValidatePathBelongsToTenant(tenant1, tenant1Path)); - Assert.False(resolver.ValidatePathBelongsToTenant(tenant1, tenant2Path)); - } - - #endregion - - #region EgressPolicy Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EgressPolicy_AllowsByDefault() - { - var options = new TenantEgressPolicyOptions { AllowByDefault = true }; - var policy = CreateEgressPolicy(options); - var tenant = new TenantContext("tenant-1", "project-1"); - - var result = await policy.CheckEgressAsync(tenant, "example.com", 443); - - Assert.True(result.IsAllowed); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EgressPolicy_BlocksGlobalBlocklist() - { - var options = new TenantEgressPolicyOptions - { - AllowByDefault = true, - GlobalBlocklist = ["blocked.com"] - }; - var policy = CreateEgressPolicy(options); - var tenant = new TenantContext("tenant-1", "project-1"); - - var result = await policy.CheckEgressAsync(tenant, "blocked.com", 443); - - Assert.False(result.IsAllowed); - Assert.Equal(EgressBlockReason.GlobalPolicy, result.BlockReason); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EgressPolicy_BlocksSuspendedTenants() - { - var options = new TenantEgressPolicyOptions { AllowByDefault = true }; - var policy = CreateEgressPolicy(options); - var tenant = new TenantContext( - "tenant-1", - "project-1", - restrictions: new TenantRestrictions { Suspended = true }); - - var result = await policy.CheckEgressAsync(tenant, "example.com", 443); - - Assert.False(result.IsAllowed); - Assert.Equal(EgressBlockReason.TenantSuspended, result.BlockReason); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EgressPolicy_BlocksRestrictedTenants() - { - var options = new TenantEgressPolicyOptions { AllowByDefault = true }; - var policy = CreateEgressPolicy(options); - var tenant = new TenantContext( - "tenant-1", - "project-1", - restrictions: new TenantRestrictions { EgressBlocked = true }); - - var result = await policy.CheckEgressAsync(tenant, "example.com", 443); - - Assert.False(result.IsAllowed); - Assert.Equal(EgressBlockReason.TenantRestriction, result.BlockReason); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EgressPolicy_AllowsRestrictedTenantAllowlist() - { - var options = new TenantEgressPolicyOptions { AllowByDefault = true }; - var policy = CreateEgressPolicy(options); - var tenant = new TenantContext( - "tenant-1", - "project-1", - restrictions: new TenantRestrictions - { - EgressBlocked = true, - AllowedEgressDomains = ["allowed.com"] - }); - - var allowedResult = await policy.CheckEgressAsync(tenant, "allowed.com", 443); - var blockedResult = await policy.CheckEgressAsync(tenant, "other.com", 443); - - Assert.True(allowedResult.IsAllowed); - Assert.False(blockedResult.IsAllowed); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EgressPolicy_SupportsWildcardDomains() - { - var options = new TenantEgressPolicyOptions - { - AllowByDefault = true, - GlobalBlocklist = ["*.blocked.com"] - }; - var policy = CreateEgressPolicy(options); - var tenant = new TenantContext("tenant-1", "project-1"); - - var result = await policy.CheckEgressAsync(tenant, "sub.blocked.com", 443); - - Assert.False(result.IsAllowed); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task EgressPolicy_RecordsAttempts() - { - var auditLog = new InMemoryEgressAuditLog(); - var options = new TenantEgressPolicyOptions - { - AllowByDefault = true, - LogBlockedAttempts = true - }; - var policy = CreateEgressPolicy(options, auditLog); - var tenant = new TenantContext("tenant-1", "project-1"); - var uri = new Uri("https://example.com/api"); - - var result = await policy.CheckEgressAsync(tenant, uri); - await policy.RecordEgressAttemptAsync(tenant, "run-123", uri, result); - - var records = auditLog.GetAllRecords(); - Assert.Single(records); - Assert.Equal("tenant-1", records[0].TenantId); - Assert.Equal("run-123", records[0].RunId); - Assert.True(records[0].WasAllowed); - } - - #endregion - - #region TenantEnforcer Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task TenantEnforcer_RequiresTenantId() - { - var enforcer = CreateTenantEnforcer(); - var request = new PackRunTenantRequest("", "project-1"); - - var result = await enforcer.ValidateRequestAsync(request); - - Assert.False(result.IsValid); - Assert.Equal(TenantEnforcementFailureKind.MissingTenantId, result.FailureKind); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task TenantEnforcer_RequiresProjectId() - { - var options = new TenancyEnforcementOptions { RequireProjectId = true }; - var enforcer = CreateTenantEnforcer(options); - var request = new PackRunTenantRequest("tenant-1", ""); - - var result = await enforcer.ValidateRequestAsync(request); - - Assert.False(result.IsValid); - Assert.Equal(TenantEnforcementFailureKind.MissingProjectId, result.FailureKind); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task TenantEnforcer_BlocksSuspendedTenants() - { - var tenantProvider = new InMemoryTenantContextProvider(); - var tenant = new TenantContext( - "tenant-1", - "project-1", - restrictions: new TenantRestrictions { Suspended = true }); - tenantProvider.Register(tenant); - - var options = new TenancyEnforcementOptions { BlockSuspendedTenants = true }; - var enforcer = CreateTenantEnforcer(options, tenantProvider); - var request = new PackRunTenantRequest("tenant-1", "project-1"); - - var result = await enforcer.ValidateRequestAsync(request); - - Assert.False(result.IsValid); - Assert.Equal(TenantEnforcementFailureKind.TenantSuspended, result.FailureKind); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task TenantEnforcer_BlocksReadOnlyTenants() - { - var tenantProvider = new InMemoryTenantContextProvider(); - var tenant = new TenantContext( - "tenant-1", - "project-1", - restrictions: new TenantRestrictions { ReadOnly = true }); - tenantProvider.Register(tenant); - - var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider); - var request = new PackRunTenantRequest("tenant-1", "project-1"); - - var result = await enforcer.ValidateRequestAsync(request); - - Assert.False(result.IsValid); - Assert.Equal(TenantEnforcementFailureKind.TenantReadOnly, result.FailureKind); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task TenantEnforcer_EnforcesConcurrentRunLimit() - { - var tenantProvider = new InMemoryTenantContextProvider(); - var tenant = new TenantContext( - "tenant-1", - "project-1", - restrictions: new TenantRestrictions { MaxConcurrentRuns = 2 }); - tenantProvider.Register(tenant); - - var runTracker = new InMemoryConcurrentRunTracker(); - await runTracker.IncrementAsync("tenant-1", "run-1"); - await runTracker.IncrementAsync("tenant-1", "run-2"); - - var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider, runTracker: runTracker); - var request = new PackRunTenantRequest("tenant-1", "project-1"); - - var result = await enforcer.ValidateRequestAsync(request); - - Assert.False(result.IsValid); - Assert.Equal(TenantEnforcementFailureKind.MaxConcurrentRunsReached, result.FailureKind); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task TenantEnforcer_AllowsWithinConcurrentLimit() - { - var tenantProvider = new InMemoryTenantContextProvider(); - var tenant = new TenantContext( - "tenant-1", - "project-1", - restrictions: new TenantRestrictions { MaxConcurrentRuns = 5 }); - tenantProvider.Register(tenant); - - var runTracker = new InMemoryConcurrentRunTracker(); - await runTracker.IncrementAsync("tenant-1", "run-1"); - - var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider, runTracker: runTracker); - var request = new PackRunTenantRequest("tenant-1", "project-1"); - - var result = await enforcer.ValidateRequestAsync(request); - - Assert.True(result.IsValid); - Assert.NotNull(result.Tenant); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task TenantEnforcer_TracksRunStartCompletion() - { - var runTracker = new InMemoryConcurrentRunTracker(); - var enforcer = CreateTenantEnforcer(runTracker: runTracker); - var tenant = new TenantContext("tenant-1", "project-1"); - - await enforcer.RecordRunStartAsync(tenant, "run-1"); - Assert.Equal(1, await enforcer.GetConcurrentRunCountAsync(tenant)); - - await enforcer.RecordRunStartAsync(tenant, "run-2"); - Assert.Equal(2, await enforcer.GetConcurrentRunCountAsync(tenant)); - - await enforcer.RecordRunCompletionAsync(tenant, "run-1"); - Assert.Equal(1, await enforcer.GetConcurrentRunCountAsync(tenant)); - - await enforcer.RecordRunCompletionAsync(tenant, "run-2"); - Assert.Equal(0, await enforcer.GetConcurrentRunCountAsync(tenant)); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task TenantEnforcer_CreatesExecutionContext() - { - var tenantProvider = new InMemoryTenantContextProvider(); - var tenant = new TenantContext("tenant-1", "project-1"); - tenantProvider.Register(tenant); - - var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider); - var request = new PackRunTenantRequest("tenant-1", "project-1"); - - var context = await enforcer.CreateExecutionContextAsync(request, "run-123"); - - Assert.NotNull(context); - Assert.Equal("tenant-1", context.Tenant.TenantId); - Assert.Equal("project-1", context.Tenant.ProjectId); - Assert.NotNull(context.StoragePaths); - Assert.Contains("tenant-1", context.LoggingScope["TenantId"].ToString()); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task TenantEnforcer_ThrowsOnInvalidRequest() - { - var enforcer = CreateTenantEnforcer(); - var request = new PackRunTenantRequest("", "project-1"); - - await Assert.ThrowsAsync(() => - enforcer.CreateExecutionContextAsync(request, "run-123").AsTask()); - } - - #endregion - - #region ConcurrentRunTracker Tests - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ConcurrentRunTracker_TracksMultipleTenants() - { - var tracker = new InMemoryConcurrentRunTracker(); - - await tracker.IncrementAsync("tenant-1", "run-1"); - await tracker.IncrementAsync("tenant-1", "run-2"); - await tracker.IncrementAsync("tenant-2", "run-3"); - - Assert.Equal(2, await tracker.GetCountAsync("tenant-1")); - Assert.Equal(1, await tracker.GetCountAsync("tenant-2")); - Assert.Equal(0, await tracker.GetCountAsync("tenant-3")); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ConcurrentRunTracker_PreventsDoubleIncrement() - { - var tracker = new InMemoryConcurrentRunTracker(); - - await tracker.IncrementAsync("tenant-1", "run-1"); - await tracker.IncrementAsync("tenant-1", "run-1"); // Same run ID - - Assert.Equal(1, await tracker.GetCountAsync("tenant-1")); - } - - [Trait("Category", TestCategories.Unit)] - [Fact] - public async Task ConcurrentRunTracker_HandlesNonExistentDecrement() - { - var tracker = new InMemoryConcurrentRunTracker(); - - // Should not throw - await tracker.DecrementAsync("tenant-1", "non-existent"); - - Assert.Equal(0, await tracker.GetCountAsync("tenant-1")); - } - - #endregion - - #region Helper Methods - - private static TenantEgressPolicy CreateEgressPolicy( - TenantEgressPolicyOptions? options = null, - IEgressAuditLog? auditLog = null) - { - return new TenantEgressPolicy( - options ?? new TenantEgressPolicyOptions(), - auditLog ?? NullEgressAuditLog.Instance, - NullLogger.Instance); - } - - private static PackRunTenantEnforcer CreateTenantEnforcer( - TenancyEnforcementOptions? options = null, - ITenantContextProvider? tenantProvider = null, - IConcurrentRunTracker? runTracker = null) - { - var storageOptions = new TenantStoragePathOptions(); - var pathResolver = new TenantScopedStoragePathResolver(storageOptions, Path.GetTempPath()); - - return new PackRunTenantEnforcer( - tenantProvider ?? new InMemoryTenantContextProvider(), - pathResolver, - options ?? new TenancyEnforcementOptions { ValidateTenantExists = false }, - runTracker ?? new InMemoryConcurrentRunTracker(), - NullLogger.Instance); - } - - #endregion -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TestManifests.Egress.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TestManifests.Egress.cs deleted file mode 100644 index b40365b6b..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TestManifests.Egress.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace StellaOps.TaskRunner.Tests; - -internal static partial class TestManifests -{ - public const string SealedEgressBlocked = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: egress-blocked - version: 1.0.0 -spec: - steps: - - id: fetch - run: - uses: builtin:http - with: - url: "https://example.com/data" - egress: - - url: "https://example.com" -"""; -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TestManifests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TestManifests.cs deleted file mode 100644 index 3b4cdcfba..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TestManifests.cs +++ /dev/null @@ -1,465 +0,0 @@ -using System.Text.Json.Nodes; -using StellaOps.TaskRunner.Core.TaskPacks; - -namespace StellaOps.TaskRunner.Tests; - -internal static partial class TestManifests -{ - public static TaskPackManifest Load(string yaml) - { - var loader = new TaskPackManifestLoader(); - return loader.Deserialize(yaml); - } - - public const string Sample = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: sample-pack - version: 1.0.0 - description: Sample pack for planner tests - tags: [tests] -spec: - inputs: - - name: dryRun - type: boolean - required: false - default: false - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - approvals: - - id: security-review - grants: ["packs.approve"] - steps: - - id: plan-step - name: Plan - run: - uses: builtin:plan - with: - dryRun: "{{ inputs.dryRun }}" - - id: approval - gate: - approval: - id: security-review - message: "Security approval required." - - id: apply-step - when: "{{ not inputs.dryRun }}" - run: - uses: builtin:apply -"""; - - public const string RequiredInput = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: required-input-pack - version: 1.2.3 -spec: - inputs: - - name: sbomBundle - type: object - required: true - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: noop - run: - uses: builtin:noop - with: - sbom: "{{ inputs.sbomBundle }}" -"""; - - public const string StepReference = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: step-ref-pack - version: 1.0.0 -spec: - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: prepare - run: - uses: builtin:prepare - - id: consume - run: - uses: builtin:consume - with: - sourceSummary: "{{ steps.prepare.outputs.summary }}" -"""; - - public const string Map = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: map-pack - version: 1.0.0 -spec: - inputs: - - name: targets - type: array - required: true - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: maintenance-loop - map: - items: "{{ inputs.targets }}" - step: - id: echo-step - run: - uses: builtin:echo - with: - target: "{{ item }}" -"""; - - public const string Secret = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: secret-pack - version: 1.0.0 -spec: - secrets: - - name: apiKey - scope: packs.run - description: API authentication token - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: use-secret - run: - uses: builtin:http - with: - token: "{{ secrets.apiKey }}" -"""; - - public const string Output = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: output-pack - version: 1.0.0 -spec: - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: generate - run: - uses: builtin:generate - outputs: - - name: bundlePath - type: file - path: artifacts/report.txt - - name: evidenceModel - type: object - expression: "{{ steps.generate.outputs.evidence }}" -"""; - - public const string FailurePolicy = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: failure-policy-pack - version: 1.0.0 -spec: - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: build - run: - uses: builtin:build - failure: - retries: - maxAttempts: 4 - backoffSeconds: 30 - message: "Build failed." -"""; - - public const string Parallel = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: parallel-pack - version: 1.1.0 -spec: - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: fanout - parallel: - maxParallel: 2 - continueOnError: true - steps: - - id: lint - run: - uses: builtin:lint - - id: test - run: - uses: builtin:test - failure: - retries: - maxAttempts: 2 - backoffSeconds: 10 - message: "Parallel execution failed." -"""; - -public const string PolicyGate = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: policy-gate-pack - version: 1.0.0 -spec: - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: prepare - run: - uses: builtin:prepare - - id: policy-check - gate: - policy: - policy: security-hold - parameters: - threshold: high - evidenceRef: "{{ steps.prepare.outputs.evidence }}" -"""; - - public const string EgressAllowed = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: egress-allowed - version: 1.0.0 -spec: - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: fetch - run: - uses: builtin:http - with: - url: https://mirror.internal/api/status - egress: - - url: https://mirror.internal/api/status -"""; - - public const string EgressBlocked = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: egress-blocked - version: 1.0.0 -spec: - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: fetch - run: - uses: builtin:http - with: - url: https://example.com/api/status -"""; - - public const string EgressRuntime = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: egress-runtime - version: 1.0.0 -spec: - inputs: - - name: targetUrl - type: string - required: false - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: fetch - run: - uses: builtin:http - with: - url: "{{ inputs.targetUrl }}" -"""; - - public const string Loop = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: loop-pack - version: 1.0.0 -spec: - inputs: - - name: targets - type: array - required: true - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: process-loop - loop: - items: "{{ inputs.targets }}" - iterator: target - index: idx - maxIterations: 100 - aggregation: collect - steps: - - id: process-item - run: - uses: builtin:process -"""; - - public const string Conditional = """ -apiVersion: stellaops.io/pack.v1 -kind: TaskPack -metadata: - name: conditional-pack - version: 1.0.0 -spec: - inputs: - - name: environment - type: string - required: true - sandbox: - mode: sealed - egressAllowlist: [] - cpuLimitMillicores: 100 - memoryLimitMiB: 128 - quotaSeconds: 60 - slo: - runP95Seconds: 300 - approvalP95Seconds: 900 - maxQueueDepth: 100 - steps: - - id: env-branch - conditional: - branches: - - condition: "{{ inputs.environment == 'production' }}" - steps: - - id: deploy-prod - run: - uses: builtin:deploy - with: - target: production - - condition: "{{ inputs.environment == 'staging' }}" - steps: - - id: deploy-staging - run: - uses: builtin:deploy - with: - target: staging - else: - - id: deploy-dev - run: - uses: builtin:deploy - with: - target: development - outputUnion: true -"""; -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/xunit.runner.json b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/xunit.runner.json deleted file mode 100644 index 86c7ea05b..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/xunit.runner.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Deprecation/ApiDeprecationMiddleware.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Deprecation/ApiDeprecationMiddleware.cs deleted file mode 100644 index 0f2177189..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Deprecation/ApiDeprecationMiddleware.cs +++ /dev/null @@ -1,197 +0,0 @@ - -using Microsoft.Extensions.Options; -using System.Globalization; -using System.Text.RegularExpressions; - -namespace StellaOps.TaskRunner.WebService.Deprecation; - -/// -/// Middleware that adds deprecation and sunset headers per RFC 8594. -/// -public sealed class ApiDeprecationMiddleware -{ - private readonly RequestDelegate _next; - private readonly IOptionsMonitor _options; - private readonly ILogger _logger; - private readonly List _patterns; - - /// - /// HTTP header for deprecation status per draft-ietf-httpapi-deprecation-header. - /// - public const string DeprecationHeader = "Deprecation"; - - /// - /// HTTP header for sunset date per RFC 8594. - /// - public const string SunsetHeader = "Sunset"; - - /// - /// HTTP Link header for deprecation documentation. - /// - public const string LinkHeader = "Link"; - - public ApiDeprecationMiddleware( - RequestDelegate next, - IOptionsMonitor options, - ILogger logger) - { - _next = next ?? throw new ArgumentNullException(nameof(next)); - _options = options ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _patterns = CompilePatterns(options.CurrentValue.DeprecatedEndpoints); - - options.OnChange(newOptions => - { - _patterns.Clear(); - _patterns.AddRange(CompilePatterns(newOptions.DeprecatedEndpoints)); - }); - } - - public async Task InvokeAsync(HttpContext context) - { - var options = _options.CurrentValue; - var path = context.Request.Path.Value ?? string.Empty; - - var deprecatedEndpoint = FindMatchingEndpoint(path); - - if (deprecatedEndpoint is not null) - { - AddDeprecationHeaders(context.Response, deprecatedEndpoint, options); - - _logger.LogInformation( - "Deprecated endpoint accessed: {Path} (sunset: {Sunset})", - path, - deprecatedEndpoint.Config.SunsetAt?.ToString("o", CultureInfo.InvariantCulture) ?? "not set"); - } - - await _next(context).ConfigureAwait(false); - } - - private CompiledEndpointPattern? FindMatchingEndpoint(string path) - { - foreach (var pattern in _patterns) - { - if (pattern.Regex.IsMatch(path)) - { - return pattern; - } - } - return null; - } - - private static void AddDeprecationHeaders( - HttpResponse response, - CompiledEndpointPattern endpoint, - ApiDeprecationOptions options) - { - var config = endpoint.Config; - - // Add Deprecation header per draft-ietf-httpapi-deprecation-header - if (options.EmitDeprecationHeaders && config.DeprecatedAt.HasValue) - { - // RFC 7231 date format: Sun, 06 Nov 1994 08:49:37 GMT - var deprecationDate = config.DeprecatedAt.Value.ToString("R", CultureInfo.InvariantCulture); - response.Headers.Append(DeprecationHeader, deprecationDate); - } - else if (options.EmitDeprecationHeaders) - { - // If no specific date, use "true" to indicate deprecated - response.Headers.Append(DeprecationHeader, "true"); - } - - // Add Sunset header per RFC 8594 - if (options.EmitSunsetHeaders && config.SunsetAt.HasValue) - { - var sunsetDate = config.SunsetAt.Value.ToString("R", CultureInfo.InvariantCulture); - response.Headers.Append(SunsetHeader, sunsetDate); - } - - // Add Link headers for documentation - var links = new List(); - - if (!string.IsNullOrWhiteSpace(config.DeprecationLink)) - { - links.Add($"<{config.DeprecationLink}>; rel=\"deprecation\"; type=\"text/html\""); - } - - if (!string.IsNullOrWhiteSpace(options.DeprecationPolicyUrl)) - { - links.Add($"<{options.DeprecationPolicyUrl}>; rel=\"sunset\"; type=\"text/html\""); - } - - if (!string.IsNullOrWhiteSpace(config.ReplacementPath)) - { - links.Add($"<{config.ReplacementPath}>; rel=\"successor-version\""); - } - - if (links.Count > 0) - { - response.Headers.Append(LinkHeader, string.Join(", ", links)); - } - - // Add custom deprecation message header - if (!string.IsNullOrWhiteSpace(config.Message)) - { - response.Headers.Append("X-Deprecation-Notice", config.Message); - } - } - - private static List CompilePatterns(List endpoints) - { - var patterns = new List(endpoints.Count); - - foreach (var endpoint in endpoints) - { - if (string.IsNullOrWhiteSpace(endpoint.PathPattern)) - { - continue; - } - - // Convert wildcard pattern to regex - var pattern = "^" + Regex.Escape(endpoint.PathPattern) - .Replace("\\*\\*", ".*") - .Replace("\\*", "[^/]*") + "$"; - - try - { - var regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); - patterns.Add(new CompiledEndpointPattern(regex, endpoint)); - } - catch (ArgumentException) - { - // Invalid regex pattern, skip - } - } - - return patterns; - } - - private sealed record CompiledEndpointPattern(Regex Regex, DeprecatedEndpoint Config); -} - -/// -/// Extension methods for adding API deprecation middleware. -/// -public static class ApiDeprecationMiddlewareExtensions -{ - /// - /// Adds the API deprecation middleware to the pipeline. - /// - public static IApplicationBuilder UseApiDeprecation(this IApplicationBuilder app) - { - return app.UseMiddleware(); - } - - /// - /// Adds API deprecation services to the service collection. - /// - public static IServiceCollection AddApiDeprecation( - this IServiceCollection services, - IConfiguration configuration) - { - services.Configure( - configuration.GetSection(ApiDeprecationOptions.SectionName)); - - return services; - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Deprecation/ApiDeprecationOptions.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Deprecation/ApiDeprecationOptions.cs deleted file mode 100644 index 1a0a4e2e5..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Deprecation/ApiDeprecationOptions.cs +++ /dev/null @@ -1,68 +0,0 @@ -namespace StellaOps.TaskRunner.WebService.Deprecation; - -/// -/// Configuration options for API deprecation and sunset headers. -/// -public sealed class ApiDeprecationOptions -{ - /// - /// Configuration section name. - /// - public const string SectionName = "TaskRunner:ApiDeprecation"; - - /// - /// Whether to emit deprecation headers for deprecated endpoints. - /// - public bool EmitDeprecationHeaders { get; set; } = true; - - /// - /// Whether to emit sunset headers per RFC 8594. - /// - public bool EmitSunsetHeaders { get; set; } = true; - - /// - /// URL to deprecation policy documentation. - /// - public string? DeprecationPolicyUrl { get; set; } = "https://docs.stellaops.io/api/deprecation-policy"; - - /// - /// List of deprecated endpoints with their sunset dates. - /// - public List DeprecatedEndpoints { get; set; } = []; -} - -/// -/// Configuration for a deprecated endpoint. -/// -public sealed class DeprecatedEndpoint -{ - /// - /// Path pattern to match (supports wildcards like /v1/packs/*). - /// - public string PathPattern { get; set; } = string.Empty; - - /// - /// Date when the endpoint was deprecated. - /// - public DateTimeOffset? DeprecatedAt { get; set; } - - /// - /// Date when the endpoint will be removed (sunset date per RFC 8594). - /// - public DateTimeOffset? SunsetAt { get; set; } - - /// - /// URL to documentation about the deprecation and migration path. - /// - public string? DeprecationLink { get; set; } - - /// - /// Suggested replacement endpoint path. - /// - public string? ReplacementPath { get; set; } - - /// - /// Human-readable deprecation message. - /// - public string? Message { get; set; } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Deprecation/IDeprecationNotificationService.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Deprecation/IDeprecationNotificationService.cs deleted file mode 100644 index 534367126..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Deprecation/IDeprecationNotificationService.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Microsoft.Extensions.Options; - -namespace StellaOps.TaskRunner.WebService.Deprecation; - -/// -/// Service for sending deprecation notifications to API consumers. -/// -public interface IDeprecationNotificationService -{ - /// - /// Sends a notification about an upcoming deprecation. - /// - /// Deprecation notification details. - /// Cancellation token. - Task NotifyAsync(DeprecationNotification notification, CancellationToken cancellationToken = default); - - /// - /// Gets upcoming deprecations within a specified number of days. - /// - /// Number of days to look ahead. - /// Cancellation token. - /// List of upcoming deprecations. - Task> GetUpcomingDeprecationsAsync( - int withinDays = 90, - CancellationToken cancellationToken = default); -} - -/// -/// Deprecation notification details. -/// -public sealed record DeprecationNotification( - string EndpointPath, - string? ReplacementPath, - DateTimeOffset? SunsetDate, - string? Message, - string? DocumentationUrl, - IReadOnlyList? AffectedConsumerIds); - -/// -/// Information about a deprecation. -/// -public sealed record DeprecationInfo( - string EndpointPath, - DateTimeOffset? DeprecatedAt, - DateTimeOffset? SunsetAt, - string? ReplacementPath, - string? DocumentationUrl, - int DaysUntilSunset); - -/// -/// Default implementation that logs deprecation notifications. -/// -public sealed class LoggingDeprecationNotificationService : IDeprecationNotificationService -{ - private readonly ILogger _logger; - private readonly IOptionsMonitor _options; - - public LoggingDeprecationNotificationService( - ILogger logger, - IOptionsMonitor options) - { - _logger = logger; - _options = options; - } - - public Task NotifyAsync(DeprecationNotification notification, CancellationToken cancellationToken = default) - { - _logger.LogWarning( - "Deprecation notification: Endpoint {Endpoint} will be sunset on {SunsetDate}. " + - "Replacement: {Replacement}. Message: {Message}", - notification.EndpointPath, - notification.SunsetDate?.ToString("o"), - notification.ReplacementPath ?? "(none)", - notification.Message ?? "(none)"); - - return Task.CompletedTask; - } - - public Task> GetUpcomingDeprecationsAsync( - int withinDays = 90, - CancellationToken cancellationToken = default) - { - var options = _options.CurrentValue; - var now = DateTimeOffset.UtcNow; - var cutoff = now.AddDays(withinDays); - - var upcoming = options.DeprecatedEndpoints - .Where(e => e.SunsetAt.HasValue && e.SunsetAt.Value <= cutoff && e.SunsetAt.Value > now) - .OrderBy(e => e.SunsetAt) - .Select(e => new DeprecationInfo( - e.PathPattern, - e.DeprecatedAt, - e.SunsetAt, - e.ReplacementPath, - e.DeprecationLink, - e.SunsetAt.HasValue ? (int)(e.SunsetAt.Value - now).TotalDays : int.MaxValue)) - .ToList(); - - return Task.FromResult>(upcoming); - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/OpenApiMetadataFactory.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/OpenApiMetadataFactory.cs deleted file mode 100644 index 0af335a2f..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/OpenApiMetadataFactory.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Reflection; -using System.Security.Cryptography; -using System.Text; - -namespace StellaOps.TaskRunner.WebService; - -/// -/// Factory for creating OpenAPI metadata including version, build info, and spec signature. -/// -public static class OpenApiMetadataFactory -{ - /// API version from the OpenAPI spec (docs/api/taskrunner-openapi.yaml). - public const string ApiVersion = "0.1.0-draft"; - - internal static Type ResponseType => typeof(OpenApiMetadata); - - /// - /// Creates OpenAPI metadata with versioning and signature information. - /// - /// URL path to the OpenAPI spec endpoint. - /// Metadata record with version, build, ETag, and signature. - public static OpenApiMetadata Create(string? specUrl = null) - { - var assembly = Assembly.GetExecutingAssembly(); - var assemblyName = assembly.GetName(); - - // Get informational version (includes git hash if available) or fall back to assembly version - var informationalVersion = assembly - .GetCustomAttribute()?.InformationalVersion; - var buildVersion = !string.IsNullOrWhiteSpace(informationalVersion) - ? informationalVersion - : assemblyName.Version?.ToString() ?? "0.0.0"; - - var url = string.IsNullOrWhiteSpace(specUrl) ? "/openapi" : specUrl; - - // ETag combines API version and build version for cache invalidation - var etag = CreateEtag(ApiVersion, buildVersion); - - // Signature is SHA-256 of spec URL + API version + build version - var signature = ComputeSignature(url, ApiVersion, buildVersion); - - return new OpenApiMetadata(url, ApiVersion, buildVersion, etag, signature); - } - - /// - /// Creates a weak ETag from version components. - /// - private static string CreateEtag(string apiVersion, string buildVersion) - { - // Use SHA-256 of combined versions for a stable, fixed-length ETag - var combined = $"{apiVersion}:{buildVersion}"; - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(combined)); - var shortHash = Convert.ToHexString(hash)[..16].ToLowerInvariant(); - return $"W/\"{shortHash}\""; - } - - /// - /// Computes a SHA-256 signature for spec verification. - /// - private static string ComputeSignature(string url, string apiVersion, string buildVersion) - { - // Include all metadata components in signature - var data = Encoding.UTF8.GetBytes($"{url}|{apiVersion}|{buildVersion}"); - var hash = SHA256.HashData(data); - return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; - } - - /// - /// OpenAPI metadata for the /.well-known/openapi endpoint. - /// - /// URL to fetch the full OpenAPI specification. - /// API version (e.g., "0.1.0-draft"). - /// Build/assembly version with optional git info. - /// ETag for HTTP caching. - /// SHA-256 signature for verification. - public sealed record OpenApiMetadata( - string SpecUrl, - string Version, - string BuildVersion, - string ETag, - string Signature); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs deleted file mode 100644 index d6ea5f65c..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs +++ /dev/null @@ -1,1340 +0,0 @@ - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using StellaOps.Infrastructure.Postgres.Options; -using StellaOps.Localization; -using static StellaOps.Localization.T; -using Microsoft.Extensions.Options; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; -using StellaOps.AirGap.Policy; -using StellaOps.Auth.ServerIntegration; -using StellaOps.Auth.ServerIntegration.Tenancy; -using StellaOps.Router.AspNet; -using StellaOps.TaskRunner.Core.AirGap; -using StellaOps.TaskRunner.Core.Attestation; -using StellaOps.TaskRunner.Core.Configuration; -using StellaOps.TaskRunner.Core.Events; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Execution.Simulation; -using StellaOps.TaskRunner.Core.IncidentMode; -using StellaOps.TaskRunner.Core.Planning; -using StellaOps.TaskRunner.Core.TaskPacks; -using StellaOps.TaskRunner.Infrastructure.AirGap; -using StellaOps.TaskRunner.Infrastructure.Execution; -using StellaOps.TaskRunner.Persistence.Postgres; -using StellaOps.TaskRunner.Persistence.Postgres.Repositories; -using StellaOps.TaskRunner.WebService; -using StellaOps.TaskRunner.WebService.Deprecation; -using StellaOps.Telemetry.Core; -using System.Collections.ObjectModel; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.Configure(builder.Configuration.GetSection("TaskRunner")); -builder.Services.AddAirGapEgressPolicy(builder.Configuration); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => -{ - var egressPolicy = sp.GetRequiredService(); - return new TaskPackPlanner(egressPolicy); -}); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddStellaOpsTelemetry( - builder.Configuration, - serviceName: "StellaOps.TaskRunner.WebService", - configureTracing: tracing => tracing.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation(), - configureMetrics: metrics => metrics - .AddRuntimeInstrumentation() - .AddMeter(TaskRunnerTelemetry.MeterName)); - -var storageDriver = ResolveStorageDriver(builder.Configuration, "TaskRunner"); -RegisterStateStores(builder.Services, builder.Configuration, builder.Environment.IsDevelopment(), storageDriver); -ValidateObjectStoreContract(builder.Configuration, "TaskRunner"); - -builder.Services.AddSingleton(sp => -{ - var options = sp.GetRequiredService>().Value; - var configuration = sp.GetRequiredService(); - var artifactsRoot = ResolveSeedFsRootPath(configuration, "TaskRunner", options.ArtifactsPath); - return new FilesystemPackRunArtifactReader(artifactsRoot); -}); - -builder.Services.AddSingleton(sp => -{ - var options = sp.GetRequiredService>().Value; - return new FilesystemPackRunDispatcher(options.QueuePath, options.ArchivePath); -}); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(); -builder.Services.AddApiDeprecation(builder.Configuration); -builder.Services.AddSingleton(); - -// Sealed install enforcement (TASKRUN-AIRGAP-57-001) -builder.Services.Configure( - builder.Configuration.GetSection("TaskRunner:Enforcement:SealedInstall")); -builder.Services.Configure( - builder.Configuration.GetSection("TaskRunner:AirGap")); -builder.Services.AddHttpClient((sp, client) => -{ - var options = sp.GetRequiredService>().Value; - client.BaseAddress = new Uri(options.BaseUrl); - client.Timeout = TimeSpan.FromSeconds(10); -}); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// Pack run attestations (TASKRUN-OBS-54-001) -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// Pack run incident mode (TASKRUN-OBS-55-001) -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -builder.Services.AddOpenApi(); - -// Determinism: TimeProvider injection -builder.Services.AddSingleton(TimeProvider.System); - -builder.Services.AddStellaOpsTenantServices(); -builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration); - -builder.Services.AddStellaOpsLocalization(builder.Configuration); -builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly()); - -// Stella Router integration -var routerEnabled = builder.Services.AddRouterMicroservice( - builder.Configuration, - serviceName: "taskrunner", - version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0", - routerOptionsSection: "Router"); - -builder.TryAddStellaOpsLocalBinding("taskrunner"); -var app = builder.Build(); -app.LogStellaOpsLocalHostname("taskrunner"); - -// Add deprecation middleware for sunset headers (RFC 8594) -app.UseStellaOpsCors(); -app.UseStellaOpsLocalization(); -app.UseStellaOpsTenantMiddleware(); -app.UseApiDeprecation(); -app.TryUseStellaRouter(routerEnabled); - -await app.LoadTranslationsAsync(); - -app.MapOpenApi("/openapi"); - -// Deprecation status endpoint -app.MapGet("/v1/task-runner/deprecations", async ( - IDeprecationNotificationService deprecationService, - [FromQuery] int? withinDays, - CancellationToken cancellationToken) => -{ - var days = withinDays ?? 90; - var deprecations = await deprecationService.GetUpcomingDeprecationsAsync(days, cancellationToken) - .ConfigureAwait(false); - - return Results.Ok(new - { - withinDays = days, - deprecations = deprecations.Select(d => new - { - endpoint = d.EndpointPath, - deprecatedAt = d.DeprecatedAt?.ToString("o"), - sunsetAt = d.SunsetAt?.ToString("o"), - daysUntilSunset = d.DaysUntilSunset, - replacement = d.ReplacementPath, - documentation = d.DocumentationUrl - }) - }); -}) -.WithName("GetDeprecations") -.WithDescription(_t("taskrunner.deprecations.list_description")) -.WithTags("API Governance") -.RequireTenant(); - -app.MapPost("/v1/task-runner/simulations", async ( - [FromBody] SimulationRequest request, - TaskPackManifestLoader loader, - TaskPackPlanner planner, - PackRunSimulationEngine simulationEngine, - CancellationToken cancellationToken) => -{ - if (string.IsNullOrWhiteSpace(request.Manifest)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.manifest_required") }); - } - - TaskPackManifest manifest; - try - { - manifest = loader.Deserialize(request.Manifest); - } - catch (Exception ex) - { - return Results.BadRequest(new { error = _t("taskrunner.error.manifest_invalid"), detail = ex.Message }); - } - - var inputs = ConvertInputs(request.Inputs); - var planResult = planner.Plan(manifest, inputs); - if (!planResult.Success || planResult.Plan is null) - { - return Results.BadRequest(new - { - errors = planResult.Errors.Select(error => new { error.Path, error.Message }) - }); - } - - var plan = planResult.Plan; - var simulation = simulationEngine.Simulate(plan); - var response = SimulationMapper.ToResponse(plan, simulation); - return Results.Ok(response); -}) -.WithName("SimulateTaskPack") -.WithDescription(_t("taskrunner.simulations.create_description")) -.RequireTenant(); - -app.MapPost("/v1/task-runner/runs", HandleCreateRun) - .WithName("CreatePackRun") - .WithDescription(_t("taskrunner.runs.create_description")) - .RequireTenant(); -app.MapPost("/api/runs", HandleCreateRun) - .WithName("CreatePackRunApi") - .WithDescription(_t("taskrunner.runs.create_legacy_description")) - .RequireTenant(); - -app.MapGet("/v1/task-runner/runs/{runId}", HandleGetRunState) - .WithName("GetRunState") - .WithDescription(_t("taskrunner.runs.get_state_description")) - .RequireTenant(); -app.MapGet("/api/runs/{runId}", HandleGetRunState) - .WithName("GetRunStateApi") - .WithDescription(_t("taskrunner.runs.get_state_legacy_description")) - .RequireTenant(); - -app.MapGet("/v1/task-runner/runs/{runId}/logs", HandleStreamRunLogs) - .WithName("StreamRunLogs") - .WithDescription(_t("taskrunner.runs.stream_logs_description")) - .RequireTenant(); -app.MapGet("/api/runs/{runId}/logs", HandleStreamRunLogs) - .WithName("StreamRunLogsApi") - .WithDescription(_t("taskrunner.runs.stream_logs_legacy_description")) - .RequireTenant(); - -app.MapGet("/v1/task-runner/runs/{runId}/artifacts", HandleListArtifacts) - .WithName("ListRunArtifacts") - .WithDescription(_t("taskrunner.runs.list_artifacts_description")) - .RequireTenant(); -app.MapGet("/api/runs/{runId}/artifacts", HandleListArtifacts) - .WithName("ListRunArtifactsApi") - .WithDescription(_t("taskrunner.runs.list_artifacts_legacy_description")) - .RequireTenant(); - -app.MapPost("/v1/task-runner/runs/{runId}/approvals/{approvalId}", HandleApplyApprovalDecision) - .WithName("ApplyApprovalDecision") - .WithDescription(_t("taskrunner.runs.apply_approval_description")) - .RequireTenant(); -app.MapPost("/api/runs/{runId}/approvals/{approvalId}", HandleApplyApprovalDecision) - .WithName("ApplyApprovalDecisionApi") - .WithDescription(_t("taskrunner.runs.apply_approval_legacy_description")) - .RequireTenant(); - -app.MapPost("/v1/task-runner/runs/{runId}/cancel", HandleCancelRun) - .WithName("CancelRun") - .WithDescription(_t("taskrunner.runs.cancel_description")) - .RequireTenant(); -app.MapPost("/api/runs/{runId}/cancel", HandleCancelRun) - .WithName("CancelRunApi") - .WithDescription(_t("taskrunner.runs.cancel_legacy_description")) - .RequireTenant(); - -// Attestation endpoints (TASKRUN-OBS-54-001) -app.MapGet("/v1/task-runner/runs/{runId}/attestations", HandleListAttestations) - .WithName("ListRunAttestations") - .WithDescription(_t("taskrunner.attestations.list_description")) - .RequireTenant(); -app.MapGet("/api/runs/{runId}/attestations", HandleListAttestations) - .WithName("ListRunAttestationsApi") - .WithDescription(_t("taskrunner.attestations.list_legacy_description")) - .RequireTenant(); - -app.MapGet("/v1/task-runner/attestations/{attestationId}", HandleGetAttestation) - .WithName("GetAttestation") - .WithDescription(_t("taskrunner.attestations.get_description")) - .RequireTenant(); -app.MapGet("/api/attestations/{attestationId}", HandleGetAttestation) - .WithName("GetAttestationApi") - .WithDescription(_t("taskrunner.attestations.get_legacy_description")) - .RequireTenant(); - -app.MapGet("/v1/task-runner/attestations/{attestationId}/envelope", HandleGetAttestationEnvelope) - .WithName("GetAttestationEnvelope") - .WithDescription(_t("taskrunner.attestations.get_envelope_description")) - .RequireTenant(); -app.MapGet("/api/attestations/{attestationId}/envelope", HandleGetAttestationEnvelope) - .WithName("GetAttestationEnvelopeApi") - .WithDescription(_t("taskrunner.attestations.get_envelope_legacy_description")) - .RequireTenant(); - -app.MapPost("/v1/task-runner/attestations/{attestationId}/verify", HandleVerifyAttestation) - .WithName("VerifyAttestation") - .WithDescription(_t("taskrunner.attestations.verify_description")) - .RequireTenant(); -app.MapPost("/api/attestations/{attestationId}/verify", HandleVerifyAttestation) - .WithName("VerifyAttestationApi") - .WithDescription(_t("taskrunner.attestations.verify_legacy_description")) - .RequireTenant(); - -// Incident mode endpoints (TASKRUN-OBS-55-001) -app.MapGet("/v1/task-runner/runs/{runId}/incident-mode", HandleGetIncidentModeStatus) - .WithName("GetIncidentModeStatus") - .WithDescription(_t("taskrunner.incident_mode.get_description")) - .RequireTenant(); -app.MapGet("/api/runs/{runId}/incident-mode", HandleGetIncidentModeStatus) - .WithName("GetIncidentModeStatusApi") - .WithDescription(_t("taskrunner.incident_mode.get_legacy_description")) - .RequireTenant(); - -app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/activate", HandleActivateIncidentMode) - .WithName("ActivateIncidentMode") - .WithDescription(_t("taskrunner.incident_mode.activate_description")) - .RequireTenant(); -app.MapPost("/api/runs/{runId}/incident-mode/activate", HandleActivateIncidentMode) - .WithName("ActivateIncidentModeApi") - .WithDescription(_t("taskrunner.incident_mode.activate_legacy_description")) - .RequireTenant(); - -app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/deactivate", HandleDeactivateIncidentMode) - .WithName("DeactivateIncidentMode") - .WithDescription(_t("taskrunner.incident_mode.deactivate_description")) - .RequireTenant(); -app.MapPost("/api/runs/{runId}/incident-mode/deactivate", HandleDeactivateIncidentMode) - .WithName("DeactivateIncidentModeApi") - .WithDescription(_t("taskrunner.incident_mode.deactivate_legacy_description")) - .RequireTenant(); - -app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/escalate", HandleEscalateIncidentMode) - .WithName("EscalateIncidentMode") - .WithDescription(_t("taskrunner.incident_mode.escalate_description")) - .RequireTenant(); -app.MapPost("/api/runs/{runId}/incident-mode/escalate", HandleEscalateIncidentMode) - .WithName("EscalateIncidentModeApi") - .WithDescription(_t("taskrunner.incident_mode.escalate_legacy_description")) - .RequireTenant(); - -app.MapPost("/v1/task-runner/webhooks/slo-breach", HandleSloBreachWebhook) - .WithName("SloBreachWebhook") - .WithDescription(_t("taskrunner.webhooks.slo_breach_description")) - .AllowAnonymous(); -app.MapPost("/api/webhooks/slo-breach", HandleSloBreachWebhook) - .WithName("SloBreachWebhookApi") - .WithDescription(_t("taskrunner.webhooks.slo_breach_legacy_description")) - .AllowAnonymous(); - -app.MapGet("/.well-known/openapi", (HttpResponse response) => -{ - var metadata = OpenApiMetadataFactory.Create("/openapi"); - response.Headers.ETag = metadata.ETag; - response.Headers.Append("X-Signature", metadata.Signature); - response.Headers.Append("X-Api-Version", metadata.Version); - response.Headers.Append("X-Build-Version", metadata.BuildVersion); - return Results.Ok(metadata); -}) -.WithName("GetOpenApiMetadata") -.WithDescription(_t("taskrunner.openapi.get_metadata_description")) -.AllowAnonymous(); - -app.MapGet("/", () => Results.Redirect("/openapi")); - -// Refresh Router endpoint cache -app.TryRefreshStellaRouterEndpoints(routerEnabled); - -async Task HandleCreateRun( - [FromBody] CreateRunRequest request, - TaskPackManifestLoader loader, - TaskPackPlanner planner, - PackRunExecutionGraphBuilder executionGraphBuilder, - PackRunSimulationEngine simulationEngine, - IPackRunStateStore stateStore, - IPackRunLogStore logStore, - IPackRunJobScheduler scheduler, - ISealedInstallEnforcer sealedInstallEnforcer, - ISealedInstallAuditLogger auditLogger, - TimeProvider timeProvider, - CancellationToken cancellationToken) -{ - if (request is null || string.IsNullOrWhiteSpace(request.Manifest)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.manifest_required") }); - } - - TaskPackManifest manifest; - try - { - manifest = loader.Deserialize(request.Manifest); - } - catch (Exception ex) - { - return Results.BadRequest(new { error = _t("taskrunner.error.manifest_invalid"), detail = ex.Message }); - } - - // TASKRUN-AIRGAP-57-001: Sealed install enforcement - var enforcementResult = await sealedInstallEnforcer.EnforceAsync( - manifest, - request.TenantId, - cancellationToken).ConfigureAwait(false); - - // Log the enforcement decision - await auditLogger.LogEnforcementAsync( - manifest, - enforcementResult, - request.TenantId, - request.RunId, - cancellationToken: cancellationToken).ConfigureAwait(false); - - if (!enforcementResult.Allowed) - { - return Results.Json(new - { - error = new - { - code = enforcementResult.ErrorCode, - message = enforcementResult.Message, - details = new - { - pack_id = manifest.Metadata.Name, - pack_version = manifest.Metadata.Version, - sealed_install_required = manifest.Spec.SealedInstall, - environment_sealed = enforcementResult.Violation?.ActualSealed ?? false, - violations = enforcementResult.RequirementViolations?.Select(v => new - { - requirement = v.Requirement, - expected = v.Expected, - actual = v.Actual, - message = v.Message - }), - recommendation = enforcementResult.Violation?.Recommendation - } - }, - status = "rejected", - rejected_at = timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture) - }, statusCode: StatusCodes.Status403Forbidden); - } - - var inputs = ConvertInputs(request.Inputs); - var planResult = planner.Plan(manifest, inputs); - if (!planResult.Success || planResult.Plan is null) - { - return Results.BadRequest(new - { - errors = planResult.Errors.Select(error => new { error.Path, error.Message }) - }); - } - - var plan = planResult.Plan; - var runId = string.IsNullOrWhiteSpace(request.RunId) - ? Guid.NewGuid().ToString("n") - : request.RunId!; - - var existing = await stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false); - if (existing is not null) - { - return Results.Conflict(new { error = _t("taskrunner.error.run_already_exists") }); - } - - var requestedAt = timeProvider.GetUtcNow(); - var context = new PackRunExecutionContext(runId, plan, requestedAt, request.TenantId); - var graph = executionGraphBuilder.Build(plan); - - var state = PackRunStateFactory.CreateInitialState(context, graph, simulationEngine, requestedAt); - await stateStore.SaveAsync(state, cancellationToken).ConfigureAwait(false); - - try - { - await scheduler.ScheduleAsync(context, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - await logStore.AppendAsync( - runId, - new PackRunLogEntry(timeProvider.GetUtcNow(), "error", "run.schedule-failed", ex.Message, null, null), - cancellationToken).ConfigureAwait(false); - - return Results.StatusCode(StatusCodes.Status500InternalServerError); - } - - var metadata = new Dictionary(StringComparer.Ordinal) - { - ["planHash"] = plan.Hash, - ["requestedAt"] = requestedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) - }; - if (!string.IsNullOrWhiteSpace(context.TenantId)) - { - metadata["tenantId"] = context.TenantId!; - } - - await logStore.AppendAsync( - runId, - new PackRunLogEntry(timeProvider.GetUtcNow(), "info", "run.created", "Run created via API.", null, metadata), - cancellationToken).ConfigureAwait(false); - - var response = RunStateMapper.ToResponse(state); - return Results.Created($"/v1/task-runner/runs/{runId}", response); -} - -async Task HandleGetRunState( - string runId, - IPackRunStateStore stateStore, - CancellationToken cancellationToken) -{ - if (string.IsNullOrWhiteSpace(runId)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.run_id_required") }); - } - - var state = await stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false); - if (state is null) - { - return Results.NotFound(); - } - - return Results.Ok(RunStateMapper.ToResponse(state)); -} - -async Task HandleStreamRunLogs( - string runId, - IPackRunLogStore logStore, - CancellationToken cancellationToken) -{ - if (string.IsNullOrWhiteSpace(runId)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.run_id_required") }); - } - - if (!await logStore.ExistsAsync(runId, cancellationToken).ConfigureAwait(false)) - { - return Results.NotFound(); - } - - return Results.Stream(async stream => - { - await foreach (var entry in logStore.ReadAsync(runId, cancellationToken).ConfigureAwait(false)) - { - await RunLogMapper.WriteAsync(stream, entry, cancellationToken).ConfigureAwait(false); - } - }, "application/x-ndjson"); -} - -async Task HandleApplyApprovalDecision( - string runId, - string approvalId, - [FromBody] ApprovalDecisionDto request, - PackRunApprovalDecisionService decisionService, - CancellationToken cancellationToken) -{ - if (request is null) - { - return Results.BadRequest(new { error = _t("taskrunner.error.request_body_required") }); - } - - if (!Enum.TryParse(request.Decision, ignoreCase: true, out var decisionType)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.decision_invalid") }); - } - - if (string.IsNullOrWhiteSpace(request.PlanHash)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.plan_hash_required") }); - } - - if (!Regex.IsMatch(request.PlanHash, "^sha256:[0-9a-f]{64}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.plan_hash_format") }); - } - - var result = await decisionService.ApplyAsync( - new PackRunApprovalDecisionRequest(runId, approvalId, request.PlanHash, decisionType, request.ActorId, request.Summary), - cancellationToken).ConfigureAwait(false); - - if (ReferenceEquals(result, PackRunApprovalDecisionResult.NotFound)) - { - return Results.NotFound(); - } - - if (ReferenceEquals(result, PackRunApprovalDecisionResult.PlanHashMismatch)) - { - return Results.Conflict(new { error = _t("taskrunner.error.plan_hash_mismatch") }); - } - - return Results.Ok(new - { - status = result.Status, - resumed = result.ShouldResume - }); -} - -async Task HandleListArtifacts( - string runId, - IPackRunStateStore stateStore, - IPackRunArtifactReader artifactReader, - CancellationToken cancellationToken) -{ - if (string.IsNullOrWhiteSpace(runId)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.run_id_required") }); - } - - var state = await stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false); - if (state is null) - { - return Results.NotFound(); - } - - var artifacts = await artifactReader.ListAsync(runId, cancellationToken).ConfigureAwait(false); - var response = artifacts - .Select(artifact => new - { - artifact.Name, - artifact.Type, - artifact.SourcePath, - artifact.StoredPath, - artifact.Status, - artifact.Notes, - artifact.CapturedAt, - artifact.ExpressionJson - }) - .ToList(); - - return Results.Ok(response); -} - -async Task HandleCancelRun( - string runId, - IPackRunStateStore stateStore, - IPackRunLogStore logStore, - TimeProvider timeProvider, - CancellationToken cancellationToken) -{ - if (string.IsNullOrWhiteSpace(runId)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.run_id_required") }); - } - - var state = await stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false); - if (state is null) - { - return Results.NotFound(); - } - - var now = timeProvider.GetUtcNow(); - var updatedSteps = state.Steps.Values - .Select(step => step.Status is PackRunStepExecutionStatus.Succeeded or PackRunStepExecutionStatus.Skipped - ? step - : step with - { - Status = PackRunStepExecutionStatus.Skipped, - StatusReason = "cancelled", - LastTransitionAt = now, - NextAttemptAt = null - }) - .ToDictionary(step => step.StepId, step => step, StringComparer.Ordinal); - - var updatedState = state with - { - UpdatedAt = now, - Steps = new ReadOnlyDictionary(updatedSteps) - }; - - await stateStore.SaveAsync(updatedState, cancellationToken).ConfigureAwait(false); - - var metadata = new Dictionary(StringComparer.Ordinal) - { - ["planHash"] = state.PlanHash - }; - - await logStore.AppendAsync(runId, new PackRunLogEntry(now, "warn", "run.cancel-requested", "Run cancellation requested.", null, metadata), cancellationToken).ConfigureAwait(false); - await logStore.AppendAsync(runId, new PackRunLogEntry(timeProvider.GetUtcNow(), "info", "run.cancelled", "Run cancelled; remaining steps marked as skipped.", null, metadata), cancellationToken).ConfigureAwait(false); - - return Results.Accepted($"/v1/task-runner/runs/{runId}", new { status = "cancelled" }); -} - -// Attestation handlers (TASKRUN-OBS-54-001) -async Task HandleListAttestations( - string runId, - [FromHeader(Name = "X-Tenant-ID")] string? tenantId, - IPackRunAttestationService attestationService, - CancellationToken cancellationToken) -{ - if (string.IsNullOrWhiteSpace(runId)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.run_id_required") }); - } - - var effectiveTenantId = tenantId ?? "default"; - var attestations = await attestationService.ListByRunAsync(effectiveTenantId, runId, cancellationToken) - .ConfigureAwait(false); - - return Results.Ok(new - { - runId, - count = attestations.Count, - attestations = attestations.Select(a => new - { - attestationId = a.AttestationId, - status = a.Status.ToString().ToLowerInvariant(), - predicateType = a.PredicateType, - subjectCount = a.Subjects.Count, - createdAt = a.CreatedAt.ToString("O", CultureInfo.InvariantCulture), - hasEnvelope = a.Envelope is not null - }) - }); -} - -async Task HandleGetAttestation( - string attestationId, - IPackRunAttestationService attestationService, - CancellationToken cancellationToken) -{ - if (!Guid.TryParse(attestationId, out var id)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.attestation_id_format") }); - } - - var attestation = await attestationService.GetAsync(id, cancellationToken).ConfigureAwait(false); - if (attestation is null) - { - return Results.NotFound(); - } - - return Results.Ok(new - { - attestationId = attestation.AttestationId, - tenantId = attestation.TenantId, - runId = attestation.RunId, - planHash = attestation.PlanHash, - status = attestation.Status.ToString().ToLowerInvariant(), - predicateType = attestation.PredicateType, - subjects = attestation.Subjects.Select(s => new - { - name = s.Name, - digest = s.Digest - }), - createdAt = attestation.CreatedAt.ToString("O", CultureInfo.InvariantCulture), - evidenceSnapshotId = attestation.EvidenceSnapshotId, - error = attestation.Error, - metadata = attestation.Metadata - }); -} - -async Task HandleGetAttestationEnvelope( - string attestationId, - IPackRunAttestationService attestationService, - CancellationToken cancellationToken) -{ - if (!Guid.TryParse(attestationId, out var id)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.attestation_id_format") }); - } - - var envelope = await attestationService.GetEnvelopeAsync(id, cancellationToken).ConfigureAwait(false); - if (envelope is null) - { - return Results.NotFound(); - } - - return Results.Ok(new - { - payloadType = envelope.PayloadType, - payload = envelope.Payload, - signatures = envelope.Signatures.Select(s => new - { - keyid = s.KeyId, - sig = s.Sig - }) - }); -} - -async Task HandleVerifyAttestation( - string attestationId, - [FromBody] VerifyAttestationRequest? request, - IPackRunAttestationService attestationService, - CancellationToken cancellationToken) -{ - if (!Guid.TryParse(attestationId, out var id)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.attestation_id_format") }); - } - - var expectedSubjects = request?.ExpectedSubjects?.Select(s => - new PackRunAttestationSubject(s.Name, s.Digest ?? new Dictionary())).ToList(); - - var verifyRequest = new PackRunAttestationVerificationRequest( - AttestationId: id, - ExpectedSubjects: expectedSubjects, - VerifySignature: request?.VerifySignature ?? true, - VerifySubjects: request?.VerifySubjects ?? (expectedSubjects is not null), - CheckRevocation: request?.CheckRevocation ?? true); - - var result = await attestationService.VerifyAsync(verifyRequest, cancellationToken).ConfigureAwait(false); - - var statusCode = result.Valid ? 200 : 400; - return Results.Json(new - { - valid = result.Valid, - attestationId = result.AttestationId, - signatureStatus = result.SignatureStatus.ToString().ToLowerInvariant(), - subjectStatus = result.SubjectStatus.ToString().ToLowerInvariant(), - revocationStatus = result.RevocationStatus.ToString().ToLowerInvariant(), - errors = result.Errors, - verifiedAt = result.VerifiedAt.ToString("O", CultureInfo.InvariantCulture) - }, statusCode: statusCode); -} - -// Incident mode handlers (TASKRUN-OBS-55-001) -async Task HandleGetIncidentModeStatus( - string runId, - IPackRunIncidentModeService incidentModeService, - CancellationToken cancellationToken) -{ - if (string.IsNullOrWhiteSpace(runId)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.run_id_required") }); - } - - var status = await incidentModeService.GetStatusAsync(runId, cancellationToken).ConfigureAwait(false); - - return Results.Ok(new - { - runId, - active = status.Active, - level = status.Level.ToString().ToLowerInvariant(), - activatedAt = status.ActivatedAt?.ToString("O", CultureInfo.InvariantCulture), - activationReason = status.ActivationReason, - source = status.Source.ToString().ToLowerInvariant(), - expiresAt = status.ExpiresAt?.ToString("O", CultureInfo.InvariantCulture), - retentionPolicy = new - { - extendedRetentionActive = status.RetentionPolicy.ExtendedRetentionActive, - logRetentionDays = status.RetentionPolicy.LogRetentionDays, - artifactRetentionDays = status.RetentionPolicy.ArtifactRetentionDays - }, - telemetrySettings = new - { - enhancedTelemetryActive = status.TelemetrySettings.EnhancedTelemetryActive, - logVerbosity = status.TelemetrySettings.LogVerbosity.ToString().ToLowerInvariant(), - traceSamplingRate = status.TelemetrySettings.TraceSamplingRate - }, - debugCaptureSettings = new - { - captureActive = status.DebugCaptureSettings.CaptureActive, - captureHeapDumps = status.DebugCaptureSettings.CaptureHeapDumps, - captureThreadDumps = status.DebugCaptureSettings.CaptureThreadDumps - } - }); -} - -async Task HandleActivateIncidentMode( - string runId, - [FromBody] ActivateIncidentModeRequest? request, - [FromHeader(Name = "X-Tenant-ID")] string? tenantId, - IPackRunIncidentModeService incidentModeService, - CancellationToken cancellationToken) -{ - if (string.IsNullOrWhiteSpace(runId)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.run_id_required") }); - } - - var level = Enum.TryParse(request?.Level, ignoreCase: true, out var parsedLevel) - ? parsedLevel - : IncidentEscalationLevel.Medium; - - var activationRequest = new IncidentModeActivationRequest( - RunId: runId, - TenantId: tenantId ?? "default", - Level: level, - Source: StellaOps.TaskRunner.Core.IncidentMode.IncidentModeSource.Manual, - Reason: request?.Reason ?? "Manual activation via API", - DurationMinutes: request?.DurationMinutes, - RequestedBy: request?.RequestedBy); - - var result = await incidentModeService.ActivateAsync(activationRequest, cancellationToken).ConfigureAwait(false); - - if (!result.Success) - { - return Results.BadRequest(new { error = result.Error }); - } - - return Results.Ok(new - { - success = result.Success, - active = result.Status.Active, - level = result.Status.Level.ToString().ToLowerInvariant(), - activatedAt = result.Status.ActivatedAt?.ToString("O", CultureInfo.InvariantCulture), - expiresAt = result.Status.ExpiresAt?.ToString("O", CultureInfo.InvariantCulture) - }); -} - -async Task HandleDeactivateIncidentMode( - string runId, - [FromBody] DeactivateIncidentModeRequest? request, - IPackRunIncidentModeService incidentModeService, - CancellationToken cancellationToken) -{ - if (string.IsNullOrWhiteSpace(runId)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.run_id_required") }); - } - - var result = await incidentModeService.DeactivateAsync(runId, request?.Reason, cancellationToken) - .ConfigureAwait(false); - - return Results.Ok(new - { - success = result.Success, - active = result.Status.Active - }); -} - -async Task HandleEscalateIncidentMode( - string runId, - [FromBody] EscalateIncidentModeRequest? request, - IPackRunIncidentModeService incidentModeService, - CancellationToken cancellationToken) -{ - if (string.IsNullOrWhiteSpace(runId)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.run_id_required") }); - } - - if (request is null || string.IsNullOrWhiteSpace(request.Level)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.escalation_level_required") }); - } - - if (!Enum.TryParse(request.Level, ignoreCase: true, out var newLevel)) - { - return Results.BadRequest(new { error = _t("taskrunner.error.escalation_level_invalid", request.Level) }); - } - - var result = await incidentModeService.EscalateAsync(runId, newLevel, request.Reason, cancellationToken) - .ConfigureAwait(false); - - if (!result.Success) - { - return Results.BadRequest(new { error = result.Error }); - } - - return Results.Ok(new - { - success = result.Success, - level = result.Status.Level.ToString().ToLowerInvariant() - }); -} - -async Task HandleSloBreachWebhook( - [FromBody] SloBreachNotification notification, - IPackRunIncidentModeService incidentModeService, - CancellationToken cancellationToken) -{ - if (notification is null) - { - return Results.BadRequest(new { error = _t("taskrunner.error.notification_body_required") }); - } - - var result = await incidentModeService.HandleSloBreachAsync(notification, cancellationToken) - .ConfigureAwait(false); - - if (!result.Success) - { - return Results.BadRequest(new { error = result.Error }); - } - - return Results.Ok(new - { - success = result.Success, - runId = notification.ResourceId, - level = result.Status.Level.ToString().ToLowerInvariant(), - activatedAt = result.Status.ActivatedAt?.ToString("O", CultureInfo.InvariantCulture) - }); -} - -static void RegisterStateStores(IServiceCollection services, IConfiguration configuration, bool isDevelopment, string storageDriver) -{ - if (string.Equals(storageDriver, "postgres", StringComparison.OrdinalIgnoreCase)) - { - var connectionString = ResolvePostgresConnectionString(configuration, "TaskRunner"); - if (string.IsNullOrWhiteSpace(connectionString)) - { - if (!isDevelopment) - { - throw new InvalidOperationException( - "TaskRunner requires PostgreSQL connection settings in non-development mode. " + - "Set ConnectionStrings:Default or TaskRunner:Storage:Postgres:ConnectionString."); - } - - RegisterFilesystemStateStores(services); - return; - } - - services.Configure(options => - { - options.ConnectionString = connectionString; - options.SchemaName = ResolveSchemaName(configuration, "TaskRunner") ?? TaskRunnerDataSource.DefaultSchemaName; - }); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - return; - } - - if (string.Equals(storageDriver, "filesystem", StringComparison.OrdinalIgnoreCase)) - { - RegisterFilesystemStateStores(services); - return; - } - - if (string.Equals(storageDriver, "inmemory", StringComparison.OrdinalIgnoreCase)) - { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - return; - } - - throw new InvalidOperationException( - $"Unsupported TaskRunner storage driver '{storageDriver}'. Allowed values: postgres, filesystem, inmemory."); -} - -static void RegisterFilesystemStateStores(IServiceCollection services) -{ - services.AddSingleton(sp => - { - var options = sp.GetRequiredService>().Value; - return new FilePackRunApprovalStore(options.ApprovalStorePath); - }); - services.AddSingleton(sp => - { - var options = sp.GetRequiredService>().Value; - return new FilePackRunStateStore(options.RunStatePath); - }); - services.AddSingleton(sp => - { - var options = sp.GetRequiredService>().Value; - return new FilePackRunLogStore(options.LogsPath); - }); -} - -static string ResolveStorageDriver(IConfiguration configuration, string serviceName) -{ - return FirstNonEmpty( - configuration["Storage:Driver"], - configuration[$"{serviceName}:Storage:Driver"]) - ?? "postgres"; -} - -static string? ResolvePostgresConnectionString(IConfiguration configuration, string serviceName) -{ - return FirstNonEmpty( - configuration[$"{serviceName}:Storage:Postgres:ConnectionString"], - configuration["Storage:Postgres:ConnectionString"], - configuration[$"Postgres:{serviceName}:ConnectionString"], - configuration[$"ConnectionStrings:{serviceName}"], - configuration["ConnectionStrings:Default"]); -} - -static string? ResolveSchemaName(IConfiguration configuration, string serviceName) -{ - return FirstNonEmpty( - configuration[$"{serviceName}:Storage:Postgres:Schema"], - configuration["Storage:Postgres:Schema"], - configuration[$"Postgres:{serviceName}:SchemaName"]); -} - -static void ValidateObjectStoreContract(IConfiguration configuration, string serviceName) -{ - var objectStoreDriver = ResolveObjectStoreDriver(configuration, serviceName); - if (!string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase)) - { - if (string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"RustFS object store is configured for {serviceName}, but no RustFS adapter is implemented. Use seed-fs."); - } - - throw new InvalidOperationException( - $"Unsupported object store driver '{objectStoreDriver}' for {serviceName}. Allowed values: seed-fs."); - } -} - -static string ResolveObjectStoreDriver(IConfiguration configuration, string serviceName) -{ - return FirstNonEmpty( - configuration[$"{serviceName}:Storage:ObjectStore:Driver"], - configuration["Storage:ObjectStore:Driver"]) - ?? "seed-fs"; -} - -static string ResolveSeedFsRootPath(IConfiguration configuration, string serviceName, string fallbackPath) -{ - return FirstNonEmpty( - configuration[$"{serviceName}:Storage:ObjectStore:SeedFs:RootPath"], - configuration["Storage:ObjectStore:SeedFs:RootPath"], - configuration[$"{serviceName}:ArtifactsPath"]) - ?? fallbackPath; -} - -static string? FirstNonEmpty(params string?[] values) -{ - foreach (var value in values) - { - if (!string.IsNullOrWhiteSpace(value)) - { - return value; - } - } - - return null; -} - -await app.RunAsync().ConfigureAwait(false); - -static IDictionary? ConvertInputs(JsonObject? node) -{ - if (node is null) - { - return null; - } - - var dictionary = new Dictionary(StringComparer.Ordinal); - foreach (var property in node) - { - dictionary[property.Key] = property.Value?.DeepClone(); - } - - return dictionary; -} - -internal sealed record CreateRunRequest(string? RunId, string Manifest, JsonObject? Inputs, string? TenantId); - -internal sealed record SimulationRequest(string Manifest, JsonObject? Inputs); - -// Attestation API request models (TASKRUN-OBS-54-001) -internal sealed record VerifyAttestationRequest( - IReadOnlyList? ExpectedSubjects, - bool VerifySignature = true, - bool VerifySubjects = false, - bool CheckRevocation = true); - -internal sealed record VerifyAttestationSubject(string Name, IReadOnlyDictionary? Digest); - -// Incident mode API request models (TASKRUN-OBS-55-001) -internal sealed record ActivateIncidentModeRequest( - string? Level, - string? Reason, - int? DurationMinutes, - string? RequestedBy); - -internal sealed record DeactivateIncidentModeRequest(string? Reason); - -internal sealed record EscalateIncidentModeRequest(string Level, string? Reason); - -internal sealed record SimulationResponse( - string PlanHash, - FailurePolicyResponse FailurePolicy, - IReadOnlyList Steps, - IReadOnlyList Outputs, - bool HasPendingApprovals); - -internal sealed record SimulationStepResponse( - string Id, - string TemplateId, - string Kind, - bool Enabled, - string Status, - string? StatusReason, - string? Uses, - string? ApprovalId, - string? GateMessage, - int? MaxParallel, - bool ContinueOnError, - IReadOnlyList Children); - -internal sealed record SimulationOutputResponse( - string Name, - string Type, - bool RequiresRuntimeValue, - string? PathExpression, - string? ValueExpression); - -internal sealed record FailurePolicyResponse(int MaxAttempts, int BackoffSeconds, bool ContinueOnError); - -internal sealed record RunStateResponse( - string RunId, - string PlanHash, - FailurePolicyResponse FailurePolicy, - DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt, - IReadOnlyList Steps); - -internal sealed record RunStateStepResponse( - string StepId, - string Kind, - bool Enabled, - bool ContinueOnError, - int? MaxParallel, - string? ApprovalId, - string? GateMessage, - string Status, - int Attempts, - DateTimeOffset? LastTransitionAt, - DateTimeOffset? NextAttemptAt, - string? StatusReason); - -internal sealed record ApprovalDecisionDto(string Decision, string PlanHash, string? ActorId, string? Summary); - -internal sealed record RunLogEntryResponse( - DateTimeOffset Timestamp, - string Level, - string EventType, - string Message, - string? StepId, - IReadOnlyDictionary? Metadata); - -internal static class RunLogMapper -{ - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = false - }; - - private static readonly byte[] NewLine = Encoding.UTF8.GetBytes("\n"); - - public static RunLogEntryResponse ToResponse(PackRunLogEntry entry) - { - IReadOnlyDictionary? metadata = null; - if (entry.Metadata is { Count: > 0 }) - { - metadata = entry.Metadata - .OrderBy(pair => pair.Key, StringComparer.Ordinal) - .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); - } - - return new RunLogEntryResponse( - entry.Timestamp, - entry.Level, - entry.EventType, - entry.Message, - entry.StepId, - metadata); - } - - public static async Task WriteAsync(Stream stream, PackRunLogEntry entry, CancellationToken cancellationToken) - { - var response = ToResponse(entry); - await JsonSerializer.SerializeAsync(stream, response, SerializerOptions, cancellationToken).ConfigureAwait(false); - await stream.WriteAsync(NewLine, cancellationToken).ConfigureAwait(false); - await stream.FlushAsync(cancellationToken).ConfigureAwait(false); - } -} - -internal static class SimulationMapper -{ - public static SimulationResponse ToResponse(TaskPackPlan plan, PackRunSimulationResult result) - { - var failurePolicy = result.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy; - var steps = result.Steps.Select(MapStep).ToList(); - var outputs = result.Outputs.Select(MapOutput).ToList(); - - return new SimulationResponse( - plan.Hash, - new FailurePolicyResponse(failurePolicy.MaxAttempts, failurePolicy.BackoffSeconds, failurePolicy.ContinueOnError), - steps, - outputs, - result.HasPendingApprovals); - } - - private static SimulationStepResponse MapStep(PackRunSimulationNode node) - { - var children = node.Children.Select(MapStep).ToList(); - return new SimulationStepResponse( - node.Id, - node.TemplateId, - node.Kind.ToString(), - node.Enabled, - node.Status.ToString(), - node.Status.ToString() switch - { - nameof(PackRunSimulationStatus.RequiresApproval) => "requires-approval", - nameof(PackRunSimulationStatus.RequiresPolicy) => "requires-policy", - nameof(PackRunSimulationStatus.Skipped) => "condition-false", - _ => null - }, - node.Uses, - node.ApprovalId, - node.GateMessage, - node.MaxParallel, - node.ContinueOnError, - children); - } - - private static SimulationOutputResponse MapOutput(PackRunSimulationOutput output) - => new( - output.Name, - output.Type, - output.RequiresRuntimeValue, - output.Path?.Expression, - output.Expression?.Expression); -} - -internal static class RunStateMapper -{ - public static RunStateResponse ToResponse(PackRunState state) - { - var failurePolicy = state.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy; - var steps = state.Steps.Values - .OrderBy(step => step.StepId, StringComparer.Ordinal) - .Select(step => new RunStateStepResponse( - step.StepId, - step.Kind.ToString(), - step.Enabled, - step.ContinueOnError, - step.MaxParallel, - step.ApprovalId, - step.GateMessage, - step.Status.ToString(), - step.Attempts, - step.LastTransitionAt, - step.NextAttemptAt, - step.StatusReason)) - .ToList(); - - return new RunStateResponse( - state.RunId, - state.PlanHash, - new FailurePolicyResponse(failurePolicy.MaxAttempts, failurePolicy.BackoffSeconds, failurePolicy.ContinueOnError), - state.CreatedAt, - state.UpdatedAt, - steps); - } -} - -public partial class Program; - - - diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Properties/launchSettings.json b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Properties/launchSettings.json deleted file mode 100644 index d36862c20..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Properties/launchSettings.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:10181", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "STELLAOPS_WEBSERVICES_CORS": "true", - "STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:10180;http://localhost:10181", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "STELLAOPS_WEBSERVICES_CORS": "true", - "STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000" - } - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Security/TaskRunnerPolicies.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Security/TaskRunnerPolicies.cs deleted file mode 100644 index ce128d22f..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Security/TaskRunnerPolicies.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) StellaOps. Licensed under the BUSL-1.1. - -namespace StellaOps.TaskRunner.WebService.Security; - -/// -/// Named authorization policy constants for the Task Runner service. -/// Policies are registered via AddStellaOpsScopePolicy in Program.cs. -/// -internal static class TaskRunnerPolicies -{ - /// Policy for read-only access to run state, logs, artifacts, and attestations. Requires taskrunner:read scope. - public const string Read = "TaskRunner.Read"; - - /// Policy for state-changing operations (create run, cancel, approvals, incident mode). Requires taskrunner:operate scope. - public const string Operate = "TaskRunner.Operate"; - - /// Policy for administrative operations (simulations, deprecation queries). Requires taskrunner:admin scope. - public const string Admin = "TaskRunner.Admin"; -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/StellaOps.TaskRunner.WebService.csproj b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/StellaOps.TaskRunner.WebService.csproj deleted file mode 100644 index 51ac5e794..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/StellaOps.TaskRunner.WebService.csproj +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - net10.0 - enable - enable - preview - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1.0.0-alpha1 - 1.0.0-alpha1 - - diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/StellaOps.TaskRunner.WebService.http b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/StellaOps.TaskRunner.WebService.http deleted file mode 100644 index c2efa93ab..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/StellaOps.TaskRunner.WebService.http +++ /dev/null @@ -1,6 +0,0 @@ -@StellaOps.TaskRunner.WebService_HostAddress = http://localhost:5157 - -GET {{StellaOps.TaskRunner.WebService_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TASKS.md b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TASKS.md deleted file mode 100644 index 0f87758ef..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TASKS.md +++ /dev/null @@ -1,10 +0,0 @@ -# StellaOps.TaskRunner.WebService Task Board -This board mirrors active sprint tasks for this module. -Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`. - -| Task ID | Status | Notes | -| --- | --- | --- | -| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/StellaOps.TaskRunner.WebService.md. | -| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | -| SPRINT-312-004 | DONE | Runtime storage driver migration verified: Postgres state/log/approval default plus seed-fs artifact object-store path. | -| SPRINT-20260305-002 | DONE | Startup contract hardened: non-dev postgres missing-connection fails fast; object-store accepts seed-fs only and rejects rustfs/unknown values. | diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TaskRunnerServiceOptions.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TaskRunnerServiceOptions.cs deleted file mode 100644 index 53cfd045e..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TaskRunnerServiceOptions.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace StellaOps.TaskRunner.WebService; - -public sealed class TaskRunnerServiceOptions -{ - public string RunStatePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "state", "runs"); - public string ApprovalStorePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "approvals"); - public string QueuePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "queue"); - public string ArchivePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "queue", "archive"); - public string LogsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "logs", "runs"); - public string ArtifactsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "artifacts"); -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Translations/en-US.taskrunner.json b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Translations/en-US.taskrunner.json deleted file mode 100644 index 44a2171b4..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Translations/en-US.taskrunner.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "_meta": { "locale": "en-US", "namespace": "taskrunner", "version": "1.0" }, - - "taskrunner.deprecations.list_description": "Returns a list of deprecated API endpoints with sunset dates, optionally filtered to those expiring within a given number of days. Used for API lifecycle governance and client migration planning.", - "taskrunner.simulations.create_description": "Simulates a task pack execution plan from a manifest and input map without actually scheduling a run. Returns the execution graph with per-step status, pending approvals, and resolved outputs for pre-flight validation.", - "taskrunner.runs.create_description": "Creates and schedules a new task pack run from a manifest and optional input overrides. Enforces sealed-install policy before scheduling. Returns 201 Created with the initial run state including step graph. Returns 403 if sealed-install policy is violated.", - "taskrunner.runs.create_legacy_description": "Legacy path alias for CreatePackRun. Creates and schedules a new task pack run from a manifest and optional input overrides. Returns 201 Created with the initial run state.", - "taskrunner.runs.get_state_description": "Returns the current execution state for a task pack run including per-step status, attempt counts, and transition timestamps. Returns 404 if the run is not found.", - "taskrunner.runs.get_state_legacy_description": "Legacy path alias for GetRunState. Returns the current execution state for a task pack run. Returns 404 if the run is not found.", - "taskrunner.runs.stream_logs_description": "Streams the structured log entries for a task pack run as newline-delimited JSON (application/x-ndjson). Returns log lines in chronological order. Returns 404 if the run log is not found.", - "taskrunner.runs.stream_logs_legacy_description": "Legacy path alias for StreamRunLogs. Streams the run log entries as newline-delimited JSON.", - "taskrunner.runs.list_artifacts_description": "Lists all artifacts captured during a task pack run including artifact name, type, paths, capture timestamp, and status. Returns 404 if the run is not found.", - "taskrunner.runs.list_artifacts_legacy_description": "Legacy path alias for ListRunArtifacts. Lists all artifacts captured during a task pack run.", - "taskrunner.runs.apply_approval_description": "Submits an approval or rejection decision for a pending approval gate in a task pack run. Validates the planHash to prevent replay attacks. Returns 200 with updated approval status or 409 on plan hash mismatch.", - "taskrunner.runs.apply_approval_legacy_description": "Legacy path alias for ApplyApprovalDecision. Submits an approval or rejection decision for a pending approval gate.", - "taskrunner.runs.cancel_description": "Requests cancellation of an active task pack run. Marks all non-terminal steps as skipped and writes cancellation log entries. Returns 202 Accepted with the cancelled status.", - "taskrunner.runs.cancel_legacy_description": "Legacy path alias for CancelRun. Requests cancellation of an active task pack run and marks remaining steps as skipped.", - "taskrunner.attestations.list_description": "Lists all attestations generated for a task pack run, including predicate type, subject count, creation timestamp, and whether a DSSE envelope is present.", - "taskrunner.attestations.list_legacy_description": "Legacy path alias for ListRunAttestations. Lists all attestations generated for a task pack run.", - "taskrunner.attestations.get_description": "Returns the full attestation record for a specific attestation ID, including subjects, predicate type, status, evidence snapshot reference, and metadata. Returns 404 if not found.", - "taskrunner.attestations.get_legacy_description": "Legacy path alias for GetAttestation. Returns the full attestation record for a specific attestation ID.", - "taskrunner.attestations.get_envelope_description": "Returns the DSSE envelope for a signed attestation including payload type, base64-encoded payload, and signatures with key IDs. Returns 404 if no envelope exists.", - "taskrunner.attestations.get_envelope_legacy_description": "Legacy path alias for GetAttestationEnvelope. Returns the DSSE envelope for a signed attestation.", - "taskrunner.attestations.verify_description": "Verifies a task pack attestation against optional expected subjects. Validates signature, subject digest matching, and revocation status. Returns 200 with verification details on success or 400 with error breakdown on failure.", - "taskrunner.attestations.verify_legacy_description": "Legacy path alias for VerifyAttestation. Verifies a task pack attestation against expected subjects and returns detailed verification results.", - "taskrunner.incident_mode.get_description": "Returns the current incident mode status for a task pack run including activation level, source, expiry, retention policy, telemetry settings, and debug capture configuration.", - "taskrunner.incident_mode.get_legacy_description": "Legacy path alias for GetIncidentModeStatus. Returns the current incident mode status for a task pack run.", - "taskrunner.incident_mode.activate_description": "Activates incident mode for a task pack run at the specified escalation level. Enables extended retention, enhanced telemetry, and optional debug capture. Accepts optional duration and requesting actor.", - "taskrunner.incident_mode.activate_legacy_description": "Legacy path alias for ActivateIncidentMode. Activates incident mode for a task pack run at the specified escalation level.", - "taskrunner.incident_mode.deactivate_description": "Deactivates incident mode for a task pack run and restores normal retention and telemetry settings. Returns the updated inactive status.", - "taskrunner.incident_mode.deactivate_legacy_description": "Legacy path alias for DeactivateIncidentMode. Deactivates incident mode for a task pack run.", - "taskrunner.incident_mode.escalate_description": "Escalates an active incident mode to a higher severity level for a task pack run. Requires a valid escalation level (Low, Medium, High, Critical). Returns the updated incident level.", - "taskrunner.incident_mode.escalate_legacy_description": "Legacy path alias for EscalateIncidentMode. Escalates incident mode to a higher severity level for a task pack run.", - "taskrunner.webhooks.slo_breach_description": "Inbound webhook endpoint for SLO breach notifications. Automatically activates incident mode on the affected run when an SLO breach is detected. Authentication is handled by the caller via request payload validation.", - "taskrunner.webhooks.slo_breach_legacy_description": "Legacy path alias for SloBreachWebhook. Inbound webhook for SLO breach notifications that triggers incident mode activation.", - "taskrunner.openapi.get_metadata_description": "Returns OpenAPI metadata for the TaskRunner service including spec URL, ETag, HMAC signature, API version, and build version. Used for API discovery and integrity verification.", - - "taskrunner.error.manifest_required": "Manifest is required.", - "taskrunner.error.manifest_invalid": "Invalid manifest", - "taskrunner.error.run_already_exists": "Run already exists.", - "taskrunner.error.run_id_required": "runId is required.", - "taskrunner.error.request_body_required": "Request body is required.", - "taskrunner.error.decision_invalid": "Invalid decision. Expected approved, rejected, or expired.", - "taskrunner.error.plan_hash_required": "planHash is required.", - "taskrunner.error.plan_hash_format": "planHash must be sha256:<64-hex>.", - "taskrunner.error.plan_hash_mismatch": "Plan hash mismatch.", - "taskrunner.error.attestation_id_format": "Invalid attestationId format.", - "taskrunner.error.escalation_level_required": "Level is required for escalation.", - "taskrunner.error.escalation_level_invalid": "Invalid escalation level: {0}", - "taskrunner.error.notification_body_required": "Notification body is required." -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/appsettings.Development.json b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/appsettings.Development.json deleted file mode 100644 index 0c208ae91..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/appsettings.json b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/appsettings.json deleted file mode 100644 index 209bec11c..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/appsettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "TaskRunner": { - "RunStatePath": "state/runs" - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Program.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Program.cs deleted file mode 100644 index 308707825..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Program.cs +++ /dev/null @@ -1,226 +0,0 @@ -using Microsoft.Extensions.Options; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; -using StellaOps.AirGap.Policy; -using StellaOps.Infrastructure.Postgres.Options; -using StellaOps.TaskRunner.Core.Configuration; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Execution.Simulation; -using StellaOps.TaskRunner.Infrastructure.Execution; -using StellaOps.TaskRunner.Persistence.Postgres; -using StellaOps.TaskRunner.Persistence.Postgres.Repositories; -using StellaOps.TaskRunner.Worker.Services; -using StellaOps.Telemetry.Core; -using StellaOps.Worker.Health; - -var builder = WebApplication.CreateSlimBuilder(args); - -builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap"); -builder.Services.Configure(builder.Configuration.GetSection("Worker")); -builder.Services.Configure(builder.Configuration.GetSection("Notifications")); -builder.Services.AddHttpClient("taskrunner-notifications"); -builder.Services.AddSingleton(TimeProvider.System); - -builder.Services.AddSingleton(sp => -{ - var options = sp.GetRequiredService>(); - var egressPolicy = sp.GetRequiredService(); - return new FilesystemPackRunDispatcher(options.Value.QueuePath, options.Value.ArchivePath, egressPolicy); -}); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); - -builder.Services.AddSingleton(sp => -{ - var options = sp.GetRequiredService>().Value; - if (options.ApprovalEndpoint is not null || options.PolicyEndpoint is not null) - { - return new HttpPackRunNotificationPublisher( - sp.GetRequiredService(), - sp.GetRequiredService>(), - sp.GetRequiredService>()); - } - - return new LoggingPackRunNotificationPublisher(sp.GetRequiredService>()); -}); - -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddStellaOpsTelemetry( - builder.Configuration, - serviceName: "StellaOps.TaskRunner.Worker", - configureTracing: tracing => tracing.AddHttpClientInstrumentation(), - configureMetrics: metrics => metrics - .AddRuntimeInstrumentation() - .AddMeter(TaskRunnerTelemetry.MeterName)); - -var storageDriver = ResolveStorageDriver(builder.Configuration, "TaskRunner"); -RegisterStateStores(builder.Services, builder.Configuration, builder.Environment.IsDevelopment(), storageDriver); -ValidateObjectStoreContract(builder.Configuration, "TaskRunner"); - -builder.Services.AddSingleton(sp => -{ - var options = sp.GetRequiredService>().Value; - var timeProvider = sp.GetRequiredService(); - var logger = sp.GetRequiredService>(); - var configuration = sp.GetRequiredService(); - var artifactsRoot = ResolveSeedFsRootPath(configuration, "TaskRunner", options.ArtifactsPath); - return new FilesystemPackRunArtifactUploader(artifactsRoot, timeProvider, logger); -}); -builder.Services.AddSingleton(sp => -{ - var options = sp.GetRequiredService>().Value; - var timeProvider = sp.GetRequiredService(); - var configuration = sp.GetRequiredService(); - var artifactsRoot = ResolveSeedFsRootPath(configuration, "TaskRunner", options.ArtifactsPath); - return new FilesystemPackRunProvenanceWriter(artifactsRoot, timeProvider); -}); - -builder.Services.AddHostedService(); - -builder.Services.AddWorkerHealthChecks(); - -var app = builder.Build(); -app.MapWorkerHealthEndpoints(); -app.Run(); - -static void RegisterStateStores(IServiceCollection services, IConfiguration configuration, bool isDevelopment, string storageDriver) -{ - if (string.Equals(storageDriver, "postgres", StringComparison.OrdinalIgnoreCase)) - { - var connectionString = ResolvePostgresConnectionString(configuration, "TaskRunner"); - if (string.IsNullOrWhiteSpace(connectionString)) - { - if (!isDevelopment) - { - throw new InvalidOperationException( - "TaskRunner worker requires PostgreSQL connection settings in non-development mode. " + - "Set ConnectionStrings:Default or TaskRunner:Storage:Postgres:ConnectionString."); - } - - RegisterFilesystemStateStores(services); - return; - } - - services.Configure(options => - { - options.ConnectionString = connectionString; - options.SchemaName = ResolveSchemaName(configuration, "TaskRunner") ?? TaskRunnerDataSource.DefaultSchemaName; - }); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - return; - } - - if (string.Equals(storageDriver, "filesystem", StringComparison.OrdinalIgnoreCase)) - { - RegisterFilesystemStateStores(services); - return; - } - - if (string.Equals(storageDriver, "inmemory", StringComparison.OrdinalIgnoreCase)) - { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - return; - } - - throw new InvalidOperationException( - $"Unsupported TaskRunner storage driver '{storageDriver}'. Allowed values: postgres, filesystem, inmemory."); -} - -static void RegisterFilesystemStateStores(IServiceCollection services) -{ - services.AddSingleton(sp => - { - var options = sp.GetRequiredService>(); - return new FilePackRunApprovalStore(options.Value.ApprovalStorePath); - }); - services.AddSingleton(sp => - { - var options = sp.GetRequiredService>(); - return new FilePackRunStateStore(options.Value.RunStatePath); - }); - services.AddSingleton(sp => - { - var options = sp.GetRequiredService>(); - return new FilePackRunLogStore(options.Value.LogsPath); - }); -} - -static string ResolveStorageDriver(IConfiguration configuration, string serviceName) -{ - return FirstNonEmpty( - configuration["Storage:Driver"], - configuration[$"{serviceName}:Storage:Driver"]) - ?? "postgres"; -} - -static string? ResolvePostgresConnectionString(IConfiguration configuration, string serviceName) -{ - return FirstNonEmpty( - configuration[$"{serviceName}:Storage:Postgres:ConnectionString"], - configuration["Storage:Postgres:ConnectionString"], - configuration[$"Postgres:{serviceName}:ConnectionString"], - configuration[$"ConnectionStrings:{serviceName}"], - configuration["ConnectionStrings:Default"]); -} - -static string? ResolveSchemaName(IConfiguration configuration, string serviceName) -{ - return FirstNonEmpty( - configuration[$"{serviceName}:Storage:Postgres:Schema"], - configuration["Storage:Postgres:Schema"], - configuration[$"Postgres:{serviceName}:SchemaName"]); -} - -static void ValidateObjectStoreContract(IConfiguration configuration, string serviceName) -{ - var objectStoreDriver = ResolveObjectStoreDriver(configuration, serviceName); - if (!string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase)) - { - if (string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"RustFS object store is configured for {serviceName}, but no RustFS adapter is implemented. Use seed-fs."); - } - - throw new InvalidOperationException( - $"Unsupported object store driver '{objectStoreDriver}' for {serviceName}. Allowed values: seed-fs."); - } -} - -static string ResolveObjectStoreDriver(IConfiguration configuration, string serviceName) -{ - return FirstNonEmpty( - configuration[$"{serviceName}:Storage:ObjectStore:Driver"], - configuration["Storage:ObjectStore:Driver"]) - ?? "seed-fs"; -} - -static string ResolveSeedFsRootPath(IConfiguration configuration, string serviceName, string fallbackPath) -{ - return FirstNonEmpty( - configuration[$"{serviceName}:Storage:ObjectStore:SeedFs:RootPath"], - configuration["Storage:ObjectStore:SeedFs:RootPath"], - configuration[$"{serviceName}:Worker:ArtifactsPath"]) - ?? fallbackPath; -} - -static string? FirstNonEmpty(params string?[] values) -{ - foreach (var value in values) - { - if (!string.IsNullOrWhiteSpace(value)) - { - return value; - } - } - - return null; -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Properties/launchSettings.json b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Properties/launchSettings.json deleted file mode 100644 index 2722e495e..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Properties/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "StellaOps.TaskRunner.Worker": { - "commandName": "Project", - "dotnetRunMessages": true, - "environmentVariables": { - "DOTNET_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Services/PackRunWorkerService.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Services/PackRunWorkerService.cs deleted file mode 100644 index fd674a824..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Services/PackRunWorkerService.cs +++ /dev/null @@ -1,657 +0,0 @@ - -using Microsoft.Extensions.Options; -using StellaOps.TaskRunner.Core.Configuration; -using StellaOps.TaskRunner.Core.Execution; -using StellaOps.TaskRunner.Core.Execution.Simulation; -using StellaOps.TaskRunner.Core.Planning; -using StellaOps.TaskRunner.Infrastructure.Execution; -using System.Collections.Concurrent; -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.Diagnostics.Metrics; -using System.Globalization; -using System.Text.Json.Nodes; - -namespace StellaOps.TaskRunner.Worker.Services; - -public sealed class PackRunWorkerService : BackgroundService -{ - private const string ChildFailureReason = "child-failure"; - private const string AwaitingRetryReason = "awaiting-retry"; - - private readonly IPackRunJobDispatcher dispatcher; - private readonly PackRunProcessor processor; - private readonly PackRunWorkerOptions options; - private readonly IPackRunStateStore stateStore; - private readonly PackRunExecutionGraphBuilder graphBuilder; - private readonly PackRunSimulationEngine simulationEngine; - private readonly IPackRunStepExecutor executor; - private readonly IPackRunArtifactUploader artifactUploader; - private readonly IPackRunProvenanceWriter provenanceWriter; - private readonly IPackRunLogStore logStore; - private readonly TimeProvider timeProvider; - private readonly ILogger logger; - private readonly UpDownCounter runningSteps; - - public PackRunWorkerService( - IPackRunJobDispatcher dispatcher, - PackRunProcessor processor, - IPackRunStateStore stateStore, - PackRunExecutionGraphBuilder graphBuilder, - PackRunSimulationEngine simulationEngine, - IPackRunStepExecutor executor, - IPackRunArtifactUploader artifactUploader, - IPackRunProvenanceWriter provenanceWriter, - IPackRunLogStore logStore, - IOptions options, - TimeProvider timeProvider, - ILogger logger) - { - this.dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - this.processor = processor ?? throw new ArgumentNullException(nameof(processor)); - this.stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); - this.graphBuilder = graphBuilder ?? throw new ArgumentNullException(nameof(graphBuilder)); - this.simulationEngine = simulationEngine ?? throw new ArgumentNullException(nameof(simulationEngine)); - this.executor = executor ?? throw new ArgumentNullException(nameof(executor)); - this.artifactUploader = artifactUploader ?? throw new ArgumentNullException(nameof(artifactUploader)); - this.provenanceWriter = provenanceWriter ?? throw new ArgumentNullException(nameof(provenanceWriter)); - this.logStore = logStore ?? throw new ArgumentNullException(nameof(logStore)); - this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - this.timeProvider = timeProvider ?? TimeProvider.System; - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - runningSteps = TaskRunnerTelemetry.RunningSteps; - - if (dispatcher is FilesystemPackRunDispatcher fsDispatcher) - { - TaskRunnerTelemetry.Meter.CreateObservableGauge( - "taskrunner.queue.depth", - () => new Measurement( - Directory.Exists(fsDispatcher.QueuePath) - ? Directory.GetFiles(fsDispatcher.QueuePath, "*.json", SearchOption.TopDirectoryOnly).LongLength - : 0)); - } - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - var context = await dispatcher.TryDequeueAsync(stoppingToken).ConfigureAwait(false); - if (context is null) - { - await Task.Delay(options.IdleDelay, stoppingToken).ConfigureAwait(false); - continue; - } - - try - { - await ProcessRunAsync(context, stoppingToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - catch (Exception ex) - { - logger.LogError(ex, "Unhandled exception while processing run {RunId}.", context.RunId); - var metadata = new Dictionary(StringComparer.Ordinal) - { - ["exceptionType"] = ex.GetType().FullName ?? ex.GetType().Name - }; - await AppendLogAsync( - context.RunId, - "error", - "run.failed", - "Unhandled exception while processing run.", - stoppingToken, - metadata: metadata).ConfigureAwait(false); - } - } - } - - private async Task ProcessRunAsync(PackRunExecutionContext context, CancellationToken cancellationToken) - { - logger.LogInformation("Processing pack run {RunId}.", context.RunId); - - await AppendLogAsync( - context.RunId, - "info", - "run.received", - "Run dequeued by worker.", - cancellationToken, - metadata: new Dictionary(StringComparer.Ordinal) - { - ["planHash"] = context.Plan.Hash - }).ConfigureAwait(false); - - var processorResult = await processor.ProcessNewRunAsync(context, cancellationToken).ConfigureAwait(false); - var graph = graphBuilder.Build(context.Plan); - - var state = await stateStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false); - if (state is null || !string.Equals(state.PlanHash, context.Plan.Hash, StringComparison.Ordinal)) - { - state = await CreateInitialStateAsync(context, graph, cancellationToken).ConfigureAwait(false); - } - - if (!processorResult.ShouldResumeImmediately) - { - logger.LogInformation("Run {RunId} awaiting approvals or policy gates.", context.RunId); - await AppendLogAsync( - context.RunId, - "info", - "run.awaiting-approvals", - "Run paused awaiting approvals or policy gates.", - cancellationToken).ConfigureAwait(false); - return; - } - - var gateUpdate = PackRunGateStateUpdater.Apply(state, graph, processorResult.ApprovalCoordinator, timeProvider.GetUtcNow()); - state = gateUpdate.State; - - if (gateUpdate.HasBlockingFailure) - { - await stateStore.SaveAsync(state, cancellationToken).ConfigureAwait(false); - logger.LogWarning("Run {RunId} halted because a gate failed.", context.RunId); - await AppendLogAsync( - context.RunId, - "warn", - "run.gate-blocked", - "Run halted because a gate failed.", - cancellationToken).ConfigureAwait(false); - return; - } - - var updatedState = await ExecuteGraphAsync(context, graph, state, cancellationToken).ConfigureAwait(false); - await stateStore.SaveAsync(updatedState, cancellationToken).ConfigureAwait(false); - - if (updatedState.Steps.Values.All(step => step.Status is PackRunStepExecutionStatus.Succeeded or PackRunStepExecutionStatus.Skipped)) - { - logger.LogInformation("Run {RunId} finished successfully.", context.RunId); - await AppendLogAsync( - context.RunId, - "info", - "run.completed", - "Run finished successfully.", - cancellationToken).ConfigureAwait(false); - await artifactUploader.UploadAsync(context, updatedState, context.Plan.Outputs, cancellationToken).ConfigureAwait(false); - await provenanceWriter.WriteAsync(context, updatedState, cancellationToken).ConfigureAwait(false); - } - else - { - logger.LogInformation("Run {RunId} paused with pending work.", context.RunId); - await AppendLogAsync( - context.RunId, - "info", - "run.paused", - "Run paused with pending work.", - cancellationToken).ConfigureAwait(false); - } - } - - private async Task CreateInitialStateAsync( - PackRunExecutionContext context, - PackRunExecutionGraph graph, - CancellationToken cancellationToken) - { - var timestamp = timeProvider.GetUtcNow(); - var state = PackRunStateFactory.CreateInitialState(context, graph, simulationEngine, timestamp); - await stateStore.SaveAsync(state, cancellationToken).ConfigureAwait(false); - return state; - } - - private Task AppendLogAsync( - string runId, - string level, - string eventType, - string message, - CancellationToken cancellationToken, - string? stepId = null, - IReadOnlyDictionary? metadata = null) - { - var entry = new PackRunLogEntry(timeProvider.GetUtcNow(), level, eventType, message, stepId, metadata); - return logStore.AppendAsync(runId, entry, cancellationToken); - } - - private async Task ExecuteGraphAsync( - PackRunExecutionContext context, - PackRunExecutionGraph graph, - PackRunState state, - CancellationToken cancellationToken) - { - var mutable = new ConcurrentDictionary(state.Steps, StringComparer.Ordinal); - var failurePolicy = graph.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy; - var executionContext = new ExecutionContext(context.RunId, failurePolicy, mutable, cancellationToken); - - foreach (var step in graph.Steps) - { - var outcome = await ExecuteStepAsync(step, executionContext).ConfigureAwait(false); - if (outcome is StepExecutionOutcome.AbortRun or StepExecutionOutcome.Defer) - { - break; - } - } - - var updated = new ReadOnlyDictionary(mutable); - return state with - { - UpdatedAt = timeProvider.GetUtcNow(), - Steps = updated - }; - } - - private async Task ExecuteStepAsync( - PackRunExecutionStep step, - ExecutionContext executionContext) - { - executionContext.CancellationToken.ThrowIfCancellationRequested(); - - if (!executionContext.Steps.TryGetValue(step.Id, out var record)) - { - return StepExecutionOutcome.Continue; - } - - if (!record.Enabled) - { - return StepExecutionOutcome.Continue; - } - - if (record.Status == PackRunStepExecutionStatus.Succeeded || record.Status == PackRunStepExecutionStatus.Skipped) - { - return StepExecutionOutcome.Continue; - } - - if (record.NextAttemptAt is { } scheduled && scheduled > timeProvider.GetUtcNow()) - { - logger.LogInformation( - "Run {RunId} step {StepId} waiting until {NextAttempt} for retry.", - executionContext.RunId, - record.StepId, - scheduled); - var metadata = new Dictionary(StringComparer.Ordinal) - { - ["nextAttemptAt"] = scheduled.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), - ["attempts"] = record.Attempts.ToString(CultureInfo.InvariantCulture) - }; - await AppendLogAsync( - executionContext.RunId, - "info", - "step.awaiting-retry", - $"Step {record.StepId} waiting for retry.", - executionContext.CancellationToken, - record.StepId, - metadata).ConfigureAwait(false); - return StepExecutionOutcome.Defer; - } - - switch (step.Kind) - { - case PackRunStepKind.GateApproval: - case PackRunStepKind.GatePolicy: - executionContext.Steps[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Succeeded, - StatusReason = null, - LastTransitionAt = timeProvider.GetUtcNow(), - NextAttemptAt = null - }; - await AppendLogAsync( - executionContext.RunId, - "info", - step.Kind == PackRunStepKind.GateApproval ? "step.approval-satisfied" : "step.policy-satisfied", - $"Gate {step.Id} satisfied.", - executionContext.CancellationToken, - step.Id).ConfigureAwait(false); - return StepExecutionOutcome.Continue; - - case PackRunStepKind.Parallel: - return await ExecuteParallelStepAsync(step, executionContext).ConfigureAwait(false); - - case PackRunStepKind.Map: - return await ExecuteMapStepAsync(step, executionContext).ConfigureAwait(false); - - case PackRunStepKind.Run: - return await ExecuteRunStepAsync(step, executionContext).ConfigureAwait(false); - - default: - logger.LogWarning("Run {RunId} encountered unsupported step kind '{Kind}' for step {StepId}. Marking as skipped.", - executionContext.RunId, - step.Kind, - step.Id); - executionContext.Steps[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Skipped, - StatusReason = "unsupported-kind", - LastTransitionAt = timeProvider.GetUtcNow() - }; - await AppendLogAsync( - executionContext.RunId, - "warn", - "step.skipped", - "Step skipped because the step kind is unsupported.", - executionContext.CancellationToken, - step.Id, - new Dictionary(StringComparer.Ordinal) - { - ["kind"] = step.Kind.ToString() - }).ConfigureAwait(false); - return StepExecutionOutcome.Continue; - } - } - - private async Task ExecuteRunStepAsync( - PackRunExecutionStep step, - ExecutionContext executionContext) - { - var record = executionContext.Steps[step.Id]; - var now = timeProvider.GetUtcNow(); - var currentState = new PackRunStepState(record.Status, record.Attempts, record.LastTransitionAt, record.NextAttemptAt); - - if (currentState.Status == PackRunStepExecutionStatus.Pending) - { - currentState = PackRunStepStateMachine.Start(currentState, now); - record = record with - { - Status = currentState.Status, - LastTransitionAt = currentState.LastTransitionAt, - NextAttemptAt = currentState.NextAttemptAt, - StatusReason = null - }; - executionContext.Steps[step.Id] = record; - var startMetadata = new Dictionary(StringComparer.Ordinal) - { - ["attempt"] = currentState.Attempts.ToString(CultureInfo.InvariantCulture) - }; - await AppendLogAsync( - executionContext.RunId, - "info", - "step.started", - $"Step {step.Id} started.", - executionContext.CancellationToken, - step.Id, - startMetadata).ConfigureAwait(false); - } - - runningSteps.Add(1); - var stopwatch = Stopwatch.StartNew(); - var result = await executor.ExecuteAsync(step, step.Parameters ?? PackRunExecutionStep.EmptyParameters, executionContext.CancellationToken).ConfigureAwait(false); - stopwatch.Stop(); - TaskRunnerTelemetry.StepDurationMs.Record( - stopwatch.Elapsed.TotalMilliseconds, - new KeyValuePair("step_kind", step.Kind.ToString())); - runningSteps.Add(-1); - - if (result.Succeeded) - { - currentState = PackRunStepStateMachine.CompleteSuccess(currentState, timeProvider.GetUtcNow()); - executionContext.Steps[step.Id] = record with - { - Status = currentState.Status, - Attempts = currentState.Attempts, - LastTransitionAt = currentState.LastTransitionAt, - NextAttemptAt = currentState.NextAttemptAt, - StatusReason = null - }; - - var successMetadata = new Dictionary(StringComparer.Ordinal) - { - ["attempt"] = currentState.Attempts.ToString(CultureInfo.InvariantCulture) - }; - await AppendLogAsync( - executionContext.RunId, - "info", - "step.succeeded", - $"Step {step.Id} succeeded.", - executionContext.CancellationToken, - step.Id, - successMetadata).ConfigureAwait(false); - - return StepExecutionOutcome.Continue; - } - - logger.LogWarning( - "Run {RunId} step {StepId} failed: {Error}", - executionContext.RunId, - step.Id, - result.Error ?? "unknown error"); - - var failure = PackRunStepStateMachine.RegisterFailure(currentState, timeProvider.GetUtcNow(), executionContext.FailurePolicy); - var updatedRecord = record with - { - Status = failure.State.Status, - Attempts = failure.State.Attempts, - LastTransitionAt = failure.State.LastTransitionAt, - NextAttemptAt = failure.State.NextAttemptAt, - StatusReason = result.Error - }; - - executionContext.Steps[step.Id] = updatedRecord; - - var failureMetadata = new Dictionary(StringComparer.Ordinal) - { - ["attempt"] = failure.State.Attempts.ToString(CultureInfo.InvariantCulture) - }; - if (!string.IsNullOrWhiteSpace(result.Error)) - { - failureMetadata["error"] = result.Error; - } - if (failure.State.NextAttemptAt is { } retryAt) - { - failureMetadata["nextAttemptAt"] = retryAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); - } - - var failureLevel = failure.Outcome == PackRunStepFailureOutcome.Abort && !step.ContinueOnError - ? "error" - : "warn"; - - await AppendLogAsync( - executionContext.RunId, - failureLevel, - "step.failed", - $"Step {step.Id} failed.", - executionContext.CancellationToken, - step.Id, - failureMetadata).ConfigureAwait(false); - - if (failure.Outcome == PackRunStepFailureOutcome.Retry) - { - TaskRunnerTelemetry.StepRetryCount.Add(1, new KeyValuePair("step_kind", step.Kind.ToString())); - var retryMetadata = new Dictionary(failureMetadata, StringComparer.Ordinal) - { - ["outcome"] = "retry" - }; - await AppendLogAsync( - executionContext.RunId, - "info", - "step.retry-scheduled", - $"Step {step.Id} scheduled for retry.", - executionContext.CancellationToken, - step.Id, - retryMetadata).ConfigureAwait(false); - } - - return failure.Outcome switch - { - PackRunStepFailureOutcome.Retry => StepExecutionOutcome.Defer, - PackRunStepFailureOutcome.Abort when step.ContinueOnError => StepExecutionOutcome.Continue, - PackRunStepFailureOutcome.Abort => StepExecutionOutcome.AbortRun, - _ => StepExecutionOutcome.AbortRun - }; - } - - private async Task ExecuteParallelStepAsync( - PackRunExecutionStep step, - ExecutionContext executionContext) - { - var children = step.Children; - if (children.Count == 0) - { - MarkContainerSucceeded(step, executionContext); - return StepExecutionOutcome.Continue; - } - - var maxParallel = step.MaxParallel is > 0 ? step.MaxParallel.Value : children.Count; - var queue = new Queue(children); - var running = new List>(maxParallel); - var outcome = StepExecutionOutcome.Continue; - var childFailureDetected = false; - - while (queue.Count > 0 || running.Count > 0) - { - while (queue.Count > 0 && running.Count < maxParallel) - { - var child = queue.Dequeue(); - running.Add(ExecuteStepAsync(child, executionContext)); - } - - var completed = await Task.WhenAny(running).ConfigureAwait(false); - running.Remove(completed); - var childOutcome = await completed.ConfigureAwait(false); - - switch (childOutcome) - { - case StepExecutionOutcome.AbortRun: - if (step.ContinueOnError) - { - childFailureDetected = true; - outcome = StepExecutionOutcome.Continue; - } - else - { - outcome = StepExecutionOutcome.AbortRun; - running.Clear(); - queue.Clear(); - } - break; - - case StepExecutionOutcome.Defer: - outcome = StepExecutionOutcome.Defer; - running.Clear(); - queue.Clear(); - break; - - default: - break; - } - - if (!step.ContinueOnError && outcome != StepExecutionOutcome.Continue) - { - break; - } - } - - if (outcome == StepExecutionOutcome.Continue) - { - if (childFailureDetected) - { - MarkContainerFailure(step, executionContext, ChildFailureReason); - } - else - { - MarkContainerSucceeded(step, executionContext); - } - } - else if (outcome == StepExecutionOutcome.AbortRun) - { - MarkContainerFailure(step, executionContext, ChildFailureReason); - } - else if (outcome == StepExecutionOutcome.Defer) - { - MarkContainerPending(step, executionContext, AwaitingRetryReason); - } - - return outcome; - } - - private async Task ExecuteMapStepAsync( - PackRunExecutionStep step, - ExecutionContext executionContext) - { - foreach (var child in step.Children) - { - var outcome = await ExecuteStepAsync(child, executionContext).ConfigureAwait(false); - if (outcome != StepExecutionOutcome.Continue) - { - if (outcome == StepExecutionOutcome.Defer) - { - MarkContainerPending(step, executionContext, AwaitingRetryReason); - return outcome; - } - - if (!step.ContinueOnError) - { - MarkContainerFailure(step, executionContext, ChildFailureReason); - return outcome; - } - - MarkContainerFailure(step, executionContext, ChildFailureReason); - } - } - - MarkContainerSucceeded(step, executionContext); - return StepExecutionOutcome.Continue; - } - - private void MarkContainerSucceeded(PackRunExecutionStep step, ExecutionContext executionContext) - { - if (!executionContext.Steps.TryGetValue(step.Id, out var record)) - { - return; - } - - if (record.Status == PackRunStepExecutionStatus.Succeeded) - { - return; - } - - executionContext.Steps[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Succeeded, - StatusReason = null, - LastTransitionAt = timeProvider.GetUtcNow(), - NextAttemptAt = null - }; - } - - private void MarkContainerFailure(PackRunExecutionStep step, ExecutionContext executionContext, string reason) - { - if (!executionContext.Steps.TryGetValue(step.Id, out var record)) - { - return; - } - - executionContext.Steps[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Failed, - StatusReason = reason, - LastTransitionAt = timeProvider.GetUtcNow() - }; - } - - private void MarkContainerPending(PackRunExecutionStep step, ExecutionContext executionContext, string reason) - { - if (!executionContext.Steps.TryGetValue(step.Id, out var record)) - { - return; - } - - executionContext.Steps[step.Id] = record with - { - Status = PackRunStepExecutionStatus.Pending, - StatusReason = reason, - LastTransitionAt = timeProvider.GetUtcNow() - }; - } - - private sealed record ExecutionContext( - string RunId, - TaskPackPlanFailurePolicy FailurePolicy, - ConcurrentDictionary Steps, - CancellationToken CancellationToken); - - private enum StepExecutionOutcome - { - Continue, - Defer, - AbortRun - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/StellaOps.TaskRunner.Worker.csproj b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/StellaOps.TaskRunner.Worker.csproj deleted file mode 100644 index 8ed9b3f7c..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/StellaOps.TaskRunner.Worker.csproj +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - dotnet-StellaOps.TaskRunner.Worker-ce7b902e-94f1-41c2-861b-daa533850dc5 - - - net10.0 - enable - enable - preview - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/TASKS.md b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/TASKS.md deleted file mode 100644 index 2e8e377be..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/TASKS.md +++ /dev/null @@ -1,10 +0,0 @@ -# StellaOps.TaskRunner.Worker Task Board -This board mirrors active sprint tasks for this module. -Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`. - -| Task ID | Status | Notes | -| --- | --- | --- | -| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/StellaOps.TaskRunner.Worker.md. | -| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | -| SPRINT-312-004 | DONE | Worker storage wiring aligned to Postgres state/log/approval and seed-fs artifact/provenance object-store contract. | -| SPRINT-20260305-002 | DONE | Worker startup contract now rejects rustfs/unknown object-store drivers and keeps seed-fs as the deterministic supported payload channel. | diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/appsettings.Development.json b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/appsettings.Development.json deleted file mode 100644 index b2dcdb674..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" - } - } -} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/appsettings.json b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/appsettings.json deleted file mode 100644 index 0f980c81d..000000000 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/appsettings.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "Worker": { - "IdleDelay": "00:00:01", - "QueuePath": "queue", - "ArchivePath": "queue/archive", - "ApprovalStorePath": "state/approvals", - "RunStatePath": "state/runs" - }, - "Notifications": { - "ApprovalEndpoint": null, - "PolicyEndpoint": null - } -} diff --git a/src/StellaOps.sln b/src/StellaOps.sln index e298d6cd6..e2bac6c87 100644 --- a/src/StellaOps.sln +++ b/src/StellaOps.sln @@ -1287,22 +1287,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Symbols.Infrastru EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Symbols.Server", "BinaryIndex\StellaOps.Symbols.Server\StellaOps.Symbols.Server.csproj", "{264AC7DD-45B3-7E71-BC04-F21E2D4E308A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Client", "JobEngine\StellaOps.TaskRunner\StellaOps.TaskRunner.Client\StellaOps.TaskRunner.Client.csproj", "{354964EE-A866-C110-B5F7-A75EF69E0F9C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Core", "JobEngine\StellaOps.TaskRunner\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj", "{33D54B61-15BD-DE57-D0A6-3D21BD838893}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Infrastructure", "JobEngine\StellaOps.TaskRunner\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj", "{6FC9CED3-E386-2677-703F-D14FB9A986A6}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Persistence", "JobEngine\StellaOps.TaskRunner.__Libraries\StellaOps.TaskRunner.Persistence\StellaOps.TaskRunner.Persistence.csproj", "{3FEA0432-5B0B-94CC-A61B-D691CC525087}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Persistence.Tests", "JobEngine\StellaOps.TaskRunner.__Tests\StellaOps.TaskRunner.Persistence.Tests\StellaOps.TaskRunner.Persistence.Tests.csproj", "{CB7BA5B1-C704-EC7B-F299-B7BA9C74AE08}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Tests", "JobEngine\StellaOps.TaskRunner\StellaOps.TaskRunner.Tests\StellaOps.TaskRunner.Tests.csproj", "{8A278B7C-E423-981F-AA27-283AF2E17698}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.WebService", "JobEngine\StellaOps.TaskRunner\StellaOps.TaskRunner.WebService\StellaOps.TaskRunner.WebService.csproj", "{9D21040D-1B36-F047-A8D9-49686E6454B7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Worker", "JobEngine\StellaOps.TaskRunner\StellaOps.TaskRunner.Worker\StellaOps.TaskRunner.Worker.csproj", "{01815E3E-DBA9-1B8E-CC8D-2C88939EE1E9}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Telemetry.Analyzers", "Telemetry\StellaOps.Telemetry.Analyzers\StellaOps.Telemetry.Analyzers.csproj", "{1C00C081-9E6C-034C-6BF2-5BBC7A927489}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Telemetry.Analyzers.Tests", "Telemetry\StellaOps.Telemetry.Analyzers\StellaOps.Telemetry.Analyzers.Tests\StellaOps.Telemetry.Analyzers.Tests.csproj", "{3267C3FE-F721-B951-34B9-D453A4D0B3DA}" @@ -9831,102 +9815,6 @@ Global {264AC7DD-45B3-7E71-BC04-F21E2D4E308A}.Release|x64.Build.0 = Release|Any CPU {264AC7DD-45B3-7E71-BC04-F21E2D4E308A}.Release|x86.ActiveCfg = Release|Any CPU {264AC7DD-45B3-7E71-BC04-F21E2D4E308A}.Release|x86.Build.0 = Release|Any CPU - {354964EE-A866-C110-B5F7-A75EF69E0F9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {354964EE-A866-C110-B5F7-A75EF69E0F9C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {354964EE-A866-C110-B5F7-A75EF69E0F9C}.Debug|x64.ActiveCfg = Debug|Any CPU - {354964EE-A866-C110-B5F7-A75EF69E0F9C}.Debug|x64.Build.0 = Debug|Any CPU - {354964EE-A866-C110-B5F7-A75EF69E0F9C}.Debug|x86.ActiveCfg = Debug|Any CPU - {354964EE-A866-C110-B5F7-A75EF69E0F9C}.Debug|x86.Build.0 = Debug|Any CPU - {354964EE-A866-C110-B5F7-A75EF69E0F9C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {354964EE-A866-C110-B5F7-A75EF69E0F9C}.Release|Any CPU.Build.0 = Release|Any CPU - {354964EE-A866-C110-B5F7-A75EF69E0F9C}.Release|x64.ActiveCfg = Release|Any CPU - {354964EE-A866-C110-B5F7-A75EF69E0F9C}.Release|x64.Build.0 = Release|Any CPU - {354964EE-A866-C110-B5F7-A75EF69E0F9C}.Release|x86.ActiveCfg = Release|Any CPU - {354964EE-A866-C110-B5F7-A75EF69E0F9C}.Release|x86.Build.0 = Release|Any CPU - {33D54B61-15BD-DE57-D0A6-3D21BD838893}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {33D54B61-15BD-DE57-D0A6-3D21BD838893}.Debug|Any CPU.Build.0 = Debug|Any CPU - {33D54B61-15BD-DE57-D0A6-3D21BD838893}.Debug|x64.ActiveCfg = Debug|Any CPU - {33D54B61-15BD-DE57-D0A6-3D21BD838893}.Debug|x64.Build.0 = Debug|Any CPU - {33D54B61-15BD-DE57-D0A6-3D21BD838893}.Debug|x86.ActiveCfg = Debug|Any CPU - {33D54B61-15BD-DE57-D0A6-3D21BD838893}.Debug|x86.Build.0 = Debug|Any CPU - {33D54B61-15BD-DE57-D0A6-3D21BD838893}.Release|Any CPU.ActiveCfg = Release|Any CPU - {33D54B61-15BD-DE57-D0A6-3D21BD838893}.Release|Any CPU.Build.0 = Release|Any CPU - {33D54B61-15BD-DE57-D0A6-3D21BD838893}.Release|x64.ActiveCfg = Release|Any CPU - {33D54B61-15BD-DE57-D0A6-3D21BD838893}.Release|x64.Build.0 = Release|Any CPU - {33D54B61-15BD-DE57-D0A6-3D21BD838893}.Release|x86.ActiveCfg = Release|Any CPU - {33D54B61-15BD-DE57-D0A6-3D21BD838893}.Release|x86.Build.0 = Release|Any CPU - {6FC9CED3-E386-2677-703F-D14FB9A986A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6FC9CED3-E386-2677-703F-D14FB9A986A6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6FC9CED3-E386-2677-703F-D14FB9A986A6}.Debug|x64.ActiveCfg = Debug|Any CPU - {6FC9CED3-E386-2677-703F-D14FB9A986A6}.Debug|x64.Build.0 = Debug|Any CPU - {6FC9CED3-E386-2677-703F-D14FB9A986A6}.Debug|x86.ActiveCfg = Debug|Any CPU - {6FC9CED3-E386-2677-703F-D14FB9A986A6}.Debug|x86.Build.0 = Debug|Any CPU - {6FC9CED3-E386-2677-703F-D14FB9A986A6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6FC9CED3-E386-2677-703F-D14FB9A986A6}.Release|Any CPU.Build.0 = Release|Any CPU - {6FC9CED3-E386-2677-703F-D14FB9A986A6}.Release|x64.ActiveCfg = Release|Any CPU - {6FC9CED3-E386-2677-703F-D14FB9A986A6}.Release|x64.Build.0 = Release|Any CPU - {6FC9CED3-E386-2677-703F-D14FB9A986A6}.Release|x86.ActiveCfg = Release|Any CPU - {6FC9CED3-E386-2677-703F-D14FB9A986A6}.Release|x86.Build.0 = Release|Any CPU - {3FEA0432-5B0B-94CC-A61B-D691CC525087}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3FEA0432-5B0B-94CC-A61B-D691CC525087}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3FEA0432-5B0B-94CC-A61B-D691CC525087}.Debug|x64.ActiveCfg = Debug|Any CPU - {3FEA0432-5B0B-94CC-A61B-D691CC525087}.Debug|x64.Build.0 = Debug|Any CPU - {3FEA0432-5B0B-94CC-A61B-D691CC525087}.Debug|x86.ActiveCfg = Debug|Any CPU - {3FEA0432-5B0B-94CC-A61B-D691CC525087}.Debug|x86.Build.0 = Debug|Any CPU - {3FEA0432-5B0B-94CC-A61B-D691CC525087}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3FEA0432-5B0B-94CC-A61B-D691CC525087}.Release|Any CPU.Build.0 = Release|Any CPU - {3FEA0432-5B0B-94CC-A61B-D691CC525087}.Release|x64.ActiveCfg = Release|Any CPU - {3FEA0432-5B0B-94CC-A61B-D691CC525087}.Release|x64.Build.0 = Release|Any CPU - {3FEA0432-5B0B-94CC-A61B-D691CC525087}.Release|x86.ActiveCfg = Release|Any CPU - {3FEA0432-5B0B-94CC-A61B-D691CC525087}.Release|x86.Build.0 = Release|Any CPU - {CB7BA5B1-C704-EC7B-F299-B7BA9C74AE08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CB7BA5B1-C704-EC7B-F299-B7BA9C74AE08}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CB7BA5B1-C704-EC7B-F299-B7BA9C74AE08}.Debug|x64.ActiveCfg = Debug|Any CPU - {CB7BA5B1-C704-EC7B-F299-B7BA9C74AE08}.Debug|x64.Build.0 = Debug|Any CPU - {CB7BA5B1-C704-EC7B-F299-B7BA9C74AE08}.Debug|x86.ActiveCfg = Debug|Any CPU - {CB7BA5B1-C704-EC7B-F299-B7BA9C74AE08}.Debug|x86.Build.0 = Debug|Any CPU - {CB7BA5B1-C704-EC7B-F299-B7BA9C74AE08}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CB7BA5B1-C704-EC7B-F299-B7BA9C74AE08}.Release|Any CPU.Build.0 = Release|Any CPU - {CB7BA5B1-C704-EC7B-F299-B7BA9C74AE08}.Release|x64.ActiveCfg = Release|Any CPU - {CB7BA5B1-C704-EC7B-F299-B7BA9C74AE08}.Release|x64.Build.0 = Release|Any CPU - {CB7BA5B1-C704-EC7B-F299-B7BA9C74AE08}.Release|x86.ActiveCfg = Release|Any CPU - {CB7BA5B1-C704-EC7B-F299-B7BA9C74AE08}.Release|x86.Build.0 = Release|Any CPU - {8A278B7C-E423-981F-AA27-283AF2E17698}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8A278B7C-E423-981F-AA27-283AF2E17698}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8A278B7C-E423-981F-AA27-283AF2E17698}.Debug|x64.ActiveCfg = Debug|Any CPU - {8A278B7C-E423-981F-AA27-283AF2E17698}.Debug|x64.Build.0 = Debug|Any CPU - {8A278B7C-E423-981F-AA27-283AF2E17698}.Debug|x86.ActiveCfg = Debug|Any CPU - {8A278B7C-E423-981F-AA27-283AF2E17698}.Debug|x86.Build.0 = Debug|Any CPU - {8A278B7C-E423-981F-AA27-283AF2E17698}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8A278B7C-E423-981F-AA27-283AF2E17698}.Release|Any CPU.Build.0 = Release|Any CPU - {8A278B7C-E423-981F-AA27-283AF2E17698}.Release|x64.ActiveCfg = Release|Any CPU - {8A278B7C-E423-981F-AA27-283AF2E17698}.Release|x64.Build.0 = Release|Any CPU - {8A278B7C-E423-981F-AA27-283AF2E17698}.Release|x86.ActiveCfg = Release|Any CPU - {8A278B7C-E423-981F-AA27-283AF2E17698}.Release|x86.Build.0 = Release|Any CPU - {9D21040D-1B36-F047-A8D9-49686E6454B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9D21040D-1B36-F047-A8D9-49686E6454B7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9D21040D-1B36-F047-A8D9-49686E6454B7}.Debug|x64.ActiveCfg = Debug|Any CPU - {9D21040D-1B36-F047-A8D9-49686E6454B7}.Debug|x64.Build.0 = Debug|Any CPU - {9D21040D-1B36-F047-A8D9-49686E6454B7}.Debug|x86.ActiveCfg = Debug|Any CPU - {9D21040D-1B36-F047-A8D9-49686E6454B7}.Debug|x86.Build.0 = Debug|Any CPU - {9D21040D-1B36-F047-A8D9-49686E6454B7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9D21040D-1B36-F047-A8D9-49686E6454B7}.Release|Any CPU.Build.0 = Release|Any CPU - {9D21040D-1B36-F047-A8D9-49686E6454B7}.Release|x64.ActiveCfg = Release|Any CPU - {9D21040D-1B36-F047-A8D9-49686E6454B7}.Release|x64.Build.0 = Release|Any CPU - {9D21040D-1B36-F047-A8D9-49686E6454B7}.Release|x86.ActiveCfg = Release|Any CPU - {9D21040D-1B36-F047-A8D9-49686E6454B7}.Release|x86.Build.0 = Release|Any CPU - {01815E3E-DBA9-1B8E-CC8D-2C88939EE1E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {01815E3E-DBA9-1B8E-CC8D-2C88939EE1E9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {01815E3E-DBA9-1B8E-CC8D-2C88939EE1E9}.Debug|x64.ActiveCfg = Debug|Any CPU - {01815E3E-DBA9-1B8E-CC8D-2C88939EE1E9}.Debug|x64.Build.0 = Debug|Any CPU - {01815E3E-DBA9-1B8E-CC8D-2C88939EE1E9}.Debug|x86.ActiveCfg = Debug|Any CPU - {01815E3E-DBA9-1B8E-CC8D-2C88939EE1E9}.Debug|x86.Build.0 = Debug|Any CPU - {01815E3E-DBA9-1B8E-CC8D-2C88939EE1E9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {01815E3E-DBA9-1B8E-CC8D-2C88939EE1E9}.Release|Any CPU.Build.0 = Release|Any CPU - {01815E3E-DBA9-1B8E-CC8D-2C88939EE1E9}.Release|x64.ActiveCfg = Release|Any CPU - {01815E3E-DBA9-1B8E-CC8D-2C88939EE1E9}.Release|x64.Build.0 = Release|Any CPU - {01815E3E-DBA9-1B8E-CC8D-2C88939EE1E9}.Release|x86.ActiveCfg = Release|Any CPU - {01815E3E-DBA9-1B8E-CC8D-2C88939EE1E9}.Release|x86.Build.0 = Release|Any CPU {1C00C081-9E6C-034C-6BF2-5BBC7A927489}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1C00C081-9E6C-034C-6BF2-5BBC7A927489}.Debug|Any CPU.Build.0 = Debug|Any CPU {1C00C081-9E6C-034C-6BF2-5BBC7A927489}.Debug|x64.ActiveCfg = Debug|Any CPU diff --git a/src/Web/StellaOps.Web/src/app/core/api/jobengine-jobs.client.ts b/src/Web/StellaOps.Web/src/app/core/api/jobengine-jobs.client.ts index aa2fb39f0..4e2606a8c 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/jobengine-jobs.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/jobengine-jobs.client.ts @@ -33,7 +33,7 @@ export interface JobEngineJobRecord { readonly maxAttempts: number; readonly correlationId?: string | null; readonly workerId?: string | null; - readonly taskRunnerId?: string | null; + /** @deprecated taskRunnerId removed — nullable legacy column */ readonly createdAt: string; readonly scheduledAt?: string | null; readonly leasedAt?: string | null; @@ -87,7 +87,7 @@ interface JobEngineJobApiRecord { readonly maxAttempts?: number; readonly correlationId?: string | null; readonly workerId?: string | null; - readonly taskRunnerId?: string | null; + /** @deprecated taskRunnerId removed — nullable legacy column */ readonly createdAt?: string; readonly scheduledAt?: string | null; readonly leasedAt?: string | null; @@ -217,7 +217,7 @@ export class JobEngineJobsClient { maxAttempts: job.maxAttempts ?? 0, correlationId: job.correlationId ?? null, workerId: job.workerId ?? null, - taskRunnerId: job.taskRunnerId ?? null, + createdAt: job.createdAt ?? new Date().toISOString(), scheduledAt: job.scheduledAt ?? null, leasedAt: job.leasedAt ?? null, diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/pipeline-to-workflow.service.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/pipeline-to-workflow.service.ts index 141bd5ebc..86a7e3fa0 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/pipeline-to-workflow.service.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/pipeline-to-workflow.service.ts @@ -425,7 +425,7 @@ export class PipelineToWorkflowService { $type: 'call-transport', stepName: `test-${config.testType}`, invocation: { - address: { $type: 'microservice', microserviceName: 'taskrunner', command: `execute-${config.testType}-test` }, + address: { $type: 'microservice', microserviceName: 'scheduler', command: `execute-${config.testType}-test` }, payloadExpression: obj([ { name: 'releaseId', expression: path('state.releaseId') }, { name: 'environment', expression: path('state.targetEnvironment') },