diff --git a/docs/implplan/SPRINTS.md b/docs/implplan/SPRINTS.md index 5b189b8ec..0d21030f9 100644 --- a/docs/implplan/SPRINTS.md +++ b/docs/implplan/SPRINTS.md @@ -1,31 +1,31 @@ -# Sprint Index - -Follow the sprint files below in order. Update task status in both `SPRINTS` and module `TASKS.md` as you progress. - -- [Identity & Signing](./SPRINT_100_identity_signing.md) -- [Ingestion & Evidence](./SPRINT_110_ingestion_evidence.md) -- [Policy & Reasoning](./SPRINT_120_policy_reasoning.md) -- [Scanner & Surface](./SPRINT_130_scanner_surface.md) -- [Runtime & Signals](./SPRINT_140_runtime_signals.md) -- [Scheduling & Automation](./SPRINT_150_scheduling_automation.md) -- [Export & Evidence](./SPRINT_160_export_evidence.md) -- [Notifications & Telemetry](./SPRINT_170_notifications_telemetry.md) -- [Experience & SDKs](./SPRINT_180_experience_sdks.md) -- [Ops & Offline](./SPRINT_190_ops_offline.md) -- [Documentation & Process](./SPRINT_200_documentation_process.md) - +# Sprint Index + +Follow the sprint files below in order. Update task status in both `SPRINTS` and module `TASKS.md` as you progress. + +- [Identity & Signing](./SPRINT_100_identity_signing.md) +- [Ingestion & Evidence](./SPRINT_110_ingestion_evidence.md) +- [Policy & Reasoning](./SPRINT_120_policy_reasoning.md) +- [Scanner & Surface](./SPRINT_130_scanner_surface.md) +- [Runtime & Signals](./SPRINT_140_runtime_signals.md) +- [Scheduling & Automation](./SPRINT_150_scheduling_automation.md) +- [Export & Evidence](./SPRINT_160_export_evidence.md) +- [Notifications & Telemetry](./SPRINT_170_notifications_telemetry.md) +- [Experience & SDKs](./SPRINT_180_experience_sdks.md) +- [Ops & Offline](./SPRINT_190_ops_offline.md) +- [Documentation & Process](./SPRINT_200_documentation_process.md) + > 2025-11-03: ATTESTOR-72-003 moved to DOING (Attestor Service Guild) – running live TTL validation against local MongoDB/Redis processes (manual hosts, no Docker). -> 2025-11-03: ATTESTOR-72-003 marked DONE (Attestor Service Guild) – Mongo/Redis TTL expiry logs archived under `docs/modules/attestor/evidence/2025-11-03-*.txt` with summary in `docs/modules/attestor/ttl-validation.md`. -> 2025-11-03: AIAI-31-004B moved to DOING (Advisory AI Guild) – starting prompt assembler/guardrail plumbing, cache persistence contract, and DSSE provenance wiring. -> 2025-11-03: PLG7.RFC marked DONE (Auth Plugin Guild, Security Guild) – LDAP plugin RFC accepted; review log stored at `docs/notes/2025-11-03-authority-plugin-ldap-review.md`, follow-up PLG7.IMPL-001..005 queued. -> 2025-11-03: PLG7.IMPL-001 marked DONE (Auth Plugin Guild) – new `StellaOps.Authority.Plugin.Ldap` project/tests scaffolded with configuration normalization & validation; sample manifest refreshed and smoke tests run (`dotnet test`). -> 2025-11-03: AIAI-31-004B marked DONE (Advisory AI Guild) – prompt assembler, guardrail hooks, DSSE-ready output persistence, and golden prompt tests landed. -> 2025-11-03: AIAI-31-005 moved to DOING (Advisory AI Guild) – beginning guardrail enforcement (redaction, injection defence, output validator) implementation. -> 2025-11-03: AIAI-31-006 moved to DOING (Advisory AI Guild) – starting Advisory AI REST API surface work (RBAC, rate limits, batching contract). -> 2025-11-03: EVID-OBS-53-001 moved to DOING (Evidence Locker Guild) – bootstrapping Evidence Locker schema and storage abstractions. -> 2025-11-03: GRAPH-INDEX-28-002 marked DONE (Graph Indexer Guild) – SBOM ingest transformer, processor, and metrics landed with refreshed fixtures/tests for license and base artifact determinism. -> 2025-11-03: GRAPH-INDEX-28-003 marked DONE (Graph Indexer Guild) – advisory linkset snapshot model repaired, transformer finalized with dedupe/canonical provenance, fixtures refreshed, and overlay tests passing across the graph suite. -> 2025-11-03: GRAPH-INDEX-28-004 moved to DOING (Graph Indexer Guild) – beginning VEX overlay integration with precedent/justification metadata. +> 2025-11-03: ATTESTOR-72-003 marked DONE (Attestor Service Guild) – Mongo/Redis TTL expiry logs archived under `docs/modules/attestor/evidence/2025-11-03-*.txt` with summary in `docs/modules/attestor/ttl-validation.md`. +> 2025-11-03: AIAI-31-004B moved to DOING (Advisory AI Guild) – starting prompt assembler/guardrail plumbing, cache persistence contract, and DSSE provenance wiring. +> 2025-11-03: PLG7.RFC marked DONE (Auth Plugin Guild, Security Guild) – LDAP plugin RFC accepted; review log stored at `docs/notes/2025-11-03-authority-plugin-ldap-review.md`, follow-up PLG7.IMPL-001..005 queued. +> 2025-11-03: PLG7.IMPL-001 marked DONE (Auth Plugin Guild) – new `StellaOps.Authority.Plugin.Ldap` project/tests scaffolded with configuration normalization & validation; sample manifest refreshed and smoke tests run (`dotnet test`). +> 2025-11-03: AIAI-31-004B marked DONE (Advisory AI Guild) – prompt assembler, guardrail hooks, DSSE-ready output persistence, and golden prompt tests landed. +> 2025-11-03: AIAI-31-005 moved to DOING (Advisory AI Guild) – beginning guardrail enforcement (redaction, injection defence, output validator) implementation. +> 2025-11-03: AIAI-31-006 moved to DOING (Advisory AI Guild) – starting Advisory AI REST API surface work (RBAC, rate limits, batching contract). +> 2025-11-03: EVID-OBS-53-001 moved to DOING (Evidence Locker Guild) – bootstrapping Evidence Locker schema and storage abstractions. +> 2025-11-03: GRAPH-INDEX-28-002 marked DONE (Graph Indexer Guild) – SBOM ingest transformer, processor, and metrics landed with refreshed fixtures/tests for license and base artifact determinism. +> 2025-11-03: GRAPH-INDEX-28-003 marked DONE (Graph Indexer Guild) – advisory linkset snapshot model repaired, transformer finalized with dedupe/canonical provenance, fixtures refreshed, and overlay tests passing across the graph suite. +> 2025-11-03: GRAPH-INDEX-28-004 moved to DOING (Graph Indexer Guild) – beginning VEX overlay integration with precedent/justification metadata. > 2025-11-03: GRAPH-INDEX-28-004 marked DONE (Graph Indexer Guild) – VEX snapshot/transformer merged with deterministic overlays, fixtures refreshed, and graph indexer tests passing. > 2025-11-03: GRAPH-INDEX-28-005 moved to DOING (Graph Indexer Guild, Policy Guild) – starting policy overlay hydration (`governs_with` nodes/edges) with explain hash references. > 2025-11-03: GRAPH-INDEX-28-005 marked DONE (Graph Indexer Guild, Policy Guild) – policy overlay snapshot/transformer landed with deterministic nodes/edges and fixture-backed tests; Mongo writer tests now probe `STELLAOPS_TEST_MONGO_URI`/localhost before falling back to Mongo2Go and skip when no mongod is reachable. @@ -38,123 +38,123 @@ Follow the sprint files below in order. Update task status in both `SPRINTS` and > 2025-11-04: SCHED-WEB-21-004 marked DONE (Scheduler WebService Guild, Scheduler Storage Guild) – Mongo lifecycle persistence, single-shot completion events/webhooks, and idempotent result URI refresh landed with unit/integration coverage. > 2025-11-04: TASKRUN-42-001 resumed (Task Runner Guild) – planning loops/conditionals/maxParallel execution upgrades, simulation mode, policy gate wiring, and deterministic retry/abort handling. > 2025-11-04: TASKRUN-42-001 progress update – execution graph + simulation endpoints wired; retry windows now persisted for orchestration clients. -> 2025-11-03: AIRGAP-POL-57-002 confirmed DOING (AirGap Policy Guild, Task Runner Guild) – continuing Task Runner sealed-mode egress validation and test sweep. -> 2025-11-03: AIRGAP-POL-57-002 marked DONE (AirGap Policy Guild, Task Runner Guild) – worker now injects `IEgressPolicy`, filesystem dispatcher enforces sealed-mode egress, planner grants normalized, sealed-mode dispatcher test added; follow-up queued to lift remaining dispatchers/executors onto the shared policy before sealing the full worker loop. -> 2025-11-03: MERGE-LNM-21-001 moved to DOING (BE-Merge, Architecture Guild) – drafting `no-merge` migration playbook outline and capturing rollout/backfill checkpoints. -> 2025-11-03: MERGE-LNM-21-001 marked DONE – published `docs/migration/no-merge.md` with rollout, backfill, validation, and rollback guidance for the LNM cutover. -> 2025-11-04: GRAPH-INDEX-28-011 marked DONE (Graph Indexer Guild) – SBOM ingest DI wiring now emits graph snapshots by default, snapshot root configurable via `STELLAOPS_GRAPH_SNAPSHOT_DIR`, and Graph Indexer tests exercised with Mongo URI guidance. -> 2025-11-03: MERGE-LNM-21-002 moved to DOING (BE-Merge) – auditing `AdvisoryMergeService` call sites to scope removal and analyzer enforcement. -> 2025-11-03: DOCS-LNM-22-008 moved to DOING (Docs Guild, DevOps Guild) – aligning migration playbook structure and readiness checklist. -> 2025-11-03: DOCS-LNM-22-008 marked DONE – `/docs/migration/no-merge.md` published for DevOps/Export Center planning with checklist for cutover readiness. -> 2025-11-03: SCHED-CONSOLE-27-001 marked DONE (Scheduler WebService Guild, Policy Registry Guild) – policy simulation endpoints now emit SSE retry/heartbeat, enforce metadata normalization, support Mongo-backed integration, and ship auth/stream coverage. -> 2025-11-03: SCHED-CONSOLE-27-002 moved to DOING (Scheduler WebService Guild, Observability Guild) – wiring policy simulation telemetry endpoints, OTEL metrics, and Registry webhooks on completion/failure. -> 2025-11-03: FEEDCONN-KISA-02-008 moved to DOING (BE-Conn-KISA, Models) – starting Hangul firmware range normalization and provenance mapping for KISA advisories. -> 2025-11-03: FEEDCONN-KISA-02-008 progress – SemVer normalization wired through KISA mapper with provenance slugs, exclusive marker handling, and fresh connector tests for `이상`/`미만`/`초과` scenarios plus non-numeric fallback; follow-up review queued for additional phrasing coverage before closing. Captured current detail pages via `scripts/kisa_capture_html.py` so offline HTML is available under `seed-data/kisa/html/`. -> 2025-11-03: FEEDCONN-ICSCISA-02-012 marked DONE (BE-Conn-ICS-CISA) – ICS CISA connector now emits semver-aware affected.version ranges with `ics-cisa` provenance, SourceFetchService RSS fallback passes the AOC guard, and the Fetch/Parse/Map integration test is green. -> 2025-11-01: SCANNER-ANALYZERS-LANG-10-308R marked DONE (Language Analyzer Guild) – heuristics fixtures, benchmarks, and coverage comparison published. -> 2025-11-01: SCANNER-ANALYZERS-LANG-10-309R marked DONE (Language Analyzer Guild) – Rust analyzer packaged with offline kit smoke tests and docs. -> 2025-11-01: ENTRYTRACE-SURFACE-01 moved to DOING (EntryTrace Guild) – wiring Surface.Validation and Surface.FS reuse ahead of EntryTrace runs. -> 2025-11-01: AUTH-OBS-50-001 (Sprint 50 – Observability & Forensics) moved to DOING (Authority Core & Security Guild). -> 2025-11-01: AUTH-PACKS-41-001 moved to DOING (Authority Core & Security Guild) – add Packs.* scopes to Authority. -> 2025-11-01: AUTH-OBS-55-001 (Sprint 55 – Observability & Forensics) moved to DOING (Authority Core & Security Guild, Ops Guild). -> 2025-11-01: TASKRUN-41-001 moved to DOING (Task Runner Guild) – request packs.* scopes when calling Authority. -> 2025-11-01: PACKS-REG-41-001 moved to DOING (Packs Registry Guild) – enforce packs.* scopes for registry publish/run flows. -> 2025-11-01: ATTEST-VERIFY-74-001 re-opened and set to DOING to unblock build/test regressions (Verification Guild, Observability Guild). -> 2025-11-01: ATTEST-VERIFY-74-001 marked DONE after configuration and test fixes (Verification Guild, Observability Guild). -> 2025-11-01: AUTH-AIAI-31-001 marked DONE (Authority Core & Security Guild) – Advisory AI scopes published and remote inference toggles documented. -> 2025-11-01: AUTH-AIRGAP-56-001 moved to DOING (Authority Core & Security Guild) – add airgap scope catalogue and defaults. -> 2025-11-01: AUTH-AIRGAP-56-002 moved to DOING (Authority Core & Security Guild) – implement airgap audit endpoint and logging. -> 2025-11-01: ISSUER-30-001 marked DONE (Issuer Directory Guild) – Issuer Directory service scaffolded with CRUD APIs, audit sink, CSAF seed import, and unit tests. -> 2025-11-01: ISSUER-30-002 marked DONE (Issuer Directory Guild, Security Guild) – Key management domain, Mongo persistence, CRUD/rotate/revoke endpoints, validation, and tests delivered. -> 2025-11-01: ISSUER-30-004 marked DONE (Issuer Directory Guild, VEX Lens Guild) – Excititor worker consumes issuer directory client for key/trust lookup with cached offline support. -> 2025-11-01: ISSUER-30-005 marked DONE (Issuer Directory Guild, Observability Guild) – Issuer Directory service emits structured logs + metrics for issuer/key flows with OTEL meter. -> 2025-11-02: SURFACE-ENV-01 moved to DOING (Surface Env Guild) – drafting shared environment spec for Scanner/Zastava. -> 2025-11-02: SURFACE-ENV-02 moved to DOING (Surface Env Guild) – implementing typed environment resolver and unit tests. -> 2025-11-02: SURFACE-VAL-01 moved to DOING (Surface Validation Guild) – aligning design document with implementation plan. -> 2025-11-02: SURFACE-FS-01 moved to DOING (Surface FS Guild) – finalising cache layout and manifest spec. -> 2025-11-02: SURFACE-FS-02 moved to DOING (Surface FS Guild) – building core abstractions and deterministic serializers. -> 2025-11-02: SURFACE-SECRETS-01 moved to DOING (Surface Secrets Guild) – updating secrets design for provider matrix. -> 2025-11-02: SURFACE-SECRETS-02 moved to DOING (Surface Secrets Guild) – implementing base providers + tests. -> 2025-11-02: AUTH-POLICY-27-002 marked DONE (Authority Core & Security Guild) – interactive-only policy publish/promote scopes delivered with metadata, fresh-auth enforcement, and audit/docs updates. -> 2025-11-02: SCANNER-ENTRYTRACE-18-506 moved to DOING (EntryTrace Guild, Scanner WebService Guild) – surfacing EntryTrace results via WebService/CLI with confidence metadata. -> 2025-11-02: ATTESTOR-74-001 marked DONE (Attestor Service Guild) – witness client integration, repository schema, and verification/reporting updates landed with tests. -> 2025-11-02: AUTH-OAS-63-001 moved to DOING (Authority Core & Security Guild, API Governance Guild) – verifying legacy `/oauth/*` deprecation signalling and notifications ahead of sunset. -> 2025-11-02: AUTH-OAS-63-001 marked DONE (Authority Core & Security Guild, API Governance Guild) – legacy shims emit Deprecation/Sunset/Warning headers, audit event coverage validated, and migration guide published. -> 2025-11-02: AUTH-NOTIFY-40-001 marked DONE (Authority Core & Security Guild) – `/notify/ack-tokens/rotate` (notify.admin) now rotates DSSE keys with audit trails and integration tests. -> 2025-11-02: AUTH-OAS-62-001 moved to DOING (Authority Core & Security Guild, SDK Generator Guild) – wiring SDK helpers for OAuth2/PAT flows and tenancy override header. -> 2025-11-02: AUTH-OAS-62-001 marked DONE (Authority Core & Security Guild, SDK Generator Guild) – HttpClient auth helper (OAuth2/PAT) shipped with tenant header support and unit tests. -> 2025-11-02: AUTH-OBS-50-001 moved to DOING (Authority Core & Security Guild) – defining observability scopes and updating discovery/offline defaults. -> 2025-11-02: AUTH-OBS-52-001 moved to DOING (Authority Core & Security Guild) – rolling observability scopes through resource server policies and audit wiring. -> 2025-11-02: AUTH-OBS-55-001 marked DONE (Authority Core & Security Guild, Ops Guild) – incident-mode tokens now require fresh auth, audit records expose `incident.reason`, and `/authority/audit/incident` verification path documented. -> 2025-11-02: AUTH-ORCH-34-001 marked DONE (Authority Core & Security Guild) – `orch:backfill` scope enforced with reason/ticket metadata, Authority + CLI updated, docs/config refreshed for Orchestrator admins. -> 2025-11-02: AUTH-PACKS-41-001 moved to DOING (Authority Core & Security Guild) – defining packs scope catalogue, issuer templates, and offline defaults. -> 2025-11-02: AUTH-PACKS-41-001 added shared OpenSSL 1.1 test libs so Authority & Signals Mongo2Go suites run on OpenSSL 3. -> 2025-11-02: AUTH-NOTIFY-42-001 moved to DOING (Authority Core & Security Guild) – investigating `/notify/ack-tokens/rotate` 500 responses when key metadata missing. -> 2025-11-02: AUTH-NOTIFY-42-001 marked DONE (Authority Core & Security Guild) – bootstrap rotate defaults fixed, `StellaOpsBearer` test alias added, and notify ack rotation regression passes. -> 2025-11-03: AUTH-TEN-49-001 marked DONE (Authority Core & Security Guild) – service account delegation (`act` chain) shipped with quota/audit coverage; Authority tests green. -> 2025-11-03: AUTH-VULN-29-003 marked DONE (Authority Core & Docs Guild) – Vuln Explorer security docs, samples, and release notes refreshed for roles, ABAC policies, attachment signing, and ledger verification. -> 2025-11-03: ISSUER-30-003 marked DONE (Issuer Directory Guild, Policy Guild) – trust override APIs/client finalized with cache invalidation/failure-path tests; Issuer Directory suite passing. -> 2025-11-03: AUTH-AIRGAP-56-001/56-002 marked DONE (Authority Core & Security Guild) – air-gap scope catalog surfaced in discovery/OpenAPI and `/authority/audit/airgap` endpoint shipped with tests. -> 2025-11-03: AUTH-PACKS-41-001 marked DONE (Authority Core & Security Guild) – packs scope bundle now emitted via discovery metadata, reflected in OpenAPI, and covered by Authority tests. -> 2025-11-03: AUTH-POLICY-27-003 marked DONE (Authority Core & Docs Guild) – Policy Studio docs/config updated for publish/promote signing workflow, CLI commands, and compliance checklist. -> 2025-11-02: ENTRYTRACE-SURFACE-02 moved to DOING (EntryTrace Guild) – replacing direct env/secret access with Surface.Secrets provider for EntryTrace runs. -> 2025-11-02: ENTRYTRACE-SURFACE-01 marked DONE (EntryTrace Guild) – Surface.Validation + Surface.FS cache now drive EntryTrace reuse with regression tests. -> 2025-11-02: ENTRYTRACE-SURFACE-02 marked DONE (EntryTrace Guild) – EntryTrace environment placeholders resolved via Surface.Secrets with updated docs/tests. -> 2025-11-02: SCANNER-ENTRYTRACE-18-506 marked DONE (EntryTrace Guild, Scanner WebService Guild) – EntryTrace graph surfaced via WebService and CLI with confidence metadata. -> 2025-11-02: SCANNER-ENTRYTRACE-18-509 moved to DOING (EntryTrace Guild, QA Guild) – adding regression coverage for EntryTrace surfaces and NDJSON hashing. -> 2025-11-02: SCANNER-ENTRYTRACE-18-509 marked DONE (EntryTrace Guild, QA Guild) – regression coverage landed for result store/WebService/CLI with NDJSON hashing snapshot. -> 2025-11-02: SCANNER-ENTRYTRACE-18-507 marked DONE (EntryTrace Guild) – fallback candidate discovery now covers history, supervisor configs, service directories, and entrypoint scripts with tests. -> 2025-11-02: SCANNER-ENTRYTRACE-18-508 marked DONE (EntryTrace Guild) – wrapper catalogue expanded for bundle, docker-php-entrypoint, npm, yarn, pipenv, and poetry with wrapper metadata assertions. -> 2025-11-02: CONCELIER-WEB-OAS-61-001 moved to DOING (Concelier WebService Guild) – implementing discovery endpoint for `.well-known/openapi` with version metadata and ETag. -> 2025-11-02: CONCELIER-WEB-OAS-61-001 marked DONE (Concelier WebService Guild) – discovery endpoint now serves signed OpenAPI 3.1 document with ETag support. -> 2025-11-02: DOCS-SCANNER-BENCH-62-001 moved to DOING (Docs Guild, Scanner Guild) – refreshing Trivy/Grype/Snyk comparison docs and ecosystem matrix with source-linked coverage. -> 2025-11-02: DOCS-SCANNER-BENCH-62-001 marked DONE (Docs Guild, Scanner Guild) – matrix updated with Windows/macOS coverage row and secret detection techniques; deep dives cite Trivy/Grype/Snyk sources. -> 2025-11-02: DOCS-SCANNER-BENCH-62-003 added (Docs Guild, Product Guild) – recording Python lockfile/editable-install demand signals for policy guidance follow-up. -> 2025-11-02: DOCS-SCANNER-BENCH-62-004 added (Docs Guild, Java Analyzer Guild) – documenting Java lockfile ingestion plan and policy templates. -> 2025-11-02: DOCS-SCANNER-BENCH-62-005 added (Docs Guild, Go Analyzer Guild) – documenting Go stripped-binary fallback enrichment guidance. -> 2025-11-02: DOCS-SCANNER-BENCH-62-006 added (Docs Guild, Rust Analyzer Guild) – documenting Rust fingerprint enrichment guidance. +> 2025-11-03: AIRGAP-POL-57-002 confirmed DOING (AirGap Policy Guild, Task Runner Guild) – continuing Task Runner sealed-mode egress validation and test sweep. +> 2025-11-03: AIRGAP-POL-57-002 marked DONE (AirGap Policy Guild, Task Runner Guild) – worker now injects `IEgressPolicy`, filesystem dispatcher enforces sealed-mode egress, planner grants normalized, sealed-mode dispatcher test added; follow-up queued to lift remaining dispatchers/executors onto the shared policy before sealing the full worker loop. +> 2025-11-03: MERGE-LNM-21-001 moved to DOING (BE-Merge, Architecture Guild) – drafting `no-merge` migration playbook outline and capturing rollout/backfill checkpoints. +> 2025-11-03: MERGE-LNM-21-001 marked DONE – published `docs/migration/no-merge.md` with rollout, backfill, validation, and rollback guidance for the LNM cutover. +> 2025-11-04: GRAPH-INDEX-28-011 marked DONE (Graph Indexer Guild) – SBOM ingest DI wiring now emits graph snapshots by default, snapshot root configurable via `STELLAOPS_GRAPH_SNAPSHOT_DIR`, and Graph Indexer tests exercised with Mongo URI guidance. +> 2025-11-06: MERGE-LNM-21-002 remains DOING (BE-Merge) – default-off merge DI + job gating landed, but Concelier WebService ingest/mirror tests are failing; guard and migration fixes pending before completion. +> 2025-11-03: DOCS-LNM-22-008 moved to DOING (Docs Guild, DevOps Guild) – aligning migration playbook structure and readiness checklist. +> 2025-11-03: DOCS-LNM-22-008 marked DONE – `/docs/migration/no-merge.md` published for DevOps/Export Center planning with checklist for cutover readiness. +> 2025-11-03: SCHED-CONSOLE-27-001 marked DONE (Scheduler WebService Guild, Policy Registry Guild) – policy simulation endpoints now emit SSE retry/heartbeat, enforce metadata normalization, support Mongo-backed integration, and ship auth/stream coverage. +> 2025-11-03: SCHED-CONSOLE-27-002 moved to DOING (Scheduler WebService Guild, Observability Guild) – wiring policy simulation telemetry endpoints, OTEL metrics, and Registry webhooks on completion/failure. +> 2025-11-03: FEEDCONN-KISA-02-008 moved to DOING (BE-Conn-KISA, Models) – starting Hangul firmware range normalization and provenance mapping for KISA advisories. +> 2025-11-03: FEEDCONN-KISA-02-008 progress – SemVer normalization wired through KISA mapper with provenance slugs, exclusive marker handling, and fresh connector tests for `이상`/`미만`/`초과` scenarios plus non-numeric fallback; follow-up review queued for additional phrasing coverage before closing. Captured current detail pages via `scripts/kisa_capture_html.py` so offline HTML is available under `seed-data/kisa/html/`. +> 2025-11-03: FEEDCONN-ICSCISA-02-012 marked DONE (BE-Conn-ICS-CISA) – ICS CISA connector now emits semver-aware affected.version ranges with `ics-cisa` provenance, SourceFetchService RSS fallback passes the AOC guard, and the Fetch/Parse/Map integration test is green. +> 2025-11-01: SCANNER-ANALYZERS-LANG-10-308R marked DONE (Language Analyzer Guild) – heuristics fixtures, benchmarks, and coverage comparison published. +> 2025-11-01: SCANNER-ANALYZERS-LANG-10-309R marked DONE (Language Analyzer Guild) – Rust analyzer packaged with offline kit smoke tests and docs. +> 2025-11-01: ENTRYTRACE-SURFACE-01 moved to DOING (EntryTrace Guild) – wiring Surface.Validation and Surface.FS reuse ahead of EntryTrace runs. +> 2025-11-01: AUTH-OBS-50-001 (Sprint 50 – Observability & Forensics) moved to DOING (Authority Core & Security Guild). +> 2025-11-01: AUTH-PACKS-41-001 moved to DOING (Authority Core & Security Guild) – add Packs.* scopes to Authority. +> 2025-11-01: AUTH-OBS-55-001 (Sprint 55 – Observability & Forensics) moved to DOING (Authority Core & Security Guild, Ops Guild). +> 2025-11-01: TASKRUN-41-001 moved to DOING (Task Runner Guild) – request packs.* scopes when calling Authority. +> 2025-11-01: PACKS-REG-41-001 moved to DOING (Packs Registry Guild) – enforce packs.* scopes for registry publish/run flows. +> 2025-11-01: ATTEST-VERIFY-74-001 re-opened and set to DOING to unblock build/test regressions (Verification Guild, Observability Guild). +> 2025-11-01: ATTEST-VERIFY-74-001 marked DONE after configuration and test fixes (Verification Guild, Observability Guild). +> 2025-11-01: AUTH-AIAI-31-001 marked DONE (Authority Core & Security Guild) – Advisory AI scopes published and remote inference toggles documented. +> 2025-11-01: AUTH-AIRGAP-56-001 moved to DOING (Authority Core & Security Guild) – add airgap scope catalogue and defaults. +> 2025-11-01: AUTH-AIRGAP-56-002 moved to DOING (Authority Core & Security Guild) – implement airgap audit endpoint and logging. +> 2025-11-01: ISSUER-30-001 marked DONE (Issuer Directory Guild) – Issuer Directory service scaffolded with CRUD APIs, audit sink, CSAF seed import, and unit tests. +> 2025-11-01: ISSUER-30-002 marked DONE (Issuer Directory Guild, Security Guild) – Key management domain, Mongo persistence, CRUD/rotate/revoke endpoints, validation, and tests delivered. +> 2025-11-01: ISSUER-30-004 marked DONE (Issuer Directory Guild, VEX Lens Guild) – Excititor worker consumes issuer directory client for key/trust lookup with cached offline support. +> 2025-11-01: ISSUER-30-005 marked DONE (Issuer Directory Guild, Observability Guild) – Issuer Directory service emits structured logs + metrics for issuer/key flows with OTEL meter. +> 2025-11-02: SURFACE-ENV-01 moved to DOING (Surface Env Guild) – drafting shared environment spec for Scanner/Zastava. +> 2025-11-02: SURFACE-ENV-02 moved to DOING (Surface Env Guild) – implementing typed environment resolver and unit tests. +> 2025-11-02: SURFACE-VAL-01 moved to DOING (Surface Validation Guild) – aligning design document with implementation plan. +> 2025-11-02: SURFACE-FS-01 moved to DOING (Surface FS Guild) – finalising cache layout and manifest spec. +> 2025-11-02: SURFACE-FS-02 moved to DOING (Surface FS Guild) – building core abstractions and deterministic serializers. +> 2025-11-02: SURFACE-SECRETS-01 moved to DOING (Surface Secrets Guild) – updating secrets design for provider matrix. +> 2025-11-02: SURFACE-SECRETS-02 moved to DOING (Surface Secrets Guild) – implementing base providers + tests. +> 2025-11-02: AUTH-POLICY-27-002 marked DONE (Authority Core & Security Guild) – interactive-only policy publish/promote scopes delivered with metadata, fresh-auth enforcement, and audit/docs updates. +> 2025-11-02: SCANNER-ENTRYTRACE-18-506 moved to DOING (EntryTrace Guild, Scanner WebService Guild) – surfacing EntryTrace results via WebService/CLI with confidence metadata. +> 2025-11-02: ATTESTOR-74-001 marked DONE (Attestor Service Guild) – witness client integration, repository schema, and verification/reporting updates landed with tests. +> 2025-11-02: AUTH-OAS-63-001 moved to DOING (Authority Core & Security Guild, API Governance Guild) – verifying legacy `/oauth/*` deprecation signalling and notifications ahead of sunset. +> 2025-11-02: AUTH-OAS-63-001 marked DONE (Authority Core & Security Guild, API Governance Guild) – legacy shims emit Deprecation/Sunset/Warning headers, audit event coverage validated, and migration guide published. +> 2025-11-02: AUTH-NOTIFY-40-001 marked DONE (Authority Core & Security Guild) – `/notify/ack-tokens/rotate` (notify.admin) now rotates DSSE keys with audit trails and integration tests. +> 2025-11-02: AUTH-OAS-62-001 moved to DOING (Authority Core & Security Guild, SDK Generator Guild) – wiring SDK helpers for OAuth2/PAT flows and tenancy override header. +> 2025-11-02: AUTH-OAS-62-001 marked DONE (Authority Core & Security Guild, SDK Generator Guild) – HttpClient auth helper (OAuth2/PAT) shipped with tenant header support and unit tests. +> 2025-11-02: AUTH-OBS-50-001 moved to DOING (Authority Core & Security Guild) – defining observability scopes and updating discovery/offline defaults. +> 2025-11-02: AUTH-OBS-52-001 moved to DOING (Authority Core & Security Guild) – rolling observability scopes through resource server policies and audit wiring. +> 2025-11-02: AUTH-OBS-55-001 marked DONE (Authority Core & Security Guild, Ops Guild) – incident-mode tokens now require fresh auth, audit records expose `incident.reason`, and `/authority/audit/incident` verification path documented. +> 2025-11-02: AUTH-ORCH-34-001 marked DONE (Authority Core & Security Guild) – `orch:backfill` scope enforced with reason/ticket metadata, Authority + CLI updated, docs/config refreshed for Orchestrator admins. +> 2025-11-02: AUTH-PACKS-41-001 moved to DOING (Authority Core & Security Guild) – defining packs scope catalogue, issuer templates, and offline defaults. +> 2025-11-02: AUTH-PACKS-41-001 added shared OpenSSL 1.1 test libs so Authority & Signals Mongo2Go suites run on OpenSSL 3. +> 2025-11-02: AUTH-NOTIFY-42-001 moved to DOING (Authority Core & Security Guild) – investigating `/notify/ack-tokens/rotate` 500 responses when key metadata missing. +> 2025-11-02: AUTH-NOTIFY-42-001 marked DONE (Authority Core & Security Guild) – bootstrap rotate defaults fixed, `StellaOpsBearer` test alias added, and notify ack rotation regression passes. +> 2025-11-03: AUTH-TEN-49-001 marked DONE (Authority Core & Security Guild) – service account delegation (`act` chain) shipped with quota/audit coverage; Authority tests green. +> 2025-11-03: AUTH-VULN-29-003 marked DONE (Authority Core & Docs Guild) – Vuln Explorer security docs, samples, and release notes refreshed for roles, ABAC policies, attachment signing, and ledger verification. +> 2025-11-03: ISSUER-30-003 marked DONE (Issuer Directory Guild, Policy Guild) – trust override APIs/client finalized with cache invalidation/failure-path tests; Issuer Directory suite passing. +> 2025-11-03: AUTH-AIRGAP-56-001/56-002 marked DONE (Authority Core & Security Guild) – air-gap scope catalog surfaced in discovery/OpenAPI and `/authority/audit/airgap` endpoint shipped with tests. +> 2025-11-03: AUTH-PACKS-41-001 marked DONE (Authority Core & Security Guild) – packs scope bundle now emitted via discovery metadata, reflected in OpenAPI, and covered by Authority tests. +> 2025-11-03: AUTH-POLICY-27-003 marked DONE (Authority Core & Docs Guild) – Policy Studio docs/config updated for publish/promote signing workflow, CLI commands, and compliance checklist. +> 2025-11-02: ENTRYTRACE-SURFACE-02 moved to DOING (EntryTrace Guild) – replacing direct env/secret access with Surface.Secrets provider for EntryTrace runs. +> 2025-11-02: ENTRYTRACE-SURFACE-01 marked DONE (EntryTrace Guild) – Surface.Validation + Surface.FS cache now drive EntryTrace reuse with regression tests. +> 2025-11-02: ENTRYTRACE-SURFACE-02 marked DONE (EntryTrace Guild) – EntryTrace environment placeholders resolved via Surface.Secrets with updated docs/tests. +> 2025-11-02: SCANNER-ENTRYTRACE-18-506 marked DONE (EntryTrace Guild, Scanner WebService Guild) – EntryTrace graph surfaced via WebService and CLI with confidence metadata. +> 2025-11-02: SCANNER-ENTRYTRACE-18-509 moved to DOING (EntryTrace Guild, QA Guild) – adding regression coverage for EntryTrace surfaces and NDJSON hashing. +> 2025-11-02: SCANNER-ENTRYTRACE-18-509 marked DONE (EntryTrace Guild, QA Guild) – regression coverage landed for result store/WebService/CLI with NDJSON hashing snapshot. +> 2025-11-02: SCANNER-ENTRYTRACE-18-507 marked DONE (EntryTrace Guild) – fallback candidate discovery now covers history, supervisor configs, service directories, and entrypoint scripts with tests. +> 2025-11-02: SCANNER-ENTRYTRACE-18-508 marked DONE (EntryTrace Guild) – wrapper catalogue expanded for bundle, docker-php-entrypoint, npm, yarn, pipenv, and poetry with wrapper metadata assertions. +> 2025-11-02: CONCELIER-WEB-OAS-61-001 moved to DOING (Concelier WebService Guild) – implementing discovery endpoint for `.well-known/openapi` with version metadata and ETag. +> 2025-11-02: CONCELIER-WEB-OAS-61-001 marked DONE (Concelier WebService Guild) – discovery endpoint now serves signed OpenAPI 3.1 document with ETag support. +> 2025-11-02: DOCS-SCANNER-BENCH-62-001 moved to DOING (Docs Guild, Scanner Guild) – refreshing Trivy/Grype/Snyk comparison docs and ecosystem matrix with source-linked coverage. +> 2025-11-02: DOCS-SCANNER-BENCH-62-001 marked DONE (Docs Guild, Scanner Guild) – matrix updated with Windows/macOS coverage row and secret detection techniques; deep dives cite Trivy/Grype/Snyk sources. +> 2025-11-02: DOCS-SCANNER-BENCH-62-003 added (Docs Guild, Product Guild) – recording Python lockfile/editable-install demand signals for policy guidance follow-up. +> 2025-11-02: DOCS-SCANNER-BENCH-62-004 added (Docs Guild, Java Analyzer Guild) – documenting Java lockfile ingestion plan and policy templates. +> 2025-11-02: DOCS-SCANNER-BENCH-62-005 added (Docs Guild, Go Analyzer Guild) – documenting Go stripped-binary fallback enrichment guidance. +> 2025-11-02: DOCS-SCANNER-BENCH-62-006 added (Docs Guild, Rust Analyzer Guild) – documenting Rust fingerprint enrichment guidance. > 2025-11-02: DOCS-SCANNER-BENCH-62-007 added (Docs Guild, Security Guild) – documenting secret leak detection guidance. > 2025-11-05: DOCS-SCANNER-BENCH-62-007 marked DONE (Docs Guild, Security Guild) – secret leak detection runbook, benchmark updates, and policy templates published. -> 2025-11-02: DOCS-SCANNER-BENCH-62-008 added (Docs Guild, EntryTrace Guild) – documenting EntryTrace heuristic maintenance guidance. -> 2025-11-02: DOCS-SCANNER-BENCH-62-009 added (Docs Guild, Ruby Analyzer Guild) – deepening Ruby gap analysis with detection tables; status set to DOING. -> 2025-11-02: DOCS-SCANNER-BENCH-62-010 added (Docs Guild, PHP Analyzer Guild) – documenting PHP analyzer parity gaps; status set to DOING. -> 2025-11-02: DOCS-SCANNER-BENCH-62-011 added (Docs Guild, Language Analyzer Guild) – capturing Deno runtime gap analysis; status set to DOING. -> 2025-11-02: DOCS-SCANNER-BENCH-62-012 added (Docs Guild, Language Analyzer Guild) – expanding Dart ecosystem comparison; status set to DOING. -> 2025-11-02: DOCS-SCANNER-BENCH-62-013 added (Docs Guild, Swift Analyzer Guild) – expanding Swift coverage analysis; status set to DOING. -> 2025-11-02: DOCS-SCANNER-BENCH-62-014 added (Docs Guild, Runtime Guild) – detailing Kubernetes/VM coverage plan; status set to DOING. -> 2025-11-02: DOCS-SCANNER-BENCH-62-015 added (Docs Guild, Export Center Guild) – outlining DSSE/Rekor operator enablement guidance; status set to DOING. -> 2025-11-02: DOCS-SCANNER-BENCH-62-009 marked DONE (Docs Guild, Ruby Analyzer Guild) – Ruby gap section delivered with detection tables and backlog links. -> 2025-11-02: DOCS-SCANNER-BENCH-62-010 marked DONE (Docs Guild, PHP Analyzer Guild) – PHP gap analysis updated with implementation notes. -> 2025-11-02: DOCS-SCANNER-BENCH-62-011 marked DONE (Docs Guild, Language Analyzer Guild) – Deno plan documented with detection technique table. -> 2025-11-02: DOCS-SCANNER-BENCH-62-012 marked DONE (Docs Guild, Language Analyzer Guild) – Dart coverage section fleshed out with detection strategies. -> 2025-11-02: DOCS-SCANNER-BENCH-62-013 marked DONE (Docs Guild, Swift Analyzer Guild) – Swift analyzer roadmap captured with policy hooks. -> 2025-11-02: DOCS-SCANNER-BENCH-62-014 marked DONE (Docs Guild, Runtime Guild) – Kubernetes/VM alignment section published. +> 2025-11-02: DOCS-SCANNER-BENCH-62-008 added (Docs Guild, EntryTrace Guild) – documenting EntryTrace heuristic maintenance guidance. +> 2025-11-02: DOCS-SCANNER-BENCH-62-009 added (Docs Guild, Ruby Analyzer Guild) – deepening Ruby gap analysis with detection tables; status set to DOING. +> 2025-11-02: DOCS-SCANNER-BENCH-62-010 added (Docs Guild, PHP Analyzer Guild) – documenting PHP analyzer parity gaps; status set to DOING. +> 2025-11-02: DOCS-SCANNER-BENCH-62-011 added (Docs Guild, Language Analyzer Guild) – capturing Deno runtime gap analysis; status set to DOING. +> 2025-11-02: DOCS-SCANNER-BENCH-62-012 added (Docs Guild, Language Analyzer Guild) – expanding Dart ecosystem comparison; status set to DOING. +> 2025-11-02: DOCS-SCANNER-BENCH-62-013 added (Docs Guild, Swift Analyzer Guild) – expanding Swift coverage analysis; status set to DOING. +> 2025-11-02: DOCS-SCANNER-BENCH-62-014 added (Docs Guild, Runtime Guild) – detailing Kubernetes/VM coverage plan; status set to DOING. +> 2025-11-02: DOCS-SCANNER-BENCH-62-015 added (Docs Guild, Export Center Guild) – outlining DSSE/Rekor operator enablement guidance; status set to DOING. +> 2025-11-02: DOCS-SCANNER-BENCH-62-009 marked DONE (Docs Guild, Ruby Analyzer Guild) – Ruby gap section delivered with detection tables and backlog links. +> 2025-11-02: DOCS-SCANNER-BENCH-62-010 marked DONE (Docs Guild, PHP Analyzer Guild) – PHP gap analysis updated with implementation notes. +> 2025-11-02: DOCS-SCANNER-BENCH-62-011 marked DONE (Docs Guild, Language Analyzer Guild) – Deno plan documented with detection technique table. +> 2025-11-02: DOCS-SCANNER-BENCH-62-012 marked DONE (Docs Guild, Language Analyzer Guild) – Dart coverage section fleshed out with detection strategies. +> 2025-11-02: DOCS-SCANNER-BENCH-62-013 marked DONE (Docs Guild, Swift Analyzer Guild) – Swift analyzer roadmap captured with policy hooks. +> 2025-11-02: DOCS-SCANNER-BENCH-62-014 marked DONE (Docs Guild, Runtime Guild) – Kubernetes/VM alignment section published. > 2025-11-02: DOCS-SCANNER-BENCH-62-015 marked DONE (Docs Guild, Export Center Guild) – DSSE/Rekor enablement guidance appended to gap doc. > 2025-11-05: SCANNER-SURFACE-02 marked DONE (Scanner WebService Guild) – WebService now persists `surface` manifest pointers in scan/report APIs, orchestrator samples and DSSE fixtures refreshed, and readiness tests updated with Surface validators stubbed for deterministic health checks. -> 2025-11-02: SCANNER-ENG-0009 moved to DOING (Ruby Analyzer Guild) – drafting Ruby analyzer parity design package. -> 2025-11-02: SCANNER-ENG-0016 added (Ruby Analyzer Guild) – implementing Ruby lock collector & vendor cache ingestion. -> 2025-11-02: SCANNER-ENG-0016 moved to DOING (Ruby Analyzer Guild) – lockfile parser skeleton committed with initial Gemfile.lock parsing. -> 2025-11-02: SCANNER-ENG-0017 added (Ruby Analyzer Guild) – building runtime require/autoload graph builder. -> 2025-11-02: SCANNER-ENG-0018 added (Ruby Analyzer Guild) – emitting Ruby capability and framework signals. -> 2025-11-02: SCANNER-ENG-0019 added (Ruby Analyzer Guild, CLI Guild) – delivering Ruby CLI verbs and Offline Kit packaging. -> 2025-11-02: SCANNER-LIC-0001 added (Scanner Guild, Legal Guild) – vetting tree-sitter Ruby licensing/offline packaging. -> 2025-11-02: SCANNER-LIC-0001 moved to DOING (Scanner Guild, Legal Guild) – SPDX review in progress. -> 2025-11-02: SCANNER-POLICY-0001 added (Policy Guild, Ruby Analyzer Guild) – defining Ruby capability predicates in Policy Engine. -> 2025-11-02: SCANNER-CLI-0001 added (CLI Guild, Ruby Analyzer Guild) – coordinating CLI UX/docs for Ruby verbs. -> 2025-11-02: AIAI-31-011 moved to DOING (Advisory AI Guild) – implementing Excititor VEX document provider. -> 2025-11-02: AIAI-31-011 marked DONE (Advisory AI Guild) – Excititor VEX provider + OpenVEX chunking shipped with tests. -> 2025-11-02: AIAI-31-002 moved to DOING (Advisory AI Guild, SBOM Service Guild) – building SBOM context retriever for timelines/paths/blast radius. -> 2025-11-02: AIAI-31-002 progressing – SBOM context models/tests landed; awaiting SBOM guild client hookup. -> 2025-11-04: AIAI-31-002 marked DONE – SBOM context HTTP client + DI wiring delivered, retriever integrated, HTTP unit tests added. - -> 2025-11-02: AIAI-31-003 moved to DOING – kicking off deterministic tooling (comparators, dependency lookup). First drop covers semver range evaluator + RPM EVR comparator. -> 2025-11-04: AIAI-31-003 marked DONE – deterministic toolset now DI-registered with SBOM context client, added semver/EVR comparison & range tests, and dependency analysis feeds orchestrator metadata. - -> 2025-11-02: AIAI-31-004 moved to DOING – starting deterministic orchestration pipeline (summary/conflict/remediation flow). - -> 2025-11-02: ISSUER-30-006 moved to DOING (Issuer Directory Guild, DevOps Guild) – deployment manifests, backup/restore, secret handling, and offline kit docs in progress. -> 2025-11-04: EVID-OBS-55-001 moved to DOING (Evidence Locker Guild, DevOps Guild) – enabling incident mode retention extension, debug artefacts, and timeline/notifier hooks. -> 2025-11-04: EVID-OBS-55-001 marked DONE (Evidence Locker Guild, DevOps Guild) – incident mode retention, timeline events, notifier stubs, and incident artefact packaging shipped with tests/docs. -> 2025-11-04: EVID-OBS-60-001 moved to DOING (Evidence Locker Guild) – starting sealed-mode portable evidence export flow with redacted bundle packaging and offline verification guidance. -> 2025-11-04: EVID-OBS-60-001 marked DONE (Evidence Locker Guild) – `/evidence/{id}/portable` now emits `portable-bundle-v1.tgz` with sanitized metadata, offline verification script, docs (`docs/airgap/portable-evidence.md`) and unit/web coverage. -> 2025-11-04: DVOFF-64-001 moved to DOING (DevPortal Offline Guild, Exporter Guild) – beginning `devportal --offline` export job bundling portal HTML, specs, SDKs, and changelog assets. +> 2025-11-02: SCANNER-ENG-0009 moved to DOING (Ruby Analyzer Guild) – drafting Ruby analyzer parity design package. +> 2025-11-02: SCANNER-ENG-0016 added (Ruby Analyzer Guild) – implementing Ruby lock collector & vendor cache ingestion. +> 2025-11-02: SCANNER-ENG-0016 moved to DOING (Ruby Analyzer Guild) – lockfile parser skeleton committed with initial Gemfile.lock parsing. +> 2025-11-02: SCANNER-ENG-0017 added (Ruby Analyzer Guild) – building runtime require/autoload graph builder. +> 2025-11-02: SCANNER-ENG-0018 added (Ruby Analyzer Guild) – emitting Ruby capability and framework signals. +> 2025-11-02: SCANNER-ENG-0019 added (Ruby Analyzer Guild, CLI Guild) – delivering Ruby CLI verbs and Offline Kit packaging. +> 2025-11-02: SCANNER-LIC-0001 added (Scanner Guild, Legal Guild) – vetting tree-sitter Ruby licensing/offline packaging. +> 2025-11-02: SCANNER-LIC-0001 moved to DOING (Scanner Guild, Legal Guild) – SPDX review in progress. +> 2025-11-02: SCANNER-POLICY-0001 added (Policy Guild, Ruby Analyzer Guild) – defining Ruby capability predicates in Policy Engine. +> 2025-11-02: SCANNER-CLI-0001 added (CLI Guild, Ruby Analyzer Guild) – coordinating CLI UX/docs for Ruby verbs. +> 2025-11-02: AIAI-31-011 moved to DOING (Advisory AI Guild) – implementing Excititor VEX document provider. +> 2025-11-02: AIAI-31-011 marked DONE (Advisory AI Guild) – Excititor VEX provider + OpenVEX chunking shipped with tests. +> 2025-11-02: AIAI-31-002 moved to DOING (Advisory AI Guild, SBOM Service Guild) – building SBOM context retriever for timelines/paths/blast radius. +> 2025-11-02: AIAI-31-002 progressing – SBOM context models/tests landed; awaiting SBOM guild client hookup. +> 2025-11-04: AIAI-31-002 marked DONE – SBOM context HTTP client + DI wiring delivered, retriever integrated, HTTP unit tests added. + +> 2025-11-02: AIAI-31-003 moved to DOING – kicking off deterministic tooling (comparators, dependency lookup). First drop covers semver range evaluator + RPM EVR comparator. +> 2025-11-04: AIAI-31-003 marked DONE – deterministic toolset now DI-registered with SBOM context client, added semver/EVR comparison & range tests, and dependency analysis feeds orchestrator metadata. + +> 2025-11-02: AIAI-31-004 moved to DOING – starting deterministic orchestration pipeline (summary/conflict/remediation flow). + +> 2025-11-02: ISSUER-30-006 moved to DOING (Issuer Directory Guild, DevOps Guild) – deployment manifests, backup/restore, secret handling, and offline kit docs in progress. +> 2025-11-04: EVID-OBS-55-001 moved to DOING (Evidence Locker Guild, DevOps Guild) – enabling incident mode retention extension, debug artefacts, and timeline/notifier hooks. +> 2025-11-04: EVID-OBS-55-001 marked DONE (Evidence Locker Guild, DevOps Guild) – incident mode retention, timeline events, notifier stubs, and incident artefact packaging shipped with tests/docs. +> 2025-11-04: EVID-OBS-60-001 moved to DOING (Evidence Locker Guild) – starting sealed-mode portable evidence export flow with redacted bundle packaging and offline verification guidance. +> 2025-11-04: EVID-OBS-60-001 marked DONE (Evidence Locker Guild) – `/evidence/{id}/portable` now emits `portable-bundle-v1.tgz` with sanitized metadata, offline verification script, docs (`docs/airgap/portable-evidence.md`) and unit/web coverage. +> 2025-11-04: DVOFF-64-001 moved to DOING (DevPortal Offline Guild, Exporter Guild) – beginning `devportal --offline` export job bundling portal HTML, specs, SDKs, and changelog assets. diff --git a/docs/implplan/SPRINT_110_ingestion_evidence.md b/docs/implplan/SPRINT_110_ingestion_evidence.md index 791985ff7..1ccb3a7c4 100644 --- a/docs/implplan/SPRINT_110_ingestion_evidence.md +++ b/docs/implplan/SPRINT_110_ingestion_evidence.md @@ -210,7 +210,7 @@ Depends on: Sprint 110.B - Concelier.VI Summary: Ingestion & Evidence focus on Concelier (phase VII). Task ID | State | Task description | Owners (Source) --- | --- | --- | --- -MERGE-LNM-21-002 | DOING (2025-11-03) | Refactor or retire `AdvisoryMergeService` and related pipelines, ensuring callers transition to observation/linkset APIs; add compile-time analyzer preventing merge service usage.
2025-11-03: Began dependency audit and call-site inventory ahead of deprecation plan; cataloging service registrations/tests referencing merge APIs.
2025-11-05 14:42Z: Drafting `concelier:features:noMergeEnabled` gating, merge job allowlist handling, and deprecation/telemetry changes prior to analyzer rollout.
2025-11-06 16:10Z: Landed analyzer project (`CONCELIER0002`), wired into Concelier WebService/tests, and updated docs to direct suppressions through explicit migration notes.
2025-11-06 23:45Z: Analyzer enforcement merged; DI removal + flag defaults pending. Analyzer test project blocked by offline feed (`Microsoft.Bcl.AsyncInterfaces >= 8.0` missing) — rerun once nuget mirror refreshed. | BE-Merge (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) +MERGE-LNM-21-002 | DOING (2025-11-06) | Refactor or retire `AdvisoryMergeService` and related pipelines, ensuring callers transition to observation/linkset APIs; add compile-time analyzer preventing merge service usage.
2025-11-03: Began dependency audit and call-site inventory ahead of deprecation plan; cataloging service registrations/tests referencing merge APIs.
2025-11-05 14:42Z: Drafted `concelier:features:noMergeEnabled` gating, merge job allowlist handling, and deprecation/telemetry changes prior to analyzer rollout.
2025-11-06 16:10Z: Landed analyzer project (`CONCELIER0002`), wired into Concelier WebService/tests, and updated docs to direct suppressions through explicit migration notes.
2025-11-07 03:25Z: Default-on toggle + job gating break existing Concelier WebService tests; guard/migration adjustments pending before closing the task. | BE-Merge (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) MERGE-LNM-21-003 Determinism/test updates | QA Guild, BE-Merge | Replace merge determinism suites with observation/linkset regression tests verifying no data mutation and conflicts remain visible. Dependencies: MERGE-LNM-21-002. | MERGE-LNM-21-002 (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) diff --git a/docs/implplan/SPRINT_130_scanner_surface.md b/docs/implplan/SPRINT_130_scanner_surface.md index 308f245f3..bbf0e4671 100644 --- a/docs/implplan/SPRINT_130_scanner_surface.md +++ b/docs/implplan/SPRINT_130_scanner_surface.md @@ -142,8 +142,8 @@ SCANNER-EVENTS-16-302 | DONE (2025-11-06) | Extend orchestrator event links (rep SCANNER-GRAPH-21-001 | TODO | Provide webhook/REST endpoint for Cartographer to request policy overlays and runtime evidence for graph nodes, ensuring determinism and tenant scoping. | Scanner WebService Guild, Cartographer Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) SCANNER-LNM-21-001 | TODO | Update `/reports` and `/policy/runtime` payloads to consume advisory/vex linksets, exposing source severity arrays and conflict summaries alongside effective verdicts. | Scanner WebService Guild, Policy Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) SCANNER-LNM-21-002 | TODO | Add evidence endpoint for Console to fetch linkset summaries with policy overlay for a component/SBOM, including AOC references. Dependencies: SCANNER-LNM-21-001. | Scanner WebService Guild, UI Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) -SCANNER-SECRETS-01 | DOING (2025-11-02) | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution.
2025-11-02: Worker integration tests added for CAS token retrieval via Surface.Secrets abstraction; refactor under review. | Scanner Worker Guild, Security Guild (src/Scanner/StellaOps.Scanner.Worker/TASKS.md) -SCANNER-SECRETS-02 | DOING (2025-11-02) | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens). Dependencies: SCANNER-SECRETS-01.
2025-11-02: WebService export path now resolves registry credentials via Surface.Secrets stub; CI pipeline hook in progress. | Scanner WebService Guild, Security Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) +SCANNER-SECRETS-01 | DOING (2025-11-06) | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution.
2025-11-02: Worker integration tests added for CAS token retrieval via Surface.Secrets abstraction; refactor under review.
2025-11-06: Resumed to replace remaining registry credential plumbing and emit rotation-aware metrics.
2025-11-06 21:35Z: Surface secret configurator now hydrates `ScannerStorageOptions` from `cas-access` payloads; unit coverage added. | Scanner Worker Guild, Security Guild (src/Scanner/StellaOps.Scanner.Worker/TASKS.md) +SCANNER-SECRETS-02 | DOING (2025-11-06) | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens). Dependencies: SCANNER-SECRETS-01.
2025-11-02: WebService export path now resolves registry credentials via Surface.Secrets stub; CI pipeline hook in progress.
2025-11-06: Picking up Surface.Secrets provider usage across report/export flows and removing legacy secret file readers.
2025-11-06 21:40Z: WebService options now consume `cas-access` secrets via configurator; storage mirrors updated; targeted tests passing. | Scanner WebService Guild, Security Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) SCANNER-SECRETS-03 | TODO | Use Surface.Secrets to retrieve registry credentials when interacting with CAS/referrers. Dependencies: SCANNER-SECRETS-02. | BuildX Plugin Guild, Security Guild (src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md) SCANNER-ENG-0020 | TODO | Implement Homebrew collector & fragment mapper per `design/macos-analyzer.md` §3.1. | Scanner Guild (docs/modules/scanner/TASKS.md) SCANNER-ENG-0021 | TODO | Implement pkgutil receipt collector per `design/macos-analyzer.md` §3.2. | Scanner Guild (docs/modules/scanner/TASKS.md) @@ -153,9 +153,9 @@ SCANNER-ENG-0024 | TODO | Implement Windows MSI collector per `design/windows-an SCANNER-ENG-0025 | TODO | Implement WinSxS manifest collector per `design/windows-analyzer.md` §3.2. | Scanner Guild (docs/modules/scanner/TASKS.md) SCANNER-ENG-0026 | TODO | Implement Windows Chocolatey & registry collectors per `design/windows-analyzer.md` §3.3–3.4. | Scanner Guild (docs/modules/scanner/TASKS.md) SCANNER-ENG-0027 | TODO | Deliver Windows policy/offline integration per `design/windows-analyzer.md` §5–6. | Scanner Guild, Policy Guild, Offline Kit Guild (docs/modules/scanner/TASKS.md) -SCANNER-SURFACE-01 | DOING (2025-11-02) | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.
2025-11-02: Worker pipeline emitting draft Surface.FS manifests for sample scans; determinism checks running. | Scanner Worker Guild (src/Scanner/StellaOps.Scanner.Worker/TASKS.md) +SCANNER-SURFACE-01 | DOING (2025-11-06) | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.
2025-11-02: Worker pipeline emitting draft Surface.FS manifests for sample scans; determinism checks running.
2025-11-06: Continuing with manifest writer abstraction + telemetry wiring for Surface.FS persistence. | Scanner Worker Guild (src/Scanner/StellaOps.Scanner.Worker/TASKS.md) SCANNER-SURFACE-02 | DONE (2025-11-05) | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata. Dependencies: SCANNER-SURFACE-01.
2025-11-05: Surface pointer projection wired through WebService endpoints, orchestrator samples & DSSE fixtures refreshed with `surface` manifest block, and regression suite (platform events, report sample, ready check) updated. | Scanner WebService Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) -SCANNER-SURFACE-03 | TODO | Push layer manifests and entry fragments into Surface.FS during build-time SBOM generation. Dependencies: SCANNER-SURFACE-02. | BuildX Plugin Guild (src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md) +SCANNER-SURFACE-03 | DOING (2025-11-06) | Push layer manifests and entry fragments into Surface.FS during build-time SBOM generation. Dependencies: SCANNER-SURFACE-02.
2025-11-06: Starting BuildX manifest upload implementation with Surface.FS client abstraction and integration tests. | BuildX Plugin Guild (src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md) [Scanner & Surface] 130.A) Scanner.VIII Depends on: Sprint 130.A - Scanner.VII diff --git a/src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/Routing/AocGuardEndpointFilter.cs b/src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/Routing/AocGuardEndpointFilter.cs index 0d4a5527a..1a74942ed 100644 --- a/src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/Routing/AocGuardEndpointFilter.cs +++ b/src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/Routing/AocGuardEndpointFilter.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using StellaOps.Aoc; +using StellaOps.Aoc.AspNetCore.Results; namespace StellaOps.Aoc.AspNetCore.Routing; @@ -55,7 +56,14 @@ public sealed class AocGuardEndpointFilter : IEndpointFilter _ => JsonSerializer.SerializeToElement(payload, _serializerOptions) }; - guard.ValidateOrThrow(element, options); + try + { + guard.ValidateOrThrow(element, options); + } + catch (AocGuardException exception) + { + return AocHttpResults.Problem(context.HttpContext, exception); + } } } } diff --git a/src/Concelier/StellaOps.Concelier.WebService/Extensions/JobRegistrationExtensions.cs b/src/Concelier/StellaOps.Concelier.WebService/Extensions/JobRegistrationExtensions.cs index f6823294e..febbdaa64 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Extensions/JobRegistrationExtensions.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Extensions/JobRegistrationExtensions.cs @@ -111,7 +111,7 @@ internal static class JobRegistrationExtensions private static void ConfigureMergeJob(JobSchedulerOptions options, IConfiguration configuration) { - var noMergeEnabled = configuration.GetValue("concelier:features:noMergeEnabled", true); + var noMergeEnabled = configuration.GetValue("concelier:features:noMergeEnabled") ?? true; if (noMergeEnabled) { options.Definitions.Remove(MergeReconcileBuiltInJob.Kind); diff --git a/src/Concelier/StellaOps.Concelier.WebService/Program.cs b/src/Concelier/StellaOps.Concelier.WebService/Program.cs index 6821564d3..0a42a322d 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Program.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Program.cs @@ -11,10 +11,10 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MongoDB.Bson; using MongoDB.Driver; @@ -55,17 +55,17 @@ const string AdvisoryIngestPolicyName = "Concelier.Advisories.Ingest"; const string AdvisoryReadPolicyName = "Concelier.Advisories.Read"; const string AocVerifyPolicyName = "Concelier.Aoc.Verify"; const string TenantHeaderName = "X-Stella-Tenant"; - -builder.Configuration.AddStellaOpsDefaults(options => -{ - options.BasePath = builder.Environment.ContentRootPath; - options.EnvironmentPrefix = "CONCELIER_"; - options.ConfigureBuilder = configurationBuilder => - { - configurationBuilder.AddConcelierYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/concelier.yaml")); - }; -}); - + +builder.Configuration.AddStellaOpsDefaults(options => +{ + options.BasePath = builder.Environment.ContentRootPath; + options.EnvironmentPrefix = "CONCELIER_"; + options.ConfigureBuilder = configurationBuilder => + { + configurationBuilder.AddConcelierYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/concelier.yaml")); + }; +}); + var contentRootPath = builder.Environment.ContentRootPath; var concelierOptions = builder.Configuration.BindOptions(postConfigure: (opts, _) => @@ -244,7 +244,7 @@ if (resolvedAuthority.Enabled && resolvedAuthority.AllowAnonymousFallback) app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority); -app.MapGet("/.well-known/openapi", (OpenApiDiscoveryDocumentProvider provider, HttpContext context) => +app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvider provider, HttpContext context) => { var (payload, etag) = provider.GetDocument(); @@ -299,7 +299,7 @@ var observationsEndpoint = app.MapGet("/concelier/observations", async ( [FromQuery(Name = "cpe")] string[]? cpes, [FromQuery(Name = "limit")] int? limit, [FromQuery(Name = "cursor")] string? cursor, - IAdvisoryObservationQueryService queryService, + [FromServices] IAdvisoryObservationQueryService queryService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); @@ -356,8 +356,8 @@ if (authorityConfigured) var advisoryIngestEndpoint = app.MapPost("/ingest/advisory", async ( HttpContext context, AdvisoryIngestRequest request, - IAdvisoryRawService rawService, - TimeProvider timeProvider, + [FromServices] IAdvisoryRawService rawService, + [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); @@ -470,7 +470,7 @@ if (authorityConfigured) var advisoryRawListEndpoint = app.MapGet("/advisories/raw", async ( HttpContext context, - IAdvisoryRawService rawService, + [FromServices] IAdvisoryRawService rawService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); @@ -560,7 +560,7 @@ if (authorityConfigured) var advisoryRawGetEndpoint = app.MapGet("/advisories/raw/{id}", async ( string id, HttpContext context, - IAdvisoryRawService rawService, + [FromServices] IAdvisoryRawService rawService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); @@ -604,7 +604,7 @@ if (authorityConfigured) var advisoryRawProvenanceEndpoint = app.MapGet("/advisories/raw/{id}/provenance", async ( string id, HttpContext context, - IAdvisoryRawService rawService, + [FromServices] IAdvisoryRawService rawService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); @@ -650,8 +650,8 @@ if (authorityConfigured) var aocVerifyEndpoint = app.MapPost("/aoc/verify", async ( HttpContext context, AocVerifyRequest request, - IAdvisoryRawService rawService, - TimeProvider timeProvider, + [FromServices] IAdvisoryRawService rawService, + [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); @@ -734,7 +734,7 @@ if (authorityConfigured) app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async ( string vulnerabilityKey, DateTimeOffset? asOf, - IAdvisoryEventLog eventLog, + [FromServices] IAdvisoryEventLog eventLog, CancellationToken cancellationToken) => { if (string.IsNullOrWhiteSpace(vulnerabilityKey)) @@ -798,29 +798,29 @@ if (loggingEnabled) }; }); } - + app.UseExceptionHandler(errorApp => { errorApp.Run(async context => { context.Response.ContentType = "application/problem+json"; - var feature = context.Features.Get(); - var error = feature?.Error; - - var extensions = new Dictionary(StringComparer.Ordinal) - { - ["traceId"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier, - }; - - var problem = Results.Problem( - detail: error?.Message, - instance: context.Request.Path, - statusCode: StatusCodes.Status500InternalServerError, - title: "Unexpected server error", - type: ProblemTypes.JobFailure, - extensions: extensions); - - await problem.ExecuteAsync(context); + var feature = context.Features.Get(); + var error = feature?.Error; + + var extensions = new Dictionary(StringComparer.Ordinal) + { + ["traceId"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier, + }; + + var problem = Results.Problem( + detail: error?.Message, + instance: context.Request.Path, + statusCode: StatusCodes.Status500InternalServerError, + title: "Unexpected server error", + type: ProblemTypes.JobFailure, + extensions: extensions); + + await problem.ExecuteAsync(context); }); }); @@ -868,13 +868,13 @@ if (authorityConfigured) app.UseAuthentication(); app.UseAuthorization(); } - -IResult JsonResult(T value, int? statusCode = null) -{ - var payload = JsonSerializer.Serialize(value, jsonOptions); - return Results.Content(payload, "application/json", Encoding.UTF8, statusCode); -} - + +IResult JsonResult(T value, int? statusCode = null) +{ + var payload = JsonSerializer.Serialize(value, jsonOptions); + return Results.Content(payload, "application/json", Encoding.UTF8, statusCode); +} + IResult Problem(HttpContext context, string title, int statusCode, string type, string? detail = null, IDictionary? extensions = null) { var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier; @@ -987,157 +987,157 @@ IResult MapAocGuardException(HttpContext context, ConcelierAocGuardException exc var guardException = new AocGuardException(exception.Result); return AocHttpResults.Problem(context, guardException); } - -static KeyValuePair[] BuildJobMetricTags(string jobKind, string trigger, string outcome) - => new[] - { - new KeyValuePair("job.kind", jobKind), - new KeyValuePair("job.trigger", trigger), - new KeyValuePair("job.outcome", outcome), - }; - -void ApplyNoCache(HttpResponse response) -{ - if (response is null) - { - return; - } - - response.Headers.CacheControl = "no-store, no-cache, max-age=0, must-revalidate"; - response.Headers.Pragma = "no-cache"; - response.Headers["Expires"] = "0"; -} - -await InitializeMongoAsync(app); - -app.MapGet("/health", (IOptions opts, ServiceStatus status, HttpContext context) => -{ - ApplyNoCache(context.Response); - - var snapshot = status.CreateSnapshot(); - var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d); - - var storage = new StorageBootstrapHealth( - Driver: opts.Value.Storage.Driver, - Completed: snapshot.BootstrapCompletedAt is not null, - CompletedAt: snapshot.BootstrapCompletedAt, - DurationMs: snapshot.BootstrapDuration?.TotalMilliseconds); - - var telemetry = new TelemetryHealth( - Enabled: opts.Value.Telemetry.Enabled, - Tracing: opts.Value.Telemetry.EnableTracing, - Metrics: opts.Value.Telemetry.EnableMetrics, - Logging: opts.Value.Telemetry.EnableLogging); - - var response = new HealthDocument( - Status: "healthy", - StartedAt: snapshot.StartedAt, - UptimeSeconds: uptimeSeconds, - Storage: storage, - Telemetry: telemetry); - - return JsonResult(response); -}); - -app.MapGet("/ready", async (IMongoDatabase database, ServiceStatus status, HttpContext context, CancellationToken cancellationToken) => -{ - ApplyNoCache(context.Response); - - var stopwatch = Stopwatch.StartNew(); - try - { - await database.RunCommandAsync((Command)"{ ping: 1 }", cancellationToken: cancellationToken).ConfigureAwait(false); - stopwatch.Stop(); - status.RecordMongoCheck(success: true, latency: stopwatch.Elapsed, error: null); - - var snapshot = status.CreateSnapshot(); - var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d); - - var mongo = new MongoReadyHealth( - Status: "ready", - LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds, - CheckedAt: snapshot.LastReadyCheckAt, - Error: null); - - var response = new ReadyDocument( - Status: "ready", - StartedAt: snapshot.StartedAt, - UptimeSeconds: uptimeSeconds, - Mongo: mongo); - - return JsonResult(response); - } - catch (Exception ex) - { - stopwatch.Stop(); - status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message); - - var snapshot = status.CreateSnapshot(); - var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d); - - var mongo = new MongoReadyHealth( - Status: "unready", - LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds, - CheckedAt: snapshot.LastReadyCheckAt, - Error: snapshot.LastMongoError ?? ex.Message); - - var response = new ReadyDocument( - Status: "unready", - StartedAt: snapshot.StartedAt, - UptimeSeconds: uptimeSeconds, - Mongo: mongo); - - var extensions = new Dictionary(StringComparer.Ordinal) - { - ["mongoLatencyMs"] = snapshot.LastMongoLatency?.TotalMilliseconds, - ["mongoError"] = snapshot.LastMongoError ?? ex.Message, - }; - - return Problem(context, "Mongo unavailable", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, snapshot.LastMongoError ?? ex.Message, extensions); - } -}); - -app.MapGet("/diagnostics/aliases/{seed}", async (string seed, AliasGraphResolver resolver, HttpContext context, CancellationToken cancellationToken) => -{ - ApplyNoCache(context.Response); - - if (string.IsNullOrWhiteSpace(seed)) - { - return Problem(context, "Seed advisory key is required.", StatusCodes.Status400BadRequest, ProblemTypes.Validation); - } - - var component = await resolver.BuildComponentAsync(seed, cancellationToken).ConfigureAwait(false); - - var aliases = component.AliasMap.ToDictionary( - static kvp => kvp.Key, - static kvp => kvp.Value - .Select(record => new - { - record.Scheme, - record.Value, - UpdatedAt = record.UpdatedAt - }) - .ToArray()); - - var response = new - { - Seed = component.SeedAdvisoryKey, - Advisories = component.AdvisoryKeys, - Collisions = component.Collisions - .Select(collision => new - { - collision.Scheme, - collision.Value, - AdvisoryKeys = collision.AdvisoryKeys - }) - .ToArray(), - Aliases = aliases - }; - - return JsonResult(response); -}); - -var jobsListEndpoint = app.MapGet("/jobs", async (string? kind, int? limit, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => + +static KeyValuePair[] BuildJobMetricTags(string jobKind, string trigger, string outcome) + => new[] + { + new KeyValuePair("job.kind", jobKind), + new KeyValuePair("job.trigger", trigger), + new KeyValuePair("job.outcome", outcome), + }; + +void ApplyNoCache(HttpResponse response) +{ + if (response is null) + { + return; + } + + response.Headers.CacheControl = "no-store, no-cache, max-age=0, must-revalidate"; + response.Headers.Pragma = "no-cache"; + response.Headers["Expires"] = "0"; +} + +await InitializeMongoAsync(app); + +app.MapGet("/health", ([FromServices] IOptions opts, [FromServices] ServiceStatus status, HttpContext context) => +{ + ApplyNoCache(context.Response); + + var snapshot = status.CreateSnapshot(); + var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d); + + var storage = new StorageBootstrapHealth( + Driver: opts.Value.Storage.Driver, + Completed: snapshot.BootstrapCompletedAt is not null, + CompletedAt: snapshot.BootstrapCompletedAt, + DurationMs: snapshot.BootstrapDuration?.TotalMilliseconds); + + var telemetry = new TelemetryHealth( + Enabled: opts.Value.Telemetry.Enabled, + Tracing: opts.Value.Telemetry.EnableTracing, + Metrics: opts.Value.Telemetry.EnableMetrics, + Logging: opts.Value.Telemetry.EnableLogging); + + var response = new HealthDocument( + Status: "healthy", + StartedAt: snapshot.StartedAt, + UptimeSeconds: uptimeSeconds, + Storage: storage, + Telemetry: telemetry); + + return JsonResult(response); +}); + +app.MapGet("/ready", async ([FromServices] IMongoDatabase database, [FromServices] ServiceStatus status, HttpContext context, CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + var stopwatch = Stopwatch.StartNew(); + try + { + await database.RunCommandAsync((Command)"{ ping: 1 }", cancellationToken: cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + status.RecordMongoCheck(success: true, latency: stopwatch.Elapsed, error: null); + + var snapshot = status.CreateSnapshot(); + var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d); + + var mongo = new MongoReadyHealth( + Status: "ready", + LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds, + CheckedAt: snapshot.LastReadyCheckAt, + Error: null); + + var response = new ReadyDocument( + Status: "ready", + StartedAt: snapshot.StartedAt, + UptimeSeconds: uptimeSeconds, + Mongo: mongo); + + return JsonResult(response); + } + catch (Exception ex) + { + stopwatch.Stop(); + status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message); + + var snapshot = status.CreateSnapshot(); + var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d); + + var mongo = new MongoReadyHealth( + Status: "unready", + LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds, + CheckedAt: snapshot.LastReadyCheckAt, + Error: snapshot.LastMongoError ?? ex.Message); + + var response = new ReadyDocument( + Status: "unready", + StartedAt: snapshot.StartedAt, + UptimeSeconds: uptimeSeconds, + Mongo: mongo); + + var extensions = new Dictionary(StringComparer.Ordinal) + { + ["mongoLatencyMs"] = snapshot.LastMongoLatency?.TotalMilliseconds, + ["mongoError"] = snapshot.LastMongoError ?? ex.Message, + }; + + return Problem(context, "Mongo unavailable", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, snapshot.LastMongoError ?? ex.Message, extensions); + } +}); + +app.MapGet("/diagnostics/aliases/{seed}", async (string seed, [FromServices] AliasGraphResolver resolver, HttpContext context, CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (string.IsNullOrWhiteSpace(seed)) + { + return Problem(context, "Seed advisory key is required.", StatusCodes.Status400BadRequest, ProblemTypes.Validation); + } + + var component = await resolver.BuildComponentAsync(seed, cancellationToken).ConfigureAwait(false); + + var aliases = component.AliasMap.ToDictionary( + static kvp => kvp.Key, + static kvp => kvp.Value + .Select(record => new + { + record.Scheme, + record.Value, + UpdatedAt = record.UpdatedAt + }) + .ToArray()); + + var response = new + { + Seed = component.SeedAdvisoryKey, + Advisories = component.AdvisoryKeys, + Collisions = component.Collisions + .Select(collision => new + { + collision.Scheme, + collision.Value, + AdvisoryKeys = collision.AdvisoryKeys + }) + .ToArray(), + Aliases = aliases + }; + + return JsonResult(response); +}); + +var jobsListEndpoint = app.MapGet("/jobs", async (string? kind, int? limit, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); @@ -1151,7 +1151,7 @@ if (enforceAuthority) jobsListEndpoint.RequireAuthorization(JobsPolicyName); } -var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => +var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); @@ -1168,25 +1168,25 @@ if (enforceAuthority) jobByIdEndpoint.RequireAuthorization(JobsPolicyName); } -var jobDefinitionsEndpoint = app.MapGet("/jobs/definitions", async (IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => +var jobDefinitionsEndpoint = app.MapGet("/jobs/definitions", async ([FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => { - ApplyNoCache(context.Response); - - var definitions = await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false); - if (definitions.Count == 0) - { - return JsonResult(Array.Empty()); - } - - var definitionKinds = definitions.Select(static definition => definition.Kind).ToArray(); - var lastRuns = await coordinator.GetLastRunsAsync(definitionKinds, cancellationToken).ConfigureAwait(false); - - var responses = new List(definitions.Count); - foreach (var definition in definitions) - { - lastRuns.TryGetValue(definition.Kind, out var lastRun); - responses.Add(JobDefinitionResponse.FromDefinition(definition, lastRun)); - } + ApplyNoCache(context.Response); + + var definitions = await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false); + if (definitions.Count == 0) + { + return JsonResult(Array.Empty()); + } + + var definitionKinds = definitions.Select(static definition => definition.Kind).ToArray(); + var lastRuns = await coordinator.GetLastRunsAsync(definitionKinds, cancellationToken).ConfigureAwait(false); + + var responses = new List(definitions.Count); + foreach (var definition in definitions) + { + lastRuns.TryGetValue(definition.Kind, out var lastRun); + responses.Add(JobDefinitionResponse.FromDefinition(definition, lastRun)); + } return JsonResult(responses); }).AddEndpointFilter(); @@ -1195,20 +1195,20 @@ if (enforceAuthority) jobDefinitionsEndpoint.RequireAuthorization(JobsPolicyName); } -var jobDefinitionEndpoint = app.MapGet("/jobs/definitions/{kind}", async (string kind, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => +var jobDefinitionEndpoint = app.MapGet("/jobs/definitions/{kind}", async (string kind, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => { - ApplyNoCache(context.Response); - - var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false)) - .FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal)); - - if (definition is null) - { - return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered."); - } - - var lastRuns = await coordinator.GetLastRunsAsync(new[] { definition.Kind }, cancellationToken).ConfigureAwait(false); - lastRuns.TryGetValue(definition.Kind, out var lastRun); + ApplyNoCache(context.Response); + + var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false)) + .FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal)); + + if (definition is null) + { + return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered."); + } + + var lastRuns = await coordinator.GetLastRunsAsync(new[] { definition.Kind }, cancellationToken).ConfigureAwait(false); + lastRuns.TryGetValue(definition.Kind, out var lastRun); var response = JobDefinitionResponse.FromDefinition(definition, lastRun); return JsonResult(response); @@ -1218,18 +1218,18 @@ if (enforceAuthority) jobDefinitionEndpoint.RequireAuthorization(JobsPolicyName); } -var jobDefinitionRunsEndpoint = app.MapGet("/jobs/definitions/{kind}/runs", async (string kind, int? limit, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => +var jobDefinitionRunsEndpoint = app.MapGet("/jobs/definitions/{kind}/runs", async (string kind, int? limit, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => { - ApplyNoCache(context.Response); - - var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false)) - .FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal)); - - if (definition is null) - { - return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered."); - } - + ApplyNoCache(context.Response); + + var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false)) + .FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal)); + + if (definition is null) + { + return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered."); + } + var take = Math.Clamp(limit.GetValueOrDefault(20), 1, 200); var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false); var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); @@ -1240,9 +1240,9 @@ if (enforceAuthority) jobDefinitionRunsEndpoint.RequireAuthorization(JobsPolicyName); } -var activeJobsEndpoint = app.MapGet("/jobs/active", async (IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => +var activeJobsEndpoint = app.MapGet("/jobs/active", async ([FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => { - ApplyNoCache(context.Response); + ApplyNoCache(context.Response); var runs = await coordinator.GetActiveRunsAsync(cancellationToken).ConfigureAwait(false); var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); @@ -1253,22 +1253,22 @@ if (enforceAuthority) activeJobsEndpoint.RequireAuthorization(JobsPolicyName); } -var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind, JobTriggerRequest request, IJobCoordinator coordinator, HttpContext context) => +var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind, JobTriggerRequest request, [FromServices] IJobCoordinator coordinator, HttpContext context) => { - ApplyNoCache(context.Response); - - request ??= new JobTriggerRequest(); - request.Parameters ??= new Dictionary(StringComparer.Ordinal); - var trigger = string.IsNullOrWhiteSpace(request.Trigger) ? "api" : request.Trigger; - - var lifetime = context.RequestServices.GetRequiredService(); - var result = await coordinator.TriggerAsync(jobKind, request.Parameters, trigger, lifetime.ApplicationStopping).ConfigureAwait(false); - - var outcome = result.Outcome; - var tags = BuildJobMetricTags(jobKind, trigger, outcome.ToString().ToLowerInvariant()); - - switch (outcome) - { + ApplyNoCache(context.Response); + + request ??= new JobTriggerRequest(); + request.Parameters ??= new Dictionary(StringComparer.Ordinal); + var trigger = string.IsNullOrWhiteSpace(request.Trigger) ? "api" : request.Trigger; + + var lifetime = context.RequestServices.GetRequiredService(); + var result = await coordinator.TriggerAsync(jobKind, request.Parameters, trigger, lifetime.ApplicationStopping).ConfigureAwait(false); + + var outcome = result.Outcome; + var tags = BuildJobMetricTags(jobKind, trigger, outcome.ToString().ToLowerInvariant()); + + switch (outcome) + { case JobTriggerOutcome.Accepted: JobMetrics.TriggerCounter.Add(1, tags); if (result.Run is null) @@ -1279,54 +1279,54 @@ var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind, var acceptedRun = JobRunResponse.FromSnapshot(result.Run); context.Response.Headers.Location = $"/jobs/{acceptedRun.RunId}"; return JsonResult(acceptedRun, StatusCodes.Status202Accepted); - - case JobTriggerOutcome.NotFound: - JobMetrics.TriggerConflictCounter.Add(1, tags); - return Problem(context, "Job not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, result.ErrorMessage ?? $"Job '{jobKind}' is not registered."); - - case JobTriggerOutcome.Disabled: - JobMetrics.TriggerConflictCounter.Add(1, tags); - return Problem(context, "Job disabled", StatusCodes.Status423Locked, ProblemTypes.Locked, result.ErrorMessage ?? $"Job '{jobKind}' is disabled."); - - case JobTriggerOutcome.AlreadyRunning: - JobMetrics.TriggerConflictCounter.Add(1, tags); - return Problem(context, "Job already running", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' already has an active run."); - - case JobTriggerOutcome.LeaseRejected: - JobMetrics.TriggerConflictCounter.Add(1, tags); - return Problem(context, "Job lease rejected", StatusCodes.Status409Conflict, ProblemTypes.LeaseRejected, result.ErrorMessage ?? $"Job '{jobKind}' could not acquire a lease."); - - case JobTriggerOutcome.InvalidParameters: - { - JobMetrics.TriggerConflictCounter.Add(1, tags); - var extensions = new Dictionary(StringComparer.Ordinal) - { - ["parameters"] = request.Parameters, - }; - return Problem(context, "Invalid job parameters", StatusCodes.Status400BadRequest, ProblemTypes.Validation, result.ErrorMessage, extensions); - } - - case JobTriggerOutcome.Cancelled: - { - JobMetrics.TriggerConflictCounter.Add(1, tags); - var extensions = new Dictionary(StringComparer.Ordinal) - { - ["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run), - }; - - return Problem(context, "Job cancelled", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' was cancelled before completion.", extensions); - } - - case JobTriggerOutcome.Failed: - { - JobMetrics.TriggerFailureCounter.Add(1, tags); - var extensions = new Dictionary(StringComparer.Ordinal) - { - ["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run), - }; - - return Problem(context, "Job execution failed", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, result.ErrorMessage, extensions); - } + + case JobTriggerOutcome.NotFound: + JobMetrics.TriggerConflictCounter.Add(1, tags); + return Problem(context, "Job not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, result.ErrorMessage ?? $"Job '{jobKind}' is not registered."); + + case JobTriggerOutcome.Disabled: + JobMetrics.TriggerConflictCounter.Add(1, tags); + return Problem(context, "Job disabled", StatusCodes.Status423Locked, ProblemTypes.Locked, result.ErrorMessage ?? $"Job '{jobKind}' is disabled."); + + case JobTriggerOutcome.AlreadyRunning: + JobMetrics.TriggerConflictCounter.Add(1, tags); + return Problem(context, "Job already running", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' already has an active run."); + + case JobTriggerOutcome.LeaseRejected: + JobMetrics.TriggerConflictCounter.Add(1, tags); + return Problem(context, "Job lease rejected", StatusCodes.Status409Conflict, ProblemTypes.LeaseRejected, result.ErrorMessage ?? $"Job '{jobKind}' could not acquire a lease."); + + case JobTriggerOutcome.InvalidParameters: + { + JobMetrics.TriggerConflictCounter.Add(1, tags); + var extensions = new Dictionary(StringComparer.Ordinal) + { + ["parameters"] = request.Parameters, + }; + return Problem(context, "Invalid job parameters", StatusCodes.Status400BadRequest, ProblemTypes.Validation, result.ErrorMessage, extensions); + } + + case JobTriggerOutcome.Cancelled: + { + JobMetrics.TriggerConflictCounter.Add(1, tags); + var extensions = new Dictionary(StringComparer.Ordinal) + { + ["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run), + }; + + return Problem(context, "Job cancelled", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' was cancelled before completion.", extensions); + } + + case JobTriggerOutcome.Failed: + { + JobMetrics.TriggerFailureCounter.Add(1, tags); + var extensions = new Dictionary(StringComparer.Ordinal) + { + ["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run), + }; + + return Problem(context, "Job execution failed", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, result.ErrorMessage, extensions); + } default: JobMetrics.TriggerFailureCounter.Add(1, tags); @@ -1337,61 +1337,61 @@ if (enforceAuthority) { triggerJobEndpoint.RequireAuthorization(JobsPolicyName); } - -await app.RunAsync(); - -static PluginHostOptions BuildPluginOptions(ConcelierOptions options, string contentRoot) -{ - var pluginOptions = new PluginHostOptions - { + +await app.RunAsync(); + +static PluginHostOptions BuildPluginOptions(ConcelierOptions options, string contentRoot) +{ + var pluginOptions = new PluginHostOptions + { BaseDirectory = options.Plugins.BaseDirectory ?? contentRoot, PluginsDirectory = options.Plugins.Directory ?? Path.Combine(contentRoot, "StellaOps.Concelier.PluginBinaries"), PrimaryPrefix = "StellaOps.Concelier", - EnsureDirectoryExists = true, - RecursiveSearch = false, - }; - - if (options.Plugins.SearchPatterns.Count == 0) - { - pluginOptions.SearchPatterns.Add("StellaOps.Concelier.Plugin.*.dll"); - } - else - { - foreach (var pattern in options.Plugins.SearchPatterns) - { - if (!string.IsNullOrWhiteSpace(pattern)) - { - pluginOptions.SearchPatterns.Add(pattern); - } - } - } - - return pluginOptions; -} - -static async Task InitializeMongoAsync(WebApplication app) -{ - await using var scope = app.Services.CreateAsyncScope(); - var bootstrapper = scope.ServiceProvider.GetRequiredService(); - var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("MongoBootstrapper"); - var status = scope.ServiceProvider.GetRequiredService(); - - var stopwatch = Stopwatch.StartNew(); - - try - { - await bootstrapper.InitializeAsync(app.Lifetime.ApplicationStopping).ConfigureAwait(false); - stopwatch.Stop(); - status.MarkBootstrapCompleted(stopwatch.Elapsed); - logger.LogInformation("Mongo bootstrap completed in {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds); - } - catch (Exception ex) - { - stopwatch.Stop(); - status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message); - logger.LogCritical(ex, "Mongo bootstrap failed after {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds); - throw; - } -} - -public partial class Program; + EnsureDirectoryExists = true, + RecursiveSearch = false, + }; + + if (options.Plugins.SearchPatterns.Count == 0) + { + pluginOptions.SearchPatterns.Add("StellaOps.Concelier.Plugin.*.dll"); + } + else + { + foreach (var pattern in options.Plugins.SearchPatterns) + { + if (!string.IsNullOrWhiteSpace(pattern)) + { + pluginOptions.SearchPatterns.Add(pattern); + } + } + } + + return pluginOptions; +} + +static async Task InitializeMongoAsync(WebApplication app) +{ + await using var scope = app.Services.CreateAsyncScope(); + var bootstrapper = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("MongoBootstrapper"); + var status = scope.ServiceProvider.GetRequiredService(); + + var stopwatch = Stopwatch.StartNew(); + + try + { + await bootstrapper.InitializeAsync(app.Lifetime.ApplicationStopping).ConfigureAwait(false); + stopwatch.Stop(); + status.MarkBootstrapCompleted(stopwatch.Elapsed); + logger.LogInformation("Mongo bootstrap completed in {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds); + } + catch (Exception ex) + { + stopwatch.Stop(); + status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message); + logger.LogCritical(ex, "Mongo bootstrap failed after {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds); + throw; + } +} + +public partial class Program; diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/MergeServiceCollectionExtensions.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/MergeServiceCollectionExtensions.cs index 1f9add9c0..6a5c9d585 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/MergeServiceCollectionExtensions.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/MergeServiceCollectionExtensions.cs @@ -18,7 +18,7 @@ public static class MergeServiceCollectionExtensions ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configuration); - var noMergeEnabled = configuration.GetValue("concelier:features:noMergeEnabled"); + var noMergeEnabled = configuration.GetValue("concelier:features:noMergeEnabled") ?? true; if (noMergeEnabled) { return services; diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md index 589bc785a..e30363fd0 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md @@ -10,6 +10,6 @@ | Task | Owner(s) | Depends on | Notes | |---|---|---|---| |MERGE-LNM-21-001 Migration plan authoring|BE-Merge, Architecture Guild|CONCELIER-LNM-21-101|**DONE (2025-11-03)** – Authored `docs/migration/no-merge.md` with rollout phases, backfill/validation checklists, rollback guidance, and ownership matrix for the Link-Not-Merge cutover.| -|MERGE-LNM-21-002 Merge service deprecation|BE-Merge|MERGE-LNM-21-001|**DONE (2025-11-06)** – Audited service registrations, gated legacy bindings, and delivered analyzer coverage ahead of removal.
2025-11-05 14:42Z: Implemented `concelier:features:noMergeEnabled` gate, merge job allowlist checks, `[Obsolete]` markings, and analyzer scaffolding to steer consumers toward linkset APIs.
2025-11-06 16:10Z: Introduced Roslyn analyzer (`CONCELIER0002`) referenced by Concelier WebService + tests, documented suppression guidance, and updated migration playbook.
2025-11-06 23:58Z: Defaulted `concelier:features:noMergeEnabled` to `true`, removed the built-in `merge:reconcile` job unless explicitly allowlisted, refreshed WebService tests/docs, and verified analyzer suites restore against local feeds.| +|MERGE-LNM-21-002 Merge service deprecation|BE-Merge|MERGE-LNM-21-001|**DOING (2025-11-06)** – Defaulted `concelier:features:noMergeEnabled` to `true`, added merge job allowlist gate, and began rewiring guard/tier tests; follow-up work required to restore Concelier WebService test suite before declaring completion.
2025-11-05 14:42Z: Implemented `concelier:features:noMergeEnabled` gate, merge job allowlist checks, `[Obsolete]` markings, and analyzer scaffolding to steer consumers toward linkset APIs.
2025-11-06 16:10Z: Introduced Roslyn analyzer (`CONCELIER0002`) referenced by Concelier WebService + tests, documented suppression guidance, and updated migration playbook.
2025-11-07 03:25Z: Default-on toggle + job gating break existing Concelier WebService tests; guard + seed fixes pending to unblock ingest/mirror suites.| > 2025-11-03: Catalogued call sites (WebService Program `AddMergeModule`, built-in job registration `merge:reconcile`, `MergeReconcileJob`) and confirmed unit tests are the only direct `MergeAsync` callers; next step is to define analyzer + replacement observability coverage. |MERGE-LNM-21-003 Determinism/test updates|QA Guild, BE-Merge|MERGE-LNM-21-002|Replace merge determinism suites with observation/linkset regression tests verifying no data mutation and conflicts remain visible.| diff --git a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md index c54f1ea0e..f9ac29dd4 100644 --- a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md +++ b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md @@ -2,6 +2,6 @@ | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | |----|--------|----------|------------|-------------|---------------| -| SCANNER-SURFACE-03 | TODO | BuildX Plugin Guild | SURFACE-FS-02 | Push layer manifests and entry fragments into Surface.FS during build-time SBOM generation. | BuildX integration tests confirm cache population; CLI docs updated. | +| SCANNER-SURFACE-03 | DOING (2025-11-06) | BuildX Plugin Guild | SURFACE-FS-02 | Push layer manifests and entry fragments into Surface.FS during build-time SBOM generation.
2025-11-06: Kicked off manifest emitter wiring within BuildX export pipeline and outlined test fixtures targeting Surface.FS client mock. | BuildX integration tests confirm cache population; CLI docs updated. | | SCANNER-ENV-03 | TODO | BuildX Plugin Guild | SURFACE-ENV-02 | Adopt Surface.Env helpers for plugin configuration (cache roots, CAS endpoints, feature toggles). | Plugin loads helper; misconfig errors logged; README updated. | | SCANNER-SECRETS-03 | TODO | BuildX Plugin Guild, Security Guild | SURFACE-SECRETS-02 | Use Surface.Secrets to retrieve registry credentials when interacting with CAS/referrers. | Secrets retrieved via shared library; e2e tests cover rotation; operations guide refreshed. | diff --git a/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs b/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs index 7644be678..44e83a206 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs @@ -1,6 +1,6 @@ using System; -using System.Collections.Generic; using System.Text.Json.Serialization; +using StellaOps.Scanner.Surface.FS; namespace StellaOps.Scanner.WebService.Contracts; @@ -28,60 +28,3 @@ public sealed record SurfacePointersDto [JsonPropertyOrder(4)] public SurfaceManifestDocument Manifest { get; init; } = new(); } - -public sealed record SurfaceManifestDocument -{ - [JsonPropertyName("schema")] - [JsonPropertyOrder(0)] - public string Schema { get; init; } = "stellaops.surface.manifest@1"; - - [JsonPropertyName("tenant")] - [JsonPropertyOrder(1)] - public string Tenant { get; init; } = string.Empty; - - [JsonPropertyName("imageDigest")] - [JsonPropertyOrder(2)] - public string ImageDigest { get; init; } = string.Empty; - - [JsonPropertyName("generatedAt")] - [JsonPropertyOrder(3)] - public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow; - - [JsonPropertyName("artifacts")] - [JsonPropertyOrder(4)] - public IReadOnlyList Artifacts { get; init; } = Array.Empty(); -} - -public sealed record SurfaceManifestArtifact -{ - [JsonPropertyName("kind")] - [JsonPropertyOrder(0)] - public string Kind { get; init; } = string.Empty; - - [JsonPropertyName("uri")] - [JsonPropertyOrder(1)] - public string Uri { get; init; } = string.Empty; - - [JsonPropertyName("digest")] - [JsonPropertyOrder(2)] - public string Digest { get; init; } = string.Empty; - - [JsonPropertyName("mediaType")] - [JsonPropertyOrder(3)] - public string MediaType { get; init; } = string.Empty; - - [JsonPropertyName("format")] - [JsonPropertyOrder(4)] - public string Format { get; init; } = string.Empty; - - [JsonPropertyName("sizeBytes")] - [JsonPropertyOrder(5)] - public long SizeBytes { get; init; } - = 0; - - [JsonPropertyName("view")] - [JsonPropertyOrder(6)] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? View { get; init; } - = null; -} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerStorageOptionsPostConfigurator.cs b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerStorageOptionsPostConfigurator.cs new file mode 100644 index 000000000..54cfd3157 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerStorageOptionsPostConfigurator.cs @@ -0,0 +1,118 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Scanner.Storage; + +namespace StellaOps.Scanner.WebService.Options; + +internal sealed class ScannerStorageOptionsPostConfigurator : IPostConfigureOptions +{ + private readonly IOptionsMonitor _webOptions; + private readonly ILogger _logger; + + public ScannerStorageOptionsPostConfigurator( + IOptionsMonitor webOptions, + ILogger logger) + { + _webOptions = webOptions ?? throw new ArgumentNullException(nameof(webOptions)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void PostConfigure(string? name, ScannerStorageOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var source = _webOptions.CurrentValue?.ArtifactStore; + if (source is null) + { + return; + } + + var target = options.ObjectStore ??= new ObjectStoreOptions(); + + if (!string.IsNullOrWhiteSpace(source.Driver)) + { + target.Driver = source.Driver; + } + + if (!string.IsNullOrWhiteSpace(source.Region)) + { + target.Region = source.Region!; + } + + if (!string.IsNullOrWhiteSpace(source.Bucket)) + { + target.BucketName = source.Bucket!; + } + + if (!string.IsNullOrWhiteSpace(source.RootPrefix)) + { + target.RootPrefix = source.RootPrefix; + } + + if (!string.IsNullOrWhiteSpace(source.Endpoint)) + { + if (target.IsRustFsDriver()) + { + target.RustFs ??= new RustFsOptions(); + target.RustFs.BaseUrl = source.Endpoint; + } + else + { + target.ServiceUrl = source.Endpoint; + } + } + + if (target.IsRustFsDriver()) + { + if (target.RustFs is null) + { + target.RustFs = new RustFsOptions(); + } + + target.RustFs.AllowInsecureTls = source.AllowInsecureTls; + + if (!string.IsNullOrWhiteSpace(source.ApiKeyHeader)) + { + target.RustFs.ApiKeyHeader = source.ApiKeyHeader!; + } + + if (!string.IsNullOrWhiteSpace(source.ApiKey)) + { + target.RustFs.ApiKey = source.ApiKey; + } + + if (!string.IsNullOrWhiteSpace(source.Endpoint)) + { + target.RustFs.BaseUrl = source.Endpoint!; + } + } + + if (!string.IsNullOrWhiteSpace(source.AccessKey)) + { + target.AccessKeyId = source.AccessKey; + } + + if (!string.IsNullOrWhiteSpace(source.SecretKey)) + { + target.SecretAccessKey = source.SecretKey; + } + + if (source.Headers is { Count: > 0 }) + { + foreach (var (key, value) in source.Headers) + { + if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value)) + { + continue; + } + + target.Headers[key] = value; + } + } + + _logger.LogDebug( + "Mirrored artifact store settings into scanner storage options (driver: {Driver}, bucket: {Bucket}).", + target.Driver, + target.BucketName); + } +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerSurfaceSecretConfigurator.cs b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerSurfaceSecretConfigurator.cs new file mode 100644 index 000000000..5fd781f97 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerSurfaceSecretConfigurator.cs @@ -0,0 +1,124 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Scanner.Surface.Env; +using StellaOps.Scanner.Surface.Secrets; + +namespace StellaOps.Scanner.WebService.Options; + +internal sealed class ScannerSurfaceSecretConfigurator : IConfigureOptions +{ + private const string ComponentName = "Scanner.WebService"; + + private readonly ISurfaceSecretProvider _secretProvider; + private readonly ISurfaceEnvironment _surfaceEnvironment; + private readonly ILogger _logger; + + public ScannerSurfaceSecretConfigurator( + ISurfaceSecretProvider secretProvider, + ISurfaceEnvironment surfaceEnvironment, + ILogger logger) + { + _secretProvider = secretProvider ?? throw new ArgumentNullException(nameof(secretProvider)); + _surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Configure(ScannerWebServiceOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var tenant = _surfaceEnvironment.Settings.Secrets.Tenant; + var request = new SurfaceSecretRequest( + Tenant: tenant, + Component: ComponentName, + SecretType: "cas-access"); + + CasAccessSecret? secret = null; + try + { + using var handle = _secretProvider.GetAsync(request).AsTask().GetAwaiter().GetResult(); + secret = SurfaceSecretParser.ParseCasAccessSecret(handle); + } + catch (SurfaceSecretNotFoundException) + { + _logger.LogDebug("Surface secret 'cas-access' not found for {Component}; retaining configured artifact store settings.", ComponentName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to resolve surface secret 'cas-access' for {Component}.", ComponentName); + } + + if (secret is null) + { + return; + } + + ApplySecret(options.ArtifactStore ??= new ScannerWebServiceOptions.ArtifactStoreOptions(), secret); + } + + private void ApplySecret(ScannerWebServiceOptions.ArtifactStoreOptions artifactStore, CasAccessSecret secret) + { + if (!string.IsNullOrWhiteSpace(secret.Driver)) + { + artifactStore.Driver = secret.Driver; + } + + if (!string.IsNullOrWhiteSpace(secret.Endpoint)) + { + artifactStore.Endpoint = secret.Endpoint!; + } + + if (secret.AllowInsecureTls is { } insecure) + { + artifactStore.AllowInsecureTls = insecure; + artifactStore.UseTls = !insecure; + } + + if (!string.IsNullOrWhiteSpace(secret.Region)) + { + artifactStore.Region = secret.Region; + } + + if (!string.IsNullOrWhiteSpace(secret.Bucket)) + { + artifactStore.Bucket = secret.Bucket!; + } + + if (!string.IsNullOrWhiteSpace(secret.RootPrefix)) + { + artifactStore.RootPrefix = secret.RootPrefix!; + } + + if (!string.IsNullOrWhiteSpace(secret.ApiKeyHeader)) + { + artifactStore.ApiKeyHeader = secret.ApiKeyHeader!; + } + + if (!string.IsNullOrWhiteSpace(secret.ApiKey)) + { + artifactStore.ApiKey = secret.ApiKey; + } + + if (!string.IsNullOrWhiteSpace(secret.AccessKeyId) && !string.IsNullOrWhiteSpace(secret.SecretAccessKey)) + { + artifactStore.AccessKey = secret.AccessKeyId!; + artifactStore.SecretKey = secret.SecretAccessKey!; + } + + foreach (var header in secret.Headers) + { + if (string.IsNullOrWhiteSpace(header.Key) || string.IsNullOrWhiteSpace(header.Value)) + { + continue; + } + + artifactStore.Headers[header.Key] = header.Value; + } + + _logger.LogInformation( + "Surface secret 'cas-access' applied for {Component} (driver: {Driver}, bucket: {Bucket}).", + ComponentName, + artifactStore.Driver, + artifactStore.Bucket); + } +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptionsPostConfigure.cs b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptionsPostConfigure.cs index 03b9426a5..84c7908ff 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptionsPostConfigure.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptionsPostConfigure.cs @@ -30,13 +30,7 @@ public static class ScannerWebServiceOptionsPostConfigure options.ArtifactStore ??= new ScannerWebServiceOptions.ArtifactStoreOptions(); var artifactStore = options.ArtifactStore; - if (string.IsNullOrWhiteSpace(artifactStore.SecretKey) - && !string.IsNullOrWhiteSpace(artifactStore.SecretKeyFile)) - { - artifactStore.SecretKey = ReadSecretFile(artifactStore.SecretKeyFile!, contentRootPath); - } - - options.Signing ??= new ScannerWebServiceOptions.SigningOptions(); + options.Signing ??= new ScannerWebServiceOptions.SigningOptions(); var signing = options.Signing; if (string.IsNullOrWhiteSpace(signing.KeyPem) && !string.IsNullOrWhiteSpace(signing.KeyPemFile)) diff --git a/src/Scanner/StellaOps.Scanner.WebService/Program.cs b/src/Scanner/StellaOps.Scanner.WebService/Program.cs index 83df2c0cc..c9a95c6b3 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Program.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Program.cs @@ -97,6 +97,7 @@ builder.Services.AddSurfaceEnvironment(options => builder.Services.AddSurfaceValidation(); builder.Services.AddSurfaceFileCache(); builder.Services.AddSurfaceSecrets(); +builder.Services.AddSingleton, ScannerSurfaceSecretConfigurator>(); builder.Services.AddSingleton>(sp => new SurfaceCacheOptionsConfigurator(sp.GetRequiredService())); builder.Services.AddSingleton(); @@ -179,6 +180,7 @@ builder.Services.AddScannerStorage(storageOptions => storageOptions.ObjectStore.RustFs.BaseUrl = string.Empty; } }); +builder.Services.AddSingleton, ScannerStorageOptionsPostConfigurator>(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md index fd383c5b5..35fdfa54e 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md +++ b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md @@ -6,7 +6,7 @@ | SCANNER-SURFACE-02 | DONE (2025-11-05) | Scanner WebService Guild | SURFACE-FS-02 | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata.
2025-11-05: Surface pointers projected through scan/report endpoints, orchestrator samples + DSSE fixtures refreshed with manifest block, readiness tests updated to use validator stub. | OpenAPI updated; clients regenerated; integration tests validate pointer presence and tenancy. | | SCANNER-ENV-02 | TODO (2025-11-06) | Scanner WebService Guild, Ops Guild | SURFACE-ENV-02 | Wire Surface.Env helpers into WebService hosting (cache roots, feature flags) and document configuration.
2025-11-02: Cache root resolution switched to helper; feature flag bindings updated; Helm/Compose updates pending review.
2025-11-05 14:55Z: Aligning readiness checks, docs, and Helm/Compose templates with Surface.Env outputs and planning test coverage for configuration fallbacks.
2025-11-06 17:05Z: Surface.Env documentation/README refreshed; warning catalogue captured for ops handoff.
2025-11-06 07:45Z: Helm values (dev/stage/prod/airgap/mirror) and Compose examples updated with `SCANNER_SURFACE_*` defaults plus rollout warning note in `deploy/README.md`.
2025-11-06 07:55Z: Paused; follow-up automation captured under `DEVOPS-OPENSSL-11-001/002` and pending Surface.Env readiness tests. | Service uses helper; env table documented; helm/compose templates updated. | > 2025-11-05 19:18Z: Added configurator to project wiring and unit test ensuring Surface.Env cache root is honoured. -| SCANNER-SECRETS-02 | DOING (2025-11-02) | Scanner WebService Guild, Security Guild | SURFACE-SECRETS-02 | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens).
2025-11-02: Export/report flows now depend on Surface.Secrets stub; integration tests in progress. | Secrets fetched through shared provider; unit/integration tests cover rotation + failure cases. | +| SCANNER-SECRETS-02 | DOING (2025-11-06) | Scanner WebService Guild, Security Guild | SURFACE-SECRETS-02 | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens).
2025-11-02: Export/report flows now depend on Surface.Secrets stub; integration tests in progress.
2025-11-06: Restarting work to eliminate file-based secrets, plumb provider handles through report/export services, and extend failure/rotation tests.
2025-11-06 21:40Z: Added configurator + storage post-config to hydrate artifact/CAS credentials from `cas-access` secrets with unit coverage. | Secrets fetched through shared provider; unit/integration tests cover rotation + failure cases. | | SCANNER-EVENTS-16-301 | BLOCKED (2025-10-26) | Scanner WebService Guild | ORCH-SVC-38-101, NOTIFY-SVC-38-001 | Emit orchestrator-compatible envelopes (`scanner.event.*`) and update integration tests to verify Notifier ingestion (no Redis queue coupling). | Tests assert envelope schema + orchestrator publish; Notifier consumer harness passes; docs updated with new event contract. Blocked by .NET 10 preview OpenAPI/Auth dependency drift preventing `dotnet test` completion. | | SCANNER-EVENTS-16-302 | DONE (2025-11-06) | Scanner WebService Guild | SCANNER-EVENTS-16-301 | Extend orchestrator event links (report/policy/attestation) once endpoints are finalised across gateway + console.
2025-11-06 22:55Z: Dispatcher now honours configurable API/console base segments, JSON samples/docs refreshed, and `ReportEventDispatcherTests` extended. Tests: `StellaOps.Scanner.WebService.Tests` build until pre-existing `SurfaceCacheOptionsConfiguratorTests` ctor signature drift (tracked separately). | Links section covers UI/API targets; downstream consumers validated; docs/samples updated. | diff --git a/src/Scanner/StellaOps.Scanner.Worker/Options/ScannerStorageSurfaceSecretConfigurator.cs b/src/Scanner/StellaOps.Scanner.Worker/Options/ScannerStorageSurfaceSecretConfigurator.cs new file mode 100644 index 000000000..5540fb080 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Worker/Options/ScannerStorageSurfaceSecretConfigurator.cs @@ -0,0 +1,141 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Scanner.Storage; +using StellaOps.Scanner.Surface.Env; +using StellaOps.Scanner.Surface.Secrets; + +namespace StellaOps.Scanner.Worker.Options; + +internal sealed class ScannerStorageSurfaceSecretConfigurator : IConfigureOptions +{ + private static readonly string ComponentName = "Scanner.Worker"; + + private readonly ISurfaceSecretProvider _secretProvider; + private readonly ISurfaceEnvironment _surfaceEnvironment; + private readonly ILogger _logger; + + public ScannerStorageSurfaceSecretConfigurator( + ISurfaceSecretProvider secretProvider, + ISurfaceEnvironment surfaceEnvironment, + ILogger logger) + { + _secretProvider = secretProvider ?? throw new ArgumentNullException(nameof(secretProvider)); + _surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Configure(ScannerStorageOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var tenant = _surfaceEnvironment.Settings.Secrets.Tenant; + var request = new SurfaceSecretRequest( + Tenant: tenant, + Component: ComponentName, + SecretType: "cas-access"); + + CasAccessSecret? secret = null; + try + { + using var handle = _secretProvider.GetAsync(request).AsTask().GetAwaiter().GetResult(); + secret = SurfaceSecretParser.ParseCasAccessSecret(handle); + } + catch (SurfaceSecretNotFoundException) + { + _logger.LogDebug("Surface secret 'cas-access' not found for {Component}; using configured storage settings.", ComponentName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to resolve surface secret 'cas-access' for {Component}.", ComponentName); + } + + if (secret is null) + { + return; + } + + ApplySecret(options, secret); + } + + private void ApplySecret(ScannerStorageOptions options, CasAccessSecret secret) + { + var objectStore = options.ObjectStore ??= new ObjectStoreOptions(); + + if (!string.IsNullOrWhiteSpace(secret.Driver)) + { + objectStore.Driver = secret.Driver; + } + + if (!string.IsNullOrWhiteSpace(secret.Region)) + { + objectStore.Region = secret.Region; + } + + if (!string.IsNullOrWhiteSpace(secret.Bucket)) + { + objectStore.BucketName = secret.Bucket; + } + + if (!string.IsNullOrWhiteSpace(secret.RootPrefix)) + { + objectStore.RootPrefix = secret.RootPrefix; + } + + if (!string.IsNullOrWhiteSpace(secret.Endpoint)) + { + if (objectStore.IsRustFsDriver()) + { + objectStore.RustFs ??= new RustFsOptions(); + objectStore.RustFs.BaseUrl = secret.Endpoint!; + } + else + { + objectStore.ServiceUrl = secret.Endpoint; + } + } + + if (objectStore.IsRustFsDriver()) + { + objectStore.RustFs ??= new RustFsOptions(); + + if (!string.IsNullOrWhiteSpace(secret.ApiKeyHeader)) + { + objectStore.RustFs.ApiKeyHeader = secret.ApiKeyHeader!; + } + + if (!string.IsNullOrWhiteSpace(secret.ApiKey)) + { + objectStore.RustFs.ApiKey = secret.ApiKey; + } + + if (secret.AllowInsecureTls is { } insecure) + { + objectStore.RustFs.AllowInsecureTls = insecure; + } + } + + if (!string.IsNullOrWhiteSpace(secret.AccessKeyId) && !string.IsNullOrWhiteSpace(secret.SecretAccessKey)) + { + objectStore.AccessKeyId = secret.AccessKeyId; + objectStore.SecretAccessKey = secret.SecretAccessKey; + objectStore.SessionToken = secret.SessionToken; + } + + foreach (var kvp in secret.Headers) + { + if (string.IsNullOrWhiteSpace(kvp.Key) || string.IsNullOrWhiteSpace(kvp.Value)) + { + continue; + } + + objectStore.Headers[kvp.Key] = kvp.Value; + } + + _logger.LogInformation( + "Surface secret 'cas-access' applied for {Component} (driver: {Driver}, bucket: {Bucket}, region: {Region}).", + ComponentName, + objectStore.Driver, + objectStore.BucketName, + objectStore.Region); + } +} diff --git a/src/Scanner/StellaOps.Scanner.Worker/Processing/CompositeScanAnalyzerDispatcher.cs b/src/Scanner/StellaOps.Scanner.Worker/Processing/CompositeScanAnalyzerDispatcher.cs index bcd193f2a..dab083a25 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Processing/CompositeScanAnalyzerDispatcher.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Processing/CompositeScanAnalyzerDispatcher.cs @@ -21,6 +21,7 @@ using StellaOps.Scanner.Surface.Env; using StellaOps.Scanner.Surface.FS; using StellaOps.Scanner.Surface.Validation; using StellaOps.Scanner.Worker.Options; +using StellaOps.Scanner.Worker.Diagnostics; namespace StellaOps.Scanner.Worker.Processing; @@ -206,7 +207,7 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher try { var engine = new LanguageAnalyzerEngine(new[] { analyzer }); - var cacheEntry = await cacheAdapter.GetOrCreateAsync( + var cacheEntry = await cacheAdapter.GetOrCreateEntryAsync( _logger, analyzer.Id, workspaceFingerprint, diff --git a/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestPublisher.cs b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestPublisher.cs new file mode 100644 index 000000000..4b5f8cc57 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestPublisher.cs @@ -0,0 +1,264 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Scanner.Core.Contracts; +using StellaOps.Scanner.Surface.FS; +using StellaOps.Scanner.Storage; +using StellaOps.Scanner.Storage.Catalog; +using StellaOps.Scanner.Storage.ObjectStore; +using StellaOps.Scanner.Storage.Repositories; +using StellaOps.Scanner.Storage.Services; +using StellaOps.Scanner.Surface.Env; + +namespace StellaOps.Scanner.Worker.Processing.Surface; + +internal sealed record SurfaceManifestPayload( + ArtifactDocumentType ArtifactType, + ArtifactDocumentFormat ArtifactFormat, + string Kind, + string MediaType, + ReadOnlyMemory Content, + string? View = null, + IReadOnlyDictionary? Metadata = null, + bool RegisterArtifact = false); + +internal sealed record SurfaceManifestRequest( + string ScanId, + string ImageDigest, + int Attempt, + IReadOnlyDictionary Metadata, + IReadOnlyList Payloads, + string Component, + string? Version, + string? WorkerInstance); + +internal sealed class SurfaceManifestPublisher +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly IArtifactObjectStore _objectStore; + private readonly ArtifactRepository _artifactRepository; + private readonly LinkRepository _linkRepository; + private readonly ScannerStorageOptions _storageOptions; + private readonly ISurfaceEnvironment _surfaceEnvironment; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public SurfaceManifestPublisher( + IArtifactObjectStore objectStore, + ArtifactRepository artifactRepository, + LinkRepository linkRepository, + IOptions storageOptions, + ISurfaceEnvironment surfaceEnvironment, + TimeProvider timeProvider, + ILogger logger) + { + _objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore)); + _artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository)); + _linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository)); + _storageOptions = (storageOptions ?? throw new ArgumentNullException(nameof(storageOptions))).Value; + _surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task PublishAsync(SurfaceManifestRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (request.Payloads.Count == 0) + { + throw new ArgumentException("At least one payload must be provided.", nameof(request)); + } + + var tenant = _surfaceEnvironment.Settings.Tenant; + var generatedAt = _timeProvider.GetUtcNow(); + var artifacts = new List(request.Payloads.Count); + + foreach (var payload in request.Payloads) + { + var artifact = await StorePayloadAsync(payload, tenant, cancellationToken).ConfigureAwait(false); + artifacts.Add(artifact); + } + + var manifestDocument = new SurfaceManifestDocument + { + Tenant = tenant, + ImageDigest = NormalizeDigest(request.ImageDigest), + ScanId = request.ScanId, + GeneratedAt = generatedAt, + Source = new SurfaceManifestSource + { + Component = request.Component, + Version = request.Version, + WorkerInstance = request.WorkerInstance, + Attempt = request.Attempt + }, + Artifacts = artifacts.ToImmutableArray() + }; + + var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifestDocument, SerializerOptions); + var manifestDigest = ComputeDigest(manifestBytes); + var manifestKey = ArtifactObjectKeyBuilder.Build( + ArtifactDocumentType.SurfaceManifest, + ArtifactDocumentFormat.SurfaceManifestJson, + manifestDigest, + _storageOptions.ObjectStore.RootPrefix); + var manifestDescriptor = new ArtifactObjectDescriptor( + _storageOptions.ObjectStore.BucketName, + manifestKey, + Immutable: true, + RetainFor: _storageOptions.ObjectStore.ComplianceRetention); + + await using (var stream = new MemoryStream(manifestBytes, writable: false)) + { + await _objectStore.PutAsync(manifestDescriptor, stream, cancellationToken).ConfigureAwait(false); + } + + if (_storageOptions.DualWrite.Enabled && !string.IsNullOrWhiteSpace(_storageOptions.DualWrite.MirrorBucket)) + { + await using var mirrorStream = new MemoryStream(manifestBytes, writable: false); + var mirrorDescriptor = manifestDescriptor with { Bucket = _storageOptions.DualWrite.MirrorBucket! }; + await _objectStore.PutAsync(mirrorDescriptor, mirrorStream, cancellationToken).ConfigureAwait(false); + } + + var nowUtc = generatedAt.UtcDateTime; + var artifactId = CatalogIdFactory.CreateArtifactId(ArtifactDocumentType.SurfaceManifest, manifestDigest); + var manifestDocumentRecord = new ArtifactDocument + { + Id = artifactId, + Type = ArtifactDocumentType.SurfaceManifest, + Format = ArtifactDocumentFormat.SurfaceManifestJson, + MediaType = "application/vnd.stellaops.surface.manifest+json", + BytesSha256 = manifestDigest, + SizeBytes = manifestBytes.Length, + Immutable = true, + RefCount = 1, + CreatedAtUtc = nowUtc, + UpdatedAtUtc = nowUtc, + TtlClass = "surface.manifest" + }; + + await _artifactRepository.UpsertAsync(manifestDocumentRecord, cancellationToken).ConfigureAwait(false); + + var link = new LinkDocument + { + Id = CatalogIdFactory.CreateLinkId(LinkSourceType.Image, manifestDocument.ImageDigest ?? request.ScanId, artifactId), + FromType = LinkSourceType.Image, + FromDigest = manifestDocument.ImageDigest ?? request.ScanId, + ArtifactId = artifactId, + CreatedAtUtc = nowUtc + }; + + await _linkRepository.UpsertAsync(link, cancellationToken).ConfigureAwait(false); + + var manifestUri = BuildCasUri(_storageOptions.ObjectStore.BucketName, manifestKey); + _logger.LogInformation("Published surface manifest {Manifest} for image {ImageDigest}.", artifactId, manifestDocument.ImageDigest); + + return new SurfaceManifestPublishResult( + ManifestDigest: manifestDigest, + ManifestUri: manifestUri, + ArtifactId: artifactId, + Document: manifestDocument); + } + + private async Task StorePayloadAsync(SurfaceManifestPayload payload, string tenant, CancellationToken cancellationToken) + { + var digest = ComputeDigest(payload.Content.Span); + var key = ArtifactObjectKeyBuilder.Build( + payload.ArtifactType, + payload.ArtifactFormat, + digest, + _storageOptions.ObjectStore.RootPrefix); + + await using (var stream = new MemoryStream(payload.Content.ToArray(), writable: false)) + { + var descriptor = new ArtifactObjectDescriptor( + _storageOptions.ObjectStore.BucketName, + key, + Immutable: true, + RetainFor: _storageOptions.ObjectStore.ComplianceRetention); + + await _objectStore.PutAsync(descriptor, stream, cancellationToken).ConfigureAwait(false); + + if (_storageOptions.DualWrite.Enabled && !string.IsNullOrWhiteSpace(_storageOptions.DualWrite.MirrorBucket)) + { + await using var mirrorStream = new MemoryStream(payload.Content.ToArray(), writable: false); + var mirrorDescriptor = descriptor with { Bucket = _storageOptions.DualWrite.MirrorBucket! }; + await _objectStore.PutAsync(mirrorDescriptor, mirrorStream, cancellationToken).ConfigureAwait(false); + } + } + + return new SurfaceManifestArtifact + { + Kind = payload.Kind, + Uri = BuildCasUri(_storageOptions.ObjectStore.BucketName, key), + Digest = digest, + MediaType = payload.MediaType, + Format = MapFormat(payload.ArtifactFormat), + SizeBytes = payload.Content.Length, + View = payload.View, + Storage = new SurfaceManifestStorage + { + Bucket = _storageOptions.ObjectStore.BucketName, + ObjectKey = key, + SizeBytes = payload.Content.Length, + ContentType = payload.MediaType + }, + Metadata = payload.Metadata + }; + } + + private static string BuildCasUri(string bucket, string key) + { + var normalizedKey = string.IsNullOrWhiteSpace(key) ? string.Empty : key.Trim().TrimStart('/'); + return $"cas://{bucket}/{normalizedKey}"; + } + + private static string MapFormat(ArtifactDocumentFormat format) + => format switch + { + ArtifactDocumentFormat.EntryTraceNdjson => "entrytrace.ndjson", + ArtifactDocumentFormat.EntryTraceGraphJson => "entrytrace.graph", + ArtifactDocumentFormat.ComponentFragmentJson => "layer.fragments", + ArtifactDocumentFormat.SurfaceManifestJson => "surface.manifest", + ArtifactDocumentFormat.CycloneDxJson => "cdx-json", + ArtifactDocumentFormat.CycloneDxProtobuf => "cdx-protobuf", + ArtifactDocumentFormat.SpdxJson => "spdx-json", + ArtifactDocumentFormat.BomIndex => "bom-index", + ArtifactDocumentFormat.DsseJson => "dsse-json", + _ => format.ToString().ToLowerInvariant() + }; + + private static string ComputeDigest(ReadOnlySpan content) + { + Span hash = stackalloc byte[32]; + SHA256.HashData(content, hash); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static string ComputeDigest(byte[] content) + => ComputeDigest(content.AsSpan()); + + private static string NormalizeDigest(string digest) + { + if (string.IsNullOrWhiteSpace(digest)) + { + return string.Empty; + } + + var trimmed = digest.Trim(); + return trimmed.Contains(':', StringComparison.Ordinal) + ? trimmed + : $"sha256:{trimmed}"; + } +} diff --git a/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs new file mode 100644 index 000000000..d2e18caf0 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using StellaOps.Scanner.Core.Contracts; +using StellaOps.Scanner.EntryTrace; +using StellaOps.Scanner.EntryTrace.Serialization; +using StellaOps.Scanner.Surface.FS; + +namespace StellaOps.Scanner.Worker.Processing.Surface; + +internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly SurfaceManifestPublisher _publisher; + private readonly ILogger _logger; + private readonly string _componentVersion; + + public SurfaceManifestStageExecutor( + SurfaceManifestPublisher publisher, + ILogger logger) + { + _publisher = publisher ?? throw new ArgumentNullException(nameof(publisher)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _componentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + } + + public string StageName => ScanStageNames.ComposeArtifacts; + + public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + + var payloads = CollectPayloads(context); + if (payloads.Count == 0) + { + _logger.LogDebug("No surface payloads available for job {JobId}; skipping manifest publish.", context.JobId); + return; + } + + var request = new SurfaceManifestRequest( + ScanId: context.ScanId, + ImageDigest: ResolveImageDigest(context), + Attempt: context.Lease.Attempt, + Metadata: context.Lease.Metadata, + Payloads: payloads, + Component: "scanner.worker", + Version: _componentVersion, + WorkerInstance: Environment.MachineName); + + var result = await _publisher.PublishAsync(request, cancellationToken).ConfigureAwait(false); + context.Analysis.Set(ScanAnalysisKeys.SurfaceManifest, result); + _logger.LogInformation("Surface manifest stored for job {JobId} with digest {Digest}.", context.JobId, result.ManifestDigest); + } + + private List CollectPayloads(ScanJobContext context) + { + var payloads = new List(); + + if (context.Analysis.TryGet(ScanAnalysisKeys.EntryTraceGraph, out var graph) && graph is not null) + { + var graphJson = EntryTraceGraphSerializer.Serialize(graph); + payloads.Add(new SurfaceManifestPayload( + ArtifactDocumentType.SurfaceEntryTrace, + ArtifactDocumentFormat.EntryTraceGraphJson, + Kind: "entrytrace.graph", + MediaType: "application/json", + Content: Encoding.UTF8.GetBytes(graphJson), + Metadata: new Dictionary + { + ["artifact"] = "entrytrace.graph", + ["nodes"] = graph.Nodes.Length.ToString(CultureInfoInvariant), + ["edges"] = graph.Edges.Length.ToString(CultureInfoInvariant) + })); + } + + if (context.Analysis.TryGet(ScanAnalysisKeys.EntryTraceNdjson, out ImmutableArray ndjson) && !ndjson.IsDefaultOrEmpty) + { + var builder = new StringBuilder(); + for (var i = 0; i < ndjson.Length; i++) + { + builder.Append(ndjson[i]); + if (!ndjson[i].EndsWith('\n')) + { + builder.Append('\n'); + } + } + + payloads.Add(new SurfaceManifestPayload( + ArtifactDocumentType.SurfaceEntryTrace, + ArtifactDocumentFormat.EntryTraceNdjson, + Kind: "entrytrace.ndjson", + MediaType: "application/x-ndjson", + Content: Encoding.UTF8.GetBytes(builder.ToString()))); + } + + var fragments = context.Analysis.GetLayerFragments(); + if (!fragments.IsDefaultOrEmpty && fragments.Length > 0) + { + var fragmentsJson = JsonSerializer.Serialize(fragments, JsonOptions); + payloads.Add(new SurfaceManifestPayload( + ArtifactDocumentType.SurfaceLayerFragment, + ArtifactDocumentFormat.ComponentFragmentJson, + Kind: "layer.fragments", + MediaType: "application/json", + Content: Encoding.UTF8.GetBytes(fragmentsJson), + View: "inventory")); + } + + return payloads; + } + + private static string ResolveImageDigest(ScanJobContext context) + { + static bool TryGet(IReadOnlyDictionary metadata, string key, out string value) + { + if (metadata.TryGetValue(key, out var found) && !string.IsNullOrWhiteSpace(found)) + { + value = found.Trim(); + return true; + } + + value = string.Empty; + return false; + } + + var metadata = context.Lease.Metadata; + if (TryGet(metadata, "image.digest", out var digest) || + TryGet(metadata, "imageDigest", out digest) || + TryGet(metadata, "scanner.image.digest", out digest)) + { + return digest; + } + + return context.ScanId; + } + + private static readonly IFormatProvider CultureInfoInvariant = System.Globalization.CultureInfo.InvariantCulture; +} diff --git a/src/Scanner/StellaOps.Scanner.Worker/Program.cs b/src/Scanner/StellaOps.Scanner.Worker/Program.cs index be845f993..ad945c71f 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Program.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Program.cs @@ -18,7 +18,9 @@ using StellaOps.Scanner.Worker.Diagnostics; using StellaOps.Scanner.Worker.Hosting; using StellaOps.Scanner.Worker.Options; using StellaOps.Scanner.Worker.Processing; +using StellaOps.Scanner.Worker.Processing.Surface; using StellaOps.Scanner.Storage.Extensions; +using StellaOps.Scanner.Storage; var builder = Host.CreateApplicationBuilder(args); @@ -52,6 +54,9 @@ var connectionString = storageSection.GetValue("Mongo:ConnectionString") if (!string.IsNullOrWhiteSpace(connectionString)) { builder.Services.AddScannerStorage(storageSection); + builder.Services.AddSingleton, ScannerStorageSurfaceSecretConfigurator>(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); } builder.Services.TryAddSingleton(); diff --git a/src/Scanner/StellaOps.Scanner.Worker/TASKS.md b/src/Scanner/StellaOps.Scanner.Worker/TASKS.md index 9c179643a..40f5b3e38 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/TASKS.md +++ b/src/Scanner/StellaOps.Scanner.Worker/TASKS.md @@ -3,7 +3,7 @@ | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | |----|--------|----------|------------|-------------|---------------| | SCAN-REPLAY-186-002 | TODO | Scanner Worker Guild | REPLAY-CORE-185-001 | Enforce deterministic analyzer execution when consuming replay input bundles, emit layer Merkle metadata, and author `docs/modules/scanner/deterministic-execution.md` summarising invariants from `docs/replay/DETERMINISTIC_REPLAY.md` Section 4. | Replay mode analyzers pass determinism tests; new doc merged; integration fixtures updated. | -| SCANNER-SURFACE-01 | DOING (2025-11-02) | Scanner Worker Guild | SURFACE-FS-02 | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.
2025-11-02: Draft Surface.FS manifests emitted for sample scans; telemetry counters under review. | Integration tests prove cache entries exist; telemetry counters exported. | +| SCANNER-SURFACE-01 | DOING (2025-11-06) | Scanner Worker Guild | SURFACE-FS-02 | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.
2025-11-02: Draft Surface.FS manifests emitted for sample scans; telemetry counters under review.
2025-11-06: Resuming with manifest writer abstraction, rotation metadata, and telemetry counters for Surface.FS persistence. | Integration tests prove cache entries exist; telemetry counters exported. | | SCANNER-ENV-01 | TODO (2025-11-06) | Scanner Worker Guild | SURFACE-ENV-02 | Replace ad-hoc environment reads with `StellaOps.Scanner.Surface.Env` helpers for cache roots and CAS endpoints.
2025-11-02: Worker bootstrap now resolves cache roots via helper; warning path documented; smoke tests running.
2025-11-05 14:55Z: Extending helper usage into cache/secrets configuration, updating worker validator wiring, and drafting docs/tests for new Surface.Env outputs.
2025-11-06 17:05Z: README/design docs updated with warning catalogue; startup logging guidance captured for ops runbooks.
2025-11-06 07:45Z: Helm/Compose env profiles (dev/stage/prod/airgap/mirror) now seed `SCANNER_SURFACE_*` defaults to keep worker cache roots aligned with Surface.Env helpers.
2025-11-06 07:55Z: Paused; pending automation tracked via `DEVOPS-OPENSSL-11-001/002` and Surface.Env test fixtures. | Worker boots with helper; misconfiguration warnings documented; smoke tests updated. | > 2025-11-05 19:18Z: Bound `SurfaceCacheOptions` root directory to resolved Surface.Env settings and added unit coverage around the configurator. -| SCANNER-SECRETS-01 | DOING (2025-11-02) | Scanner Worker Guild, Security Guild | SURFACE-SECRETS-02 | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution.
2025-11-02: Surface.Secrets provider wired for CAS token retrieval; integration tests added. | Secrets fetched via shared provider; legacy secret code removed; integration tests cover rotation. | +| SCANNER-SECRETS-01 | DOING (2025-11-06) | Scanner Worker Guild, Security Guild | SURFACE-SECRETS-02 | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution.
2025-11-02: Surface.Secrets provider wired for CAS token retrieval; integration tests added.
2025-11-06: Continuing to replace legacy registry credential plumbing and extend rotation metrics/fixtures.
2025-11-06 21:35Z: Introduced `ScannerStorageSurfaceSecretConfigurator` mapping `cas-access` secrets into storage options plus unit coverage. | Secrets fetched via shared provider; legacy secret code removed; integration tests cover rotation. | diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/Core/Internal/LanguageAnalyzerSurfaceCache.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/Core/Internal/LanguageAnalyzerSurfaceCache.cs index 888e1bc62..12470fcb9 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/Core/Internal/LanguageAnalyzerSurfaceCache.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/Core/Internal/LanguageAnalyzerSurfaceCache.cs @@ -4,6 +4,8 @@ using System.Text; using Microsoft.Extensions.Logging; using StellaOps.Scanner.Surface.FS; +public readonly record struct LanguageAnalyzerSurfaceCacheEntry(LanguageAnalyzerResult Result, bool IsHit); + public sealed class LanguageAnalyzerSurfaceCache { private const string CacheNamespace = "scanner/lang/analyzers"; @@ -24,6 +26,17 @@ public sealed class LanguageAnalyzerSurfaceCache string fingerprint, Func> factory, CancellationToken cancellationToken) + { + var entry = await GetOrCreateEntryAsync(logger, analyzerId, fingerprint, factory, cancellationToken).ConfigureAwait(false); + return entry.Result; + } + + public async ValueTask GetOrCreateEntryAsync( + ILogger logger, + string analyzerId, + string fingerprint, + Func> factory, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(factory); @@ -62,7 +75,7 @@ public sealed class LanguageAnalyzerSurfaceCache fingerprint); result = await factory(cancellationToken).ConfigureAwait(false); - return result; + return new LanguageAnalyzerSurfaceCacheEntry(result, false); } if (cacheHit) @@ -82,7 +95,7 @@ public sealed class LanguageAnalyzerSurfaceCache fingerprint); } - return result; + return new LanguageAnalyzerSurfaceCacheEntry(result, cacheHit); } private static ReadOnlyMemory Serialize(LanguageAnalyzerResult result) diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Contracts/ScanAnalysisKeys.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Contracts/ScanAnalysisKeys.cs index 5784818c2..74eb9ee69 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Contracts/ScanAnalysisKeys.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Contracts/ScanAnalysisKeys.cs @@ -15,4 +15,6 @@ public static class ScanAnalysisKeys public const string EntryTraceGraph = "analysis.entrytrace.graph"; public const string EntryTraceNdjson = "analysis.entrytrace.ndjson"; + + public const string SurfaceManifest = "analysis.surface.manifest"; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Catalog/ArtifactDocument.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Catalog/ArtifactDocument.cs index cbe44e3dc..2f05c7989 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Catalog/ArtifactDocument.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Catalog/ArtifactDocument.cs @@ -2,23 +2,30 @@ using MongoDB.Bson.Serialization.Attributes; namespace StellaOps.Scanner.Storage.Catalog; -public enum ArtifactDocumentType -{ - LayerBom, - ImageBom, - Diff, - Index, - Attestation, -} - -public enum ArtifactDocumentFormat -{ - CycloneDxJson, - CycloneDxProtobuf, - SpdxJson, - BomIndex, - DsseJson, -} +public enum ArtifactDocumentType +{ + LayerBom, + ImageBom, + Diff, + Index, + Attestation, + SurfaceManifest, + SurfaceEntryTrace, + SurfaceLayerFragment, +} + +public enum ArtifactDocumentFormat +{ + CycloneDxJson, + CycloneDxProtobuf, + SpdxJson, + BomIndex, + DsseJson, + SurfaceManifestJson, + EntryTraceNdjson, + EntryTraceGraphJson, + ComponentFragmentJson, +} [BsonIgnoreExtraElements] public sealed class ArtifactDocument diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs index 211cfb1a1..95a9e9d9f 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Net.Http; using Amazon; using Amazon.S3; +using Amazon.Runtime; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -150,14 +151,22 @@ public static class ServiceCollectionExtensions var options = provider.GetRequiredService>().Value.ObjectStore; var config = new AmazonS3Config { - RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region), - ForcePathStyle = options.ForcePathStyle, - }; - - if (!string.IsNullOrWhiteSpace(options.ServiceUrl)) - { - config.ServiceURL = options.ServiceUrl; - } + RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region), + ForcePathStyle = options.ForcePathStyle, + }; + + if (!string.IsNullOrWhiteSpace(options.ServiceUrl)) + { + config.ServiceURL = options.ServiceUrl; + } + + if (!string.IsNullOrWhiteSpace(options.AccessKeyId) && !string.IsNullOrWhiteSpace(options.SecretAccessKey)) + { + AWSCredentials credentials = string.IsNullOrWhiteSpace(options.SessionToken) + ? new BasicAWSCredentials(options.AccessKeyId, options.SecretAccessKey) + : new SessionAWSCredentials(options.AccessKeyId, options.SecretAccessKey, options.SessionToken); + return new AmazonS3Client(credentials, config); + } return new AmazonS3Client(config); } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/ArtifactObjectKeyBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/ArtifactObjectKeyBuilder.cs index 7d88ecead..a1882535b 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/ArtifactObjectKeyBuilder.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/ArtifactObjectKeyBuilder.cs @@ -33,6 +33,9 @@ public static class ArtifactObjectKeyBuilder ArtifactDocumentType.ImageBom => ScannerStorageDefaults.ObjectPrefixes.Images, ArtifactDocumentType.Index => ScannerStorageDefaults.ObjectPrefixes.Indexes, ArtifactDocumentType.Attestation => ScannerStorageDefaults.ObjectPrefixes.Attestations, + ArtifactDocumentType.SurfaceManifest => ScannerStorageDefaults.ObjectPrefixes.SurfaceManifests, + ArtifactDocumentType.SurfaceEntryTrace => ScannerStorageDefaults.ObjectPrefixes.SurfaceEntryTrace, + ArtifactDocumentType.SurfaceLayerFragment => ScannerStorageDefaults.ObjectPrefixes.SurfaceLayerFragments, ArtifactDocumentType.Diff => "diffs", _ => ScannerStorageDefaults.ObjectPrefixes.Images, }; @@ -44,6 +47,10 @@ public static class ArtifactObjectKeyBuilder ArtifactDocumentFormat.SpdxJson => "sbom.spdx.json", ArtifactDocumentFormat.BomIndex => "bom-index.bin", ArtifactDocumentFormat.DsseJson => "artifact.dsse.json", + ArtifactDocumentFormat.SurfaceManifestJson => "surface.manifest.json", + ArtifactDocumentFormat.EntryTraceNdjson => "entrytrace.ndjson", + ArtifactDocumentFormat.EntryTraceGraphJson => "entrytrace.graph.json", + ArtifactDocumentFormat.ComponentFragmentJson => "layer-fragments.json", _ => "artifact.bin", }; diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageDefaults.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageDefaults.cs index 20af262a8..decac0da5 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageDefaults.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageDefaults.cs @@ -26,11 +26,14 @@ public static class ScannerStorageDefaults public const string Migrations = "schema_migrations"; } - public static class ObjectPrefixes - { - public const string Layers = "layers"; - public const string Images = "images"; - public const string Indexes = "indexes"; - public const string Attestations = "attest"; - } -} + public static class ObjectPrefixes + { + public const string Layers = "layers"; + public const string Images = "images"; + public const string Indexes = "indexes"; + public const string Attestations = "attest"; + public const string SurfaceManifests = "surface/manifests"; + public const string SurfaceEntryTrace = "surface/payloads/entrytrace"; + public const string SurfaceLayerFragments = "surface/payloads/layer-fragments"; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageOptions.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageOptions.cs index 4ef8c7c21..d983f0fa4 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageOptions.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageOptions.cs @@ -102,6 +102,15 @@ public sealed class ObjectStoreOptions public TimeSpan? ComplianceRetention { get; set; } = TimeSpan.FromDays(90); + public string? AccessKeyId { get; set; } + = null; + + public string? SecretAccessKey { get; set; } + = null; + + public string? SessionToken { get; set; } + = null; + public IDictionary Headers { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); public RustFsOptions RustFs { get; set; } = new(); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/SurfaceManifestModels.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/SurfaceManifestModels.cs new file mode 100644 index 000000000..1758f9f84 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/SurfaceManifestModels.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +namespace StellaOps.Scanner.Surface.FS; + +/// +/// Canonical manifest describing surface artefacts produced for a scan. +/// +public sealed record SurfaceManifestDocument +{ + public const string DefaultSchema = "stellaops.surface.manifest@1"; + + [JsonPropertyName("schema")] + public string Schema { get; init; } = DefaultSchema; + + [JsonPropertyName("tenant")] + public string Tenant { get; init; } = string.Empty; + + [JsonPropertyName("imageDigest")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ImageDigest { get; init; } + = null; + + [JsonPropertyName("scanId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ScanId { get; init; } + = null; + + [JsonPropertyName("generatedAt")] + public DateTimeOffset GeneratedAt { get; init; } + = DateTimeOffset.UtcNow; + + [JsonPropertyName("source")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SurfaceManifestSource? Source { get; init; } + = null; + + [JsonPropertyName("artifacts")] + public IReadOnlyList Artifacts { get; init; } + = ImmutableArray.Empty; +} + +/// +/// Identifies the producer of the manifest. +/// +public sealed record SurfaceManifestSource +{ + [JsonPropertyName("component")] + public string Component { get; init; } = "scanner.worker"; + + [JsonPropertyName("version")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Version { get; init; } + = null; + + [JsonPropertyName("workerInstance")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WorkerInstance { get; init; } + = null; + + [JsonPropertyName("attempt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Attempt { get; init; } + = null; +} + +/// +/// Describes a surface artefact referenced by the manifest. +/// +public sealed record SurfaceManifestArtifact +{ + [JsonPropertyName("kind")] + public string Kind { get; init; } = string.Empty; + + [JsonPropertyName("uri")] + public string Uri { get; init; } = string.Empty; + + [JsonPropertyName("digest")] + public string Digest { get; init; } = string.Empty; + + [JsonPropertyName("mediaType")] + public string MediaType { get; init; } = string.Empty; + + [JsonPropertyName("format")] + public string Format { get; init; } = string.Empty; + + [JsonPropertyName("sizeBytes")] + public long SizeBytes { get; init; } + = 0; + + [JsonPropertyName("view")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? View { get; init; } + = null; + + [JsonPropertyName("storage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SurfaceManifestStorage? Storage { get; init; } + = null; + + [JsonPropertyName("metadata")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyDictionary? Metadata { get; init; } + = null; +} + +/// +/// Storage descriptor for an artefact. +/// +public sealed record SurfaceManifestStorage +{ + [JsonPropertyName("bucket")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Bucket { get; init; } + = null; + + [JsonPropertyName("objectKey")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ObjectKey { get; init; } + = null; + + [JsonPropertyName("sizeBytes")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? SizeBytes { get; init; } + = null; + + [JsonPropertyName("contentType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContentType { get; init; } + = null; +} + +/// +/// Result from publishing a surface manifest. +/// +public sealed record SurfaceManifestPublishResult( + string ManifestDigest, + string ManifestUri, + string ArtifactId, + SurfaceManifestDocument Document); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets/CasAccessSecret.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets/CasAccessSecret.cs new file mode 100644 index 000000000..ee1b9ad8f --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets/CasAccessSecret.cs @@ -0,0 +1,207 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Text; +using System.Text.Json; + +namespace StellaOps.Scanner.Surface.Secrets; + +public sealed record CasAccessSecret( + string Driver, + string? Endpoint, + string? Region, + string? Bucket, + string? RootPrefix, + string? ApiKey, + string? ApiKeyHeader, + IReadOnlyDictionary Headers, + string? AccessKeyId, + string? SecretAccessKey, + string? SessionToken, + bool? AllowInsecureTls); + +public static class SurfaceSecretParser +{ + public static CasAccessSecret ParseCasAccessSecret(SurfaceSecretHandle handle) + { + ArgumentNullException.ThrowIfNull(handle); + var payload = handle.AsBytes(); + if (payload.IsEmpty) + { + throw new InvalidOperationException("Surface secret payload is empty."); + } + + var jsonText = DecodeUtf8(payload); + using var document = JsonDocument.Parse(jsonText); + var root = document.RootElement; + + string driver = GetString(root, "driver") ?? GetMetadataValue(handle.Metadata, "driver") ?? "s3"; + string? endpoint = GetString(root, "endpoint") ?? GetMetadataValue(handle.Metadata, "endpoint"); + string? region = GetString(root, "region") ?? GetMetadataValue(handle.Metadata, "region"); + string? bucket = GetString(root, "bucket") ?? GetMetadataValue(handle.Metadata, "bucket"); + string? rootPrefix = GetString(root, "rootPrefix") ?? GetMetadataValue(handle.Metadata, "rootPrefix"); + string? apiKey = GetString(root, "apiKey") ?? GetMetadataValue(handle.Metadata, "apiKey"); + string? apiKeyHeader = GetString(root, "apiKeyHeader") ?? GetMetadataValue(handle.Metadata, "apiKeyHeader"); + string? accessKeyId = GetString(root, "accessKeyId") ?? GetMetadataValue(handle.Metadata, "accessKeyId"); + string? secretAccessKey = GetString(root, "secretAccessKey") ?? GetMetadataValue(handle.Metadata, "secretAccessKey"); + string? sessionToken = GetString(root, "sessionToken") ?? GetMetadataValue(handle.Metadata, "sessionToken"); + bool? allowInsecureTls = GetBoolean(root, "allowInsecureTls"); + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + PopulateHeaders(root, headers); + PopulateMetadataHeaders(handle.Metadata, headers); + + return new CasAccessSecret( + driver.Trim(), + endpoint?.Trim(), + region?.Trim(), + bucket?.Trim(), + rootPrefix?.Trim(), + apiKey?.Trim(), + apiKeyHeader?.Trim(), + new ReadOnlyDictionary(headers), + accessKeyId?.Trim(), + secretAccessKey?.Trim(), + sessionToken?.Trim(), + allowInsecureTls); + } + + private static string DecodeUtf8(ReadOnlyMemory payload) + { + if (payload.IsEmpty) + { + return string.Empty; + } + + try + { + return Encoding.UTF8.GetString(payload.Span); + } + catch (DecoderFallbackException ex) + { + throw new InvalidOperationException("Surface secret payload is not valid UTF-8 JSON.", ex); + } + } + + private static string? GetString(JsonElement element, string propertyName) + { + if (TryGetPropertyIgnoreCase(element, propertyName, out var property)) + { + return property.ValueKind switch + { + JsonValueKind.String => property.GetString(), + JsonValueKind.Number => property.GetRawText(), + JsonValueKind.True => bool.TrueString, + JsonValueKind.False => bool.FalseString, + _ => null + }; + } + + return null; + } + + private static bool? GetBoolean(JsonElement element, string propertyName) + { + if (TryGetPropertyIgnoreCase(element, propertyName, out var property)) + { + return property.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String when bool.TryParse(property.GetString(), out var parsed) => parsed, + _ => null + }; + } + + return null; + } + + private static bool TryGetPropertyIgnoreCase(JsonElement element, string propertyName, out JsonElement property) + { + if (element.ValueKind != JsonValueKind.Object) + { + property = default; + return false; + } + + if (element.TryGetProperty(propertyName, out property)) + { + return true; + } + + foreach (var candidate in element.EnumerateObject()) + { + if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + property = candidate.Value; + return true; + } + } + + property = default; + return false; + } + + private static void PopulateHeaders(JsonElement element, IDictionary headers) + { + if (!TryGetPropertyIgnoreCase(element, "headers", out var headersElement)) + { + return; + } + + if (headersElement.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (var property in headersElement.EnumerateObject()) + { + var value = property.Value.ValueKind switch + { + JsonValueKind.String => property.Value.GetString(), + JsonValueKind.Number => property.Value.GetRawText(), + JsonValueKind.True => bool.TrueString, + JsonValueKind.False => bool.FalseString, + _ => null + }; + + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + headers[property.Name] = value.Trim(); + } + } + + private static void PopulateMetadataHeaders(IReadOnlyDictionary metadata, IDictionary headers) + { + foreach (var (key, value) in metadata) + { + if (!key.StartsWith("header:", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var headerName = key["header:".Length..]; + if (string.IsNullOrWhiteSpace(headerName) || string.IsNullOrWhiteSpace(value)) + { + continue; + } + + headers[headerName] = value; + } + } + + private static string? GetMetadataValue(IReadOnlyDictionary metadata, string key) + { + foreach (var (metadataKey, metadataValue) in metadata) + { + if (string.Equals(metadataKey, key, StringComparison.OrdinalIgnoreCase)) + { + return metadataValue; + } + } + + return null; + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Surface.Secrets.Tests/CasAccessSecretParserTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Surface.Secrets.Tests/CasAccessSecretParserTests.cs new file mode 100644 index 000000000..f7afa336b --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Surface.Secrets.Tests/CasAccessSecretParserTests.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Text; +using StellaOps.Scanner.Surface.Secrets; +using Xunit; + +namespace StellaOps.Scanner.Surface.Secrets.Tests; + +public sealed class CasAccessSecretParserTests +{ + [Fact] + public void ParseCasAccessSecret_WithRustFsPayload_ReturnsExpectedValues() + { + const string json = """ + { + "driver": "rustfs", + "endpoint": "https://surface.test.local", + "region": "us-gov-west-1", + "bucket": "stellaops-surface", + "rootPrefix": "scanner", + "apiKey": "secret-api-key", + "apiKeyHeader": "X-Api-Key", + "allowInsecureTls": true, + "headers": { + "X-Surface-Tenant": "tenant-a" + } + } + """; + + using var handle = SurfaceSecretHandle.FromBytes(Encoding.UTF8.GetBytes(json)); + var secret = SurfaceSecretParser.ParseCasAccessSecret(handle); + + Assert.Equal("rustfs", secret.Driver); + Assert.Equal("https://surface.test.local", secret.Endpoint); + Assert.Equal("us-gov-west-1", secret.Region); + Assert.Equal("stellaops-surface", secret.Bucket); + Assert.Equal("scanner", secret.RootPrefix); + Assert.Equal("secret-api-key", secret.ApiKey); + Assert.Equal("X-Api-Key", secret.ApiKeyHeader); + Assert.True(secret.AllowInsecureTls); + Assert.Single(secret.Headers); + Assert.Equal("tenant-a", secret.Headers["X-Surface-Tenant"]); + } + + [Fact] + public void ParseCasAccessSecret_UsesMetadataFallback_WhenFieldsMissing() + { + const string json = @"{ ""driver"": ""s3"" }"; + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["endpoint"] = "https://s3.test.local", + ["accessKeyId"] = "AKIA123", + ["secretAccessKey"] = "s3-secret", + ["header:X-Custom"] = "value" + }; + + using var handle = SurfaceSecretHandle.FromBytes(Encoding.UTF8.GetBytes(json), metadata); + var secret = SurfaceSecretParser.ParseCasAccessSecret(handle); + + Assert.Equal("s3", secret.Driver); + Assert.Equal("https://s3.test.local", secret.Endpoint); + Assert.Equal("AKIA123", secret.AccessKeyId); + Assert.Equal("s3-secret", secret.SecretAccessKey); + Assert.Equal("value", secret.Headers["X-Custom"]); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScannerSurfaceSecretConfiguratorTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScannerSurfaceSecretConfiguratorTests.cs new file mode 100644 index 000000000..1c2d8797e --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScannerSurfaceSecretConfiguratorTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StellaOps.Scanner.Surface.Env; +using StellaOps.Scanner.Surface.Secrets; +using StellaOps.Scanner.WebService.Options; +using StellaOps.Scanner.Storage; +using Xunit; + +namespace StellaOps.Scanner.WebService.Tests; + +public sealed class ScannerSurfaceSecretConfiguratorTests +{ + [Fact] + public void Configure_AppliesCasAccessSecretToArtifactStore() + { + const string json = """ + { + "driver": "rustfs", + "endpoint": "https://surface.api", + "bucket": "surface-artifacts", + "apiKey": "rust-key", + "apiKeyHeader": "X-Surface-Api-Key", + "region": "ap-southeast-2" + } + """; + + using var handle = SurfaceSecretHandle.FromBytes(Encoding.UTF8.GetBytes(json)); + var secretProvider = new StubSecretProvider(handle); + var environment = new StubSurfaceEnvironment(); + var options = new ScannerWebServiceOptions(); + + var configurator = new ScannerSurfaceSecretConfigurator( + secretProvider, + environment, + NullLogger.Instance); + + configurator.Configure(options); + + Assert.Equal("rustfs", options.ArtifactStore.Driver); + Assert.Equal("https://surface.api", options.ArtifactStore.Endpoint); + Assert.Equal("surface-artifacts", options.ArtifactStore.Bucket); + Assert.Equal("rust-key", options.ArtifactStore.ApiKey); + Assert.Equal("X-Surface-Api-Key", options.ArtifactStore.ApiKeyHeader); + Assert.Equal("ap-southeast-2", options.ArtifactStore.Region); + } + + [Fact] + public void PostConfigure_SynchronizesArtifactStoreToScannerStorageOptions() + { + var webOptions = Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions + { + ArtifactStore = new ScannerWebServiceOptions.ArtifactStoreOptions + { + Driver = "rustfs", + Endpoint = "https://surface.sync", + ApiKey = "sync-key", + ApiKeyHeader = "X-Sync", + Bucket = "sync-bucket", + Region = "us-west-2", + RootPrefix = "sync" + } + }); + + var configurator = new ScannerStorageOptionsPostConfigurator( + new OptionsMonitorStub(webOptions), + NullLogger.Instance); + + var storageOptions = new ScannerStorageOptions(); + configurator.PostConfigure(Microsoft.Extensions.Options.Options.DefaultName, storageOptions); + + Assert.Equal("rustfs", storageOptions.ObjectStore.Driver); + Assert.Equal("https://surface.sync", storageOptions.ObjectStore.RustFs.BaseUrl); + Assert.Equal("sync-bucket", storageOptions.ObjectStore.BucketName); + Assert.Equal("sync", storageOptions.ObjectStore.RootPrefix); + Assert.Equal("us-west-2", storageOptions.ObjectStore.Region); + Assert.Equal("sync-key", storageOptions.ObjectStore.RustFs.ApiKey); + Assert.Equal("X-Sync", storageOptions.ObjectStore.RustFs.ApiKeyHeader); + } + + private sealed class StubSecretProvider : ISurfaceSecretProvider + { + private readonly SurfaceSecretHandle _handle; + + public StubSecretProvider(SurfaceSecretHandle handle) + { + _handle = handle; + } + + public ValueTask GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default) + => ValueTask.FromResult(_handle); + } + + private sealed class StubSurfaceEnvironment : ISurfaceEnvironment + { + public StubSurfaceEnvironment() + { + Settings = new SurfaceEnvironmentSettings( + new Uri("https://surface"), + "bucket", + "region", + new DirectoryInfo(Path.GetTempPath()), + 256, + false, + Array.Empty(), + new SurfaceSecretsConfiguration("inline", "tenant", null, null, null, true), + "tenant", + new SurfaceTlsConfiguration(null, null, null)); + RawVariables = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public SurfaceEnvironmentSettings Settings { get; } + + public IReadOnlyDictionary RawVariables { get; } + } + + private sealed class OptionsMonitorStub : IOptionsMonitor where T : class + { + private readonly IOptions _options; + + public OptionsMonitorStub(IOptions options) + { + _options = options; + } + + public T CurrentValue => _options.Value; + + public T Get(string? name) => _options.Value; + + public IDisposable OnChange(Action listener) => NullDisposable.Instance; + + private sealed class NullDisposable : IDisposable + { + public static readonly NullDisposable Instance = new(); + public void Dispose() { } + } + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/ScannerStorageSurfaceSecretConfiguratorTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/ScannerStorageSurfaceSecretConfiguratorTests.cs new file mode 100644 index 000000000..74957c29b --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/ScannerStorageSurfaceSecretConfiguratorTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.Scanner.Storage; +using StellaOps.Scanner.Surface.Env; +using StellaOps.Scanner.Surface.Secrets; +using StellaOps.Scanner.Worker.Options; +using Xunit; + +namespace StellaOps.Scanner.Worker.Tests; + +public sealed class ScannerStorageSurfaceSecretConfiguratorTests +{ + [Fact] + public void Configure_WithCasAccessSecret_AppliesSettings() + { + const string json = """ + { + "driver": "rustfs", + "endpoint": "https://surface.example", + "region": "eu-central-1", + "bucket": "surface-bucket", + "rootPrefix": "scanner", + "apiKey": "rustfs-api", + "apiKeyHeader": "X-Rustfs-Key", + "allowInsecureTls": false + } + """; + + using var handle = SurfaceSecretHandle.FromBytes(Encoding.UTF8.GetBytes(json)); + var secretProvider = new StubSecretProvider(handle); + var environment = new StubSurfaceEnvironment("tenant-eu"); + + var configurator = new ScannerStorageSurfaceSecretConfigurator( + secretProvider, + environment, + NullLogger.Instance); + + var options = new ScannerStorageOptions(); + configurator.Configure(options); + + Assert.Equal("rustfs", options.ObjectStore.Driver); + Assert.Equal("https://surface.example", options.ObjectStore.RustFs.BaseUrl); + Assert.Equal("eu-central-1", options.ObjectStore.Region); + Assert.Equal("surface-bucket", options.ObjectStore.BucketName); + Assert.Equal("scanner", options.ObjectStore.RootPrefix); + Assert.Equal("rustfs-api", options.ObjectStore.RustFs.ApiKey); + Assert.Equal("X-Rustfs-Key", options.ObjectStore.RustFs.ApiKeyHeader); + } + + private sealed class StubSecretProvider : ISurfaceSecretProvider + { + private readonly SurfaceSecretHandle _handle; + + public StubSecretProvider(SurfaceSecretHandle handle) + { + _handle = handle; + } + + public ValueTask GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default) + => ValueTask.FromResult(_handle); + } + + private sealed class StubSurfaceEnvironment : ISurfaceEnvironment + { + public StubSurfaceEnvironment(string tenant) + { + Settings = new SurfaceEnvironmentSettings( + new Uri("https://surface"), + "bucket", + "region-1", + new DirectoryInfo(Path.GetTempPath()), + 1024, + false, + Array.Empty(), + new SurfaceSecretsConfiguration("inline", tenant, null, null, null, true), + tenant, + new SurfaceTlsConfiguration(null, null, null)) + { + CreatedAtUtc = DateTimeOffset.UtcNow + }; + RawVariables = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public SurfaceEnvironmentSettings Settings { get; } + + public IReadOnlyDictionary RawVariables { get; } + } +}