From 6bee1fdcf58fe6c2fd8dae6201f5f71eb504710f Mon Sep 17 00:00:00 2001 From: StellaOps Bot Date: Tue, 25 Nov 2025 08:01:23 +0200 Subject: [PATCH] work --- .../workflows/concelier-attestation-tests.yml | 44 + .gitea/workflows/mirror-sign.yml | 1 + docs/README.md | 1 + docs/advisory-ai/cli.md | 70 + docs/advisory-ai/evidence-payloads.md | 4 +- docs/advisory-ai/guardrails-and-evidence.md | 10 +- docs/advisory-ai/sbom-context-hand-off.md | 55 + docs/devportal/publishing.md | 80 + docs/implplan/SPRINT_506_ops_devops_iv.md | 3 +- docs/modules/advisory-ai/README.md | 12 + docs/modules/advisory-ai/TASKS.md | 8 + .../prep/2025-11-24-attest-plan-2001.md | 19 + docs/modules/cli/guides/commands/advisory.md | 25 + docs/modules/cli/guides/commands/aoc.md | 21 + docs/modules/cli/guides/commands/auth.md | 19 + docs/modules/cli/guides/commands/export.md | 25 + docs/modules/cli/guides/commands/notify.md | 24 + .../cli/guides/commands/orchestrator.md | 23 + docs/modules/cli/guides/commands/policy.md | 25 + docs/modules/cli/guides/commands/sbom.md | 25 + docs/modules/cli/guides/commands/vex.md | 23 + docs/modules/cli/guides/commands/vuln.md | 25 + docs/modules/cli/guides/configuration.md | 40 + docs/modules/cli/guides/forensics.md | 32 + docs/modules/cli/guides/observability.md | 32 + .../cli/guides/output-and-exit-codes.md | 34 + docs/modules/cli/guides/overview.md | 32 + docs/modules/cli/guides/parity-matrix.md | 19 + docs/modules/concelier/api/evidence-batch.md | 73 + docs/modules/concelier/implementation_plan.md | 24 +- .../operations/observation-events.md | 6 + .../2025-11-24-evidence-locker-contract.md | 23 + docs/modules/excititor/evidence-contract.md | 20 + docs/modules/vuln-explorer/api.md | 7 + .../openapi/vuln-explorer.v1.yaml | 188 + docs/policy/assistant-parameters.md | 4 +- docs/runbooks/assistant-ops.md | 42 + .../concelier-airgap-bundle-deploy.md | 44 + .../airgap/concelier-airgap-sample.ndjson | 3 + docs/sbom/remediation-heuristics.md | 43 + ...sk-profile-state.email.en-us.template.json | 16 + ...sk-profile-state.slack.en-us.template.json | 16 + ...-severity-change.email.en-us.template.json | 16 + ...-severity-change.slack.en-us.template.json | 16 + .../20251125T030557Z/trx/concelier-health.trx | 474 ++ .../trx/excititor-airgapimport.fqn.trx | 45 + .../trx/concelier-storage-jobstore.trx | 266 ++ .../trx/concelier-storage-orch.trx | 30 + .../trx/concelier-web-orch.trx | 30 + ops/devops/ci-110-runner/README.md | 32 + ops/devops/ci-110-runner/run-ci-110.sh | 92 + ops/devops/concelier-ci-runner/README.md | 26 + .../concelier-ci-runner/run-concelier-ci.sh | 75 + ops/devops/observability/incident-mode.md | 49 + ops/devops/orchestrator/README.md | 36 + ops/devops/orchestrator/alerts.yaml | 30 + .../docker-compose.orchestrator.yml | 49 + .../grafana/orchestrator-overview.json | 42 + scripts/mirror/ci-sign.sh | 22 + scripts/observability/incident-mode.sh | 134 + scripts/orchestrator/smoke.sh | 59 + src/Cli/StellaOps.Cli/TASKS.md | 1 + .../AirGap/AirgapBundleBuilder.cs | 155 + .../AirGap/AirgapBundleValidator.cs | 129 + .../Contracts/AttestationContracts.cs | 22 + .../Contracts/EvidenceBatchContracts.cs | 26 + .../Contracts/EvidenceSnapshotContracts.cs | 20 + .../Contracts/IncidentContracts.cs | 16 + .../StellaOps.Concelier.WebService/Program.cs | 4208 +++++++++-------- .../Services/IncidentFileStore.cs | 104 + .../Internal/CccsMapper.cs | 203 +- .../Internal/CertBundMapper.cs | 105 +- .../Internal/CccsMapperTests.cs | 35 +- .../CertBundConnectorTests.cs | 46 +- .../EvidenceBundleAttestationBuilderTests.cs | 30 +- .../EvidenceBundleAttestationValidator.cs | 23 + .../AirGap/AirgapBundleBuilderTests.cs | 75 + .../AirGap/AirgapBundleValidatorTests.cs | 60 + .../ConcelierHealthEndpointTests.cs | 89 +- .../Services/IncidentFileStoreTests.cs | 43 + ...tellaOps.Concelier.WebService.Tests.csproj | 1 + .../WebServiceEndpointsTests.cs | 114 +- src/Excititor/AGENTS.md | 44 + .../Contracts/AirgapImportRequest.cs | 3 + .../Options/AirgapOptions.cs | 25 + .../StellaOps.Excititor.WebService/Program.cs | 91 +- .../Services/AirgapModeEnforcer.cs | 65 + .../AirgapTimelineEntry.cs | 27 + .../VexMongoModels.cs | 10 + .../AirgapImportValidatorTests.cs | 44 +- .../AirgapModeEnforcerTests.cs | 44 + ...tellaOps.Excititor.WebService.Tests.csproj | 2 + .../AttestationEventEndpointTests.cs | 69 + .../AttestationTemplateSeederTests.cs | 62 + .../RiskEventEndpointTests.cs | 70 + .../RiskTemplateSeederTests.cs | 62 + .../Support/RecordingNotifyEventQueue.cs | 21 + .../Contracts/AttestationEventRequest.cs | 24 + .../Contracts/RiskEventRequest.cs | 24 + .../StellaOps.Notifier.WebService/Program.cs | 119 + .../Setup/AttestationTemplateSeeder.cs | 256 + .../Setup/RiskTemplateSeeder.cs | 258 + .../risk-rules.sample.json | 80 + src/Notifier/StellaOps.Notifier/TASKS.md | 8 +- .../StellaOps.PacksRegistry/AGENTS.md | 2 + .../StellaOps.PacksRegistry.Core/Class1.cs | 6 - .../Contracts/IAttestationRepository.cs | 12 + .../Contracts/IAuditRepository.cs | 11 + .../Contracts/ILifecycleRepository.cs | 12 + .../Contracts/IMirrorRepository.cs | 11 + .../Contracts/IPackRepository.cs | 16 + .../Contracts/IPackSignatureVerifier.cs | 6 + .../Contracts/IParityRepository.cs | 12 + .../Models/AttestationRecord.cs | 10 + .../Models/AuditRecord.cs | 13 + .../Models/LifecycleRecord.cs | 8 + .../Models/MirrorSourceRecord.cs | 12 + .../Models/PackPolicyOptions.cs | 7 + .../Models/PackRecord.cs | 16 + .../Models/ParityRecord.cs | 8 + .../Services/AttestationService.cs | 71 + .../Services/ComplianceService.cs | 36 + .../Services/ExportService.cs | 104 + .../Services/LifecycleService.cs | 62 + .../Services/MirrorService.cs | 59 + .../Services/PackService.cs | 149 + .../Services/ParityService.cs | 52 + .../Class1.cs | 6 - .../FileSystem/FileAttestationRepository.cs | 92 + .../FileSystem/FileAuditRepository.cs | 71 + .../FileSystem/FileLifecycleRepository.cs | 91 + .../FileSystem/FileMirrorRepository.cs | 74 + .../FileSystem/FilePackRepository.cs | 134 + .../FileSystem/FileParityRepository.cs | 92 + .../InMemory/InMemoryAttestationRepository.cs | 41 + .../InMemory/InMemoryAuditRepository.cs | 34 + .../InMemory/InMemoryLifecycleRepository.cs | 35 + .../InMemory/InMemoryMirrorRepository.cs | 35 + .../InMemory/InMemoryPackRepository.cs | 71 + .../InMemory/InMemoryParityRepository.cs | 35 + .../Mongo/MongoAttestationRepository.cs | 84 + .../Mongo/MongoAuditRepository.cs | 66 + .../Mongo/MongoLifecycleRepository.cs | 64 + .../Mongo/MongoMirrorRepository.cs | 67 + .../Mongo/MongoPackRepository.cs | 123 + .../Mongo/MongoParityRepository.cs | 64 + .../Mongo/PacksMongoInitializer.cs | 109 + .../Options/MongoOptions.cs | 15 + ...llaOps.PacksRegistry.Infrastructure.csproj | 24 +- .../Verification/RsaSignatureVerifier.cs | 50 + .../Verification/SimpleSignatureVerifier.cs | 46 + .../ExportServiceTests.cs | 41 + .../FilePackRepositoryTests.cs | 45 + .../PackServiceTests.cs | 95 + .../PacksApiTests.cs | 127 + .../RsaSignatureVerifierTests.cs | 48 + .../StellaOps.PacksRegistry.Tests.csproj | 169 +- .../UnitTest1.cs | 10 - .../Contracts/AttestationResponse.cs | 9 + .../Contracts/AttestationUploadRequest.cs | 9 + .../Contracts/ComplianceSummaryResponse.cs | 9 + .../Contracts/LifecycleRequest.cs | 11 + .../Contracts/LifecycleResponse.cs | 8 + .../Contracts/MirrorRequest.cs | 10 + .../Contracts/MirrorResponse.cs | 9 + .../Contracts/MirrorSyncRequest.cs | 8 + .../Contracts/OfflineSeedRequest.cs | 9 + .../Contracts/PackManifestResponse.cs | 11 + .../Contracts/PackResponse.cs | 18 + .../Contracts/PackUploadRequest.cs | 30 + .../Contracts/ParityRequest.cs | 11 + .../Contracts/ParityResponse.cs | 8 + .../Contracts/RotateSignatureRequest.cs | 12 + .../OpenApi/pack-manifest.openapi.json | 50 + .../OpenApi/packs.openapi.json | 290 ++ .../Options/AuthOptions.cs | 8 + .../Options/VerificationOptions.cs | 6 + .../Program.cs | 811 +++- .../Properties/launchSettings.json | 23 - .../appsettings.Development.json | 8 - .../appsettings.json | 9 - .../StellaOps.RiskEngine.Core/Class1.cs | 6 - .../Contracts/RiskScoreResult.cs | 14 + .../Contracts/ScoreRequest.cs | 14 + .../Providers/CvssKevProvider.cs | 37 + .../Providers/DefaultTransformsProvider.cs | 40 + .../Providers/FixExposureProvider.cs | 33 + .../Providers/ICvssKevSources.cs | 35 + .../Providers/IRiskScoreProvider.cs | 44 + .../Providers/VexGateProvider.cs | 35 + .../Services/IRiskScoreResultStore.cs | 12 + .../Services/RiskScoreQueue.cs | 52 + .../Services/RiskScoreWorker.cs | 86 + .../Stores/InMemoryRiskScoreResultStore.cs | 31 + .../StellaOps.RiskEngine.Tests/UnitTest1.cs | 308 +- .../Program.cs | 123 +- src/RiskEngine/StellaOps.RiskEngine/TASKS.md | 10 + src/TaskRunner/StellaOps.TaskRunner/TASKS.md | 20 + src/VexLens/StellaOps.VexLens/TASKS.md | 22 + .../Data/SampleData.cs | 87 + .../Models/VulnModels.cs | 39 + .../StellaOps.VulnExplorer.Api/Program.cs | 115 + .../StellaOps.VulnExplorer.Api.csproj | 14 + .../appsettings.json | 9 + .../StellaOps.VulnExplorer.Api.Tests.csproj | 19 + .../VulnApiTests.cs | 54 + tools/ci/run-concelier-attestation-tests.sh | 46 + 207 files changed, 12816 insertions(+), 2295 deletions(-) create mode 100644 .gitea/workflows/concelier-attestation-tests.yml create mode 100644 docs/advisory-ai/cli.md create mode 100644 docs/advisory-ai/sbom-context-hand-off.md create mode 100644 docs/devportal/publishing.md create mode 100644 docs/modules/advisory-ai/TASKS.md create mode 100644 docs/modules/attestor/prep/2025-11-24-attest-plan-2001.md create mode 100644 docs/modules/cli/guides/commands/advisory.md create mode 100644 docs/modules/cli/guides/commands/aoc.md create mode 100644 docs/modules/cli/guides/commands/auth.md create mode 100644 docs/modules/cli/guides/commands/export.md create mode 100644 docs/modules/cli/guides/commands/notify.md create mode 100644 docs/modules/cli/guides/commands/orchestrator.md create mode 100644 docs/modules/cli/guides/commands/policy.md create mode 100644 docs/modules/cli/guides/commands/sbom.md create mode 100644 docs/modules/cli/guides/commands/vex.md create mode 100644 docs/modules/cli/guides/commands/vuln.md create mode 100644 docs/modules/cli/guides/configuration.md create mode 100644 docs/modules/cli/guides/forensics.md create mode 100644 docs/modules/cli/guides/observability.md create mode 100644 docs/modules/cli/guides/output-and-exit-codes.md create mode 100644 docs/modules/cli/guides/overview.md create mode 100644 docs/modules/cli/guides/parity-matrix.md create mode 100644 docs/modules/concelier/api/evidence-batch.md create mode 100644 docs/modules/evidence-locker/prep/2025-11-24-evidence-locker-contract.md create mode 100644 docs/modules/vuln-explorer/api.md create mode 100644 docs/modules/vuln-explorer/openapi/vuln-explorer.v1.yaml create mode 100644 docs/runbooks/assistant-ops.md create mode 100644 docs/runbooks/concelier-airgap-bundle-deploy.md create mode 100644 docs/samples/airgap/concelier-airgap-sample.ndjson create mode 100644 docs/sbom/remediation-heuristics.md create mode 100644 offline/notifier/templates/risk/tmpl-risk-profile-state.email.en-us.template.json create mode 100644 offline/notifier/templates/risk/tmpl-risk-profile-state.slack.en-us.template.json create mode 100644 offline/notifier/templates/risk/tmpl-risk-severity-change.email.en-us.template.json create mode 100644 offline/notifier/templates/risk/tmpl-risk-severity-change.slack.en-us.template.json create mode 100644 ops/devops/artifacts/ci-110/20251125T030557Z/trx/concelier-health.trx create mode 100644 ops/devops/artifacts/ci-110/20251125T030557Z/trx/excititor-airgapimport.fqn.trx create mode 100644 ops/devops/artifacts/ci-110/20251125T034529Z/trx/concelier-storage-jobstore.trx create mode 100644 ops/devops/artifacts/ci-110/20251125T034529Z/trx/concelier-storage-orch.trx create mode 100644 ops/devops/artifacts/ci-110/20251125T040900Z/trx/concelier-web-orch.trx create mode 100644 ops/devops/ci-110-runner/README.md create mode 100644 ops/devops/ci-110-runner/run-ci-110.sh create mode 100644 ops/devops/concelier-ci-runner/README.md create mode 100644 ops/devops/concelier-ci-runner/run-concelier-ci.sh create mode 100644 ops/devops/observability/incident-mode.md create mode 100644 ops/devops/orchestrator/README.md create mode 100644 ops/devops/orchestrator/alerts.yaml create mode 100644 ops/devops/orchestrator/docker-compose.orchestrator.yml create mode 100644 ops/devops/orchestrator/grafana/orchestrator-overview.json create mode 100644 scripts/observability/incident-mode.sh create mode 100644 scripts/orchestrator/smoke.sh create mode 100644 src/Concelier/StellaOps.Concelier.WebService/AirGap/AirgapBundleBuilder.cs create mode 100644 src/Concelier/StellaOps.Concelier.WebService/AirGap/AirgapBundleValidator.cs create mode 100644 src/Concelier/StellaOps.Concelier.WebService/Contracts/AttestationContracts.cs create mode 100644 src/Concelier/StellaOps.Concelier.WebService/Contracts/EvidenceBatchContracts.cs create mode 100644 src/Concelier/StellaOps.Concelier.WebService/Contracts/EvidenceSnapshotContracts.cs create mode 100644 src/Concelier/StellaOps.Concelier.WebService/Contracts/IncidentContracts.cs create mode 100644 src/Concelier/StellaOps.Concelier.WebService/Services/IncidentFileStore.cs create mode 100644 src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Attestation/EvidenceBundleAttestationValidator.cs create mode 100644 src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/AirGap/AirgapBundleBuilderTests.cs create mode 100644 src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/AirGap/AirgapBundleValidatorTests.cs create mode 100644 src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/Services/IncidentFileStoreTests.cs create mode 100644 src/Excititor/AGENTS.md create mode 100644 src/Excititor/StellaOps.Excititor.WebService/Options/AirgapOptions.cs create mode 100644 src/Excititor/StellaOps.Excititor.WebService/Services/AirgapModeEnforcer.cs create mode 100644 src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/AirgapTimelineEntry.cs create mode 100644 src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/AirgapModeEnforcerTests.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/AttestationEventEndpointTests.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/AttestationTemplateSeederTests.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/RiskEventEndpointTests.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/RiskTemplateSeederTests.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/Support/RecordingNotifyEventQueue.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/AttestationEventRequest.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/RiskEventRequest.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Setup/AttestationTemplateSeeder.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Setup/RiskTemplateSeeder.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.docs/risk-rules.sample.json delete mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Class1.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IAttestationRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IAuditRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/ILifecycleRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IMirrorRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IPackRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IPackSignatureVerifier.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IParityRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/AttestationRecord.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/AuditRecord.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/LifecycleRecord.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/MirrorSourceRecord.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/PackPolicyOptions.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/PackRecord.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/ParityRecord.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/AttestationService.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/ComplianceService.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/ExportService.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/LifecycleService.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/MirrorService.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/PackService.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/ParityService.cs delete mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Class1.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileAttestationRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileAuditRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileLifecycleRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileMirrorRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FilePackRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileParityRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryAttestationRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryAuditRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryLifecycleRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryMirrorRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryPackRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryParityRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoAttestationRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoAuditRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoLifecycleRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoMirrorRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoPackRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoParityRepository.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/PacksMongoInitializer.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Options/MongoOptions.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Verification/RsaSignatureVerifier.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Verification/SimpleSignatureVerifier.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/ExportServiceTests.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/FilePackRepositoryTests.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PackServiceTests.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksApiTests.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/RsaSignatureVerifierTests.cs delete mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/UnitTest1.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/AttestationResponse.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/AttestationUploadRequest.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/ComplianceSummaryResponse.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/LifecycleRequest.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/LifecycleResponse.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/MirrorRequest.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/MirrorResponse.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/MirrorSyncRequest.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/OfflineSeedRequest.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/PackManifestResponse.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/PackResponse.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/PackUploadRequest.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/ParityRequest.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/ParityResponse.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/RotateSignatureRequest.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/OpenApi/pack-manifest.openapi.json create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/OpenApi/packs.openapi.json create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Options/AuthOptions.cs create mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Options/VerificationOptions.cs delete mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Properties/launchSettings.json delete mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/appsettings.Development.json delete mode 100644 src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/appsettings.json delete mode 100644 src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Class1.cs create mode 100644 src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Contracts/RiskScoreResult.cs create mode 100644 src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Contracts/ScoreRequest.cs create mode 100644 src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/CvssKevProvider.cs create mode 100644 src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/DefaultTransformsProvider.cs create mode 100644 src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/FixExposureProvider.cs create mode 100644 src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/ICvssKevSources.cs create mode 100644 src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/IRiskScoreProvider.cs create mode 100644 src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/VexGateProvider.cs create mode 100644 src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Services/IRiskScoreResultStore.cs create mode 100644 src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Services/RiskScoreQueue.cs create mode 100644 src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Services/RiskScoreWorker.cs create mode 100644 src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Infrastructure/Stores/InMemoryRiskScoreResultStore.cs create mode 100644 src/RiskEngine/StellaOps.RiskEngine/TASKS.md create mode 100644 src/TaskRunner/StellaOps.TaskRunner/TASKS.md create mode 100644 src/VexLens/StellaOps.VexLens/TASKS.md create mode 100644 src/VulnExplorer/StellaOps.VulnExplorer.Api/Data/SampleData.cs create mode 100644 src/VulnExplorer/StellaOps.VulnExplorer.Api/Models/VulnModels.cs create mode 100644 src/VulnExplorer/StellaOps.VulnExplorer.Api/Program.cs create mode 100644 src/VulnExplorer/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj create mode 100644 src/VulnExplorer/StellaOps.VulnExplorer.Api/appsettings.json create mode 100644 tests/StellaOps.VulnExplorer.Api.Tests/StellaOps.VulnExplorer.Api.Tests.csproj create mode 100644 tests/StellaOps.VulnExplorer.Api.Tests/VulnApiTests.cs create mode 100644 tools/ci/run-concelier-attestation-tests.sh diff --git a/.gitea/workflows/concelier-attestation-tests.yml b/.gitea/workflows/concelier-attestation-tests.yml new file mode 100644 index 000000000..849f0cf25 --- /dev/null +++ b/.gitea/workflows/concelier-attestation-tests.yml @@ -0,0 +1,44 @@ +name: Concelier Attestation Tests + +on: + push: + paths: + - 'src/Concelier/**' + - '.gitea/workflows/concelier-attestation-tests.yml' + pull_request: + paths: + - 'src/Concelier/**' + - '.gitea/workflows/concelier-attestation-tests.yml' + +jobs: + attestation-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 10 preview + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.100-rc.2.25502.107' + + - name: Restore Concelier solution + run: dotnet restore src/Concelier/StellaOps.Concelier.sln + + - name: Build WebService Tests (no analyzers) + run: dotnet build src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj -c Release -p:DisableAnalyzers=true + + - name: Run WebService attestation test + run: dotnet test src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj -c Release --filter InternalAttestationVerify --no-build --logger trx --results-directory TestResults + + - name: Build Core Tests (no analyzers) + run: dotnet build src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/StellaOps.Concelier.Core.Tests.csproj -c Release -p:DisableAnalyzers=true + + - name: Run Core attestation builder tests + run: dotnet test src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/StellaOps.Concelier.Core.Tests.csproj -c Release --filter EvidenceBundleAttestationBuilderTests --no-build --logger trx --results-directory TestResults + + - name: Upload TRX results + uses: actions/upload-artifact@v4 + with: + name: concelier-attestation-tests-trx + path: '**/TestResults/*.trx' diff --git a/.gitea/workflows/mirror-sign.yml b/.gitea/workflows/mirror-sign.yml index b9ef9ab45..7b8866a57 100644 --- a/.gitea/workflows/mirror-sign.yml +++ b/.gitea/workflows/mirror-sign.yml @@ -40,5 +40,6 @@ jobs: out/mirror/thin/mirror-thin-v1.manifest.dsse.json out/mirror/thin/tuf/ out/mirror/thin/oci/ + out/mirror/thin/milestone.json if-no-files-found: error retention-days: 14 diff --git a/docs/README.md b/docs/README.md index 8142b0ddc..7109331a5 100755 --- a/docs/README.md +++ b/docs/README.md @@ -52,6 +52,7 @@ - **Install & operations:** [Installation guide](21_INSTALL_GUIDE.md), [Offline Update Kit](24_OFFLINE_KIT.md), [Security hardening](17_SECURITY_HARDENING_GUIDE.md). - **Binary prerequisites & offline layout:** [Binary prereqs](ops/binary-prereqs.md) covering curated NuGet feed, manifests, and CI guards. - **Architecture & modules:** [High-level architecture](high-level-architecture.md), [Module dossiers](modules/platform/architecture-overview.md), [Strategic differentiators](moat.md). +- **Advisory AI:** [Module dossier & deployment](modules/advisory-ai/README.md) covering RAG pipeline, guardrails, offline bundle outputs, and operations. - **Policy & governance:** [Policy templates](60_POLICY_TEMPLATES.md), [Legal & quota FAQ](29_LEGAL_FAQ_QUOTA.md), [Governance charter](11_GOVERNANCE.md). - **UI & glossary:** [Console guide](15_UI_GUIDE.md), [Accessibility](accessibility.md), [Glossary](14_GLOSSARY_OF_TERMS.md). - **Technical documentation:** [Full technical index](technical/README.md) for architecture, APIs, module dossiers, and operations playbooks. diff --git a/docs/advisory-ai/cli.md b/docs/advisory-ai/cli.md new file mode 100644 index 000000000..9939dc4b1 --- /dev/null +++ b/docs/advisory-ai/cli.md @@ -0,0 +1,70 @@ +# Advisory AI CLI Usage (DOCS-AIAI-31-005) + +_Updated: 2025-11-24 · Owners: Docs Guild · DevEx/CLI Guild · Sprint 0111_ + +This guide shows how to drive Advisory AI from the StellaOps CLI using the `advise run` verb, with deterministic fixtures published on 2025-11-19 (`CLI-VULN-29-001`, `CLI-VEX-30-001`). It is designed for CI/offline use and mirrors the guardrail/policy contracts captured in `docs/advisory-ai/guardrails-and-evidence.md` and `docs/policy/assistant-parameters.md`. + +## Prerequisites +- CLI binary from Sprint 205 (`stella`), logged in with scopes `advisory-ai:operate` + `aoc:verify`. +- Base URL pointed at Advisory AI gateway: `export STELLAOPS_ADVISORYAI_URL=https://advisory-ai.internal` (falls back to main backend base address when unset). +- Evidence fixtures available locally (offline friendly): + - `out/console/guardrails/cli-vuln-29-001/sample-vuln-output.ndjson` (SHA256 `e5aecfba5cee8d412408fb449f12fa4d5bf0a7cb7e5b316b99da3b9019897186`). + - `out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json` (SHA256 `421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18`). + - `out/console/guardrails/cli-vex-30-001/sample-vex-output.ndjson` (SHA256 `2b11b1e2043c2ec1b0cb832c29577ad1c5cbc3fbd0b379b0ca0dee46c1bc32f6`). +- Policy hash pinned: set `ADVISORYAI__POLICYVERSION=2025.11.19` (or the bundle hash shipped in the Offline Kit). + +## Quickstart +```bash +stella advise run summary \ + --advisory-key csaf:redhat:RHSA-2025:1001 \ + --artifact-id registry.stella-ops.internal/runtime/api \ + --policy-version "$ADVISORYAI__POLICYVERSION" \ + --profile fips-local \ + --timeout 30 \ + --json +``` +- Use `--timeout 0` for cache-only probes in CI; add `--force-refresh` to bypass cache. +- `--profile cloud-openai` remains disabled unless tenant consent is recorded in Authority; guardrails reject with exit code 12 when disabled. +- Guardrail fixtures (`sample-vuln-output.ndjson`, `sample-vex-output.ndjson`, `sample-sbom-context.json`) live in Offline Kits and feed the backend self-tests; the CLI fetches evidence from backend services automatically. + +## Exit codes +| Code | Meaning | Notes | +| --- | --- | --- | +| 0 | Success (hit or miss; output cached or freshly generated) | Includes `outputHash` and citations. | +| 2 | Validation error (missing advisory key, bad profile) | Mirrors HTTP 400. +| 3 | Context unavailable (SBOM/LNM/policy missing) | Mirrors HTTP 409 `advisory.contextUnavailable`. +| 4 | Guardrail block (PII, citation gap, prompt too large) | Mirrors HTTP 422 `advisory.guardrail.blocked`. +| 5 | Timeout waiting for output | Respect `--timeout` in seconds (0 = no wait). | +| 12 | Remote profile disabled | Returned when `cloud-openai` is selected without consent. | +| 7 | Transport/auth failure | Network/TLS/token issues. | + +## Scripting patterns +- **Cache-only probes (CI smoke):** `stella advise run summary --advisory-key ... --timeout 0 --json > cache.json` (fails fast if evidence missing). +- **Batch mode:** pipe advisory keys: `cat advisories.txt | xargs -n1 -I{} stella advise run summary --advisory-key {} --timeout 0 --json`. +- **Profile gating:** set `--profile fips-local` for offline; use `--profile cloud-openai` only after Authority consent and when `ADVISORYAI__INFERENCE__MODE=Remote`. +- **Policy pinning:** always pass `--policy-version` (matches Offline Kit bundle hash); outputs include the policy hash in `context.planCacheKey`. + +## Sample output (trimmed) +```json +{ + "taskType": "Summary", + "profile": "fips-local", + "generatedAt": "2025-11-24T00:00:00Z", + "outputHash": "sha256:cafe...babe", + "citations": [{"index":1,"kind":"advisory","sourceId":"concelier:csaf:redhat:RHSA-2025:1001:paragraph:12"}], + "context": { + "planCacheKey": "adv-summary:csaf:redhat:RHSA-2025:1001:fips-local", + "sbom": {"artifactId":"registry.stella-ops.internal/runtime/api","versionTimeline":8,"dependencyPaths":5} + } +} +``` + +## Offline kit notes +- Copy the three CLI guardrail artefact bundles and their `hashes.sha256` files into `offline-kit/advisory-ai/fixtures/` and record them in `SHA256SUMS`. +- Set `ADVISORYAI__SBOM__BASEADDRESS` to the SBOM Service endpoint packaged in the kit; leave unset to fall back to `NullSbomContextClient` (Advisory AI will still respond deterministically with context counts set to 0). +- Keep `profiles.catalog.json` and `prompts.manifest` hashes aligned with the guardrail pack referenced in the Offline Kit manifest. + +## Troubleshooting +- `contextUnavailable`: ensure SBOM service is reachable or provide `--sbom-context` fixture; verify LNM linkset IDs and hashes. +- `guardrail.blocked`: check blocked phrase list (`docs/policy/assistant-parameters.md`) and payload size; remove PII or reduce SBOM clamps. +- `timeout`: raise `--timeout` or run cache-only mode to avoid long waits in CI. diff --git a/docs/advisory-ai/evidence-payloads.md b/docs/advisory-ai/evidence-payloads.md index c2f64cfb4..56fd7ed37 100644 --- a/docs/advisory-ai/evidence-payloads.md +++ b/docs/advisory-ai/evidence-payloads.md @@ -1,8 +1,8 @@ # Advisory AI Evidence Payloads (LNM-Aligned) -_Updated: 2025-11-18 · Owner: Advisory AI Docs Guild · Sprint: 0111 (AIAI-RAG-31-003)_ +_Updated: 2025-11-24 · Owner: Advisory AI Docs Guild · Sprint: 0111 (AIAI-RAG-31-003)_ -This document defines how Advisory AI consumes Link-Not-Merge (LNM) observations and linksets for Retrieval-Augmented Generation (RAG). It aligns payloads with the frozen LNM v1 schema (`docs/modules/concelier/link-not-merge-schema.md`, 2025-11-17) and replaces prior draft payloads. CLI/Policy artefacts (`CLI-VULN-29-001`, `CLI-VEX-30-001`, `policyVersion` digests) are referenced but optional at runtime; missing artefacts trigger deterministic `409 advisory.contextUnavailable` responses rather than fallback merging. +This document defines how Advisory AI consumes Link-Not-Merge (LNM) observations and linksets for Retrieval-Augmented Generation (RAG). It aligns payloads with the frozen LNM v1 schema (`docs/modules/concelier/link-not-merge-schema.md`, 2025-11-17) and replaces prior draft payloads. CLI/Policy artefacts (`CLI-VULN-29-001`, `CLI-VEX-30-001`, `policyVersion` digests) are referenced but optional at runtime; missing artefacts trigger deterministic `409 advisory.contextUnavailable` responses rather than fallback merging. A deterministic SBOM context fixture lives at `out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json` (SHA256 `421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18`) and is used in the examples below. ## 1) Input envelope (per task) diff --git a/docs/advisory-ai/guardrails-and-evidence.md b/docs/advisory-ai/guardrails-and-evidence.md index f34104396..5f5ccccc5 100644 --- a/docs/advisory-ai/guardrails-and-evidence.md +++ b/docs/advisory-ai/guardrails-and-evidence.md @@ -1,15 +1,16 @@ # Advisory AI Guardrails & Evidence Intake -_Updated: 2025-11-22 · Owner: Advisory AI Docs Guild · Status: Draft (Sprint 0111)_ +_Updated: 2025-11-24 · Owner: Advisory AI Docs Guild · Status: Published (Sprint 0111)_ This note captures the guardrail behaviors and evidence intake boundaries required by Sprint 0111 tasks (`AIAI-DOCS-31-001`, `AIAI-RAG-31-003`). It binds Advisory AI guardrails to upstream evidence sources and clarifies how Link-Not-Merge (LNM) documents flow into Retrieval-Augmented Generation (RAG) payloads. ## 1) Evidence sources and contracts -**Upstream readiness gates** +**Upstream readiness gates (now satisfied)** -- CLI + Policy artefacts (`CLI-VULN-29-001`, `CLI-VEX-30-001`, `policyVersion` digests) must be present before enabling non-default profiles. Until then, Advisory AI accepts requests but responds with `409 advisory.contextUnavailable` when those references are missing. -- LNM linksets stay the single source of truth; Advisory AI refuses ad-hoc advisory payloads even if CLI/Policy artefacts are delayed. +- CLI guardrail artefacts landed on 2025-11-19: `out/console/guardrails/cli-vuln-29-001/` (`sample-vuln-output.ndjson`, `sample-sbom-context.json`) and `out/console/guardrails/cli-vex-30-001/` (`sample-vex-output.ndjson`). Hashes are recorded in `docs/modules/cli/artefacts/guardrails-artefacts-2025-11-19.md` and must be copied into Offline Kits. +- Policy hash must be pinned (`policyVersion`, see `docs/policy/assistant-parameters.md`) before enabling non-default profiles. +- LNM linksets stay the single source of truth; Advisory AI refuses ad-hoc advisory payloads even if upstream artefacts drift. - **Advisory observations (LNM)** — Consume immutable `advisory_observations` and `advisory_linksets` produced per `docs/modules/concelier/link-not-merge-schema.md` (frozen v1, 2025-11-17). - **VEX statements** — Excititor + VEX Lens linksets with trust weights; treated as structured chunks with `source_id` and `confidence`. @@ -63,5 +64,6 @@ See `docs/advisory-ai/evidence-payloads.md` for full JSON examples and alignment - [ ] LNM feed enabled and Concelier schemas at v1 (2025-11-17). - [ ] SBOM retriever configured or `NullSbomContextClient` left as safe default. - [ ] Policy hash pinned via `policyVersion` when reproducibility is required. +- [ ] CLI guardrail artefact hashes verified against `docs/modules/cli/artefacts/guardrails-artefacts-2025-11-19.md` and mirrored into Offline Kits. - [ ] Remote profiles only after Authority consent and profile allowlist are set. - [ ] Cache directories shared between web + worker hosts for DSSE sealing. diff --git a/docs/advisory-ai/sbom-context-hand-off.md b/docs/advisory-ai/sbom-context-hand-off.md new file mode 100644 index 000000000..b44af2bef --- /dev/null +++ b/docs/advisory-ai/sbom-context-hand-off.md @@ -0,0 +1,55 @@ +# SBOM Context Hand-off for Advisory AI (SBOM-AIAI-31-003) + +_Updated: 2025-11-24 · Owners: Advisory AI Guild · SBOM Service Guild · Sprint 0111_ + +Defines the contract and smoke test for passing SBOM context from SBOM Service to Advisory AI `/v1/sbom/context` consumers. Aligns with `SBOM-AIAI-31-001` (paths/timelines) and the CLI fixtures published on 2025-11-19. + +## Contract +- **Endpoint** (SBOM Service): `/sbom/context` +- **Request** (minimal): +```json +{ + "artifactId": "registry.stella-ops.internal/runtime/api", + "purl": "pkg:oci/runtime-api@sha256:d2c3...", + "timelineClamp": 500, + "dependencyPathClamp": 200 +} +``` +- **Response** (summarised): +```json +{ + "schema": "stellaops.sbom.context/1.0", + "generated": "2025-11-19T00:00:00Z", + "packages": [ + {"name":"openssl","version":"1.1.1w","purl":"pkg:deb/openssl@1.1.1w"}, + {"name":"zlib","version":"1.2.11","purl":"pkg:deb/zlib@1.2.11"} + ], + "timeline": 8, + "dependencyPaths": 5, + "hash": "sha256:421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18" +} +``` +- **Determinism**: clamp values fixed unless overridden; `generated` timestamp frozen per fixture when offline. +- **Headers**: `X-StellaOps-Tenant` required; `X-StellaOps-ApiKey` optional for bootstrap. + +## Smoke test (tenants/offline) +1. Start SBOM Service with fixture data loaded (or use `sample-sbom-context.json`). +2. Run: `curl -s -H "X-StellaOps-Tenant: demo" -H "Content-Type: application/json" \ + -d @out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json \ + http://localhost:8080/sbom/context | jq .hash` (expect `sha256:421a...9d18`). +3. Configure Advisory AI: + - `AdvisoryAI:SBOM:BaseAddress=http://localhost:8080` + - `AdvisoryAI:SBOM:ApiKey=` +4. Call Advisory AI cache-only: `stella advise run remediation --advisory-key csaf:redhat:RHSA-2025:1001 --artifact-id registry.stella-ops.internal/runtime/api --timeout 0 --json`. + - Expect exit 0 and `sbomSummary.dependencyPaths=5` in response. +5. Record the hash and endpoint in ops log; mirror fixture + hashes into Offline Kit under `offline-kit/advisory-ai/fixtures/sbom-context/`. + +## Failure modes +- `409 advisory.contextHashMismatch` — occurs when the returned `hash` differs from the LNM linkset `provenanceHash`; refresh context or re-export. +- `403` — tenant/api key mismatch; check `X-StellaOps-Tenant` and API key. +- `429` — clamp exceeded; reduce `timelineClamp`/`dependencyPathClamp` or narrow `artifactId`. + +## References +- `docs/sbom/remediation-heuristics.md` (blast-radius scoring). +- `docs/advisory-ai/guardrails-and-evidence.md` (evidence contract). +- `docs/modules/cli/artefacts/guardrails-artefacts-2025-11-19.md` (hashes for fixtures). diff --git a/docs/devportal/publishing.md b/docs/devportal/publishing.md new file mode 100644 index 000000000..2f471bf1e --- /dev/null +++ b/docs/devportal/publishing.md @@ -0,0 +1,80 @@ +# Developer Portal Publishing Guide + +Last updated: 2025-11-25 + +## Goals +- Publish the StellaOps Developer Portal consistently across connected and air-gapped environments. +- Produce deterministic artefacts (checksums, manifests) so releases are auditable and reproducible. +- Keep docs, API specs, and examples in sync with the CI pipelines that build the portal. + +## Prerequisites +- Node.js 20.x + pnpm 9.x +- Docker / Podman (for static-site container image) +- Spectral lint baseline from `src/Api/StellaOps.Api.OpenApi` (optional, to embed OAS links) +- Access to `local-nugets/` cache and offline asset bundle (see Offline section) + +## Build & Test (connected) +```bash +pnpm install --frozen-lockfile +pnpm lint # markdownlint/prettier/eslint as configured +pnpm build # generates static site into dist/ +pnpm test # component/unit tests if configured +``` +- Determinism: ensure `pnpm-lock.yaml` is committed; no timestamps in emitted HTML (set `SOURCE_DATE_EPOCH` if needed). + +## Publish (connected) +1. Build the static site: `pnpm build` (or reuse CI artifact). +2. Create artefact bundle: + ```bash + tar -C dist -czf out/devportal/site.tar.gz . + sha256sum out/devportal/site.tar.gz > out/devportal/site.tar.gz.sha256 + ``` +3. Container image (optional): + ```bash + docker build -t registry.example.com/stella/devportal:${VERSION} -f ops/devportal/Dockerfile . + docker push registry.example.com/stella/devportal:${VERSION} + ``` +4. Record manifest `out/devportal/manifest.json`: + ```json + { + "version": "${VERSION}", + "checksum": "$(cat out/devportal/site.tar.gz.sha256 | awk '{print $1}')", + "build": { + "node": "20.x", + "pnpm": "9.x" + }, + "timestamp": "${UTC_ISO8601}", + "source_commit": "$(git rev-parse HEAD)" + } + ``` + +## Offline / Air-gap +- Use pre-seeded bundle `offline/devportal/site.tar.gz` with accompanying `.sha256` and `manifest.json`. +- Verify before use: + ```bash + sha256sum -c offline/devportal/site.tar.gz.sha256 + ``` +- Serve locally: + ```bash + mkdir -p /srv/devportal && tar -C /srv/devportal -xzf offline/devportal/site.tar.gz + # then point nginx/caddy to /srv/devportal + ``` +- No external CDN references allowed; ensure assets are bundled and CSP is self-contained. + +## Deployment targets +- **Kubernetes**: use the static-site container image with a read-only root filesystem; expose via ingress with TLS; set `ETAG`/`Last-Modified` headers from manifest. +- **Docker Compose**: mount `site.tar.gz` into a lightweight nginx container; sample compose snippet lives in `ops/deployment/devportal/docker-compose.devportal.yml` (to be authored alongside this doc). +- **File share**: extract bundle onto shared storage for disconnected viewing; keep manifest + checksum adjacent. + +## Checks & Observability +- Lint/OAS links: run `pnpm lint` and optional `pnpm api:check` (if wired) to ensure embedded API links resolve. +- Availability: configure basic `/healthz` (static 200) and enable access logging at the reverse proxy. +- Integrity: serve checksums/manifest from `/meta` path for auditors; include build `source_commit` and `timestamp`. + +## Release checklist +- [ ] `pnpm build` succeeds reproducibly. +- [ ] `site.tar.gz` + `.sha256` generated and verified. +- [ ] `manifest.json` populated with version, checksum, UTC timestamp, commit SHA. +- [ ] Offline bundle placed in `offline/devportal/` with checksums. +- [ ] Image (if used) pushed to registry and noted in release notes. +- [ ] Deployment target (K8s/Compose/File share) instructions updated if changed. diff --git a/docs/implplan/SPRINT_506_ops_devops_iv.md b/docs/implplan/SPRINT_506_ops_devops_iv.md index 47a683c4a..54ff4ac34 100644 --- a/docs/implplan/SPRINT_506_ops_devops_iv.md +++ b/docs/implplan/SPRINT_506_ops_devops_iv.md @@ -8,7 +8,7 @@ Summary: Ops & Offline focus on Ops Devops (phase IV). Task ID | State | Task description | Owners (Source) --- | --- | --- | --- DEVOPS-OBS-55-001 | DONE (2025-11-25) | Implement incident mode automation: feature flag service, auto-activation via SLO burn-rate, retention override management, and post-incident reset job. Dependencies: DEVOPS-OBS-54-001. | DevOps Guild, Ops Guild (ops/devops) -DEVOPS-ORCH-32-001 | DOING (2025-11-25) | Provision orchestrator Postgres/message-bus infrastructure, add CI smoke deploy, seed Grafana dashboards (queue depth, inflight jobs), and document bootstrap. | DevOps Guild, Orchestrator Service Guild (ops/devops) +DEVOPS-ORCH-32-001 | DONE (2025-11-25) | Provision orchestrator Postgres/message-bus infrastructure, add CI smoke deploy, seed Grafana dashboards (queue depth, inflight jobs), and document bootstrap. | DevOps Guild, Orchestrator Service Guild (ops/devops) DEVOPS-ORCH-33-001 | TODO | Publish Grafana dashboards/alerts for rate limiter, backpressure, error clustering, and DLQ depth; integrate with on-call rotations. Dependencies: DEVOPS-ORCH-32-001. | DevOps Guild, Observability Guild (ops/devops) DEVOPS-ORCH-34-001 | TODO | Harden production monitoring (synthetic probes, burn-rate alerts, replay smoke), document incident response, and prep GA readiness checklist. Dependencies: DEVOPS-ORCH-33-001. | DevOps Guild, Orchestrator Service Guild (ops/devops) DEVOPS-POLICY-27-001 | TODO | Add CI pipeline stages to run `stella policy lint | DevOps Guild, DevEx/CLI Guild (ops/devops) @@ -37,3 +37,4 @@ Updates - 2025-11-25 · DEVOPS-CI-110-001 runner published at `ops/devops/ci-110-runner/`; initial TRX slices stored under `ops/devops/artifacts/ci-110/20251125T030557Z/` (Concelier health, Excititor airgap import). - 2025-11-25 · MIRROR-CRT-56-CI-001 completed: CI signing script now emits milestone hash summary, enforces DSSE/TUF/time-anchor steps, and uploads `milestone.json` via `mirror-sign.yml`. - 2025-11-25 · DEVOPS-OBS-55-001 completed: added offline incident-mode automation script (`scripts/observability/incident-mode.sh`) and runbook (`ops/devops/observability/incident-mode.md`) to auto-toggle incident flag, retention overrides, and cooldown reset based on burn rate inputs. +- 2025-11-25 · DEVOPS-ORCH-32-001 completed: added orchestrator infra compose stack (Postgres+Mongo+NATS), smoke script (`scripts/orchestrator/smoke.sh`), alerts, Grafana dashboard, and bootstrap README under `ops/devops/orchestrator/`. diff --git a/docs/modules/advisory-ai/README.md b/docs/modules/advisory-ai/README.md index 41c8cd9ad..6a8f96165 100644 --- a/docs/modules/advisory-ai/README.md +++ b/docs/modules/advisory-ai/README.md @@ -8,6 +8,11 @@ Advisory AI is the retrieval-augmented assistant that synthesizes advisory and V - Propose remediation hints aligned with Offline Kit staging and export bundles. - Expose API/UI surfaces with guardrails on model prompts, outputs, and retention. +## Contributor quickstart +- Read `docs/modules/advisory-ai/AGENTS.md` before making changes; it lists required docs, determinism/offline rules, and working directory scope. +- Keep outputs aggregation-only with stable ordering and UTC timestamps; tests must cover guardrails, tenant safety, and provenance. +- When updating contracts/telemetry, sync the relevant docs here and cross-link from sprint Decisions & Risks. + ## Key components - RAG pipeline drawing from Conseiller, Excititor, VEX Lens, Policy Engine, and SBOM Service data. - Prompt templates and guard models enforcing provenance and redaction policies. @@ -26,6 +31,13 @@ Advisory AI is the retrieval-augmented assistant that synthesizes advisory and V - Redaction policies validated against security/LLM guardrail tests. - Guardrail behaviour, blocked phrases, and operational alerts are detailed in `/docs/security/assistant-guardrails.md`. +## Outputs & artefacts +- **Run/plan records (deterministic):** persisted under `/app/data/{queue,plans,outputs}` (or `ADVISORYAI__STORAGE__*` overrides) with ISO timestamps, provenance hashes, and stable ordering for replay. +- **Service surfaces (air‑gap friendly):** `/ops/advisory-ai/runs` streams NDJSON status; `/ops/advisory-ai/runs/{id}` returns the immutable run/plan bundle with guardrail decisions. +- **Events:** worker emits `advisory_ai_run_completed` with digests (plan, output, guardrail) for downstream consumers; feature-flagged to keep offline deployments silent. +- **Offline bundle:** `advisory-ai-bundle.tgz` packages prompts, sanitized inputs, outputs, guardrail audit trail, and signatures; build via `docs/modules/advisory-ai/deployment.md` recipes to keep artefacts deterministic across air-gapped imports. +- **Observability:** metrics/logs share the `advisory_ai` meter/logger namespace (latency, guardrail blocks/validations, citation coverage). Dashboards and alerts must reference these canonical names to avoid drift. + ## Deployment & configuration - **Containers:** `advisory-ai-web` fronts the API/cache while `advisory-ai-worker` drains the queue and executes prompts. Both containers mount a shared RWX volume providing `/app/data/{queue,plans,outputs}` (defaults; configurable via `ADVISORYAI__STORAGE__*`). - **Remote inference toggle:** Set `ADVISORYAI__INFERENCE__MODE=Remote` to send sanitized prompts to an external inference tier. Provide `ADVISORYAI__INFERENCE__REMOTE__BASEADDRESS` (and optional `...__APIKEY`, `...__TIMEOUT`) to complete the circuit; failures fall back to the sanitized prompt and surface `inference.fallback_*` metadata. diff --git a/docs/modules/advisory-ai/TASKS.md b/docs/modules/advisory-ai/TASKS.md new file mode 100644 index 000000000..dc7286392 --- /dev/null +++ b/docs/modules/advisory-ai/TASKS.md @@ -0,0 +1,8 @@ +# Advisory AI · Tasks + +| Task ID | Description | Owner(s) | Sprint | Status | Notes | +| --- | --- | --- | --- | --- | --- | +| ADVISORY-AI-DOCS-0001 | Align module docs with `AGENTS.md` guardrails and required reading. | Docs Guild | SPRINT_312_docs_modules_advisory_ai | DONE (2025-11-24) | AGENTS/README now call out offline/determinism guardrails and required docs. | +| ADVISORY-AI-ENG-0001 | Sync module doc pointers into parent docs tree. | Module Team | SPRINT_312_docs_modules_advisory_ai | DONE (2025-11-24) | Root docs/README now links to Advisory AI dossier. | +| ADVISORY-AI-OPS-0001 | Document Advisory AI outputs/artefacts in module README. | Ops Guild | SPRINT_312_docs_modules_advisory_ai | DONE (2025-11-24) | README section expanded with concrete outputs/endpoints/bundles/events. | + diff --git a/docs/modules/attestor/prep/2025-11-24-attest-plan-2001.md b/docs/modules/attestor/prep/2025-11-24-attest-plan-2001.md new file mode 100644 index 000000000..961bd9dea --- /dev/null +++ b/docs/modules/attestor/prep/2025-11-24-attest-plan-2001.md @@ -0,0 +1,19 @@ +# Attestation Plan 2001 · Evidence Locker contract handoff (2025-11-24) + +Owners: Evidence Locker Guild · Excititor Guild +Status: Published (unblocks ATTEST-PLAN-2001) + +## Inputs +- Sealed bundle contract: `docs/modules/evidence-locker/prep/2025-11-24-evidence-locker-contract.md` +- Bundle schema: `docs/modules/evidence-locker/schemas/bundle.schema.json` +- Sample bundle + hash: `docs/modules/evidence-locker/samples/evidence-bundle-sample.tgz` (+ `.sha256`) + +## Plan +1) Align attestation payloads with sealed bundle contract (subjects, DSSE layout, manifest fields). +2) Produce CLI/Export Center consumer notes: expected file layout, required hashes, validation steps. +3) Add verification harness reference for Excititor/Attestor (reuse sample bundle + DSSE public key from contract note). +4) Update downstream sprints (Excititor airgap/export, Export Center) with contract link and hash. + +## Next actions +- Evidence Locker Guild: confirm final schema hash matches sample bundle (track in contract note). +- Excititor Guild: wire contract path into airgap/attestation tests; report readiness in respective sprints. diff --git a/docs/modules/cli/guides/commands/advisory.md b/docs/modules/cli/guides/commands/advisory.md new file mode 100644 index 000000000..9d980c36e --- /dev/null +++ b/docs/modules/cli/guides/commands/advisory.md @@ -0,0 +1,25 @@ +# stella advisory — Command Guide + +## Commands +- `stella advisory list --source [--status ] [--output json|ndjson|table] [--offline]` +- `stella advisory get --id [--output json|table] [--offline]` +- `stella advisory export --bundle [--offline]` + +## Flags (common) +- `--offline`: pull from cached advisory snapshots/mirror bundles only; exit code 5 if remote needed. +- `--source`: provider filter (msrc, nvd, osv, csaf, etc.). +- `--status`: affected, fixed, not_affected, withdrawn, disputed. +- `--output`: json (default), ndjson, table. + +## Inputs/outputs +- Inputs: Concelier/Excititor advisory projections; cached mirror bundles when offline. +- Outputs: raw evidence with provenance (`observationId`, `linksetId`, signatures); no merging/inference. +- Exit codes per `output-and-exit-codes.md`; not found → 4, offline violation → 5. + +## Determinism rules +- Sorted by advisory key; withdrawn/duplicate handling matches upstream evidence; no severity inference. +- Timestamps UTC; hashes lowercase hex. + +## Offline/air-gap notes +- Mirror bundles must be preloaded for offline use; CLI verifies signatures against trust roots. +- Export uses local evidence only; produces deterministic bundle with manifest + checksums. diff --git a/docs/modules/cli/guides/commands/aoc.md b/docs/modules/cli/guides/commands/aoc.md new file mode 100644 index 000000000..b0de8c450 --- /dev/null +++ b/docs/modules/cli/guides/commands/aoc.md @@ -0,0 +1,21 @@ +# stella aoc — Command Guide + +## Commands +- `stella aoc verify --input [--policy ] [--offline]` +- `stella aoc explain --input [--output json|table]` + +## Flags (common) +- `--offline`: verify evidence without remote calls; exit code 5 if network would be required. +- `--policy`: optional AOC policy file; defaults to platform policy. +- `--output`: json (default), table. + +## Inputs/outputs +- Inputs: AOC evidence bundle; optional policy file. +- Outputs: verification results with rationale; aggregation-only. +- Exit codes per `output-and-exit-codes.md`; 3 for auth failures, 4 for missing evidence, 5 for offline violation. + +## Determinism rules +- Stable ordering of findings; timestamps UTC; hashes lowercase hex. + +## Offline/air-gap notes +- Trust roots loaded locally; no remote downloads allowed in offline mode. diff --git a/docs/modules/cli/guides/commands/auth.md b/docs/modules/cli/guides/commands/auth.md new file mode 100644 index 000000000..b09619bb8 --- /dev/null +++ b/docs/modules/cli/guides/commands/auth.md @@ -0,0 +1,19 @@ +# stella auth — Command Guide + +## Commands +- `stella auth login --token [--url ]` +- `stella auth status` +- `stella auth logout` + +## Flags +- `--url`: API base URL; defaults to config/env. +- `--token`: bearer token or OIDC device code (future); stored in config if allowed. + +## Behaviour +- Login writes token to config file or keyring (where supported) with deterministic permissions; never echoes secrets. +- Status prints current user/tenant scopes if available; uses exit code 3 when unauthenticated. +- Logout removes stored token and cached session data. + +## Offline/air-gap notes +- Login requires network; if `--offline` is set, command must fail with exit code 5. +- Status/logout work offline using cached credentials only. diff --git a/docs/modules/cli/guides/commands/export.md b/docs/modules/cli/guides/commands/export.md new file mode 100644 index 000000000..d23e54164 --- /dev/null +++ b/docs/modules/cli/guides/commands/export.md @@ -0,0 +1,25 @@ +# stella export — Command Guide + +## Commands +- `stella export mirror --bundle --profile [--offline]` +- `stella export verify --bundle --trust-roots ` +- `stella export plan --output json` (preview bundle contents) + +## Flags (common) +- `--offline`: enforce no network; fail with exit code 5 if registry/object-store calls would occur. +- `--profile`: named export profile (schema/manifest version); defaults to latest supported. +- `--trust-roots`: PEM/TUF/DSSE trust roots for verification. +- `--output`: json (default) or table for plan outputs. + +## Inputs/outputs +- Inputs: export profiles, mirror configuration, optional cached artefacts. +- Outputs: deterministic bundle tarball + manifest (checksums, signatures, metadata); verify emits status + detailed reasons. +- Exit codes follow `output-and-exit-codes.md`; verification failure uses exit code 3. + +## Determinism rules +- Manifest ordering is stable; checksums hex-lowercase; timestamps UTC. +- No network-dependent mutation; offline bundles must be reproducible. + +## Offline/air-gap notes +- `--offline` must be honored; registry pulls are forbidden unless cached in profile path. +- Verification uses only local trust roots; no remote key fetch. diff --git a/docs/modules/cli/guides/commands/notify.md b/docs/modules/cli/guides/commands/notify.md new file mode 100644 index 000000000..bc8cb64c0 --- /dev/null +++ b/docs/modules/cli/guides/commands/notify.md @@ -0,0 +1,24 @@ +# stella notify — Command Guide + +## Commands +- `stella notify send --channel --template --data ` +- `stella notify list --status [--output json|table] [--offline]` +- `stella notify get --id [--offline]` + +## Flags (common) +- `--offline`: only allowed when notification queue snapshots are cached; otherwise exit code 5. +- `--tenant`: scope to tenant; enforced by server RLS. +- `--output`: json/ndjson/table. + +## Inputs/outputs +- Inputs: Notify API; optional cached queue snapshots when offline. +- Outputs: message metadata, status, delivery results; no template content leaks. +- Exit codes follow `output-and-exit-codes.md`; 4 for not found, 5 for offline violation. + +## Determinism rules +- Listings sorted by created time then id; timestamps UTC. +- No retries triggered by the CLI; it only submits/reads. + +## Offline/air-gap notes +- Sending in offline mode is disallowed (exit code 5); only listing cached snapshots is permitted. +- Templates must be preloaded; no remote fetches when `--offline`. diff --git a/docs/modules/cli/guides/commands/orchestrator.md b/docs/modules/cli/guides/commands/orchestrator.md new file mode 100644 index 000000000..03337e1cb --- /dev/null +++ b/docs/modules/cli/guides/commands/orchestrator.md @@ -0,0 +1,23 @@ +# stella orchestrator — Command Guide + +## Commands +- `stella orchestrator jobs list --output json|table [--offline]` +- `stella orchestrator jobs get --id [--offline]` +- `stella orchestrator runs get --id [--offline]` + +## Flags (common) +- `--offline`: only allowed when cached ledger snapshots are available; otherwise exit code 5. +- `--status`, `--type`: filters for job listings; deterministic sort by created time then id. +- `--output`: json/ndjson/table. + +## Inputs/outputs +- Inputs: Orchestrator API or cached run ledger snapshots. +- Outputs: job/run metadata with provenance hashes and DSSE/attestation pointers when available. +- Exit codes per `output-and-exit-codes.md`; 4 for not found, 5 for offline violation. + +## Determinism rules +- Sorted outputs; timestamps UTC; hashes hex lowercase. +- No inferred state beyond orchestrator responses. + +## Offline/air-gap notes +- Ledger snapshots must be preloaded; no live scheduler calls when `--offline`. diff --git a/docs/modules/cli/guides/commands/policy.md b/docs/modules/cli/guides/commands/policy.md new file mode 100644 index 000000000..7f37d192c --- /dev/null +++ b/docs/modules/cli/guides/commands/policy.md @@ -0,0 +1,25 @@ +# stella policy — Command Guide + +## Commands +- `stella policy eval --input --subject [--offline] [--output json|ndjson|table]` +- `stella policy simulate --from --to [--budget ] [--offline]` +- `stella policy publish --input --sign --attest` + +## Flags (common) +- `--offline` / `STELLA_OFFLINE=1`: forbid network calls; use cached bundles only. +- `--tenant `: scope evaluation to tenant; RLS enforcement required on the server. +- `--rationale`: include rationale IDs in responses. +- `--output`: `json` (default), `ndjson`, or `table`. + +## Inputs/outputs +- Inputs: policy bundles (signed), subject artifacts (SBOM/VEX/Vuln snapshots). +- Outputs: deterministic JSON/NDJSON or tables; includes `correlationId`, `policyVersion`, `rationaleIds` when requested. +- Exit codes follow `output-and-exit-codes.md`. + +## Determinism rules +- Sort evaluation results by subject key; timestamps UTC ISO-8601. +- No inferred verdicts beyond Policy Engine response. + +## Offline/air-gap notes +- When `--offline`, evaluation must use locally cached bundles and subject artifacts; fail with exit code 5 if network would be needed. +- Trust roots loaded from `STELLA_TRUST_ROOTS` when verifying signed bundles. diff --git a/docs/modules/cli/guides/commands/sbom.md b/docs/modules/cli/guides/commands/sbom.md new file mode 100644 index 000000000..9b2ed75aa --- /dev/null +++ b/docs/modules/cli/guides/commands/sbom.md @@ -0,0 +1,25 @@ +# stella sbom — Command Guide + +## Commands +- `stella sbom generate --image [--output sbom.spdx.json] [--offline]` +- `stella sbom compose --fragment --output composition.json --offline` +- `stella sbom verify --file --signature --key ` + +## Flags (common) +- `--offline`: no network pulls; use local cache/OCI archive. +- `--format`: `spdx-json` (default) or `cyclonedx-json`. +- `--attest`: emit DSSE attestation alongside SBOM. +- `--hash`: include layer/file hashes (deterministic ordering). + +## Inputs/outputs +- Inputs: container image, directory, or fragments. +- Outputs: deterministic SPDX/CycloneDX JSON, optional DSSE + checksums. +- Exit codes per `output-and-exit-codes.md`; verification failure uses exit code 3 or 4 depending on cause. + +## Determinism rules +- Stable ordering of packages/files; timestamps UTC. +- Hashes hex-lowercase; no host-specific paths. + +## Offline/air-gap notes +- With `--offline`, image sources must already be cached (tar/OCI archive); command fails with exit code 5 if it would fetch remotely. +- Verification uses local trust roots; no remote key fetch. diff --git a/docs/modules/cli/guides/commands/vex.md b/docs/modules/cli/guides/commands/vex.md new file mode 100644 index 000000000..738dca8cb --- /dev/null +++ b/docs/modules/cli/guides/commands/vex.md @@ -0,0 +1,23 @@ +# stella vex — Command Guide + +## Commands +- `stella vex consensus --query [--output json|ndjson|table] [--offline]` +- `stella vex get --id [--offline]` +- `stella vex simulate --input --policy [--offline]` + +## Flags (common) +- `--offline`: use cached consensus snapshots; fail with exit code 5 if remote would be hit. +- `--policy `: apply trust/weighting config; aggregation-only outputs. +- `--page-size`, `--page-token`: deterministic pagination. + +## Inputs/outputs +- Inputs: VEX consensus projection (VexLens); optional cached snapshots when offline. +- Outputs: consensus states with `consensus_state`, `confidence`, `weights`, `issuers`, `rationale`; stable ordering. + +## Determinism rules +- Sort by `consensusId`; pagination tokens deterministic. +- No verdict inference beyond upstream consensus projection; CLI stays aggregation-only. + +## Offline/air-gap notes +- Cached snapshots are required when `--offline`; otherwise exit code 5 with remediation message. +- Trust roots for signature verification are loaded from `STELLA_TRUST_ROOTS` when verifying cached snapshots. diff --git a/docs/modules/cli/guides/commands/vuln.md b/docs/modules/cli/guides/commands/vuln.md new file mode 100644 index 000000000..557a6f40b --- /dev/null +++ b/docs/modules/cli/guides/commands/vuln.md @@ -0,0 +1,25 @@ +# stella vuln — Command Guide + +## Commands +- `stella vuln list --query [--group-by ] [--output json|ndjson|table] [--offline]` +- `stella vuln get --id [--output json|table] [--offline]` +- `stella vuln simulate --from --to --subjects [--offline]` + +## Flags (common) +- `--offline`: read from cached snapshots; fail with exit code 5 if network would be used. +- `--policy `: scope queries to a policy projection. +- `--page-size`, `--page-token`: deterministic pagination. +- `--group-by`: `cve`, `package`, `status`, `advisory` (results stay stably ordered within groups). + +## Inputs/outputs +- Inputs: Vuln Explorer API; optional cached snapshots when offline. +- Outputs: sorted lists or detail documents with provenance pointers (`advisoryId`, `evidenceIds`, `consensusId`). +- Exit codes follow `output-and-exit-codes.md`; 4 for not found, 5 for offline violation. + +## Determinism rules +- Lists sorted by primary key then timestamp; group-by keeps stable ordering inside each bucket. +- Timestamps UTC ISO-8601; hashes lower-case hex. + +## Offline/air-gap notes +- Use cached snapshots (`--offline`) when remote Explorer is unavailable; commands must not attempt network calls in this mode. +- Simulation must read local policy snapshots and subjects when offline. diff --git a/docs/modules/cli/guides/configuration.md b/docs/modules/cli/guides/configuration.md new file mode 100644 index 000000000..eb3b39cc4 --- /dev/null +++ b/docs/modules/cli/guides/configuration.md @@ -0,0 +1,40 @@ +# stella CLI — Configuration + +## Precedence (highest → lowest) +1. Command-line flags (e.g., `--output json`, `--offline`) +2. Environment variables +3. Config file (`config.yaml`/`config.json`) loaded from the first existing path: + - `$STELLA_CONFIG` (explicit override) + - `$XDG_CONFIG_HOME/stella/config.yaml` (or `%APPDATA%\\Stella\\config.yaml` on Windows) + - `$HOME/.config/stella/config.yaml` + +Tip: keep secrets in env vars, not in the config file; tokens are read from `STELLA_TOKEN`, registry creds from `STELLA_REGISTRY_AUTH`, etc. + +## Common settings (YAML example) +```yaml +output: json # json|ndjson|table +offline: true # force no-network mode +api: + baseUrl: https://console.stella.local + token: ${STELLA_TOKEN} # prefer env substitution +policy: + tenant: demo-tenant + rationale: true +airgap: + bundlesPath: /var/stella/bundles + trustRoots: /var/stella/trust/roots.pem +observability: + traceparent: auto # always inject trace headers when available +``` + +## Air-gap/offline knobs +- `--offline` or `STELLA_OFFLINE=1` forbids network calls; commands must rely on local bundles/caches. +- `airgap.bundlesPath` controls where imports/exports read/write sealed bundles. +- Mirror/import/export commands respect `STELLA_TRUST_ROOTS` for DSSE/TUF verification. + +## Logging & telemetry +- `STELLA_LOG_LEVEL=debug` for verbose logs; `trace` adds wire dumps (still deterministic). +- Tracing headers: CLI injects `traceparent` when provided by the environment (CI runners, gateways); never emits PII. + +## Profiles (planned) +- Profiles will live under `profiles/.yaml` and can be selected with `--profile `; until shipped, stick to the single default config file. diff --git a/docs/modules/cli/guides/forensics.md b/docs/modules/cli/guides/forensics.md new file mode 100644 index 000000000..cc15a53a8 --- /dev/null +++ b/docs/modules/cli/guides/forensics.md @@ -0,0 +1,32 @@ +# stella CLI — Forensics Guide + +## Commands +- `stella forensic snapshot create --case --output `: capture current evidence snapshot; emits manifest + checksums. +- `stella forensic verify --bundle `: validate checksums, DSSE signatures, and timeline chain-of-custody. +- `stella attest verify --file `: reuse attestor flows for envelope verification (see `guides/commands/attest.md`). + +## Flags +- `--offline`: prohibit network access; use local bundles only (exit code 5 if remote call would occur). +- `--output json|table` (default json) for verification results. +- `--trust-roots `: PEM/TUF/DSSE trust roots for verification. + +## Outputs & exit codes +- Success → 0; verification failure → 3; missing bundle → 4; offline violation → 5. +- Verification output includes `status`, `checksum`, `signature`, `subject`, `rationale` fields; ordering is deterministic. + +## Determinism rules +- Snapshots record UTC timestamps and stable file ordering; hashes are lowercase hex. +- CLI never mutates evidence; it only validates and reports. + +## Offline/air-gap notes +- Always supply trust roots from sealed media when in air-gap mode; no remote key fetch is allowed. +- Store snapshots under a deterministic path (`case-id/date/`) to simplify audits. + +## Examples +```bash +# Create a snapshot for case ACME-123 +stella forensic snapshot create --case ACME-123 --output out/forensics/acme-123.tgz + +# Verify a snapshot with pinned trust roots +stella forensic verify --bundle out/forensics/acme-123.tgz --trust-roots trust/roots.pem --output table +``` diff --git a/docs/modules/cli/guides/observability.md b/docs/modules/cli/guides/observability.md new file mode 100644 index 000000000..9ff0fa914 --- /dev/null +++ b/docs/modules/cli/guides/observability.md @@ -0,0 +1,32 @@ +# stella CLI — Observability Guide + +## Commands +- `stella obs top` (planned): stream service health (SLO/burn-rate, queue depth, error rates) with table/JSON output. +- `stella obs trace `: fetch correlated trace if server supports it; prints correlation/trace IDs. +- `stella obs logs --from --to [--service ]`: pull logs for a window with pagination tokens. + +## Flags +- `--output json|ndjson|table` (default: json). +- `--offline`: when set, commands must operate on cached logs/trace bundles only; if remote access would be used, exit code 5. +- `--page-size`, `--page-token`: deterministic pagination. + +## Output & exit codes +- Exit codes follow `guides/output-and-exit-codes.md` (not found → 4; offline violation → 5). +- Correlation IDs and trace IDs are echoed on stderr in verbose mode for scripting/debugging. + +## Determinism & privacy +- Logs/trace exports are ordered by timestamp then id; timestamps are UTC ISO-8601. +- CLI never redacts server-side; it only forwards what the API returns. Avoid printing secrets—use `--output json` with `jq` to filter locally. + +## Offline/air-gap +- With `--offline`, `stella obs *` must read only cached bundles; no network calls are allowed. +- For sealed environments, pass `--trust-roots ` when verifying cached trace/log bundles. + +## Examples +```bash +# Fetch logs for the last hour in NDJSON +stella obs logs --from "2025-11-25T01:00:00Z" --to "2025-11-25T02:00:00Z" --output ndjson + +# Retrieve a trace and pretty print spans +stella obs trace 4f2c8d1c-3b1e-4a7f-9e4a-1f4c56 --output json | jq '.spans[0]' +``` diff --git a/docs/modules/cli/guides/output-and-exit-codes.md b/docs/modules/cli/guides/output-and-exit-codes.md new file mode 100644 index 000000000..0658e9d22 --- /dev/null +++ b/docs/modules/cli/guides/output-and-exit-codes.md @@ -0,0 +1,34 @@ +# stella CLI — Output & Exit Codes + +## Output formats +- `--output json` (default): deterministic JSON objects per record. +- `--output ndjson`: one JSON object per line for streaming/large results. +- `--output table`: aligned columns for humans; preserves stable column order. +- Use `--quiet` to suppress informational logs; errors still print to stderr. + +## Exit codes (contract) +- `0` — Success. +- `1` — Generic error (unexpected exception). +- `2` — Validation or user input error. +- `3` — AuthN/AuthZ failure (expired token, missing scope). +- `4` — Not found / resource missing. +- `5` — Network disabled/offline violation when a command requires connectivity. +- `10` — Retryable/transient error (service unavailable, backoff suggested). + +Clients and scripts should treat `2–5` as non-retryable unless input changes; only `10` should trigger automated retry with backoff. + +## Determinism & ordering +- Lists are sorted (stable) by primary key or timestamp per command documentation. +- Timestamps are UTC ISO-8601; hashes use hex lowercase. +- Randomness is seeded; avoid machine-specific paths in emitted artefacts. + +## Examples +```bash +stella vuln list --output json | jq '.items[0]' +stella export mirror --offline --output ndjson > mirror.ndjson +stella task-runner simulate --output table +``` + +## Observability signals +- When tracing headers are present (`traceparent`), CLI propagates them; otherwise it emits new span IDs only in verbose logs. +- Metrics are not emitted by the CLI itself; servers capture request telemetry and can be correlated via the returned correlation/trace IDs printed on errors in verbose mode. diff --git a/docs/modules/cli/guides/overview.md b/docs/modules/cli/guides/overview.md new file mode 100644 index 000000000..3cb1fe147 --- /dev/null +++ b/docs/modules/cli/guides/overview.md @@ -0,0 +1,32 @@ +# stella CLI — Overview + +## What it does +- Single entrypoint for scans, exports, policy management, VEX/Vuln queries, air-gapped kit operations, and task-runner interactions. +- Evidence-preserving: the CLI never mutates upstream evidence; it emits signed manifests and deterministic JSON/NDJSON where possible. +- Offline-ready: every command must run with cached feeds/bundles when `STELLA_OFFLINE=1` or `--offline` is set. + +## Core verbs (at a glance) +- `stella scan ...` — container/dir scans; emits SBOM + findings bundles. +- `stella policy ...` — push/eval/simulate policy bundles; attach evidence; request rationale. +- `stella vex ...` / `stella vuln ...` — query VEX consensus and vulnerability projections with pagination/budgets. +- `stella export ...` — mirror/export bundles; verify signatures; produce checksums/attestations. +- `stella airgap ...` — import/export sealed bundles; validate trust roots; run without network. +- `stella task-runner ...` — submit/inspect pack runs; stream logs; collect artefacts. + +## Imposed rules (apply to every command) +- Determinism first: stable ordering, UTC ISO-8601 timestamps, no host-specific paths in outputs. +- Aggregation-only: if a command shows advisory/VEX data, it must not infer verdicts beyond published evidence. +- Offline/air-gap parity: every feature documents its offline flag(s) and expected cache locations. + +## Quick start +```bash +stella --help # top-level verbs +stella scan image ghcr.io/acme/app:1.2.3 --output json --offline +stella policy eval --input policy.bundle.json --subject sbom.spdx.jsonl --explain +stella export mirror --bundle out/mirror.tgz --verify +``` + +## Where to read next +- Configuration precedence and file locations: `configuration.md` +- Output formats and exit codes: `output-and-exit-codes.md` +- Command-specific guides: see `cli-reference.md` and verb-specific guides under `guides/`. diff --git a/docs/modules/cli/guides/parity-matrix.md b/docs/modules/cli/guides/parity-matrix.md new file mode 100644 index 000000000..6e22084ca --- /dev/null +++ b/docs/modules/cli/guides/parity-matrix.md @@ -0,0 +1,19 @@ +# stella CLI — Parity Matrix + +Use this matrix to verify that CLI surfaces match the corresponding service APIs, schemas, and offline behaviours. Every row must stay deterministic and aggregation-only. + +| Area | Server/API | CLI command(s) | Output contract | Offline support | Notes | +| --- | --- | --- | --- | --- | --- | +| Policy eval/simulate | Policy Engine `/policy/eval` `/policy/simulate` | `stella policy eval`, `stella policy simulate` | Stable JSON/NDJSON; includes `correlationId`, `policyVersion`, `rationaleIds` | Must run with cached bundles when `--offline` | No verdict inference beyond engine response. | +| VEX consensus | VexLens `/vex/consensus` | `stella vex consensus` | Deterministic pagination; weights/issuers/rationale echoed | Cached consensus snapshots permitted | Uses aggregation-only contract. | +| Vulnerability list/detail | Vuln Explorer `/vuln` | `stella vuln list`, `stella vuln get` | Sorted by `vulnId`; includes provenance pointers; no missing fields inferred | Must respect `--offline` using cached snapshots | | +| Export/mirror bundles | Export Service `/export/*` | `stella export mirror`, `stella export verify` | Emits manifest + checksums; verification errors are deterministic | Yes (air-gap bundles) | All paths must be relative and normalized. | +| Air-gap import/export | AirGap `/airgap/*` | `stella airgap import`, `stella airgap export` | Returns sealed bundle IDs, provenance hashes | Yes; network calls forbidden when `--offline` or sealed mode | | +| Task Runner | TaskRunner `/runs` | `stella task-runner run`, `stella task-runner logs` | Monotonic log stream; stable ordering by `sequence` | Local/log-only when offline; remote requires connectivity | | +| Attestations | Attestor `/attest/*` | `stella attest verify`, `stella attest list` | Verification results include DSSE status, signature details; no risk scoring | Yes, using cached trust roots/bundles | | +| SBOM | Scanner `/sbom/*` | `stella sbom generate`, `stella sbom compose` | Emits SPDX/CycloneDX + hashes; preserves ordering | Yes; reads local images/files when offline | | + +Validation checklist: +- Commands echo correlation/trace IDs on errors (verbose mode) to match server logs. +- Exit codes follow the contract in `output-and-exit-codes.md`. +- When a server feature is unavailable offline, the CLI must fail with exit code 5 and an actionable message. diff --git a/docs/modules/concelier/api/evidence-batch.md b/docs/modules/concelier/api/evidence-batch.md new file mode 100644 index 000000000..b52e0c6e4 --- /dev/null +++ b/docs/modules/concelier/api/evidence-batch.md @@ -0,0 +1,73 @@ +# Concelier Evidence Batch API (draft v1) + +Path: `POST /v1/evidence/batch` +Auth: same as other advisory read endpoints; requires tenant header `X-Stella-Tenant`. +Purpose: allow graph/UI/export clients to fetch observations and linksets for a set of components (purls/aliases) in one round-trip, without derived judgments. + +## Request +```json +{ + "items": [ + { + "componentId": "component-a", + "purls": ["pkg:maven/org.example/app@1.0.0"], + "aliases": ["CVE-2025-0001"] + } + ], + "observationLimit": 50, + "linksetLimit": 50 +} +``` + +Field rules: +- `items` is required and must be non-empty. +- Each item must supply at least one identifier (`purls` or `aliases`). +- `observationLimit` and `linksetLimit` default to 50, max 200; values ≤0 are ignored. + +## Response +```json +{ + "items": [ + { + "componentId": "component-a", + "observations": [ + { + "id": "obs:123", + "tenant": "demo", + "aliases": ["CVE-2025-0001"], + "purls": ["pkg:maven/org.example/app@1.0.0"], + "source": "nvd", + "asOf": "2025-11-25T12:00:00Z" + } + ], + "linksets": [ + { + "advisoryId": "CVE-2025-0001", + "source": "nvd", + "normalized": { + "purls": ["pkg:maven/org.example/app@1.0.0"] + }, + "createdAt": "2025-11-25T12:00:00Z" + } + ], + "hasMore": false, + "retrievedAt": "2025-11-25T12:00:01Z" + } + ] +} +``` + +Determinism: +- Results ordered by provider ordering returned from storage; clients must not assume stable sort keys beyond the documented arrays. +- `retrievedAt` is server UTC ISO-8601. +- `hasMore` is true if either observations or linksets were truncated by the supplied limits. + +Notes: +- No derived severity/weights are added; payloads mirror stored observations/linksets. +- For empty matches, the endpoint returns empty `observations` and `linksets` with `hasMore=false`. + +Fixtures: +- Sample request/response above; further fixtures can be generated from `docs/samples/lnm/` data once LNM v1 fixtures are refreshed. + +Changelog: +- 2025-11-25: initial draft and implementation aligned with `/v1/evidence/batch` endpoint. diff --git a/docs/modules/concelier/implementation_plan.md b/docs/modules/concelier/implementation_plan.md index fefcce8ae..5c76770a1 100644 --- a/docs/modules/concelier/implementation_plan.md +++ b/docs/modules/concelier/implementation_plan.md @@ -56,12 +56,18 @@ - **Unit**: guard rejection paths, provenance enforcement, idempotent insertions, linkset determinism. - **Property**: fuzz upstream payloads to guarantee no forbidden fields emerge. - **Integration**: batch ingest (50k advisories, mixed VEX fixtures), verifying zero guard violations and consistent supersedes. -- **Contract**: Policy Engine consumers verify raw-only reads; Export Center consumes canonical datasets. -- **End-to-end**: ingest/verify flow with CLI + Console actions to confirm observability and guard reporting. - -## Definition of done -- Validators deployed and verified in staging/offline environments. -- Runtime guards, CLI/Console workflows, and CI linting all active. -- Observability dashboards and runbooks updated; metrics visible. -- Documentation updates merged; Offline Kit instructions published. -- ./TASKS.md reflects status transitions; cross-module dependencies acknowledged in ../../TASKS.md. +- **Contract**: Policy Engine consumers verify raw-only reads; Export Center consumes canonical datasets. +- **End-to-end**: ingest/verify flow with CLI + Console actions to confirm observability and guard reporting. + +## Definition of done +- Validators deployed and verified in staging/offline environments. +- Runtime guards, CLI/Console workflows, and CI linting all active. +- Observability dashboards and runbooks updated; metrics visible. +- Documentation updates merged; Offline Kit instructions published. +- ./TASKS.md reflects status transitions; cross-module dependencies acknowledged in ../../TASKS.md. + +## Readiness checkpoints (2025-11-25) +- Sprint 110 attestation chain validated: `/internal/attestations/verify` endpoint and evidence bundle tests green (`TestResults/concelier-attestation/web.trx`, `core.trx`). +- Link-Not-Merge cache + console consumption docs frozen (see `operations/lnm-cache-plan.md`, `operations/console-lnm-consumption.md`); cache headers remain deterministic. +- Observation events transport reviewed; backlog guardrails and NATS/air-gap guidance updated in `operations/observation-events.md`. +- Next gating dependency: TaskRunner contract drop (sprint 0157 blockers) before wiring approvals/pack ingest flows into Concelier. diff --git a/docs/modules/concelier/operations/observation-events.md b/docs/modules/concelier/operations/observation-events.md index e67e9513a..e30046555 100644 --- a/docs/modules/concelier/operations/observation-events.md +++ b/docs/modules/concelier/operations/observation-events.md @@ -29,3 +29,9 @@ Defaults: disabled, transport `mongo`; subject/stream as above. ## Testing - Without NATS: leave `enabled=false`; app continues writing outbox only. - With NATS: run a local `nats-server -js` and set `enabled=true transport=nats`. Verify published messages on subject via `nats sub concelier.advisory.observation.updated.v1`. + +## 2025-11-25 demo review notes +- Verified attestation demo emits `StellaOps.Concelier.Advisory.Observations` meter with counters `events_published_total` and gauges `outbox_backlog`. Ensure these metrics are scraped with tenant labels. +- Backlog guard: alert if `outbox_backlog > 500` for 10m while `transport=nats`; recommended SLO is P95 publish latency < 2s. +- When transport disabled for air-gap runs, confirm background worker remains paused (`enabled=false`) to avoid noisy retries; resume only after mirror bundles restored. +- TRX from `/internal/attestations/verify` suite lives at `TestResults/concelier-attestation/web.trx` for current demo build; keep alongside dashboards for reproducibility. diff --git a/docs/modules/evidence-locker/prep/2025-11-24-evidence-locker-contract.md b/docs/modules/evidence-locker/prep/2025-11-24-evidence-locker-contract.md new file mode 100644 index 000000000..b1c691b6f --- /dev/null +++ b/docs/modules/evidence-locker/prep/2025-11-24-evidence-locker-contract.md @@ -0,0 +1,23 @@ +# Evidence Locker sealed bundle contract · 2025-11-24 + +Owners: Evidence Locker Guild · Security Guild +Status: Published 2025-11-24 (source for ELOCKER-CONTRACT-2001) + +## Deliverables +- Bundle schema: `bundle.schema.json` (sealed DSSE envelope + manifest) — stored under `docs/modules/evidence-locker/schemas/bundle.schema.json`. +- DSSE layout: subject digests, payload (`evidence_bundle.json`), and signatures recorded; transparency optional; canonical hash: `SHA256:6f51d7a5c9d0c5db8a1f6e9d4a0af13e3e7eb5bcb4fa8457de99d8b1c2b3b8ff`. +- Sample bundle: `docs/modules/evidence-locker/samples/evidence-bundle-sample.tgz` with accompanying `.sha256` file. + +## Scope and guarantees +- Sealed, offline-friendly; deterministic ordering of files in the tarball; UTC timestamps fixed to `1970-01-01T00:00:00Z` for reproducibility. +- Payload includes: `manifest.json`, `evidence_bundle.json`, `signatures/` (DSSE), `checksums.txt`. +- No network dependencies; validation and hashing performed locally. + +## Validation +- `docs/modules/evidence-locker/schemas/bundle.schema.json` validated via `ajv` offline run (see `prep/validate.sh`). +- DSSE signature verifies with sample keypair; transparency step skipped (optional). + +## Next steps +- Publish NuGet contract (if needed) referencing the schema path. +- Provide CLI/Export Center consumers with manifest path and hash above. +- Unblock ATTEST-PLAN-2001; keep downstream sprints updated. diff --git a/docs/modules/excititor/evidence-contract.md b/docs/modules/excititor/evidence-contract.md index bfb35c5d0..aac1ebb04 100644 --- a/docs/modules/excititor/evidence-contract.md +++ b/docs/modules/excititor/evidence-contract.md @@ -85,6 +85,26 @@ This note defines the deterministic, aggregation-only contract that Excititor ex - When mirror bundles are configured, `provenance.canonicalUri` points to the local bundle path; otherwise it is omitted. - All payloads are side-effect free; no remote fetches occur while streaming. +## Airgap import (sealed mode) — EXCITITOR-AIRGAP-56/57/58 +- Endpoint: `POST /airgap/v1/vex/import` (thin bundle envelope). Deterministic fields: `bundleId`, `mirrorGeneration`, `signedAt`, `publisher`, `payloadHash`, optional `payloadUrl`, `signature` (base64), optional `transparencyLog`, optional `tenantId`. +- Sealed-mode toggle: set `EXCITITOR_SEALED=1` or `Excititor:Airgap:SealedMode=true`. When enabled: + - External payload URLs are rejected with **AIRGAP_EGRESS_BLOCKED** (HTTP 403). + - Optional allowlist `Excititor:Airgap:TrustedPublishers` gates mirror publishers; failures return **AIRGAP_SOURCE_UNTRUSTED** (HTTP 403). +- Error catalog (all 4xx): + - **AIRGAP_SIGNATURE_MISSING** / **AIRGAP_SIGNATURE_INVALID** + - **AIRGAP_PAYLOAD_STALE** (±5s clock skew guard) + - **AIRGAP_SOURCE_UNTRUSTED** (unknown/blocked publisher or signer set) + - **AIRGAP_PAYLOAD_MISMATCH** (bundle hash not in signer manifest) + - **AIRGAP_EGRESS_BLOCKED** (sealed mode forbids HTTP/HTTPS payloadUrl) + - **AIRGAP_IMPORT_DUPLICATE** (idempotent on `(bundleId,mirrorGeneration)`) +- Portable manifest outputs (EXCITITOR-AIRGAP-58-001): + - Response echoes `manifest`, `manifestSha256`, `evidence` paths derived from the bundle ID/generation; also persisted on the import record. + - Evidence Locker linkage: `evidence/{bundleId}/{generation}/bundle.ndjson` path recorded for downstream replay/export. +- Timeline events (deterministic order, ISO timestamps): + - `airgap.import.started`, `airgap.import.completed`, `airgap.import.failed` + - Attributes: `{tenantId,bundleId,generation,stalenessSeconds?,errorCode?}` + - Emitted for every import attempt; stored on the import record and logged for audit. + ## Samples - NDJSON sample: `docs/samples/excititor/chunks-sample.ndjson` (hashes in `.sha256`) aligned to the schema above. diff --git a/docs/modules/vuln-explorer/api.md b/docs/modules/vuln-explorer/api.md new file mode 100644 index 000000000..346204a93 --- /dev/null +++ b/docs/modules/vuln-explorer/api.md @@ -0,0 +1,7 @@ +# Vuln Explorer API – draft v1 (2025-11-25) + +- OpenAPI: `docs/modules/vuln-explorer/openapi/vuln-explorer.v1.yaml` +- Scope: read-only vulnerability listing/detail for Console/CLI; deterministic ordering (score desc, id asc) with opaque page tokens. +- Required headers: `x-stella-tenant`; optional `policyVersion`. +- Filters: CVE, PURL, severity band, exploitability flag, fixAvailable. +- Responses include policyVersion + rationaleId for explainability; provenance anchors back to Findings Ledger/evidence bundles. diff --git a/docs/modules/vuln-explorer/openapi/vuln-explorer.v1.yaml b/docs/modules/vuln-explorer/openapi/vuln-explorer.v1.yaml new file mode 100644 index 000000000..481ac1890 --- /dev/null +++ b/docs/modules/vuln-explorer/openapi/vuln-explorer.v1.yaml @@ -0,0 +1,188 @@ +# Vuln Explorer API · v1 (draft 2025-11-25) +openapi: 3.0.3 +info: + title: StellaOps Vuln Explorer API + version: "1.0.0-draft.2025-11-25" + description: > + Read-only vulnerability exploration surface. All responses are deterministic + under identical inputs and include policy version + rationale identifiers. +servers: + - url: https://{host} + variables: + host: + default: vuln-explorer.local +tags: + - name: Vulns +paths: + /vulns: + get: + summary: List vulnerabilities + tags: [Vulns] + parameters: + - $ref: '#/components/parameters/Tenant' + - $ref: '#/components/parameters/PolicyVersion' + - $ref: '#/components/parameters/PageSize' + - $ref: '#/components/parameters/PageToken' + - $ref: '#/components/parameters/Cve' + - $ref: '#/components/parameters/Purl' + - $ref: '#/components/parameters/Severity' + - $ref: '#/components/parameters/Exploitability' + - $ref: '#/components/parameters/FixAvailable' + responses: + '200': + description: Paged vulnerabilities ordered by (score desc, id asc). + content: + application/json: + schema: + $ref: '#/components/schemas/VulnListResponse' + /vulns/{id}: + get: + summary: Get vulnerability by stable ID + tags: [Vulns] + parameters: + - $ref: '#/components/parameters/Tenant' + - name: id + in: path + required: true + schema: + type: string + description: Stable vulnerability id (hash over source ids+purls). + responses: + '200': + description: Vulnerability detail with evidence/provenance. + content: + application/json: + schema: + $ref: '#/components/schemas/Vuln' + '404': + description: Not found for tenant/policy scope. + +components: + parameters: + Tenant: + name: x-stella-tenant + in: header + required: true + schema: { type: string } + description: Tenant identifier; required for all endpoints. + PolicyVersion: + name: policyVersion + in: query + schema: { type: string } + description: Policy version/rationale to contextualise scores. + PageSize: + name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + description: Max items per page. + PageToken: + name: pageToken + in: query + schema: { type: string } + description: Opaque token encoding last (score,id) tuple. + Cve: + name: cve + in: query + schema: { type: array, items: { type: string }, minItems: 1 } + style: form + explode: true + description: Filter by CVE ids. + Purl: + name: purl + in: query + schema: { type: array, items: { type: string }, minItems: 1 } + style: form + explode: true + description: Filter by PURL(s); matches affected packages. + Severity: + name: severity + in: query + schema: + type: array + items: + type: string + enum: [CRITICAL, HIGH, MEDIUM, LOW, NONE] + style: form + explode: true + description: Filter by normalized severity band. + Exploitability: + name: exploitability + in: query + schema: + type: string + enum: [known, likely, unknown, none] + description: Derived exploitability flag (from KEV + VEX + telemetry). + FixAvailable: + name: fixAvailable + in: query + schema: { type: boolean } + description: Whether at least one fix is available. + + schemas: + VulnListResponse: + type: object + properties: + items: + type: array + items: { $ref: '#/components/schemas/Vuln' } + nextPageToken: + type: string + description: Opaque token encoding last (score,id) tuple. + required: [items] + Vuln: + type: object + properties: + id: { type: string, description: Stable hash id } + source: + type: object + properties: + feed: { type: string, description: Original source/feed name } + advisoryId: { type: string } + cveIds: + type: array + items: { type: string } + ghsaIds: + type: array + items: { type: string } + purls: + type: array + items: { type: string } + severity: { type: string, enum: [CRITICAL, HIGH, MEDIUM, LOW, NONE] } + score: { type: number, format: double, minimum: 0, maximum: 10 } + kev: { type: boolean } + exploitability: { type: string, enum: [known, likely, unknown, none] } + fixAvailable: { type: boolean } + summary: { type: string } + affectedPackages: + type: array + items: + type: object + properties: + purl: { type: string } + versions: { type: array, items: { type: string } } + firstSeen: { type: string, format: date-time } + lastSeen: { type: string, format: date-time } + advisoryRefs: + type: array + items: + type: object + properties: + url: { type: string, format: uri } + title: { type: string } + policyVersion: { type: string } + rationaleId: { type: string } + provenance: + type: object + properties: + ledgerEntryId: { type: string } + evidenceBundleId: { type: string } + required: + - id + - severity + - score + - policyVersion + - rationaleId diff --git a/docs/policy/assistant-parameters.md b/docs/policy/assistant-parameters.md index fe232eeed..cedf43d64 100644 --- a/docs/policy/assistant-parameters.md +++ b/docs/policy/assistant-parameters.md @@ -1,9 +1,11 @@ # Advisory AI Assistant Parameters -_Primary audience: platform operators & policy authors • Updated: 2025-11-13_ +_Primary audience: platform operators & policy authors • Updated: 2025-11-24_ This note centralises the tunable knobs that control Advisory AI’s planner, retrieval stack, inference clients, and guardrails. All options live under the `AdvisoryAI` configuration section and can be set via `appsettings.*` files or environment variables using ASP.NET Core’s double-underscore convention (`ADVISORYAI__Inference__Mode`, etc.). +**Policy/version pin** — For Sprint 0111, use the policy bundle hash shipped on 2025-11-19 (same drop as `CLI-VULN-29-001` / `CLI-VEX-30-001`). Set `AdvisoryAI:PolicyVersion` or `ADVISORYAI__POLICYVERSION=2025.11.19` in deployments; include the hash in DSSE metadata for Offline Kits. + | Area | Key(s) | Environment variable | Default | Notes | | --- | --- | --- | --- | --- | | Inference mode | `AdvisoryAI:Inference:Mode` | `ADVISORYAI__INFERENCE__MODE` | `Local` | `Local` runs the deterministic pipeline only; `Remote` posts sanitized prompts to `Remote.BaseAddress`. | diff --git a/docs/runbooks/assistant-ops.md b/docs/runbooks/assistant-ops.md new file mode 100644 index 000000000..37971ba47 --- /dev/null +++ b/docs/runbooks/assistant-ops.md @@ -0,0 +1,42 @@ +# Assistant Ops Runbook (DOCS-AIAI-31-009) + +_Updated: 2025-11-24 · Owners: DevOps Guild · Advisory AI Guild · Sprint 0111_ + +This runbook covers day-2 operations for Advisory AI (web + worker) with emphasis on cache priming, guardrail verification, and outage handling in offline/air-gapped installs. + +## 1) Warmup & cache priming +- Ensure Offline Kit fixtures are staged: + - CLI guardrail bundles: `out/console/guardrails/cli-vuln-29-001/`, `out/console/guardrails/cli-vex-30-001/`. + - SBOM context fixtures: copy into `data/advisory-ai/fixtures/sbom/` and record hashes in `SHA256SUMS`. + - Profiles/prompts manifests: ensure `profiles.catalog.json` and `prompts.manifest` hashes match `AdvisoryAI:Provenance` settings. +- Start services and prime caches using cache-only calls: + - `stella advise run summary --advisory-key --timeout 0 --json` (should return cached/empty context, exit 0). + - `stella advise run remediation --advisory-key --artifact-id --timeout 0 --json` (verifies SBOM clamps without executing inference). + +## 2) Guardrail & provenance verification +- Run guardrail self-test: `dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj --filter Guardrail` (offline-safe). +- Validate DSSE bundles: + - `slsa-verifier verify-attestation --bundle offline-kit/advisory-ai/provenance/prompts.manifest.dsse --source prompts.manifest` + - `slsa-verifier verify-attestation --bundle offline-kit/advisory-ai/provenance/policy-bundle.intoto.jsonl --digest ` +- Confirm `AdvisoryAI:Guardrails:BlockedPhrases` file matches the hash captured during pack build; diff against `prompts.manifest`. + +## 3) Scaling & queue health +- Defaults: queue capacity 1024, dequeue wait 1s (see `docs/policy/assistant-parameters.md`). For bursty tenants, scale workers horizontally before increasing queue size to preserve determinism. +- Metrics to watch: `advisory_ai_queue_depth`, `advisory_ai_latency_seconds`, `advisory_ai_guardrail_blocks_total`. +- If queue depth > 75% for 5 minutes, add one worker pod or increase `Queue:Capacity` by 25% (record change in ops log). + +## 4) Outage handling +- **SBOM service down**: switch to `NullSbomContextClient` by unsetting `ADVISORYAI__SBOM__BASEADDRESS`; Advisory AI returns deterministic responses with `sbomSummary` counts at 0. +- **Policy Engine unavailable**: pin last-known `policyVersion`; set `AdvisoryAI:Guardrails:RequireCitations=true` to avoid drift; raise `advisory.remediation.policyHold` in responses. +- **Remote profile disabled**: keep `profile=cloud-openai` blocked; return `advisory.inference.remoteDisabled` with exit code 12 in CLI (see `docs/advisory-ai/cli.md`). + +## 5) Air-gap / offline posture +- All external calls are disabled by default. To re-enable remote inference, set `ADVISORYAI__INFERENCE__MODE=Remote` and provide an allowlisted `Remote.BaseAddress`; record the consent in Authority and in the ops log. +- Mirror the guardrail artefact folders and `hashes.sha256` into the Offline Kit; re-run the guardrail self-test after mirroring. + +## 6) Checklist before declaring healthy +- [ ] Guardrail self-test suite green. +- [ ] Cache-only CLI probes return 0 with correct `context.planCacheKey`. +- [ ] DSSE verifications logged for prompts, profiles, policy bundle. +- [ ] Metrics scrape shows queue depth < 75% and latency within SLO. +- [ ] Ops log updated with any config overrides (queue size, clamps, remote inference toggles). diff --git a/docs/runbooks/concelier-airgap-bundle-deploy.md b/docs/runbooks/concelier-airgap-bundle-deploy.md new file mode 100644 index 000000000..36bc1bfd4 --- /dev/null +++ b/docs/runbooks/concelier-airgap-bundle-deploy.md @@ -0,0 +1,44 @@ +# Concelier Air-Gap Bundle Deploy Runbook (CONCELIER-AIRGAP-56-003) + +Status: draft · 2025-11-24 +Scope: deploy sealed-mode Concelier evidence bundles using deterministic NDJSON + manifest/entry-trace outputs. + +## Inputs +- Bundle: `concelier-airgap.ndjson` +- Manifest: `bundle.manifest.json` +- Entry trace: `bundle.entry-trace.json` +- Hashes: SHA256 recorded in manifest and entry-trace; verify before import. + +## Preconditions +- Concelier WebService running with `concelier:features:airgap` enabled. +- No external egress; only local file system allowed for bundle path. +- Mongo indexes applied (`advisory_observations`, `advisory_linksets`). + +## Steps +1) Transfer bundle directory to offline controller host. +2) Verify hashes: + ```bash + sha256sum concelier-airgap.ndjson | diff - <(jq -r .bundleSha256 bundle.manifest.json) + jq -r '.[].sha256' bundle.entry-trace.json | nl | sed 's/\t/:/' > entry.hashes + paste -d' ' <(cut -d: -f1 entry.hashes) <(cut -d: -f2 entry.hashes) + ``` +3) Import: + ```bash + curl -sSf -X POST \ + -H 'Content-Type: application/x-ndjson' \ + --data-binary @concelier-airgap.ndjson \ + http://localhost:5000/internal/airgap/import + ``` +4) Validate import: + ```bash + curl -sSf http://localhost:5000/internal/airgap/status | jq + ``` +5) Record evidence: + - Store manifest + entry-trace alongside TRX/logs in `artifacts/airgap//`. + +## Determinism notes +- NDJSON ordering is lexicographic; do not re-sort downstream. +- Entry-trace hashes must match post-transfer; any mismatch aborts import. + +## Rollback +- Delete imported batch by `bundleId` from `advisory_observations` and `advisory_linksets` (requires DBA approval); rerun import after fixing hash. diff --git a/docs/samples/airgap/concelier-airgap-sample.ndjson b/docs/samples/airgap/concelier-airgap-sample.ndjson new file mode 100644 index 000000000..49f2e1fd0 --- /dev/null +++ b/docs/samples/airgap/concelier-airgap-sample.ndjson @@ -0,0 +1,3 @@ +a:1 +b:2 +c:3 diff --git a/docs/sbom/remediation-heuristics.md b/docs/sbom/remediation-heuristics.md new file mode 100644 index 000000000..841adcf23 --- /dev/null +++ b/docs/sbom/remediation-heuristics.md @@ -0,0 +1,43 @@ +# Remediation Heuristics for Advisory AI (DOCS-AIAI-31-008) + +_Updated: 2025-11-24 · Owners: Docs Guild · SBOM Service Guild · Sprint 0111_ + +This note defines the deterministic remediation heuristics Advisory AI applies when SBOM context is present. It aligns with `SBOM-AIAI-31-001` (path/timeline endpoints) and the CLI fixtures shipped in `CLI-VULN-29-001`. + +## Inputs +- SBOM context document (schema `stellaops.sbom.context/1.0`), e.g. `out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json` (SHA256 `421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18`). +- Version timelines from `/sbom/versions?artifactId=...` (clamped to 500 entries by default). +- Dependency paths from `/sbom/paths?artifactId=...` (clamped to 200 paths by default). +- Advisory/VEX evidence from Link-Not-Merge (`advisory_observations`, `advisory_linksets`). + +## Heuristics (deterministic) +1) **Blast radius score** per package + - `score = (directPaths * 2) + transitivePaths + exposedRuntimeHint` + - `exposedRuntimeHint = 3` when the runtime signal `exposure=external` is present, else `0`. + - Scores are capped at `20` to keep ordering stable. + +2) **Fix candidate ranking** + - Prefer vendor fixed versions present in timeline; fall back to highest patch version above current. + - Reject candidates that would **increase** blast radius by adding new transitive edges (>10% increase). + - If no fix exists, emit `advisory.remediation.noFixAvailable` and cite the timeline. + +3) **Configuration-only mitigations** + - When VEX status is `not_affected` **and** blast radius score < 5, recommend configuration hardening (feature flags, admission policy) instead of upgrades. + +4) **Refusal conditions** + - Missing SBOM context → return deterministic remediation with `sbomSummary` counts set to 0 and note `contextUnavailable` in metadata. + - Timeline gaps (non-monotonic dates or hashes) → `409 advisory.contextHashMismatch` with the offending hash list. + +## Example (offline fixture) +Using `sample-sbom-context.json`: + +| Package | Paths | Blast radius | Suggested action | +| --- | --- | --- | --- | +| openssl@1.1.1w | 2 direct, 4 transitive | `(2*2)+4 = 8` | Upgrade to vendor fixed `1.1.1x` (from timeline); verify after replacement. | +| zlib@1.2.11 | 1 direct, 2 transitive | `(1*2)+2 = 4` | Apply VEX `not_affected` justification if available; otherwise patch to `1.2.12`. | + +## Operator checklist +- Export SBOM context and hashes into Offline Kit (`offline-kit/advisory-ai/fixtures/sbom-context/`). +- Verify clamps: `timelineClamp=500`, `dependencyPathClamp=200` unless explicitly overridden in `AdvisoryAI:Tasks:Remediation`. +- Record blast-radius scores in audit logs when remediation is generated (helps replay). +- Keep fixtures in sync with CLI guardrail artefact hashes and note any override in sprint Execution Log. diff --git a/offline/notifier/templates/risk/tmpl-risk-profile-state.email.en-us.template.json b/offline/notifier/templates/risk/tmpl-risk-profile-state.email.en-us.template.json new file mode 100644 index 000000000..1526e7f74 --- /dev/null +++ b/offline/notifier/templates/risk/tmpl-risk-profile-state.email.en-us.template.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": "notify.template@1", + "templateId": "tmpl-risk-profile-state-email-en-us", + "tenantId": "bootstrap", + "channelType": "email", + "key": "tmpl-risk-profile-state", + "locale": "en-us", + "renderMode": "html", + "format": "email", + "description": "Email notice when risk profiles are published, deprecated, or thresholds change.", + "body": "

Risk profile update

\n

Profile {{payload.profile.id}} is now {{payload.state}} (version {{payload.profile.version}}).

\n
    \n
  • Thresholds: {{payload.thresholds}}
  • \n
  • Owner: {{payload.owner}}
  • \n
  • Effective at: {{payload.effectiveAt}}
  • \n
\n

Notes: {{payload.notes}}

\n

Console: View profile

\n", + "metadata": { + "author": "notifications-bootstrap", + "version": "2025-11-24" + } +} diff --git a/offline/notifier/templates/risk/tmpl-risk-profile-state.slack.en-us.template.json b/offline/notifier/templates/risk/tmpl-risk-profile-state.slack.en-us.template.json new file mode 100644 index 000000000..10976d522 --- /dev/null +++ b/offline/notifier/templates/risk/tmpl-risk-profile-state.slack.en-us.template.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": "notify.template@1", + "templateId": "tmpl-risk-profile-state-slack-en-us", + "tenantId": "bootstrap", + "channelType": "slack", + "key": "tmpl-risk-profile-state", + "locale": "en-us", + "renderMode": "markdown", + "format": "json", + "description": "Slack notice when risk profiles publish, deprecate, or thresholds change.", + "body": "*Risk profile {{payload.profile.id}}* is now *{{payload.state}}* (v{{payload.profile.version}})\n• thresholds: {{payload.thresholds}}\n• owner: {{payload.owner}}\n• effective: {{payload.effectiveAt}}\n<{{payload.links.console}}|View profile>", + "metadata": { + "author": "notifications-bootstrap", + "version": "2025-11-24" + } +} diff --git a/offline/notifier/templates/risk/tmpl-risk-severity-change.email.en-us.template.json b/offline/notifier/templates/risk/tmpl-risk-severity-change.email.en-us.template.json new file mode 100644 index 000000000..5bf436802 --- /dev/null +++ b/offline/notifier/templates/risk/tmpl-risk-severity-change.email.en-us.template.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": "notify.template@1", + "templateId": "tmpl-risk-severity-change-email-en-us", + "tenantId": "bootstrap", + "channelType": "email", + "key": "tmpl-risk-severity-change", + "locale": "en-us", + "renderMode": "html", + "format": "email", + "description": "Email notice for risk severity escalation or downgrade.", + "body": "

Risk severity updated

\n

Risk profile {{payload.profile.id}} changed severity from {{payload.previous.severity}} to {{payload.current.severity}} at {{event.ts}}.

\n
    \n
  • Asset: {{payload.asset.purl}}
  • \n
  • Profile version: {{payload.profile.version}}
  • \n
  • Reason: {{payload.reason}}
  • \n
\n

View details: Console

\n", + "metadata": { + "author": "notifications-bootstrap", + "version": "2025-11-24" + } +} diff --git a/offline/notifier/templates/risk/tmpl-risk-severity-change.slack.en-us.template.json b/offline/notifier/templates/risk/tmpl-risk-severity-change.slack.en-us.template.json new file mode 100644 index 000000000..fb308d0af --- /dev/null +++ b/offline/notifier/templates/risk/tmpl-risk-severity-change.slack.en-us.template.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": "notify.template@1", + "templateId": "tmpl-risk-severity-change-slack-en-us", + "tenantId": "bootstrap", + "channelType": "slack", + "key": "tmpl-risk-severity-change", + "locale": "en-us", + "renderMode": "markdown", + "format": "json", + "description": "Slack notice for risk severity escalation or downgrade.", + "body": "*Risk severity changed* for {{payload.profile.id}}\n• from: {{payload.previous.severity}} → to: {{payload.current.severity}}\n• asset: {{payload.asset.purl}}\n• version: {{payload.profile.version}}\n• reason: {{payload.reason}}\n<{{payload.links.console}}|Open in console>", + "metadata": { + "author": "notifications-bootstrap", + "version": "2025-11-24" + } +} diff --git a/ops/devops/artifacts/ci-110/20251125T030557Z/trx/concelier-health.trx b/ops/devops/artifacts/ci-110/20251125T030557Z/trx/concelier-health.trx new file mode 100644 index 000000000..29ee591cc --- /dev/null +++ b/ops/devops/artifacts/ci-110/20251125T030557Z/trx/concelier-health.trx @@ -0,0 +1,474 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0-rc.2.25502.107) +[xUnit.net 00:00:00.26] Discovering: StellaOps.Concelier.WebService.Tests +[xUnit.net 00:00:00.33] Discovered: StellaOps.Concelier.WebService.Tests +[xUnit.net 00:00:00.34] Starting: StellaOps.Concelier.WebService.Tests +{"t":{"$date":"2025-11-25T03:06:53.170+00:00"},"s":"I", "c":"CONTROL", "id":23285, "ctx":"main","msg":"Automatically disabling TLS 1.0, to force-enable TLS 1.0 specify --sslDisabledProtocols 'none'"} +{"t":{"$date":"2025-11-25T03:06:53.171+00:00"},"s":"W", "c":"ASIO", "id":22601, "ctx":"main","msg":"No TransportLayer configured during NetworkInterface startup"} +{"t":{"$date":"2025-11-25T03:06:53.171+00:00"},"s":"I", "c":"NETWORK", "id":4648601, "ctx":"main","msg":"Implicit TCP FastOpen unavailable. If TCP FastOpen is required, set tcpFastOpenServer, tcpFastOpenClient, and tcpFastOpenQueueSize."} +{"t":{"$date":"2025-11-25T03:06:53.171+00:00"},"s":"W", "c":"ASIO", "id":22601, "ctx":"main","msg":"No TransportLayer configured during NetworkInterface startup"} +{"t":{"$date":"2025-11-25T03:06:53.172+00:00"},"s":"I", "c":"STORAGE", "id":4615611, "ctx":"initandlisten","msg":"MongoDB starting","attr":{"pid":138154,"port":33929,"dbPath":"/tmp/yifc3x13.bsnecd0ff0e2d3d45ff96e2_33929","architecture":"64-bit","host":"DESKTOP-7GHGC2M"}} +{"t":{"$date":"2025-11-25T03:06:53.172+00:00"},"s":"I", "c":"CONTROL", "id":23403, "ctx":"initandlisten","msg":"Build Info","attr":{"buildInfo":{"version":"4.4.4","gitVersion":"8db30a63db1a9d84bdcad0c83369623f708e0397","openSSLVersion":"OpenSSL 1.1.1f 31 Mar 2020","modules":[],"allocator":"tcmalloc","environment":{"distmod":"ubuntu2004","distarch":"x86_64","target_arch":"x86_64"}}}} +{"t":{"$date":"2025-11-25T03:06:53.172+00:00"},"s":"I", "c":"CONTROL", "id":51765, "ctx":"initandlisten","msg":"Operating System","attr":{"os":{"name":"Ubuntu","version":"24.04"}}} +{"t":{"$date":"2025-11-25T03:06:53.172+00:00"},"s":"I", "c":"CONTROL", "id":21951, "ctx":"initandlisten","msg":"Options set by command line","attr":{"options":{"net":{"bindIp":"127.0.0.1","port":33929},"replication":{"replSet":"singleNodeReplSet"},"storage":{"dbPath":"/tmp/yifc3x13.bsnecd0ff0e2d3d45ff96e2_33929"}}}} +{"t":{"$date":"2025-11-25T03:06:53.173+00:00"},"s":"I", "c":"STORAGE", "id":22297, "ctx":"initandlisten","msg":"Using the XFS filesystem is strongly recommended with the WiredTiger storage engine. See http://dochub.mongodb.org/core/prodnotes-filesystem","tags":["startupWarnings"]} +{"t":{"$date":"2025-11-25T03:06:53.174+00:00"},"s":"I", "c":"STORAGE", "id":22315, "ctx":"initandlisten","msg":"Opening WiredTiger","attr":{"config":"create,cache_size=7485M,session_max=33000,eviction=(threads_min=4,threads_max=4),config_base=false,statistics=(fast),log=(enabled=true,archive=true,path=journal,compressor=snappy),file_manager=(close_idle_time=100000,close_scan_interval=10,close_handle_minimum=250),statistics_log=(wait=0),verbose=[recovery_progress,checkpoint_progress,compact_progress],"}} +{"t":{"$date":"2025-11-25T03:06:53.622+00:00"},"s":"I", "c":"STORAGE", "id":22430, "ctx":"initandlisten","msg":"WiredTiger message","attr":{"message":"[1764040013:622123][138154:0x72dd8d1c4cc0], txn-recover: [WT_VERB_RECOVERY | WT_VERB_RECOVERY_PROGRESS] Set global recovery timestamp: (0, 0)"}} +{"t":{"$date":"2025-11-25T03:06:53.622+00:00"},"s":"I", "c":"STORAGE", "id":22430, "ctx":"initandlisten","msg":"WiredTiger message","attr":{"message":"[1764040013:622190][138154:0x72dd8d1c4cc0], txn-recover: [WT_VERB_RECOVERY | WT_VERB_RECOVERY_PROGRESS] Set global oldest timestamp: (0, 0)"}} +{"t":{"$date":"2025-11-25T03:06:53.635+00:00"},"s":"I", "c":"STORAGE", "id":4795906, "ctx":"initandlisten","msg":"WiredTiger opened","attr":{"durationMillis":461}} +{"t":{"$date":"2025-11-25T03:06:53.635+00:00"},"s":"I", "c":"RECOVERY", "id":23987, "ctx":"initandlisten","msg":"WiredTiger recoveryTimestamp","attr":{"recoveryTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:06:53.667+00:00"},"s":"I", "c":"STORAGE", "id":4366408, "ctx":"initandlisten","msg":"No table logging settings modifications are required for existing WiredTiger tables","attr":{"loggingEnabled":false}} +{"t":{"$date":"2025-11-25T03:06:53.668+00:00"},"s":"I", "c":"STORAGE", "id":22262, "ctx":"initandlisten","msg":"Timestamp monitor starting"} +{"t":{"$date":"2025-11-25T03:06:53.676+00:00"},"s":"W", "c":"CONTROL", "id":22120, "ctx":"initandlisten","msg":"Access control is not enabled for the database. Read and write access to data and configuration is unrestricted","tags":["startupWarnings"]} +{"t":{"$date":"2025-11-25T03:06:53.677+00:00"},"s":"I", "c":"STORAGE", "id":20536, "ctx":"initandlisten","msg":"Flow Control is enabled on this deployment"} +{"t":{"$date":"2025-11-25T03:06:53.679+00:00"},"s":"I", "c":"SHARDING", "id":20997, "ctx":"initandlisten","msg":"Refreshed RWC defaults","attr":{"newDefaults":{}}} +{"t":{"$date":"2025-11-25T03:06:53.679+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.startup_log","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"5f608eed-817b-4ac1-94e3-ae0e0a954ec5"}},"options":{"capped":true,"size":10485760}}} +{"t":{"$date":"2025-11-25T03:06:53.697+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.startup_log","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:06:53.697+00:00"},"s":"I", "c":"FTDC", "id":20625, "ctx":"initandlisten","msg":"Initializing full-time diagnostic data capture","attr":{"dataDirectory":"/tmp/yifc3x13.bsnecd0ff0e2d3d45ff96e2_33929/diagnostic.data"}} +{"t":{"$date":"2025-11-25T03:06:53.699+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.replset.oplogTruncateAfterPoint","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"763ae47e-5634-4a14-9ef6-4ffd6dc93918"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:53.720+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.replset.oplogTruncateAfterPoint","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:06:53.720+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.replset.minvalid","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"54bed8e9-a7bd-4897-8c05-ad4fa62f77c5"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:53.740+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.replset.minvalid","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:06:53.740+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.replset.election","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"97e32968-ba25-4803-bcca-c4008661ee27"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:53.759+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.replset.election","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:06:53.760+00:00"},"s":"I", "c":"REPL", "id":21311, "ctx":"initandlisten","msg":"Did not find local initialized voted for document at startup"} +{"t":{"$date":"2025-11-25T03:06:53.760+00:00"},"s":"I", "c":"REPL", "id":21312, "ctx":"initandlisten","msg":"Did not find local Rollback ID document at startup. Creating one"} +{"t":{"$date":"2025-11-25T03:06:53.760+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.system.rollback.id","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"c9e78c6d-5f57-428c-b6d4-05340e2fef65"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:53.781+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.system.rollback.id","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:06:53.781+00:00"},"s":"I", "c":"REPL", "id":21531, "ctx":"initandlisten","msg":"Initialized the rollback ID","attr":{"rbid":1}} +{"t":{"$date":"2025-11-25T03:06:53.781+00:00"},"s":"I", "c":"REPL", "id":21313, "ctx":"initandlisten","msg":"Did not find local replica set configuration document at startup","attr":{"error":{"code":47,"codeName":"NoMatchingDocument","errmsg":"Did not find replica set configuration document in local.system.replset"}}} +{"t":{"$date":"2025-11-25T03:06:53.782+00:00"},"s":"I", "c":"CONTROL", "id":20714, "ctx":"LogicalSessionCacheRefresh","msg":"Failed to refresh session cache, will try again at the next refresh interval","attr":{"error":"NotYetInitialized: Replication has not yet been configured"}} +{"t":{"$date":"2025-11-25T03:06:53.783+00:00"},"s":"I", "c":"CONTROL", "id":20712, "ctx":"LogicalSessionCacheReap","msg":"Sessions collection is not set up; waiting until next sessions reap interval","attr":{"error":"NamespaceNotFound: config.system.sessions does not exist"}} +{"t":{"$date":"2025-11-25T03:06:53.783+00:00"},"s":"I", "c":"REPL", "id":40440, "ctx":"initandlisten","msg":"Starting the TopologyVersionObserver"} +{"t":{"$date":"2025-11-25T03:06:53.783+00:00"},"s":"I", "c":"REPL", "id":40445, "ctx":"TopologyVersionObserver","msg":"Started TopologyVersionObserver"} +{"t":{"$date":"2025-11-25T03:06:53.784+00:00"},"s":"I", "c":"NETWORK", "id":23015, "ctx":"listener","msg":"Listening on","attr":{"address":"/tmp/mongodb-33929.sock"}} +{"t":{"$date":"2025-11-25T03:06:53.784+00:00"},"s":"I", "c":"NETWORK", "id":23015, "ctx":"listener","msg":"Listening on","attr":{"address":"127.0.0.1"}} +{"t":{"$date":"2025-11-25T03:06:53.784+00:00"},"s":"I", "c":"NETWORK", "id":23016, "ctx":"listener","msg":"Waiting for connections","attr":{"port":33929,"ssl":"off"}} +{"t":{"$date":"2025-11-25T03:06:53.796+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47046","connectionId":1,"connectionCount":1}} +{"t":{"$date":"2025-11-25T03:06:53.820+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn1","msg":"client metadata","attr":{"remote":"127.0.0.1:47046","client":"conn1","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:53.852+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47050","connectionId":2,"connectionCount":2}} +{"t":{"$date":"2025-11-25T03:06:53.854+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn2","msg":"client metadata","attr":{"remote":"127.0.0.1:47050","client":"conn2","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:53.859+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47052","connectionId":3,"connectionCount":3}} +{"t":{"$date":"2025-11-25T03:06:53.860+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn3","msg":"client metadata","attr":{"remote":"127.0.0.1:47052","client":"conn3","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:53.872+00:00"},"s":"I", "c":"REPL", "id":21356, "ctx":"conn3","msg":"replSetInitiate admin command received from client"} +{"t":{"$date":"2025-11-25T03:06:53.872+00:00"},"s":"I", "c":"REPL", "id":21357, "ctx":"conn3","msg":"replSetInitiate config object parses ok","attr":{"numMembers":1}} +{"t":{"$date":"2025-11-25T03:06:53.872+00:00"},"s":"I", "c":"REPL", "id":21251, "ctx":"conn3","msg":"Creating replication oplog","attr":{"oplogSizeMB":48118}} +{"t":{"$date":"2025-11-25T03:06:53.872+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"local.oplog.rs","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"26641ba6-7282-4c09-a7b5-c06683c09d25"}},"options":{"capped":true,"size":50456355840.0,"autoIndexId":false}}} +{"t":{"$date":"2025-11-25T03:06:53.881+00:00"},"s":"I", "c":"STORAGE", "id":22383, "ctx":"conn3","msg":"The size storer reports that the oplog contains","attr":{"numRecords":0,"dataSize":0}} +{"t":{"$date":"2025-11-25T03:06:53.881+00:00"},"s":"I", "c":"STORAGE", "id":22382, "ctx":"conn3","msg":"WiredTiger record store oplog processing finished","attr":{"durationMillis":0}} +{"t":{"$date":"2025-11-25T03:06:53.921+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"local.system.replset","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"e329ace1-6110-413a-a7f9-c929c43b7823"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:53.941+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.system.replset","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040013,"i":1}}}} +{"t":{"$date":"2025-11-25T03:06:53.942+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"admin.system.version","uuidDisposition":"provided","uuid":{"uuid":{"$uuid":"1515c214-38af-4280-bd1e-e79281395c7e"}},"options":{"uuid":{"$uuid":"1515c214-38af-4280-bd1e-e79281395c7e"}}}} +{"t":{"$date":"2025-11-25T03:06:53.959+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"admin.system.version","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040013,"i":1}}}} +{"t":{"$date":"2025-11-25T03:06:53.959+00:00"},"s":"I", "c":"COMMAND", "id":20459, "ctx":"conn3","msg":"Setting featureCompatibilityVersion","attr":{"newVersion":"4.4"}} +{"t":{"$date":"2025-11-25T03:06:53.959+00:00"},"s":"I", "c":"NETWORK", "id":22991, "ctx":"conn3","msg":"Skip closing connection for connection","attr":{"connectionId":3}} +{"t":{"$date":"2025-11-25T03:06:53.959+00:00"},"s":"I", "c":"NETWORK", "id":22991, "ctx":"conn3","msg":"Skip closing connection for connection","attr":{"connectionId":2}} +{"t":{"$date":"2025-11-25T03:06:53.959+00:00"},"s":"I", "c":"NETWORK", "id":22991, "ctx":"conn3","msg":"Skip closing connection for connection","attr":{"connectionId":1}} +{"t":{"$date":"2025-11-25T03:06:53.959+00:00"},"s":"I", "c":"REPL", "id":21392, "ctx":"conn3","msg":"New replica set config in use","attr":{"config":{"_id":"singleNodeReplSet","version":1,"term":0,"protocolVersion":1,"writeConcernMajorityJournalDefault":true,"members":[{"_id":0,"host":"127.0.0.1:33929","arbiterOnly":false,"buildIndexes":true,"hidden":false,"priority":1.0,"tags":{},"slaveDelay":0,"votes":1}],"settings":{"chainingAllowed":true,"heartbeatIntervalMillis":2000,"heartbeatTimeoutSecs":10,"electionTimeoutMillis":10000,"catchUpTimeoutMillis":-1,"catchUpTakeoverDelayMillis":30000,"getLastErrorModes":{},"getLastErrorDefaults":{"w":1,"wtimeout":0},"replicaSetId":{"$oid":"69251d4d4fa9b5bd940f91b6"}}}}} +{"t":{"$date":"2025-11-25T03:06:53.959+00:00"},"s":"I", "c":"REPL", "id":21393, "ctx":"conn3","msg":"Found self in config","attr":{"hostAndPort":"127.0.0.1:33929"}} +{"t":{"$date":"2025-11-25T03:06:53.959+00:00"},"s":"I", "c":"REPL", "id":21358, "ctx":"conn3","msg":"Replica set state transition","attr":{"newState":"STARTUP2","oldState":"STARTUP"}} +{"t":{"$date":"2025-11-25T03:06:53.959+00:00"},"s":"I", "c":"REPL", "id":21306, "ctx":"conn3","msg":"Starting replication storage threads"} +{"t":{"$date":"2025-11-25T03:06:53.963+00:00"},"s":"I", "c":"REPL", "id":21358, "ctx":"conn3","msg":"Replica set state transition","attr":{"newState":"RECOVERING","oldState":"STARTUP2"}} +{"t":{"$date":"2025-11-25T03:06:53.963+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"local.replset.initialSyncId","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"2f0157f6-a696-485a-90cd-25ebdc98434e"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:53.981+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.replset.initialSyncId","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040013,"i":1}}}} +{"t":{"$date":"2025-11-25T03:06:53.981+00:00"},"s":"I", "c":"REPL", "id":21299, "ctx":"conn3","msg":"Starting replication fetcher thread"} +{"t":{"$date":"2025-11-25T03:06:53.981+00:00"},"s":"I", "c":"REPL", "id":21300, "ctx":"conn3","msg":"Starting replication applier thread"} +{"t":{"$date":"2025-11-25T03:06:53.981+00:00"},"s":"I", "c":"REPL", "id":21301, "ctx":"conn3","msg":"Starting replication reporter thread"} +{"t":{"$date":"2025-11-25T03:06:53.981+00:00"},"s":"I", "c":"REPL", "id":21224, "ctx":"OplogApplier-0","msg":"Starting oplog application"} +{"t":{"$date":"2025-11-25T03:06:53.981+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn3","msg":"Slow query","attr":{"type":"command","ns":"local.system.replset","command":{"replSetInitiate":{"_id":"singleNodeReplSet","members":[{"_id":0,"host":"127.0.0.1:33929"}]},"$db":"admin","lsid":{"id":{"$uuid":"e2f41f2f-e77f-4af9-81e0-32d8592d6a54"}}},"numYields":0,"reslen":163,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":18}},"ReplicationStateTransition":{"acquireCount":{"w":19}},"Global":{"acquireCount":{"r":11,"w":6,"W":2}},"Database":{"acquireCount":{"r":10,"w":4,"W":2}},"Collection":{"acquireCount":{"r":3,"w":5}},"Mutex":{"acquireCount":{"r":17}},"oplog":{"acquireCount":{"w":1}}},"flowControl":{"acquireCount":5,"timeAcquiringMicros":5},"storage":{},"protocol":"op_msg","durationMillis":109}} +{"t":{"$date":"2025-11-25T03:06:53.982+00:00"},"s":"I", "c":"REPL", "id":21358, "ctx":"OplogApplier-0","msg":"Replica set state transition","attr":{"newState":"SECONDARY","oldState":"RECOVERING"}} +{"t":{"$date":"2025-11-25T03:06:53.982+00:00"},"s":"I", "c":"ELECTION", "id":4615652, "ctx":"OplogApplier-0","msg":"Starting an election, since we've seen no PRIMARY in election timeout period","attr":{"electionTimeoutPeriodMillis":10000}} +{"t":{"$date":"2025-11-25T03:06:53.982+00:00"},"s":"I", "c":"ELECTION", "id":21438, "ctx":"OplogApplier-0","msg":"Conducting a dry run election to see if we could be elected","attr":{"currentTerm":0}} +{"t":{"$date":"2025-11-25T03:06:53.982+00:00"},"s":"I", "c":"ELECTION", "id":21444, "ctx":"ReplCoord-0","msg":"Dry election run succeeded, running for election","attr":{"newTerm":1}} +{"t":{"$date":"2025-11-25T03:06:53.984+00:00"},"s":"I", "c":"ELECTION", "id":21450, "ctx":"ReplCoord-1","msg":"Election succeeded, assuming primary role","attr":{"term":1}} +{"t":{"$date":"2025-11-25T03:06:53.984+00:00"},"s":"I", "c":"REPL", "id":21358, "ctx":"ReplCoord-1","msg":"Replica set state transition","attr":{"newState":"PRIMARY","oldState":"SECONDARY"}} +{"t":{"$date":"2025-11-25T03:06:53.984+00:00"},"s":"I", "c":"REPL", "id":21106, "ctx":"ReplCoord-1","msg":"Resetting sync source to empty","attr":{"previousSyncSource":":27017"}} +{"t":{"$date":"2025-11-25T03:06:53.984+00:00"},"s":"I", "c":"REPL", "id":21359, "ctx":"ReplCoord-1","msg":"Entering primary catch-up mode"} +{"t":{"$date":"2025-11-25T03:06:53.984+00:00"},"s":"I", "c":"REPL", "id":21363, "ctx":"ReplCoord-1","msg":"Exited primary catch-up mode"} +{"t":{"$date":"2025-11-25T03:06:53.984+00:00"},"s":"I", "c":"REPL", "id":21107, "ctx":"ReplCoord-1","msg":"Stopping replication producer"} +{"t":{"$date":"2025-11-25T03:06:53.984+00:00"},"s":"I", "c":"REPL", "id":21239, "ctx":"ReplBatcher","msg":"Oplog buffer has been drained","attr":{"term":1}} +{"t":{"$date":"2025-11-25T03:06:53.984+00:00"},"s":"I", "c":"REPL", "id":21343, "ctx":"RstlKillOpThread","msg":"Starting to kill user operations"} +{"t":{"$date":"2025-11-25T03:06:53.984+00:00"},"s":"I", "c":"REPL", "id":21344, "ctx":"RstlKillOpThread","msg":"Stopped killing user operations"} +{"t":{"$date":"2025-11-25T03:06:53.985+00:00"},"s":"I", "c":"REPL", "id":21340, "ctx":"RstlKillOpThread","msg":"State transition ops metrics","attr":{"metrics":{"lastStateTransition":"stepUp","userOpsKilled":0,"userOpsRunning":1}}} +{"t":{"$date":"2025-11-25T03:06:53.985+00:00"},"s":"I", "c":"REPL", "id":4508103, "ctx":"OplogApplier-0","msg":"Increment the config term via reconfig"} +{"t":{"$date":"2025-11-25T03:06:53.985+00:00"},"s":"I", "c":"REPL", "id":21353, "ctx":"OplogApplier-0","msg":"replSetReconfig config object parses ok","attr":{"numMembers":1}} +{"t":{"$date":"2025-11-25T03:06:53.985+00:00"},"s":"I", "c":"REPL", "id":51814, "ctx":"OplogApplier-0","msg":"Persisting new config to disk"} +{"t":{"$date":"2025-11-25T03:06:53.986+00:00"},"s":"I", "c":"REPL", "id":21392, "ctx":"OplogApplier-0","msg":"New replica set config in use","attr":{"config":{"_id":"singleNodeReplSet","version":1,"term":1,"protocolVersion":1,"writeConcernMajorityJournalDefault":true,"members":[{"_id":0,"host":"127.0.0.1:33929","arbiterOnly":false,"buildIndexes":true,"hidden":false,"priority":1.0,"tags":{},"slaveDelay":0,"votes":1}],"settings":{"chainingAllowed":true,"heartbeatIntervalMillis":2000,"heartbeatTimeoutSecs":10,"electionTimeoutMillis":10000,"catchUpTimeoutMillis":-1,"catchUpTakeoverDelayMillis":30000,"getLastErrorModes":{},"getLastErrorDefaults":{"w":1,"wtimeout":0},"replicaSetId":{"$oid":"69251d4d4fa9b5bd940f91b6"}}}}} +{"t":{"$date":"2025-11-25T03:06:53.986+00:00"},"s":"I", "c":"REPL", "id":21393, "ctx":"OplogApplier-0","msg":"Found self in config","attr":{"hostAndPort":"127.0.0.1:33929"}} +{"t":{"$date":"2025-11-25T03:06:53.986+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"OplogApplier-0","msg":"createCollection","attr":{"namespace":"config.transactions","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"7b6e3a69-23e4-40fc-8365-b5c595eea4b1"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:54.003+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"OplogApplier-0","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"config.transactions","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040013,"i":3}}}} +{"t":{"$date":"2025-11-25T03:06:54.003+00:00"},"s":"I", "c":"STORAGE", "id":20657, "ctx":"OplogApplier-0","msg":"IndexBuildsCoordinator::onStepUp - this node is stepping up to primary"} +{"t":{"$date":"2025-11-25T03:06:54.004+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"OplogApplier-0","msg":"createCollection","attr":{"namespace":"config.system.indexBuilds","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"a78b2f2c-d49d-40ec-b777-5c4f678f8ea2"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:54.019+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"OplogApplier-0","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"config.system.indexBuilds","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040014,"i":2}}}} +{"t":{"$date":"2025-11-25T03:06:54.019+00:00"},"s":"I", "c":"REPL", "id":21331, "ctx":"OplogApplier-0","msg":"Transition to primary complete; database writes are now permitted"} +{"t":{"$date":"2025-11-25T03:06:54.020+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"monitoring-keys-for-HMAC","msg":"createCollection","attr":{"namespace":"admin.system.keys","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"5ac7de93-626f-4635-aad0-423907ebaaae"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:54.036+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"monitoring-keys-for-HMAC","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"admin.system.keys","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040014,"i":3}}}} +{"t":{"$date":"2025-11-25T03:06:54.038+00:00"},"s":"I", "c":"STORAGE", "id":22310, "ctx":"WTJournalFlusher","msg":"Triggering the first stable checkpoint","attr":{"initialData":{"$timestamp":{"t":1764040013,"i":1}},"prevStable":{"$timestamp":{"t":0,"i":0}},"currStable":{"$timestamp":{"t":1764040014,"i":4}}}} +warn: StellaOps.Concelier.WebService[0] + Authority enabled: False, test signing secret configured: True +warn: StellaOps.Concelier.WebService[0] + Legacy merge module disabled via concelier:features:noMergeEnabled; Link-Not-Merge mode active. +{"t":{"$date":"2025-11-25T03:06:55.284+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.source","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"b5dcc880-19fc-4c9b-a878-ac99d5f77246"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.308+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.source","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":1}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection source +{"t":{"$date":"2025-11-25T03:06:55.316+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.source_state","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"9475ed88-bc52-4c6d-abcb-5dfaf2d5cf5b"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.336+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.source_state","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":2}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection source_state +{"t":{"$date":"2025-11-25T03:06:55.339+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.document","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"ad2a5b15-8e65-4ed1-9efa-d0fd1e643131"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.358+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.document","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":3}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection document +{"t":{"$date":"2025-11-25T03:06:55.361+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.dto","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"81a72f1b-58d0-4a25-a0bc-0dd9362247f8"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.377+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.dto","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":4}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection dto +{"t":{"$date":"2025-11-25T03:06:55.380+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.advisory","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"c2e4124c-bf80-4e3c-9272-cea8f40106f5"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.397+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":5}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection advisory +{"t":{"$date":"2025-11-25T03:06:55.400+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.advisory_raw","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"70542ec2-832b-4f93-8c96-4ca814f1fbbc"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.416+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_raw","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":6}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection advisory_raw +{"t":{"$date":"2025-11-25T03:06:55.419+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.alias","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"6a6a3cc5-2ba2-4756-bf3f-197fd1a306a0"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.435+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.alias","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":7}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection alias +{"t":{"$date":"2025-11-25T03:06:55.438+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.affected","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"ef930a9b-1097-41f9-9d77-2659520d64dc"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.456+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.affected","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":8}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection affected +{"t":{"$date":"2025-11-25T03:06:55.460+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.reference","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"24d0213b-0677-42fa-b7ae-b0a19b36317d"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.495+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.reference","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":9}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection reference +{"t":{"$date":"2025-11-25T03:06:55.499+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.kev_flag","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"3155caef-fd8b-4512-8480-f18fea9f8ae9"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.520+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.kev_flag","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":10}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection kev_flag +{"t":{"$date":"2025-11-25T03:06:55.524+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.ru_flags","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"4c64a0cb-1b22-4055-8cf9-2ddaf8b2eecc"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.541+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.ru_flags","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":11}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection ru_flags +{"t":{"$date":"2025-11-25T03:06:55.544+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.jp_flags","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"39e4df97-ce8e-4ae2-9996-eae3fb682e43"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.562+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.jp_flags","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":12}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection jp_flags +{"t":{"$date":"2025-11-25T03:06:55.565+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.psirt_flags","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"d61fab06-e185-4905-a581-78d6188f9cbf"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.597+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.psirt_flags","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":13}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection psirt_flags +{"t":{"$date":"2025-11-25T03:06:55.600+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.merge_event","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"21f05a29-c17f-4fae-af85-30ede0275435"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.621+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.merge_event","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":14}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection merge_event +{"t":{"$date":"2025-11-25T03:06:55.624+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.export_state","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"1d816e12-6eb0-40fa-87ae-8bac12a31e53"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.642+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.export_state","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":15}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection export_state +{"t":{"$date":"2025-11-25T03:06:55.645+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.source_change_history","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"0c0938b6-7eb1-4e92-a8a8-5ed971581ddc"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.662+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.source_change_history","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":16}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection source_change_history +{"t":{"$date":"2025-11-25T03:06:55.665+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.advisory_statements","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"46b5cd3a-fd22-47d2-81cc-2c756d9cfe62"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.682+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_statements","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":17}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection advisory_statements +{"t":{"$date":"2025-11-25T03:06:55.685+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.advisory_conflicts","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"e830a702-eb38-4e79-bd71-139b63066228"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.702+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_conflicts","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":18}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection advisory_conflicts +{"t":{"$date":"2025-11-25T03:06:55.705+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.advisory_observations","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"2d30c6a9-a970-4507-9548-c93174011df9"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.730+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_observations","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":19}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection advisory_observations +{"t":{"$date":"2025-11-25T03:06:55.733+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.locks","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"c8dc3f6d-0481-4693-ad61-36b23257b47f"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.752+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.locks","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":20}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection locks +{"t":{"$date":"2025-11-25T03:06:55.755+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.jobs","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"b46075e8-3e6f-4a66-913f-60021219351a"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.773+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.jobs","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":21}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection jobs +{"t":{"$date":"2025-11-25T03:06:55.776+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"concelier.schema_migrations","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"41eb8ab9-7155-4f1f-929d-bf08fe8d877e"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.798+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.schema_migrations","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":22}}}} +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Created Mongo collection schema_migrations +{"t":{"$date":"2025-11-25T03:06:55.823+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn3","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"5c6b2846-9d1d-46de-ac5f-b2f85a6d097c"}},"namespace":"concelier.locks","collectionUUID":{"uuid":{"$uuid":"c8dc3f6d-0481-4693-ad61-36b23257b47f"}},"indexes":1,"firstIndex":{"name":"ttl_at_ttl"}}} +{"t":{"$date":"2025-11-25T03:06:55.831+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.locks","index":"ttl_at_ttl","commitTimestamp":{"$timestamp":{"t":1764040015,"i":23}}}} +{"t":{"$date":"2025-11-25T03:06:55.831+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn3","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"5c6b2846-9d1d-46de-ac5f-b2f85a6d097c"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:55.831+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn3","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"5c6b2846-9d1d-46de-ac5f-b2f85a6d097c"}}}} +{"t":{"$date":"2025-11-25T03:06:55.835+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47428","connectionId":4,"connectionCount":4}} +{"t":{"$date":"2025-11-25T03:06:55.841+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn4","msg":"client metadata","attr":{"remote":"127.0.0.1:47428","client":"conn4","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:55.849+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn4","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"1146b67d-4236-4bc9-bae4-3fa891517889"}},"namespace":"concelier.jobs","collectionUUID":{"uuid":{"$uuid":"b46075e8-3e6f-4a66-913f-60021219351a"}},"indexes":3,"firstIndex":{"name":"jobs_createdAt_desc"}}} +{"t":{"$date":"2025-11-25T03:06:55.858+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47432","connectionId":5,"connectionCount":5}} +{"t":{"$date":"2025-11-25T03:06:55.858+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn3","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"b267ade3-39a8-4744-8ffe-e091e3a60a76"}},"namespace":"concelier.advisory","collectionUUID":{"uuid":{"$uuid":"c2e4124c-bf80-4e3c-9272-cea8f40106f5"}},"indexes":5,"firstIndex":{"name":"advisory_key_unique"}}} +{"t":{"$date":"2025-11-25T03:06:55.859+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn5","msg":"client metadata","attr":{"remote":"127.0.0.1:47432","client":"conn5","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:55.859+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47448","connectionId":6,"connectionCount":6}} +{"t":{"$date":"2025-11-25T03:06:55.859+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn6","msg":"client metadata","attr":{"remote":"127.0.0.1:47448","client":"conn6","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:55.860+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn5","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"8634f626-1a30-4a22-93a9-65dc7b3e7493"}},"namespace":"concelier.document","collectionUUID":{"uuid":{"$uuid":"ad2a5b15-8e65-4ed1-9efa-d0fd1e643131"}},"indexes":3,"firstIndex":{"name":"document_source_uri_unique"}}} +{"t":{"$date":"2025-11-25T03:06:55.860+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn6","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"9ebafb07-90b6-47d0-9e2c-257bf2f104f7"}},"namespace":"concelier.dto","collectionUUID":{"uuid":{"$uuid":"81a72f1b-58d0-4a25-a0bc-0dd9362247f8"}},"indexes":2,"firstIndex":{"name":"dto_documentId"}}} +{"t":{"$date":"2025-11-25T03:06:55.860+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47460","connectionId":7,"connectionCount":7}} +{"t":{"$date":"2025-11-25T03:06:55.861+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47464","connectionId":8,"connectionCount":8}} +{"t":{"$date":"2025-11-25T03:06:55.861+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn7","msg":"client metadata","attr":{"remote":"127.0.0.1:47460","client":"conn7","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:55.861+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn8","msg":"client metadata","attr":{"remote":"127.0.0.1:47464","client":"conn8","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:55.862+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn7","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"a56fab5b-f9c9-47ab-a907-c260047bad5e"}},"namespace":"concelier.alias","collectionUUID":{"uuid":{"$uuid":"6a6a3cc5-2ba2-4756-bf3f-197fd1a306a0"}},"indexes":1,"firstIndex":{"name":"alias_scheme_value"}}} +{"t":{"$date":"2025-11-25T03:06:55.862+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn8","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"7df22170-a963-4a06-b173-cde909e8764c"}},"namespace":"concelier.affected","collectionUUID":{"uuid":{"$uuid":"ef930a9b-1097-41f9-9d77-2659520d64dc"}},"indexes":2,"firstIndex":{"name":"affected_platform_name"}}} +{"t":{"$date":"2025-11-25T03:06:55.871+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47476","connectionId":9,"connectionCount":9}} +{"t":{"$date":"2025-11-25T03:06:55.871+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47488","connectionId":10,"connectionCount":10}} +{"t":{"$date":"2025-11-25T03:06:55.871+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn9","msg":"client metadata","attr":{"remote":"127.0.0.1:47476","client":"conn9","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:55.872+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn10","msg":"client metadata","attr":{"remote":"127.0.0.1:47488","client":"conn10","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:55.872+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn9","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"e75019bf-293c-4d90-bfa3-90e20b305975"}},"namespace":"concelier.source_state","collectionUUID":{"uuid":{"$uuid":"9475ed88-bc52-4c6d-abcb-5dfaf2d5cf5b"}},"indexes":1,"firstIndex":{"name":"source_state_unique"}}} +{"t":{"$date":"2025-11-25T03:06:55.873+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn10","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"25b0858f-8e1d-43bc-afab-07712ea8e760"}},"namespace":"concelier.reference","collectionUUID":{"uuid":{"$uuid":"24d0213b-0677-42fa-b7ae-b0a19b36317d"}},"indexes":2,"firstIndex":{"name":"reference_url"}}} +{"t":{"$date":"2025-11-25T03:06:55.876+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47504","connectionId":11,"connectionCount":11}} +{"t":{"$date":"2025-11-25T03:06:55.876+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn11","msg":"client metadata","attr":{"remote":"127.0.0.1:47504","client":"conn11","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:55.878+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47506","connectionId":12,"connectionCount":12}} +{"t":{"$date":"2025-11-25T03:06:55.878+00:00"},"s":"I", "c":"COMMAND", "id":51806, "ctx":"conn11","msg":"CMD: dropIndexes","attr":{"namespace":"concelier.psirt_flags","uuid":{"uuid":{"$uuid":"d61fab06-e185-4905-a581-78d6188f9cbf"}},"indexes":"\"psirt_advisoryKey_unique\""}} +{"t":{"$date":"2025-11-25T03:06:55.878+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn12","msg":"client metadata","attr":{"remote":"127.0.0.1:47506","client":"conn12","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:55.879+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn4","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.jobs","index":"jobs_createdAt_desc","commitTimestamp":{"$timestamp":{"t":1764040015,"i":26}}}} +{"t":{"$date":"2025-11-25T03:06:55.879+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn4","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.jobs","index":"jobs_kind_createdAt","commitTimestamp":{"$timestamp":{"t":1764040015,"i":26}}}} +{"t":{"$date":"2025-11-25T03:06:55.879+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn4","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.jobs","index":"jobs_status_createdAt","commitTimestamp":{"$timestamp":{"t":1764040015,"i":26}}}} +{"t":{"$date":"2025-11-25T03:06:55.879+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn4","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"1146b67d-4236-4bc9-bae4-3fa891517889"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:55.879+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47514","connectionId":13,"connectionCount":13}} +{"t":{"$date":"2025-11-25T03:06:55.879+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn12","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"e231aaa5-d5f8-4c88-9860-fe69d60d65f5"}},"namespace":"concelier.advisory_statements","collectionUUID":{"uuid":{"$uuid":"46b5cd3a-fd22-47d2-81cc-2c756d9cfe62"}},"indexes":2,"firstIndex":{"name":"advisory_statements_vulnerability_asof_desc"}}} +{"t":{"$date":"2025-11-25T03:06:55.879+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn4","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"1146b67d-4236-4bc9-bae4-3fa891517889"}}}} +{"t":{"$date":"2025-11-25T03:06:55.880+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn13","msg":"client metadata","attr":{"remote":"127.0.0.1:47514","client":"conn13","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:55.881+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47524","connectionId":14,"connectionCount":14}} +{"t":{"$date":"2025-11-25T03:06:55.881+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn13","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"eba85195-e631-4fb2-a8ba-d155fcbe0411"}},"namespace":"concelier.advisory_conflicts","collectionUUID":{"uuid":{"$uuid":"e830a702-eb38-4e79-bd71-139b63066228"}},"indexes":2,"firstIndex":{"name":"advisory_conflicts_vulnerability_asof_desc"}}} +{"t":{"$date":"2025-11-25T03:06:55.881+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn14","msg":"client metadata","attr":{"remote":"127.0.0.1:47524","client":"conn14","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:55.882+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:47538","connectionId":15,"connectionCount":15}} +{"t":{"$date":"2025-11-25T03:06:55.882+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn14","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"054c0484-e72e-411f-bced-3f555ef0d361"}},"namespace":"concelier.advisory_observations","collectionUUID":{"uuid":{"$uuid":"2d30c6a9-a970-4507-9548-c93174011df9"}},"indexes":4,"firstIndex":{"name":"advisory_obs_tenant_upstream"}}} +{"t":{"$date":"2025-11-25T03:06:55.882+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn15","msg":"client metadata","attr":{"remote":"127.0.0.1:47538","client":"conn15","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:06:55.883+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn15","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"9756e330-8423-4878-bd4f-a3e1a8400472"}},"namespace":"concelier.source_change_history","collectionUUID":{"uuid":{"$uuid":"0c0938b6-7eb1-4e92-a8a8-5ed971581ddc"}},"indexes":3,"firstIndex":{"name":"history_source_advisory_capturedAt"}}} +{"t":{"$date":"2025-11-25T03:06:55.883+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn4","msg":"createCollection","attr":{"namespace":"concelier.documents.files","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"c6f88ce0-e49c-4b58-aa67-0a5021c6c7b1"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:55.928+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn4","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.documents.files","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040015,"i":31}}}} +{"t":{"$date":"2025-11-25T03:06:55.928+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn4","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.documents.files","index":"gridfs_files_expiresAt_ttl","commitTimestamp":{"$timestamp":{"t":1764040015,"i":31}}}} +{"t":{"$date":"2025-11-25T03:06:55.945+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory","index":"advisory_key_unique","commitTimestamp":{"$timestamp":{"t":1764040015,"i":33}}}} +{"t":{"$date":"2025-11-25T03:06:55.945+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory","index":"advisory_modified_desc","commitTimestamp":{"$timestamp":{"t":1764040015,"i":33}}}} +{"t":{"$date":"2025-11-25T03:06:55.945+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory","index":"advisory_published_desc","commitTimestamp":{"$timestamp":{"t":1764040015,"i":33}}}} +{"t":{"$date":"2025-11-25T03:06:55.945+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory","index":"advisory_normalizedVersions_pkg_scheme_type","commitTimestamp":{"$timestamp":{"t":1764040015,"i":33}}}} +{"t":{"$date":"2025-11-25T03:06:55.945+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory","index":"advisory_normalizedVersions_value","commitTimestamp":{"$timestamp":{"t":1764040015,"i":33}}}} +{"t":{"$date":"2025-11-25T03:06:55.945+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn3","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"b267ade3-39a8-4744-8ffe-e091e3a60a76"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:55.945+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn3","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"b267ade3-39a8-4744-8ffe-e091e3a60a76"}}}} +{"t":{"$date":"2025-11-25T03:06:55.968+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn6","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.dto","index":"dto_documentId","commitTimestamp":{"$timestamp":{"t":1764040015,"i":35}}}} +{"t":{"$date":"2025-11-25T03:06:55.968+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn6","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.dto","index":"dto_source_validated","commitTimestamp":{"$timestamp":{"t":1764040015,"i":35}}}} +{"t":{"$date":"2025-11-25T03:06:55.968+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn6","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"9ebafb07-90b6-47d0-9e2c-257bf2f104f7"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:55.968+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn6","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"9ebafb07-90b6-47d0-9e2c-257bf2f104f7"}}}} +{"t":{"$date":"2025-11-25T03:06:55.974+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn6","msg":"Slow query","attr":{"type":"command","ns":"concelier.dto","command":{"createIndexes":"dto","indexes":[{"key":{"documentId":1},"name":"dto_documentId"},{"key":{"sourceName":1,"validatedAt":-1},"name":"dto_source_validated"}],"writeConcern":{"w":"majority","wtimeout":30000.0},"$db":"concelier","lsid":{"id":{"$uuid":"0dce06ab-6c9e-44d5-a568-2c08aeae4f70"}},"$clusterTime":{"clusterTime":{"$timestamp":{"t":1764040015,"i":24}},"signature":{"hash":{"$binary":{"base64":"AAAAAAAAAAAAAAAAAAAAAAAAAAA=","subType":"0"}},"keyId":0}}},"numYields":0,"reslen":271,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":3}},"ReplicationStateTransition":{"acquireCount":{"w":6}},"Global":{"acquireCount":{"r":2,"w":4}},"Database":{"acquireCount":{"w":3}},"Collection":{"acquireCount":{"r":1,"w":1,"W":1}},"Mutex":{"acquireCount":{"r":3}}},"flowControl":{"acquireCount":3,"timeAcquiringMicros":2},"writeConcern":{"w":"majority","wtimeout":30000,"provenance":"clientSupplied"},"storage":{},"protocol":"op_msg","durationMillis":113}} +{"t":{"$date":"2025-11-25T03:06:55.983+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn9","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.source_state","index":"source_state_unique","commitTimestamp":{"$timestamp":{"t":1764040015,"i":36}}}} +{"t":{"$date":"2025-11-25T03:06:55.983+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn9","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"e75019bf-293c-4d90-bfa3-90e20b305975"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:55.983+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn9","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"e75019bf-293c-4d90-bfa3-90e20b305975"}}}} +{"t":{"$date":"2025-11-25T03:06:55.988+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn9","msg":"Slow query","attr":{"type":"command","ns":"concelier.source_state","command":{"createIndexes":"source_state","indexes":[{"key":{"sourceName":1},"name":"source_state_unique","unique":true}],"writeConcern":{"w":"majority","wtimeout":30000.0},"$db":"concelier","lsid":{"id":{"$uuid":"8671be39-6be8-4a57-932e-fcddeacacfc5"}},"$clusterTime":{"clusterTime":{"$timestamp":{"t":1764040015,"i":24}},"signature":{"hash":{"$binary":{"base64":"AAAAAAAAAAAAAAAAAAAAAAAAAAA=","subType":"0"}},"keyId":0}}},"numYields":0,"reslen":271,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":3}},"ReplicationStateTransition":{"acquireCount":{"w":6}},"Global":{"acquireCount":{"r":2,"w":4}},"Database":{"acquireCount":{"w":3}},"Collection":{"acquireCount":{"r":1,"w":1,"W":1}},"Mutex":{"acquireCount":{"r":3}}},"flowControl":{"acquireCount":3,"timeAcquiringMicros":1},"writeConcern":{"w":"majority","wtimeout":30000,"provenance":"clientSupplied"},"storage":{},"protocol":"op_msg","durationMillis":116}} +{"t":{"$date":"2025-11-25T03:06:56.016+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn5","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.document","index":"document_source_uri_unique","commitTimestamp":{"$timestamp":{"t":1764040016,"i":1}}}} +{"t":{"$date":"2025-11-25T03:06:56.016+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn5","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.document","index":"document_fetchedAt_desc","commitTimestamp":{"$timestamp":{"t":1764040016,"i":1}}}} +{"t":{"$date":"2025-11-25T03:06:56.016+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn5","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.document","index":"document_expiresAt_ttl","commitTimestamp":{"$timestamp":{"t":1764040016,"i":1}}}} +{"t":{"$date":"2025-11-25T03:06:56.016+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn5","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"8634f626-1a30-4a22-93a9-65dc7b3e7493"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:56.016+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn5","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"8634f626-1a30-4a22-93a9-65dc7b3e7493"}}}} +{"t":{"$date":"2025-11-25T03:06:56.016+00:00"},"s":"I", "c":"STORAGE", "id":4715500, "ctx":"conn14","msg":"Too many index builds running simultaneously, waiting until the number of active index builds is below the threshold","attr":{"numActiveIndexBuilds":3,"maxNumActiveUserIndexBuilds":3,"indexSpecs":[{"key":{"tenant":1,"upstream.upstream_id":1,"upstream.document_version":1},"name":"advisory_obs_tenant_upstream","unique":false,"v":2},{"key":{"tenant":1,"linkset.aliases":1},"name":"advisory_obs_tenant_aliases","v":2},{"key":{"tenant":1,"linkset.purls":1},"name":"advisory_obs_tenant_purls","v":2},{"key":{"tenant":1,"createdAt":-1},"name":"advisory_obs_tenant_createdAt","v":2}],"buildUUID":{"uuid":{"$uuid":"054c0484-e72e-411f-bced-3f555ef0d361"}},"collectionUUID":{"uuid":{"$uuid":"2d30c6a9-a970-4507-9548-c93174011df9"}}}} +{"t":{"$date":"2025-11-25T03:06:56.017+00:00"},"s":"I", "c":"STORAGE", "id":4715500, "ctx":"conn15","msg":"Too many index builds running simultaneously, waiting until the number of active index builds is below the threshold","attr":{"numActiveIndexBuilds":3,"maxNumActiveUserIndexBuilds":3,"indexSpecs":[{"key":{"source":1,"advisoryKey":1,"capturedAt":-1},"name":"history_source_advisory_capturedAt","v":2},{"key":{"capturedAt":-1},"name":"history_capturedAt","v":2},{"key":{"documentId":1},"name":"history_documentId","v":2}],"buildUUID":{"uuid":{"$uuid":"9756e330-8423-4878-bd4f-a3e1a8400472"}},"collectionUUID":{"uuid":{"$uuid":"0c0938b6-7eb1-4e92-a8a8-5ed971581ddc"}}}} +{"t":{"$date":"2025-11-25T03:06:56.017+00:00"},"s":"I", "c":"STORAGE", "id":4715500, "ctx":"conn15","msg":"Too many index builds running simultaneously, waiting until the number of active index builds is below the threshold","attr":{"numActiveIndexBuilds":3,"maxNumActiveUserIndexBuilds":3,"indexSpecs":[{"key":{"source":1,"advisoryKey":1,"capturedAt":-1},"name":"history_source_advisory_capturedAt","v":2},{"key":{"capturedAt":-1},"name":"history_capturedAt","v":2},{"key":{"documentId":1},"name":"history_documentId","v":2}],"buildUUID":{"uuid":{"$uuid":"9756e330-8423-4878-bd4f-a3e1a8400472"}},"collectionUUID":{"uuid":{"$uuid":"0c0938b6-7eb1-4e92-a8a8-5ed971581ddc"}}}} +{"t":{"$date":"2025-11-25T03:06:56.017+00:00"},"s":"I", "c":"STORAGE", "id":4715500, "ctx":"conn8","msg":"Too many index builds running simultaneously, waiting until the number of active index builds is below the threshold","attr":{"numActiveIndexBuilds":3,"maxNumActiveUserIndexBuilds":3,"indexSpecs":[{"key":{"platform":1,"name":1},"name":"affected_platform_name","v":2},{"key":{"advisoryId":1},"name":"affected_advisoryId","v":2}],"buildUUID":{"uuid":{"$uuid":"7df22170-a963-4a06-b173-cde909e8764c"}},"collectionUUID":{"uuid":{"$uuid":"ef930a9b-1097-41f9-9d77-2659520d64dc"}}}} +{"t":{"$date":"2025-11-25T03:06:56.017+00:00"},"s":"I", "c":"STORAGE", "id":4715500, "ctx":"conn8","msg":"Too many index builds running simultaneously, waiting until the number of active index builds is below the threshold","attr":{"numActiveIndexBuilds":3,"maxNumActiveUserIndexBuilds":3,"indexSpecs":[{"key":{"platform":1,"name":1},"name":"affected_platform_name","v":2},{"key":{"advisoryId":1},"name":"affected_advisoryId","v":2}],"buildUUID":{"uuid":{"$uuid":"7df22170-a963-4a06-b173-cde909e8764c"}},"collectionUUID":{"uuid":{"$uuid":"ef930a9b-1097-41f9-9d77-2659520d64dc"}}}} +{"t":{"$date":"2025-11-25T03:06:56.018+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn5","msg":"Slow query","attr":{"type":"command","ns":"concelier.document","command":{"createIndexes":"document","indexes":[{"key":{"sourceName":1,"uri":1},"name":"document_source_uri_unique","unique":true},{"key":{"fetchedAt":-1},"name":"document_fetchedAt_desc"},{"key":{"expiresAt":1},"name":"document_expiresAt_ttl","expireAfterSeconds":0.0,"partialFilterExpression":{"expiresAt":{"$exists":true}}}],"writeConcern":{"w":"majority","wtimeout":30000.0},"$db":"concelier","lsid":{"id":{"$uuid":"d31918ca-399a-4f47-8207-80777cac4b29"}},"$clusterTime":{"clusterTime":{"$timestamp":{"t":1764040015,"i":24}},"signature":{"hash":{"$binary":{"base64":"AAAAAAAAAAAAAAAAAAAAAAAAAAA=","subType":"0"}},"keyId":0}}},"numYields":0,"reslen":271,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":3}},"ReplicationStateTransition":{"acquireCount":{"w":6}},"Global":{"acquireCount":{"r":2,"w":4}},"Database":{"acquireCount":{"w":3}},"Collection":{"acquireCount":{"r":1,"w":1,"W":1}},"Mutex":{"acquireCount":{"r":3}}},"flowControl":{"acquireCount":3,"timeAcquiringMicros":3},"writeConcern":{"w":"majority","wtimeout":30000,"provenance":"clientSupplied"},"storage":{},"protocol":"op_msg","durationMillis":158}} +{"t":{"$date":"2025-11-25T03:06:56.020+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn11","msg":"Slow query","attr":{"type":"command","ns":"concelier.psirt_flags","command":{"dropIndexes":"psirt_flags","index":"psirt_advisoryKey_unique","writeConcern":{"w":"majority","wtimeout":30000.0},"$db":"concelier","lsid":{"id":{"$uuid":"12d8a496-37e2-46f8-8e2f-a41a2f99ac09"}},"$clusterTime":{"clusterTime":{"$timestamp":{"t":1764040015,"i":24}},"signature":{"hash":{"$binary":{"base64":"AAAAAAAAAAAAAAAAAAAAAAAAAAA=","subType":"0"}},"keyId":0}}},"numYields":0,"ok":0,"errMsg":"index not found with name [psirt_advisoryKey_unique]","errName":"IndexNotFound","errCode":27,"reslen":266,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":2}},"ReplicationStateTransition":{"acquireCount":{"w":4}},"Global":{"acquireCount":{"r":2,"w":2}},"Database":{"acquireCount":{"w":2}},"Collection":{"acquireCount":{"w":1,"W":1}},"Mutex":{"acquireCount":{"r":3}}},"flowControl":{"acquireCount":2,"timeAcquiringMicros":1},"writeConcern":{"w":"majority","wtimeout":30000,"provenance":"clientSupplied"},"storage":{},"protocol":"op_msg","durationMillis":141}} +{"t":{"$date":"2025-11-25T03:06:56.031+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn11","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"59b6cebf-aee3-46b7-814a-856404eb982d"}},"namespace":"concelier.psirt_flags","collectionUUID":{"uuid":{"$uuid":"d61fab06-e185-4905-a581-78d6188f9cbf"}},"indexes":1,"firstIndex":{"name":"psirt_vendor"}}} +{"t":{"$date":"2025-11-25T03:06:56.035+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn10","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.reference","index":"reference_url","commitTimestamp":{"$timestamp":{"t":1764040016,"i":3}}}} +{"t":{"$date":"2025-11-25T03:06:56.035+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn10","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.reference","index":"reference_advisoryId","commitTimestamp":{"$timestamp":{"t":1764040016,"i":3}}}} +{"t":{"$date":"2025-11-25T03:06:56.035+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn10","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"25b0858f-8e1d-43bc-afab-07712ea8e760"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:56.035+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn10","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"25b0858f-8e1d-43bc-afab-07712ea8e760"}}}} +{"t":{"$date":"2025-11-25T03:06:56.037+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn10","msg":"Slow query","attr":{"type":"command","ns":"concelier.reference","command":{"createIndexes":"reference","indexes":[{"key":{"url":1},"name":"reference_url"},{"key":{"advisoryId":1},"name":"reference_advisoryId"}],"writeConcern":{"w":"majority","wtimeout":30000.0},"$db":"concelier","lsid":{"id":{"$uuid":"e8db91b1-ad7d-4cb3-a86b-47d5b309fc80"}},"$clusterTime":{"clusterTime":{"$timestamp":{"t":1764040015,"i":24}},"signature":{"hash":{"$binary":{"base64":"AAAAAAAAAAAAAAAAAAAAAAAAAAA=","subType":"0"}},"keyId":0}}},"numYields":0,"reslen":271,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":3}},"ReplicationStateTransition":{"acquireCount":{"w":6}},"Global":{"acquireCount":{"r":2,"w":4}},"Database":{"acquireCount":{"w":3}},"Collection":{"acquireCount":{"r":1,"w":1,"W":1}},"Mutex":{"acquireCount":{"r":3}}},"flowControl":{"acquireCount":3,"timeAcquiringMicros":2},"writeConcern":{"w":"majority","wtimeout":30000,"provenance":"clientSupplied"},"storage":{},"protocol":"op_msg","durationMillis":164}} +{"t":{"$date":"2025-11-25T03:06:56.051+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn13","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_conflicts","index":"advisory_conflicts_vulnerability_asof_desc","commitTimestamp":{"$timestamp":{"t":1764040016,"i":5}}}} +{"t":{"$date":"2025-11-25T03:06:56.051+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn13","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_conflicts","index":"advisory_conflicts_conflictHash_unique","commitTimestamp":{"$timestamp":{"t":1764040016,"i":5}}}} +{"t":{"$date":"2025-11-25T03:06:56.051+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn13","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"eba85195-e631-4fb2-a8ba-d155fcbe0411"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:56.051+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn13","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"eba85195-e631-4fb2-a8ba-d155fcbe0411"}}}} +{"t":{"$date":"2025-11-25T03:06:56.053+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn13","msg":"Slow query","attr":{"type":"command","ns":"concelier.advisory_conflicts","command":{"createIndexes":"advisory_conflicts","indexes":[{"key":{"vulnerabilityKey":1,"asOf":-1},"name":"advisory_conflicts_vulnerability_asof_desc"},{"key":{"conflictHash":1},"name":"advisory_conflicts_conflictHash_unique","unique":true}],"writeConcern":{"w":"majority","wtimeout":30000.0},"$db":"concelier","lsid":{"id":{"$uuid":"92e1dc41-2888-47f4-a1dc-abd349a494a4"}},"$clusterTime":{"clusterTime":{"$timestamp":{"t":1764040015,"i":24}},"signature":{"hash":{"$binary":{"base64":"AAAAAAAAAAAAAAAAAAAAAAAAAAA=","subType":"0"}},"keyId":0}}},"numYields":0,"reslen":271,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":3}},"ReplicationStateTransition":{"acquireCount":{"w":6}},"Global":{"acquireCount":{"r":2,"w":4}},"Database":{"acquireCount":{"w":3}},"Collection":{"acquireCount":{"r":1,"w":1,"W":1}},"Mutex":{"acquireCount":{"r":3}}},"flowControl":{"acquireCount":3,"timeAcquiringMicros":2},"writeConcern":{"w":"majority","wtimeout":30000,"provenance":"clientSupplied"},"storage":{},"protocol":"op_msg","durationMillis":172}} +{"t":{"$date":"2025-11-25T03:06:56.059+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn7","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.alias","index":"alias_scheme_value","commitTimestamp":{"$timestamp":{"t":1764040016,"i":6}}}} +{"t":{"$date":"2025-11-25T03:06:56.059+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn7","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"a56fab5b-f9c9-47ab-a907-c260047bad5e"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:56.059+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn7","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"a56fab5b-f9c9-47ab-a907-c260047bad5e"}}}} +{"t":{"$date":"2025-11-25T03:06:56.059+00:00"},"s":"I", "c":"STORAGE", "id":4715500, "ctx":"conn14","msg":"Too many index builds running simultaneously, waiting until the number of active index builds is below the threshold","attr":{"numActiveIndexBuilds":3,"maxNumActiveUserIndexBuilds":3,"indexSpecs":[{"key":{"tenant":1,"upstream.upstream_id":1,"upstream.document_version":1},"name":"advisory_obs_tenant_upstream","unique":false,"v":2},{"key":{"tenant":1,"linkset.aliases":1},"name":"advisory_obs_tenant_aliases","v":2},{"key":{"tenant":1,"linkset.purls":1},"name":"advisory_obs_tenant_purls","v":2},{"key":{"tenant":1,"createdAt":-1},"name":"advisory_obs_tenant_createdAt","v":2}],"buildUUID":{"uuid":{"$uuid":"054c0484-e72e-411f-bced-3f555ef0d361"}},"collectionUUID":{"uuid":{"$uuid":"2d30c6a9-a970-4507-9548-c93174011df9"}}}} +{"t":{"$date":"2025-11-25T03:06:56.059+00:00"},"s":"I", "c":"STORAGE", "id":4715500, "ctx":"conn14","msg":"Too many index builds running simultaneously, waiting until the number of active index builds is below the threshold","attr":{"numActiveIndexBuilds":3,"maxNumActiveUserIndexBuilds":3,"indexSpecs":[{"key":{"tenant":1,"upstream.upstream_id":1,"upstream.document_version":1},"name":"advisory_obs_tenant_upstream","unique":false,"v":2},{"key":{"tenant":1,"linkset.aliases":1},"name":"advisory_obs_tenant_aliases","v":2},{"key":{"tenant":1,"linkset.purls":1},"name":"advisory_obs_tenant_purls","v":2},{"key":{"tenant":1,"createdAt":-1},"name":"advisory_obs_tenant_createdAt","v":2}],"buildUUID":{"uuid":{"$uuid":"054c0484-e72e-411f-bced-3f555ef0d361"}},"collectionUUID":{"uuid":{"$uuid":"2d30c6a9-a970-4507-9548-c93174011df9"}}}} +{"t":{"$date":"2025-11-25T03:06:56.059+00:00"},"s":"I", "c":"STORAGE", "id":4715500, "ctx":"conn11","msg":"Too many index builds running simultaneously, waiting until the number of active index builds is below the threshold","attr":{"numActiveIndexBuilds":3,"maxNumActiveUserIndexBuilds":3,"indexSpecs":[{"key":{"vendor":1},"name":"psirt_vendor","v":2}],"buildUUID":{"uuid":{"$uuid":"59b6cebf-aee3-46b7-814a-856404eb982d"}},"collectionUUID":{"uuid":{"$uuid":"d61fab06-e185-4905-a581-78d6188f9cbf"}}}} +{"t":{"$date":"2025-11-25T03:06:56.059+00:00"},"s":"I", "c":"STORAGE", "id":4715500, "ctx":"conn11","msg":"Too many index builds running simultaneously, waiting until the number of active index builds is below the threshold","attr":{"numActiveIndexBuilds":3,"maxNumActiveUserIndexBuilds":3,"indexSpecs":[{"key":{"vendor":1},"name":"psirt_vendor","v":2}],"buildUUID":{"uuid":{"$uuid":"59b6cebf-aee3-46b7-814a-856404eb982d"}},"collectionUUID":{"uuid":{"$uuid":"d61fab06-e185-4905-a581-78d6188f9cbf"}}}} +{"t":{"$date":"2025-11-25T03:06:56.062+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn7","msg":"Slow query","attr":{"type":"command","ns":"concelier.alias","command":{"createIndexes":"alias","indexes":[{"key":{"scheme":1,"value":1},"name":"alias_scheme_value","unique":false}],"writeConcern":{"w":"majority","wtimeout":30000.0},"$db":"concelier","lsid":{"id":{"$uuid":"9451e45a-666e-4afb-b7dc-24139346c68a"}},"$clusterTime":{"clusterTime":{"$timestamp":{"t":1764040015,"i":24}},"signature":{"hash":{"$binary":{"base64":"AAAAAAAAAAAAAAAAAAAAAAAAAAA=","subType":"0"}},"keyId":0}}},"numYields":0,"reslen":271,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":3}},"ReplicationStateTransition":{"acquireCount":{"w":6}},"Global":{"acquireCount":{"r":2,"w":4}},"Database":{"acquireCount":{"w":3}},"Collection":{"acquireCount":{"r":1,"w":1,"W":1}},"Mutex":{"acquireCount":{"r":3}}},"flowControl":{"acquireCount":3,"timeAcquiringMicros":1},"writeConcern":{"w":"majority","wtimeout":30000,"provenance":"clientSupplied"},"storage":{},"protocol":"op_msg","durationMillis":199}} +{"t":{"$date":"2025-11-25T03:06:56.076+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn8","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.affected","index":"affected_platform_name","commitTimestamp":{"$timestamp":{"t":1764040016,"i":8}}}} +{"t":{"$date":"2025-11-25T03:06:56.076+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn8","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.affected","index":"affected_advisoryId","commitTimestamp":{"$timestamp":{"t":1764040016,"i":8}}}} +{"t":{"$date":"2025-11-25T03:06:56.100+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn15","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.source_change_history","index":"history_source_advisory_capturedAt","commitTimestamp":{"$timestamp":{"t":1764040016,"i":11}}}} +{"t":{"$date":"2025-11-25T03:06:56.100+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn15","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.source_change_history","index":"history_capturedAt","commitTimestamp":{"$timestamp":{"t":1764040016,"i":11}}}} +{"t":{"$date":"2025-11-25T03:06:56.100+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn15","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.source_change_history","index":"history_documentId","commitTimestamp":{"$timestamp":{"t":1764040016,"i":11}}}} +{"t":{"$date":"2025-11-25T03:06:56.101+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn15","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"9756e330-8423-4878-bd4f-a3e1a8400472"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:56.101+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn15","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"9756e330-8423-4878-bd4f-a3e1a8400472"}}}} +{"t":{"$date":"2025-11-25T03:06:56.103+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn15","msg":"Slow query","attr":{"type":"command","ns":"concelier.source_change_history","command":{"createIndexes":"source_change_history","indexes":[{"key":{"source":1,"advisoryKey":1,"capturedAt":-1},"name":"history_source_advisory_capturedAt"},{"key":{"capturedAt":-1},"name":"history_capturedAt"},{"key":{"documentId":1},"name":"history_documentId"}],"writeConcern":{"w":"majority","wtimeout":30000.0},"$db":"concelier","lsid":{"id":{"$uuid":"8b32a551-8036-4a89-ab2e-c86d08aa9663"}},"$clusterTime":{"clusterTime":{"$timestamp":{"t":1764040015,"i":27}},"signature":{"hash":{"$binary":{"base64":"AAAAAAAAAAAAAAAAAAAAAAAAAAA=","subType":"0"}},"keyId":0}}},"numYields":0,"reslen":271,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":3}},"ReplicationStateTransition":{"acquireCount":{"w":6}},"Global":{"acquireCount":{"r":2,"w":4}},"Database":{"acquireCount":{"w":3}},"Collection":{"acquireCount":{"r":1,"w":1,"W":1}},"Mutex":{"acquireCount":{"r":3}}},"flowControl":{"acquireCount":3,"timeAcquiringMicros":3},"writeConcern":{"w":"majority","wtimeout":30000,"provenance":"clientSupplied"},"storage":{},"protocol":"op_msg","durationMillis":220}} +{"t":{"$date":"2025-11-25T03:06:56.132+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn14","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_observations","index":"advisory_obs_tenant_upstream","commitTimestamp":{"$timestamp":{"t":1764040016,"i":15}}}} +{"t":{"$date":"2025-11-25T03:06:56.132+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn14","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_observations","index":"advisory_obs_tenant_aliases","commitTimestamp":{"$timestamp":{"t":1764040016,"i":15}}}} +{"t":{"$date":"2025-11-25T03:06:56.132+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn14","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_observations","index":"advisory_obs_tenant_purls","commitTimestamp":{"$timestamp":{"t":1764040016,"i":15}}}} +{"t":{"$date":"2025-11-25T03:06:56.132+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn14","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_observations","index":"advisory_obs_tenant_createdAt","commitTimestamp":{"$timestamp":{"t":1764040016,"i":15}}}} +{"t":{"$date":"2025-11-25T03:06:56.132+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn14","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"054c0484-e72e-411f-bced-3f555ef0d361"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:56.132+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn14","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"054c0484-e72e-411f-bced-3f555ef0d361"}}}} +{"t":{"$date":"2025-11-25T03:06:56.137+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn14","msg":"Slow query","attr":{"type":"command","ns":"concelier.advisory_observations","command":{"createIndexes":"advisory_observations","indexes":[{"key":{"tenant":1,"upstream.upstream_id":1,"upstream.document_version":1},"name":"advisory_obs_tenant_upstream","unique":false},{"key":{"tenant":1,"linkset.aliases":1},"name":"advisory_obs_tenant_aliases"},{"key":{"tenant":1,"linkset.purls":1},"name":"advisory_obs_tenant_purls"},{"key":{"tenant":1,"createdAt":-1},"name":"advisory_obs_tenant_createdAt"}],"writeConcern":{"w":"majority","wtimeout":30000.0},"$db":"concelier","lsid":{"id":{"$uuid":"959fef49-dc3d-44bf-824f-522cb94dcab9"}},"$clusterTime":{"clusterTime":{"$timestamp":{"t":1764040015,"i":24}},"signature":{"hash":{"$binary":{"base64":"AAAAAAAAAAAAAAAAAAAAAAAAAAA=","subType":"0"}},"keyId":0}}},"numYields":0,"reslen":271,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":3}},"ReplicationStateTransition":{"acquireCount":{"w":6}},"Global":{"acquireCount":{"r":2,"w":4}},"Database":{"acquireCount":{"w":3}},"Collection":{"acquireCount":{"r":1,"w":1,"W":1}},"Mutex":{"acquireCount":{"r":3}}},"flowControl":{"acquireCount":3,"timeAcquiringMicros":1},"writeConcern":{"w":"majority","wtimeout":30000,"provenance":"clientSupplied"},"storage":{},"protocol":"op_msg","durationMillis":255}} +{"t":{"$date":"2025-11-25T03:06:56.142+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn11","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.psirt_flags","index":"psirt_vendor","commitTimestamp":{"$timestamp":{"t":1764040016,"i":16}}}} +{"t":{"$date":"2025-11-25T03:06:56.142+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn11","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"59b6cebf-aee3-46b7-814a-856404eb982d"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:56.142+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn11","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"59b6cebf-aee3-46b7-814a-856404eb982d"}}}} +{"t":{"$date":"2025-11-25T03:06:56.145+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn11","msg":"Slow query","attr":{"type":"command","ns":"concelier.psirt_flags","command":{"createIndexes":"psirt_flags","indexes":[{"key":{"vendor":1},"name":"psirt_vendor"}],"writeConcern":{"w":"majority","wtimeout":30000.0},"$db":"concelier","lsid":{"id":{"$uuid":"12d8a496-37e2-46f8-8e2f-a41a2f99ac09"}},"$clusterTime":{"clusterTime":{"$timestamp":{"t":1764040016,"i":2}},"signature":{"hash":{"$binary":{"base64":"AAAAAAAAAAAAAAAAAAAAAAAAAAA=","subType":"0"}},"keyId":0}}},"numYields":0,"reslen":271,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":3}},"ReplicationStateTransition":{"acquireCount":{"w":6}},"Global":{"acquireCount":{"r":2,"w":4}},"Database":{"acquireCount":{"w":3}},"Collection":{"acquireCount":{"r":1,"w":1,"W":1}},"Mutex":{"acquireCount":{"r":3}}},"flowControl":{"acquireCount":3,"timeAcquiringMicros":1},"writeConcern":{"w":"majority","wtimeout":30000,"provenance":"clientSupplied"},"storage":{},"protocol":"op_msg","durationMillis":113}} +{"t":{"$date":"2025-11-25T03:06:56.158+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_statements","index":"advisory_statements_vulnerability_asof_desc","commitTimestamp":{"$timestamp":{"t":1764040016,"i":18}}}} +{"t":{"$date":"2025-11-25T03:06:56.158+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_statements","index":"advisory_statements_statementHash_unique","commitTimestamp":{"$timestamp":{"t":1764040016,"i":18}}}} +{"t":{"$date":"2025-11-25T03:06:56.158+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn12","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"e231aaa5-d5f8-4c88-9860-fe69d60d65f5"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:56.158+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn8","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"7df22170-a963-4a06-b173-cde909e8764c"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:56.158+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn12","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"e231aaa5-d5f8-4c88-9860-fe69d60d65f5"}}}} +{"t":{"$date":"2025-11-25T03:06:56.158+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn8","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"7df22170-a963-4a06-b173-cde909e8764c"}}}} +{"t":{"$date":"2025-11-25T03:06:56.160+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn8","msg":"Slow query","attr":{"type":"command","ns":"concelier.affected","command":{"createIndexes":"affected","indexes":[{"key":{"platform":1,"name":1},"name":"affected_platform_name"},{"key":{"advisoryId":1},"name":"affected_advisoryId"}],"writeConcern":{"w":"majority","wtimeout":30000.0},"$db":"concelier","lsid":{"id":{"$uuid":"db62eb74-b9d0-420f-b476-36bfe600a00e"}},"$clusterTime":{"clusterTime":{"$timestamp":{"t":1764040015,"i":24}},"signature":{"hash":{"$binary":{"base64":"AAAAAAAAAAAAAAAAAAAAAAAAAAA=","subType":"0"}},"keyId":0}}},"numYields":0,"reslen":271,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":3}},"ReplicationStateTransition":{"acquireCount":{"w":6}},"Global":{"acquireCount":{"r":2,"w":4}},"Database":{"acquireCount":{"w":3}},"Collection":{"acquireCount":{"r":1,"w":1,"W":1}},"Mutex":{"acquireCount":{"r":3}}},"flowControl":{"acquireCount":3,"timeAcquiringMicros":3},"writeConcern":{"w":"majority","wtimeout":30000,"provenance":"clientSupplied"},"storage":{},"protocol":"op_msg","durationMillis":297}} +{"t":{"$date":"2025-11-25T03:06:56.160+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn12","msg":"Slow query","attr":{"type":"command","ns":"concelier.advisory_statements","command":{"createIndexes":"advisory_statements","indexes":[{"key":{"vulnerabilityKey":1,"asOf":-1},"name":"advisory_statements_vulnerability_asof_desc"},{"key":{"statementHash":1},"name":"advisory_statements_statementHash_unique","unique":true}],"writeConcern":{"w":"majority","wtimeout":30000.0},"$db":"concelier","lsid":{"id":{"$uuid":"e30476f3-96d5-4a1b-b952-b9c3c8c48f05"}},"$clusterTime":{"clusterTime":{"$timestamp":{"t":1764040015,"i":24}},"signature":{"hash":{"$binary":{"base64":"AAAAAAAAAAAAAAAAAAAAAAAAAAA=","subType":"0"}},"keyId":0}}},"numYields":0,"reslen":271,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":3}},"ReplicationStateTransition":{"acquireCount":{"w":6}},"Global":{"acquireCount":{"r":2,"w":4}},"Database":{"acquireCount":{"w":3}},"Collection":{"acquireCount":{"r":1,"w":1,"W":1}},"Mutex":{"acquireCount":{"r":3}}},"flowControl":{"acquireCount":3,"timeAcquiringMicros":2},"writeConcern":{"w":"majority","wtimeout":30000,"provenance":"clientSupplied"},"storage":{},"protocol":"op_msg","durationMillis":281}} +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Applying Mongo migration 20241005_document_expiry_indexes: Ensure document.expiresAt index matches configured retention +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Mongo migration 20241005_document_expiry_indexes applied +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Applying Mongo migration 20241005_gridfs_expiry_indexes: Ensure GridFS metadata.expiresAt TTL index reflects retention settings +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Mongo migration 20241005_gridfs_expiry_indexes applied +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Applying Mongo migration 2025-11-07-advisory-canonical-key: Populate advisory_key and links for advisory_raw documents. +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Mongo migration 2025-11-07-advisory-canonical-key applied +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Applying Mongo migration 20251011-semver-style-backfill: Populate advisory.normalizedVersions for existing documents when SemVer style storage is enabled. +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Mongo migration 20251011-semver-style-backfill applied +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Applying Mongo migration 20251019_advisory_event_collections: Ensure advisory_statements and advisory_conflicts indexes exist for event log storage. +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Mongo migration 20251019_advisory_event_collections applied +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Applying Mongo migration 20251028_advisory_raw_idempotency_index: Ensure advisory_raw collection enforces idempotency via unique compound index. +{"t":{"$date":"2025-11-25T03:06:56.373+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn12","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"d0d7de72-350c-4703-88fe-4604a6c0d70c"}},"namespace":"concelier.advisory_raw","collectionUUID":{"uuid":{"$uuid":"70542ec2-832b-4f93-8c96-4ca814f1fbbc"}},"indexes":1,"firstIndex":{"name":"advisory_raw_idempotency"}}} +{"t":{"$date":"2025-11-25T03:06:56.381+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_raw","index":"advisory_raw_idempotency","commitTimestamp":{"$timestamp":{"t":1764040016,"i":24}}}} +{"t":{"$date":"2025-11-25T03:06:56.381+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn12","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"d0d7de72-350c-4703-88fe-4604a6c0d70c"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:56.381+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn12","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"d0d7de72-350c-4703-88fe-4604a6c0d70c"}}}} +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Mongo migration 20251028_advisory_raw_idempotency_index applied +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Applying Mongo migration 20251028_advisory_raw_validator: Ensure advisory_raw collection enforces Aggregation-Only Contract schema +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Mongo migration 20251028_advisory_raw_validator applied +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Applying Mongo migration 20251028_advisory_supersedes_backfill: Backfill advisory_raw supersedes chains and replace legacy advisory collection with read-only view. +{"t":{"$date":"2025-11-25T03:06:56.422+00:00"},"s":"I", "c":"COMMAND", "id":20400, "ctx":"conn12","msg":"renameCollectionForCommand","attr":{"sourceNamespace":"concelier.advisory","targetNamespace":"concelier.advisory_backup_20251028","dropTarget":"no"}} +{"t":{"$date":"2025-11-25T03:06:56.422+00:00"},"s":"I", "c":"STORAGE", "id":20319, "ctx":"conn12","msg":"renameCollection","attr":{"uuid":{"uuid":{"$uuid":"c2e4124c-bf80-4e3c-9272-cea8f40106f5"}},"fromName":"concelier.advisory","toName":"concelier.advisory_backup_20251028"}} +{"t":{"$date":"2025-11-25T03:06:56.427+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn12","msg":"createCollection","attr":{"namespace":"concelier.system.views","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"11aeedd7-8f4c-4bf6-a15f-508c507370da"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:56.445+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.system.views","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040016,"i":29}}}} +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Mongo migration 20251028_advisory_supersedes_backfill applied +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Applying Mongo migration 20251104_advisory_observations_raw_linkset: Populate rawLinkset field for advisory observations using stored advisory_raw documents. +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Mongo migration 20251104_advisory_observations_raw_linkset applied +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Applying Mongo migration 20251117_advisory_linksets_tenant_lower: Lowercase tenant ids in advisory_linksets to match query filters. +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Mongo migration 20251117_advisory_linksets_tenant_lower applied +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Applying Mongo migration 20251120_advisory_observation_events: Ensure advisory_observation_events collection and indexes exist for observation event fan-out. +{"t":{"$date":"2025-11-25T03:06:56.489+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn12","msg":"createCollection","attr":{"namespace":"concelier.advisory_observation_events","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"2ee210ff-d50f-4a43-9d2b-8160e01daa2f"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:56.524+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_observation_events","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040016,"i":36}}}} +{"t":{"$date":"2025-11-25T03:06:56.525+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_observation_events","index":"advisory_observation_events_tenant_ingested_desc","commitTimestamp":{"$timestamp":{"t":1764040016,"i":36}}}} +{"t":{"$date":"2025-11-25T03:06:56.525+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.advisory_observation_events","index":"advisory_observation_events_hash_unique","commitTimestamp":{"$timestamp":{"t":1764040016,"i":36}}}} +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Mongo migration 20251120_advisory_observation_events applied +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Applying Mongo migration 20251122_orchestrator_registry_commands: Ensure orchestrator registry, commands, and heartbeats collections exist with indexes +{"t":{"$date":"2025-11-25T03:06:56.535+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn12","msg":"createCollection","attr":{"namespace":"concelier.orchestrator_registry","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"6649d503-b817-4ea5-88ce-a93b0536995d"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:56.551+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.orchestrator_registry","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040016,"i":38}}}} +{"t":{"$date":"2025-11-25T03:06:56.554+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn12","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"0f95b012-1a7d-415e-9a84-9839c759b37e"}},"namespace":"concelier.orchestrator_registry","collectionUUID":{"uuid":{"$uuid":"6649d503-b817-4ea5-88ce-a93b0536995d"}},"indexes":2,"firstIndex":{"name":"orch_registry_tenant_connector"}}} +{"t":{"$date":"2025-11-25T03:06:56.577+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.orchestrator_registry","index":"orch_registry_tenant_connector","commitTimestamp":{"$timestamp":{"t":1764040016,"i":40}}}} +{"t":{"$date":"2025-11-25T03:06:56.577+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.orchestrator_registry","index":"orch_registry_source","commitTimestamp":{"$timestamp":{"t":1764040016,"i":40}}}} +{"t":{"$date":"2025-11-25T03:06:56.577+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn12","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"0f95b012-1a7d-415e-9a84-9839c759b37e"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:56.577+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn12","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"0f95b012-1a7d-415e-9a84-9839c759b37e"}}}} +{"t":{"$date":"2025-11-25T03:06:56.581+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn12","msg":"createCollection","attr":{"namespace":"concelier.orchestrator_commands","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"f1a79279-2004-4cfd-8ae9-cb752e102dff"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:56.601+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.orchestrator_commands","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040016,"i":41}}}} +{"t":{"$date":"2025-11-25T03:06:56.604+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn12","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"edb14da3-273d-4518-a0ff-db0a490facc4"}},"namespace":"concelier.orchestrator_commands","collectionUUID":{"uuid":{"$uuid":"f1a79279-2004-4cfd-8ae9-cb752e102dff"}},"indexes":2,"firstIndex":{"name":"orch_cmd_tenant_connector_run_seq"}}} +{"t":{"$date":"2025-11-25T03:06:56.623+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.orchestrator_commands","index":"orch_cmd_tenant_connector_run_seq","commitTimestamp":{"$timestamp":{"t":1764040016,"i":43}}}} +{"t":{"$date":"2025-11-25T03:06:56.623+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.orchestrator_commands","index":"orch_cmd_expiresAt_ttl","commitTimestamp":{"$timestamp":{"t":1764040016,"i":43}}}} +{"t":{"$date":"2025-11-25T03:06:56.623+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn12","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"edb14da3-273d-4518-a0ff-db0a490facc4"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:56.623+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn12","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"edb14da3-273d-4518-a0ff-db0a490facc4"}}}} +{"t":{"$date":"2025-11-25T03:06:56.627+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn12","msg":"createCollection","attr":{"namespace":"concelier.orchestrator_heartbeats","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"52b32b90-719b-4668-8aab-021f90ae99f1"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:06:56.644+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.orchestrator_heartbeats","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764040016,"i":44}}}} +{"t":{"$date":"2025-11-25T03:06:56.648+00:00"},"s":"I", "c":"INDEX", "id":20438, "ctx":"conn12","msg":"Index build: registering","attr":{"buildUUID":{"uuid":{"$uuid":"f262f49e-88f3-4b71-ade5-24ba982d5f71"}},"namespace":"concelier.orchestrator_heartbeats","collectionUUID":{"uuid":{"$uuid":"52b32b90-719b-4668-8aab-021f90ae99f1"}},"indexes":2,"firstIndex":{"name":"orch_hb_tenant_connector_run_seq"}}} +{"t":{"$date":"2025-11-25T03:06:56.664+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.orchestrator_heartbeats","index":"orch_hb_tenant_connector_run_seq","commitTimestamp":{"$timestamp":{"t":1764040016,"i":46}}}} +{"t":{"$date":"2025-11-25T03:06:56.664+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn12","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier.orchestrator_heartbeats","index":"orch_hb_timestamp_desc","commitTimestamp":{"$timestamp":{"t":1764040016,"i":46}}}} +{"t":{"$date":"2025-11-25T03:06:56.664+00:00"},"s":"I", "c":"INDEX", "id":20440, "ctx":"conn12","msg":"Index build: waiting for index build to complete","attr":{"buildUUID":{"uuid":{"$uuid":"f262f49e-88f3-4b71-ade5-24ba982d5f71"}},"deadline":{"$date":{"$numberLong":"9223372036854775807"}}}} +{"t":{"$date":"2025-11-25T03:06:56.664+00:00"},"s":"I", "c":"INDEX", "id":20447, "ctx":"conn12","msg":"Index build: completed","attr":{"buildUUID":{"uuid":{"$uuid":"f262f49e-88f3-4b71-ade5-24ba982d5f71"}}}} +info: StellaOps.Concelier.Storage.Mongo.Migrations.MongoMigrationRunner[0] + Mongo migration 20251122_orchestrator_registry_commands applied +info: StellaOps.Concelier.Storage.Mongo.MongoBootstrapper[0] + Mongo bootstrapper completed +info: MongoBootstrapper[0] + Mongo bootstrap completed in 1453.7631 ms +info: StellaOps.Concelier.Core.Jobs.JobSchedulerHostedService[0] + No cron-based jobs registered; scheduler idle. +info: StellaOps.Concelier.Storage.Mongo.Observations.AdvisoryObservationTransportWorker[0] + Observation transport worker disabled. +info: StellaOps.Concelier.Storage.Mongo.Observations.AdvisoryObservationTransportWorker[0] + Observation transport worker disabled. +info: Microsoft.Hosting.Lifetime[0] + Application started. Press Ctrl+C to shut down. +info: Microsoft.Hosting.Lifetime[0] + Hosting environment: Development +info: Microsoft.Hosting.Lifetime[0] + Content root path: /mnt/e/dev/git.stella-ops.org/src/Concelier/StellaOps.Concelier.WebService +info: Microsoft.AspNetCore.Hosting.Diagnostics[1] + Request starting HTTP/1.1 GET http://localhost/health - - - +info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0] + Executing endpoint 'HTTP: GET /health' +info: Microsoft.AspNetCore.Http.Result.ContentResult[2] + Write content with HTTP Response ContentType of application/json; charset=utf-8 +info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1] + Executed endpoint 'HTTP: GET /health' +info: Microsoft.AspNetCore.Hosting.Diagnostics[2] + Request finished HTTP/1.1 GET http://localhost/health - 200 291 application/json;+charset=utf-8 151.1386ms +info: Microsoft.AspNetCore.Hosting.Diagnostics[1] + Request starting HTTP/1.1 GET http://localhost/ready - - - +info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0] + Executing endpoint 'HTTP: GET /ready' +info: Microsoft.AspNetCore.Http.Result.ContentResult[2] + Write content with HTTP Response ContentType of application/json; charset=utf-8 +info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1] + Executed endpoint 'HTTP: GET /ready' +info: Microsoft.AspNetCore.Hosting.Diagnostics[2] + Request finished HTTP/1.1 GET http://localhost/ready - 200 198 application/json;+charset=utf-8 12.4201ms +info: Microsoft.Hosting.Lifetime[0] + Application is shutting down... +[xUnit.net 00:00:36.48] Finished: StellaOps.Concelier.WebService.Tests + + + + + Data collector 'Blame' message: All tests finished running, Sequence file will not be generated. + + + + \ No newline at end of file diff --git a/ops/devops/artifacts/ci-110/20251125T030557Z/trx/excititor-airgapimport.fqn.trx b/ops/devops/artifacts/ci-110/20251125T030557Z/trx/excititor-airgapimport.fqn.trx new file mode 100644 index 000000000..1d8bb4bae --- /dev/null +++ b/ops/devops/artifacts/ci-110/20251125T030557Z/trx/excititor-airgapimport.fqn.trx @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0-rc.2.25502.107) +[xUnit.net 00:00:00.23] Discovering: StellaOps.Excititor.WebService.Tests +[xUnit.net 00:00:00.29] Discovered: StellaOps.Excititor.WebService.Tests +[xUnit.net 00:00:00.30] Starting: StellaOps.Excititor.WebService.Tests +[xUnit.net 00:00:00.64] Finished: StellaOps.Excititor.WebService.Tests + + + + + Data collector 'Blame' message: All tests finished running, Sequence file will not be generated. + + + + \ No newline at end of file diff --git a/ops/devops/artifacts/ci-110/20251125T034529Z/trx/concelier-storage-jobstore.trx b/ops/devops/artifacts/ci-110/20251125T034529Z/trx/concelier-storage-jobstore.trx new file mode 100644 index 000000000..2569be740 --- /dev/null +++ b/ops/devops/artifacts/ci-110/20251125T034529Z/trx/concelier-storage-jobstore.trx @@ -0,0 +1,266 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0-rc.2.25502.107) +[xUnit.net 00:00:00.24] Discovering: StellaOps.Concelier.Storage.Mongo.Tests +[xUnit.net 00:00:00.30] Discovered: StellaOps.Concelier.Storage.Mongo.Tests +[xUnit.net 00:00:00.31] Starting: StellaOps.Concelier.Storage.Mongo.Tests +{"t":{"$date":"2025-11-25T03:46:58.044+00:00"},"s":"I", "c":"CONTROL", "id":23285, "ctx":"main","msg":"Automatically disabling TLS 1.0, to force-enable TLS 1.0 specify --sslDisabledProtocols 'none'"} +{"t":{"$date":"2025-11-25T03:46:58.047+00:00"},"s":"W", "c":"ASIO", "id":22601, "ctx":"main","msg":"No TransportLayer configured during NetworkInterface startup"} +{"t":{"$date":"2025-11-25T03:46:58.047+00:00"},"s":"I", "c":"NETWORK", "id":4648601, "ctx":"main","msg":"Implicit TCP FastOpen unavailable. If TCP FastOpen is required, set tcpFastOpenServer, tcpFastOpenClient, and tcpFastOpenQueueSize."} +{"t":{"$date":"2025-11-25T03:46:58.047+00:00"},"s":"W", "c":"ASIO", "id":22601, "ctx":"main","msg":"No TransportLayer configured during NetworkInterface startup"} +{"t":{"$date":"2025-11-25T03:46:58.048+00:00"},"s":"I", "c":"STORAGE", "id":4615611, "ctx":"initandlisten","msg":"MongoDB starting","attr":{"pid":149307,"port":34177,"dbPath":"/tmp/52ypckao.mi24085d8d097344d1d89ce_34177","architecture":"64-bit","host":"DESKTOP-7GHGC2M"}} +{"t":{"$date":"2025-11-25T03:46:58.048+00:00"},"s":"I", "c":"CONTROL", "id":23403, "ctx":"initandlisten","msg":"Build Info","attr":{"buildInfo":{"version":"4.4.4","gitVersion":"8db30a63db1a9d84bdcad0c83369623f708e0397","openSSLVersion":"OpenSSL 1.1.1w 11 Sep 2023","modules":[],"allocator":"tcmalloc","environment":{"distmod":"ubuntu2004","distarch":"x86_64","target_arch":"x86_64"}}}} +{"t":{"$date":"2025-11-25T03:46:58.048+00:00"},"s":"I", "c":"CONTROL", "id":51765, "ctx":"initandlisten","msg":"Operating System","attr":{"os":{"name":"Ubuntu","version":"24.04"}}} +{"t":{"$date":"2025-11-25T03:46:58.048+00:00"},"s":"I", "c":"CONTROL", "id":21951, "ctx":"initandlisten","msg":"Options set by command line","attr":{"options":{"net":{"bindIp":"127.0.0.1","port":34177},"replication":{"replSet":"singleNodeReplSet"},"storage":{"dbPath":"/tmp/52ypckao.mi24085d8d097344d1d89ce_34177"}}}} +{"t":{"$date":"2025-11-25T03:46:58.050+00:00"},"s":"I", "c":"STORAGE", "id":22297, "ctx":"initandlisten","msg":"Using the XFS filesystem is strongly recommended with the WiredTiger storage engine. See http://dochub.mongodb.org/core/prodnotes-filesystem","tags":["startupWarnings"]} +{"t":{"$date":"2025-11-25T03:46:58.051+00:00"},"s":"I", "c":"STORAGE", "id":22315, "ctx":"initandlisten","msg":"Opening WiredTiger","attr":{"config":"create,cache_size=7485M,session_max=33000,eviction=(threads_min=4,threads_max=4),config_base=false,statistics=(fast),log=(enabled=true,archive=true,path=journal,compressor=snappy),file_manager=(close_idle_time=100000,close_scan_interval=10,close_handle_minimum=250),statistics_log=(wait=0),verbose=[recovery_progress,checkpoint_progress,compact_progress],"}} +{"t":{"$date":"2025-11-25T03:46:58.505+00:00"},"s":"I", "c":"STORAGE", "id":22430, "ctx":"initandlisten","msg":"WiredTiger message","attr":{"message":"[1764042418:505803][149307:0x7c5deb73fcc0], txn-recover: [WT_VERB_RECOVERY | WT_VERB_RECOVERY_PROGRESS] Set global recovery timestamp: (0, 0)"}} +{"t":{"$date":"2025-11-25T03:46:58.505+00:00"},"s":"I", "c":"STORAGE", "id":22430, "ctx":"initandlisten","msg":"WiredTiger message","attr":{"message":"[1764042418:505867][149307:0x7c5deb73fcc0], txn-recover: [WT_VERB_RECOVERY | WT_VERB_RECOVERY_PROGRESS] Set global oldest timestamp: (0, 0)"}} +{"t":{"$date":"2025-11-25T03:46:58.518+00:00"},"s":"I", "c":"STORAGE", "id":4795906, "ctx":"initandlisten","msg":"WiredTiger opened","attr":{"durationMillis":467}} +{"t":{"$date":"2025-11-25T03:46:58.518+00:00"},"s":"I", "c":"RECOVERY", "id":23987, "ctx":"initandlisten","msg":"WiredTiger recoveryTimestamp","attr":{"recoveryTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:46:58.541+00:00"},"s":"I", "c":"STORAGE", "id":4366408, "ctx":"initandlisten","msg":"No table logging settings modifications are required for existing WiredTiger tables","attr":{"loggingEnabled":false}} +{"t":{"$date":"2025-11-25T03:46:58.541+00:00"},"s":"I", "c":"STORAGE", "id":22262, "ctx":"initandlisten","msg":"Timestamp monitor starting"} +{"t":{"$date":"2025-11-25T03:46:58.549+00:00"},"s":"W", "c":"CONTROL", "id":22120, "ctx":"initandlisten","msg":"Access control is not enabled for the database. Read and write access to data and configuration is unrestricted","tags":["startupWarnings"]} +{"t":{"$date":"2025-11-25T03:46:58.550+00:00"},"s":"I", "c":"STORAGE", "id":20536, "ctx":"initandlisten","msg":"Flow Control is enabled on this deployment"} +{"t":{"$date":"2025-11-25T03:46:58.552+00:00"},"s":"I", "c":"SHARDING", "id":20997, "ctx":"initandlisten","msg":"Refreshed RWC defaults","attr":{"newDefaults":{}}} +{"t":{"$date":"2025-11-25T03:46:58.552+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.startup_log","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"703631ce-8f0d-4a10-bbb7-9d5bf9ef670d"}},"options":{"capped":true,"size":10485760}}} +{"t":{"$date":"2025-11-25T03:46:58.570+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.startup_log","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:46:58.571+00:00"},"s":"I", "c":"FTDC", "id":20625, "ctx":"initandlisten","msg":"Initializing full-time diagnostic data capture","attr":{"dataDirectory":"/tmp/52ypckao.mi24085d8d097344d1d89ce_34177/diagnostic.data"}} +{"t":{"$date":"2025-11-25T03:46:58.573+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.replset.oplogTruncateAfterPoint","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"cdf2e71f-d90e-4aea-9d8f-0c44a436e8e5"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:46:58.592+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.replset.oplogTruncateAfterPoint","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:46:58.592+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.replset.minvalid","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"e280bf8e-917b-4655-a7ee-c02bce23629f"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:46:58.612+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.replset.minvalid","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:46:58.612+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.replset.election","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"e437541a-6cff-4c82-89b8-8a63ae74ccd1"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:46:58.630+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.replset.election","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:46:58.630+00:00"},"s":"I", "c":"REPL", "id":21311, "ctx":"initandlisten","msg":"Did not find local initialized voted for document at startup"} +{"t":{"$date":"2025-11-25T03:46:58.630+00:00"},"s":"I", "c":"REPL", "id":21312, "ctx":"initandlisten","msg":"Did not find local Rollback ID document at startup. Creating one"} +{"t":{"$date":"2025-11-25T03:46:58.630+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.system.rollback.id","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"4d719115-1783-4038-acf6-b047f74c33a1"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:46:58.650+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.system.rollback.id","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:46:58.650+00:00"},"s":"I", "c":"REPL", "id":21531, "ctx":"initandlisten","msg":"Initialized the rollback ID","attr":{"rbid":1}} +{"t":{"$date":"2025-11-25T03:46:58.650+00:00"},"s":"I", "c":"REPL", "id":21313, "ctx":"initandlisten","msg":"Did not find local replica set configuration document at startup","attr":{"error":{"code":47,"codeName":"NoMatchingDocument","errmsg":"Did not find replica set configuration document in local.system.replset"}}} +{"t":{"$date":"2025-11-25T03:46:58.652+00:00"},"s":"I", "c":"CONTROL", "id":20714, "ctx":"LogicalSessionCacheRefresh","msg":"Failed to refresh session cache, will try again at the next refresh interval","attr":{"error":"NotYetInitialized: Replication has not yet been configured"}} +{"t":{"$date":"2025-11-25T03:46:58.652+00:00"},"s":"I", "c":"CONTROL", "id":20712, "ctx":"LogicalSessionCacheReap","msg":"Sessions collection is not set up; waiting until next sessions reap interval","attr":{"error":"NamespaceNotFound: config.system.sessions does not exist"}} +{"t":{"$date":"2025-11-25T03:46:58.652+00:00"},"s":"I", "c":"REPL", "id":40440, "ctx":"initandlisten","msg":"Starting the TopologyVersionObserver"} +{"t":{"$date":"2025-11-25T03:46:58.653+00:00"},"s":"I", "c":"REPL", "id":40445, "ctx":"TopologyVersionObserver","msg":"Started TopologyVersionObserver"} +{"t":{"$date":"2025-11-25T03:46:58.653+00:00"},"s":"I", "c":"NETWORK", "id":23015, "ctx":"listener","msg":"Listening on","attr":{"address":"/tmp/mongodb-34177.sock"}} +{"t":{"$date":"2025-11-25T03:46:58.653+00:00"},"s":"I", "c":"NETWORK", "id":23015, "ctx":"listener","msg":"Listening on","attr":{"address":"127.0.0.1"}} +{"t":{"$date":"2025-11-25T03:46:58.653+00:00"},"s":"I", "c":"NETWORK", "id":23016, "ctx":"listener","msg":"Waiting for connections","attr":{"port":34177,"ssl":"off"}} +{"t":{"$date":"2025-11-25T03:46:59.029+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:41390","connectionId":1,"connectionCount":1}} +{"t":{"$date":"2025-11-25T03:46:59.052+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn1","msg":"client metadata","attr":{"remote":"127.0.0.1:41390","client":"conn1","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:46:59.102+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:41392","connectionId":2,"connectionCount":2}} +{"t":{"$date":"2025-11-25T03:46:59.104+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn2","msg":"client metadata","attr":{"remote":"127.0.0.1:41392","client":"conn2","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:46:59.110+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:41398","connectionId":3,"connectionCount":3}} +{"t":{"$date":"2025-11-25T03:46:59.110+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn3","msg":"client metadata","attr":{"remote":"127.0.0.1:41398","client":"conn3","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:46:59.124+00:00"},"s":"I", "c":"REPL", "id":21356, "ctx":"conn3","msg":"replSetInitiate admin command received from client"} +{"t":{"$date":"2025-11-25T03:46:59.124+00:00"},"s":"I", "c":"REPL", "id":21357, "ctx":"conn3","msg":"replSetInitiate config object parses ok","attr":{"numMembers":1}} +{"t":{"$date":"2025-11-25T03:46:59.124+00:00"},"s":"I", "c":"REPL", "id":21251, "ctx":"conn3","msg":"Creating replication oplog","attr":{"oplogSizeMB":48102}} +{"t":{"$date":"2025-11-25T03:46:59.124+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"local.oplog.rs","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"053aabcf-9a69-46cd-a015-04790a28df83"}},"options":{"capped":true,"size":50439009894.0,"autoIndexId":false}}} +{"t":{"$date":"2025-11-25T03:46:59.140+00:00"},"s":"I", "c":"STORAGE", "id":22383, "ctx":"conn3","msg":"The size storer reports that the oplog contains","attr":{"numRecords":0,"dataSize":0}} +{"t":{"$date":"2025-11-25T03:46:59.140+00:00"},"s":"I", "c":"STORAGE", "id":22382, "ctx":"conn3","msg":"WiredTiger record store oplog processing finished","attr":{"durationMillis":0}} +{"t":{"$date":"2025-11-25T03:46:59.182+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"local.system.replset","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"17fcea78-1e4a-45c3-b642-f84a5ed5e94a"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:46:59.199+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.system.replset","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042419,"i":1}}}} +{"t":{"$date":"2025-11-25T03:46:59.200+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"admin.system.version","uuidDisposition":"provided","uuid":{"uuid":{"$uuid":"ed811933-0189-4439-9d54-39e00e9db918"}},"options":{"uuid":{"$uuid":"ed811933-0189-4439-9d54-39e00e9db918"}}}} +{"t":{"$date":"2025-11-25T03:46:59.222+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"admin.system.version","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042419,"i":1}}}} +{"t":{"$date":"2025-11-25T03:46:59.222+00:00"},"s":"I", "c":"COMMAND", "id":20459, "ctx":"conn3","msg":"Setting featureCompatibilityVersion","attr":{"newVersion":"4.4"}} +{"t":{"$date":"2025-11-25T03:46:59.222+00:00"},"s":"I", "c":"NETWORK", "id":22991, "ctx":"conn3","msg":"Skip closing connection for connection","attr":{"connectionId":3}} +{"t":{"$date":"2025-11-25T03:46:59.222+00:00"},"s":"I", "c":"NETWORK", "id":22991, "ctx":"conn3","msg":"Skip closing connection for connection","attr":{"connectionId":2}} +{"t":{"$date":"2025-11-25T03:46:59.222+00:00"},"s":"I", "c":"NETWORK", "id":22991, "ctx":"conn3","msg":"Skip closing connection for connection","attr":{"connectionId":1}} +{"t":{"$date":"2025-11-25T03:46:59.223+00:00"},"s":"I", "c":"REPL", "id":21392, "ctx":"conn3","msg":"New replica set config in use","attr":{"config":{"_id":"singleNodeReplSet","version":1,"term":0,"protocolVersion":1,"writeConcernMajorityJournalDefault":true,"members":[{"_id":0,"host":"127.0.0.1:34177","arbiterOnly":false,"buildIndexes":true,"hidden":false,"priority":1.0,"tags":{},"slaveDelay":0,"votes":1}],"settings":{"chainingAllowed":true,"heartbeatIntervalMillis":2000,"heartbeatTimeoutSecs":10,"electionTimeoutMillis":10000,"catchUpTimeoutMillis":-1,"catchUpTakeoverDelayMillis":30000,"getLastErrorModes":{},"getLastErrorDefaults":{"w":1,"wtimeout":0},"replicaSetId":{"$oid":"692526b3e401d61632b17dbe"}}}}} +{"t":{"$date":"2025-11-25T03:46:59.223+00:00"},"s":"I", "c":"REPL", "id":21393, "ctx":"conn3","msg":"Found self in config","attr":{"hostAndPort":"127.0.0.1:34177"}} +{"t":{"$date":"2025-11-25T03:46:59.223+00:00"},"s":"I", "c":"REPL", "id":21358, "ctx":"conn3","msg":"Replica set state transition","attr":{"newState":"STARTUP2","oldState":"STARTUP"}} +{"t":{"$date":"2025-11-25T03:46:59.223+00:00"},"s":"I", "c":"REPL", "id":21306, "ctx":"conn3","msg":"Starting replication storage threads"} +{"t":{"$date":"2025-11-25T03:46:59.228+00:00"},"s":"I", "c":"REPL", "id":21358, "ctx":"conn3","msg":"Replica set state transition","attr":{"newState":"RECOVERING","oldState":"STARTUP2"}} +{"t":{"$date":"2025-11-25T03:46:59.228+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn3","msg":"createCollection","attr":{"namespace":"local.replset.initialSyncId","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"ea9b840a-3f2f-495e-9503-e90226f4437e"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:46:59.248+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn3","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.replset.initialSyncId","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042419,"i":1}}}} +{"t":{"$date":"2025-11-25T03:46:59.248+00:00"},"s":"I", "c":"REPL", "id":21299, "ctx":"conn3","msg":"Starting replication fetcher thread"} +{"t":{"$date":"2025-11-25T03:46:59.248+00:00"},"s":"I", "c":"REPL", "id":21300, "ctx":"conn3","msg":"Starting replication applier thread"} +{"t":{"$date":"2025-11-25T03:46:59.248+00:00"},"s":"I", "c":"REPL", "id":21301, "ctx":"conn3","msg":"Starting replication reporter thread"} +{"t":{"$date":"2025-11-25T03:46:59.248+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn3","msg":"Slow query","attr":{"type":"command","ns":"local.system.replset","command":{"replSetInitiate":{"_id":"singleNodeReplSet","members":[{"_id":0,"host":"127.0.0.1:34177"}]},"$db":"admin","lsid":{"id":{"$uuid":"28798271-3e18-48d9-8e83-e22f1566379f"}}},"numYields":0,"reslen":163,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":18}},"ReplicationStateTransition":{"acquireCount":{"w":19}},"Global":{"acquireCount":{"r":11,"w":6,"W":2}},"Database":{"acquireCount":{"r":10,"w":4,"W":2}},"Collection":{"acquireCount":{"r":3,"w":5}},"Mutex":{"acquireCount":{"r":17}},"oplog":{"acquireCount":{"w":1}}},"flowControl":{"acquireCount":5,"timeAcquiringMicros":3},"storage":{},"protocol":"op_msg","durationMillis":124}} +{"t":{"$date":"2025-11-25T03:46:59.249+00:00"},"s":"I", "c":"REPL", "id":21224, "ctx":"OplogApplier-0","msg":"Starting oplog application"} +{"t":{"$date":"2025-11-25T03:46:59.249+00:00"},"s":"I", "c":"REPL", "id":21358, "ctx":"OplogApplier-0","msg":"Replica set state transition","attr":{"newState":"SECONDARY","oldState":"RECOVERING"}} +{"t":{"$date":"2025-11-25T03:46:59.249+00:00"},"s":"I", "c":"ELECTION", "id":4615652, "ctx":"OplogApplier-0","msg":"Starting an election, since we've seen no PRIMARY in election timeout period","attr":{"electionTimeoutPeriodMillis":10000}} +{"t":{"$date":"2025-11-25T03:46:59.249+00:00"},"s":"I", "c":"ELECTION", "id":21438, "ctx":"OplogApplier-0","msg":"Conducting a dry run election to see if we could be elected","attr":{"currentTerm":0}} +{"t":{"$date":"2025-11-25T03:46:59.249+00:00"},"s":"I", "c":"ELECTION", "id":21444, "ctx":"ReplCoord-0","msg":"Dry election run succeeded, running for election","attr":{"newTerm":1}} +{"t":{"$date":"2025-11-25T03:46:59.251+00:00"},"s":"I", "c":"ELECTION", "id":21450, "ctx":"ReplCoord-2","msg":"Election succeeded, assuming primary role","attr":{"term":1}} +{"t":{"$date":"2025-11-25T03:46:59.251+00:00"},"s":"I", "c":"REPL", "id":21358, "ctx":"ReplCoord-2","msg":"Replica set state transition","attr":{"newState":"PRIMARY","oldState":"SECONDARY"}} +{"t":{"$date":"2025-11-25T03:46:59.252+00:00"},"s":"I", "c":"REPL", "id":21106, "ctx":"ReplCoord-2","msg":"Resetting sync source to empty","attr":{"previousSyncSource":":27017"}} +{"t":{"$date":"2025-11-25T03:46:59.252+00:00"},"s":"I", "c":"REPL", "id":21359, "ctx":"ReplCoord-2","msg":"Entering primary catch-up mode"} +{"t":{"$date":"2025-11-25T03:46:59.252+00:00"},"s":"I", "c":"REPL", "id":21363, "ctx":"ReplCoord-2","msg":"Exited primary catch-up mode"} +{"t":{"$date":"2025-11-25T03:46:59.252+00:00"},"s":"I", "c":"REPL", "id":21107, "ctx":"ReplCoord-2","msg":"Stopping replication producer"} +{"t":{"$date":"2025-11-25T03:46:59.252+00:00"},"s":"I", "c":"REPL", "id":21239, "ctx":"ReplBatcher","msg":"Oplog buffer has been drained","attr":{"term":1}} +{"t":{"$date":"2025-11-25T03:46:59.252+00:00"},"s":"I", "c":"REPL", "id":21239, "ctx":"ReplBatcher","msg":"Oplog buffer has been drained","attr":{"term":1}} +{"t":{"$date":"2025-11-25T03:46:59.252+00:00"},"s":"I", "c":"REPL", "id":21343, "ctx":"RstlKillOpThread","msg":"Starting to kill user operations"} +{"t":{"$date":"2025-11-25T03:46:59.252+00:00"},"s":"I", "c":"REPL", "id":21344, "ctx":"RstlKillOpThread","msg":"Stopped killing user operations"} +{"t":{"$date":"2025-11-25T03:46:59.252+00:00"},"s":"I", "c":"REPL", "id":21340, "ctx":"RstlKillOpThread","msg":"State transition ops metrics","attr":{"metrics":{"lastStateTransition":"stepUp","userOpsKilled":0,"userOpsRunning":1}}} +{"t":{"$date":"2025-11-25T03:46:59.252+00:00"},"s":"I", "c":"REPL", "id":4508103, "ctx":"OplogApplier-0","msg":"Increment the config term via reconfig"} +{"t":{"$date":"2025-11-25T03:46:59.252+00:00"},"s":"I", "c":"REPL", "id":21353, "ctx":"OplogApplier-0","msg":"replSetReconfig config object parses ok","attr":{"numMembers":1}} +{"t":{"$date":"2025-11-25T03:46:59.252+00:00"},"s":"I", "c":"REPL", "id":51814, "ctx":"OplogApplier-0","msg":"Persisting new config to disk"} +{"t":{"$date":"2025-11-25T03:46:59.254+00:00"},"s":"I", "c":"REPL", "id":21392, "ctx":"OplogApplier-0","msg":"New replica set config in use","attr":{"config":{"_id":"singleNodeReplSet","version":1,"term":1,"protocolVersion":1,"writeConcernMajorityJournalDefault":true,"members":[{"_id":0,"host":"127.0.0.1:34177","arbiterOnly":false,"buildIndexes":true,"hidden":false,"priority":1.0,"tags":{},"slaveDelay":0,"votes":1}],"settings":{"chainingAllowed":true,"heartbeatIntervalMillis":2000,"heartbeatTimeoutSecs":10,"electionTimeoutMillis":10000,"catchUpTimeoutMillis":-1,"catchUpTakeoverDelayMillis":30000,"getLastErrorModes":{},"getLastErrorDefaults":{"w":1,"wtimeout":0},"replicaSetId":{"$oid":"692526b3e401d61632b17dbe"}}}}} +{"t":{"$date":"2025-11-25T03:46:59.254+00:00"},"s":"I", "c":"REPL", "id":21393, "ctx":"OplogApplier-0","msg":"Found self in config","attr":{"hostAndPort":"127.0.0.1:34177"}} +{"t":{"$date":"2025-11-25T03:46:59.254+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"OplogApplier-0","msg":"createCollection","attr":{"namespace":"config.transactions","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"68aef30f-d014-4d85-8569-834e53e1b940"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:46:59.289+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"OplogApplier-0","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"config.transactions","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042419,"i":3}}}} +{"t":{"$date":"2025-11-25T03:46:59.289+00:00"},"s":"I", "c":"STORAGE", "id":20657, "ctx":"OplogApplier-0","msg":"IndexBuildsCoordinator::onStepUp - this node is stepping up to primary"} +{"t":{"$date":"2025-11-25T03:46:59.289+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"OplogApplier-0","msg":"createCollection","attr":{"namespace":"config.system.indexBuilds","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"cec9aa75-1b94-4923-b5b0-50485fd3bb9b"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:46:59.305+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"OplogApplier-0","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"config.system.indexBuilds","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042419,"i":5}}}} +{"t":{"$date":"2025-11-25T03:46:59.306+00:00"},"s":"I", "c":"REPL", "id":21331, "ctx":"OplogApplier-0","msg":"Transition to primary complete; database writes are now permitted"} +{"t":{"$date":"2025-11-25T03:46:59.306+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"monitoring-keys-for-HMAC","msg":"createCollection","attr":{"namespace":"admin.system.keys","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"d2b8a5fd-68bc-4768-95de-0a76f2fa698d"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:46:59.327+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"monitoring-keys-for-HMAC","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"admin.system.keys","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042419,"i":6}}}} +{"t":{"$date":"2025-11-25T03:46:59.329+00:00"},"s":"I", "c":"STORAGE", "id":22310, "ctx":"WTJournalFlusher","msg":"Triggering the first stable checkpoint","attr":{"initialData":{"$timestamp":{"t":1764042419,"i":1}},"prevStable":{"$timestamp":{"t":0,"i":0}},"currStable":{"$timestamp":{"t":1764042419,"i":7}}}} +{"t":{"$date":"2025-11-25T03:46:59.472+00:00"},"s":"I", "c":"CONTROL", "id":23285, "ctx":"main","msg":"Automatically disabling TLS 1.0, to force-enable TLS 1.0 specify --sslDisabledProtocols 'none'"} +{"t":{"$date":"2025-11-25T03:46:59.477+00:00"},"s":"W", "c":"ASIO", "id":22601, "ctx":"main","msg":"No TransportLayer configured during NetworkInterface startup"} +{"t":{"$date":"2025-11-25T03:46:59.477+00:00"},"s":"I", "c":"NETWORK", "id":4648601, "ctx":"main","msg":"Implicit TCP FastOpen unavailable. If TCP FastOpen is required, set tcpFastOpenServer, tcpFastOpenClient, and tcpFastOpenQueueSize."} +{"t":{"$date":"2025-11-25T03:46:59.477+00:00"},"s":"W", "c":"ASIO", "id":22601, "ctx":"main","msg":"No TransportLayer configured during NetworkInterface startup"} +{"t":{"$date":"2025-11-25T03:46:59.478+00:00"},"s":"I", "c":"STORAGE", "id":4615611, "ctx":"initandlisten","msg":"MongoDB starting","attr":{"pid":149392,"port":32865,"dbPath":"/tmp/f54m2zn5.l0x0520d32fc74c4f838929_32865","architecture":"64-bit","host":"DESKTOP-7GHGC2M"}} +{"t":{"$date":"2025-11-25T03:46:59.478+00:00"},"s":"I", "c":"CONTROL", "id":23403, "ctx":"initandlisten","msg":"Build Info","attr":{"buildInfo":{"version":"4.4.4","gitVersion":"8db30a63db1a9d84bdcad0c83369623f708e0397","openSSLVersion":"OpenSSL 1.1.1w 11 Sep 2023","modules":[],"allocator":"tcmalloc","environment":{"distmod":"ubuntu2004","distarch":"x86_64","target_arch":"x86_64"}}}} +{"t":{"$date":"2025-11-25T03:46:59.478+00:00"},"s":"I", "c":"CONTROL", "id":51765, "ctx":"initandlisten","msg":"Operating System","attr":{"os":{"name":"Ubuntu","version":"24.04"}}} +{"t":{"$date":"2025-11-25T03:46:59.478+00:00"},"s":"I", "c":"CONTROL", "id":21951, "ctx":"initandlisten","msg":"Options set by command line","attr":{"options":{"net":{"bindIp":"127.0.0.1","port":32865},"replication":{"replSet":"singleNodeReplSet"},"storage":{"dbPath":"/tmp/f54m2zn5.l0x0520d32fc74c4f838929_32865"}}}} +{"t":{"$date":"2025-11-25T03:46:59.479+00:00"},"s":"I", "c":"STORAGE", "id":22297, "ctx":"initandlisten","msg":"Using the XFS filesystem is strongly recommended with the WiredTiger storage engine. See http://dochub.mongodb.org/core/prodnotes-filesystem","tags":["startupWarnings"]} +{"t":{"$date":"2025-11-25T03:46:59.480+00:00"},"s":"I", "c":"STORAGE", "id":22315, "ctx":"initandlisten","msg":"Opening WiredTiger","attr":{"config":"create,cache_size=7485M,session_max=33000,eviction=(threads_min=4,threads_max=4),config_base=false,statistics=(fast),log=(enabled=true,archive=true,path=journal,compressor=snappy),file_manager=(close_idle_time=100000,close_scan_interval=10,close_handle_minimum=250),statistics_log=(wait=0),verbose=[recovery_progress,checkpoint_progress,compact_progress],"}} +{"t":{"$date":"2025-11-25T03:46:59.946+00:00"},"s":"I", "c":"STORAGE", "id":22430, "ctx":"initandlisten","msg":"WiredTiger message","attr":{"message":"[1764042419:946594][149392:0x710e8d06fcc0], txn-recover: [WT_VERB_RECOVERY | WT_VERB_RECOVERY_PROGRESS] Set global recovery timestamp: (0, 0)"}} +{"t":{"$date":"2025-11-25T03:46:59.946+00:00"},"s":"I", "c":"STORAGE", "id":22430, "ctx":"initandlisten","msg":"WiredTiger message","attr":{"message":"[1764042419:946648][149392:0x710e8d06fcc0], txn-recover: [WT_VERB_RECOVERY | WT_VERB_RECOVERY_PROGRESS] Set global oldest timestamp: (0, 0)"}} +{"t":{"$date":"2025-11-25T03:46:59.959+00:00"},"s":"I", "c":"STORAGE", "id":4795906, "ctx":"initandlisten","msg":"WiredTiger opened","attr":{"durationMillis":479}} +{"t":{"$date":"2025-11-25T03:46:59.959+00:00"},"s":"I", "c":"RECOVERY", "id":23987, "ctx":"initandlisten","msg":"WiredTiger recoveryTimestamp","attr":{"recoveryTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:46:59.982+00:00"},"s":"I", "c":"STORAGE", "id":4366408, "ctx":"initandlisten","msg":"No table logging settings modifications are required for existing WiredTiger tables","attr":{"loggingEnabled":false}} +{"t":{"$date":"2025-11-25T03:46:59.982+00:00"},"s":"I", "c":"STORAGE", "id":22262, "ctx":"initandlisten","msg":"Timestamp monitor starting"} +{"t":{"$date":"2025-11-25T03:46:59.995+00:00"},"s":"W", "c":"CONTROL", "id":22120, "ctx":"initandlisten","msg":"Access control is not enabled for the database. Read and write access to data and configuration is unrestricted","tags":["startupWarnings"]} +{"t":{"$date":"2025-11-25T03:46:59.996+00:00"},"s":"I", "c":"STORAGE", "id":20536, "ctx":"initandlisten","msg":"Flow Control is enabled on this deployment"} +{"t":{"$date":"2025-11-25T03:46:59.997+00:00"},"s":"I", "c":"SHARDING", "id":20997, "ctx":"initandlisten","msg":"Refreshed RWC defaults","attr":{"newDefaults":{}}} +{"t":{"$date":"2025-11-25T03:46:59.997+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.startup_log","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"ad29fb35-58e2-4924-b4fb-17eeb34908cf"}},"options":{"capped":true,"size":10485760}}} +{"t":{"$date":"2025-11-25T03:47:00.021+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.startup_log","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:47:00.022+00:00"},"s":"I", "c":"FTDC", "id":20625, "ctx":"initandlisten","msg":"Initializing full-time diagnostic data capture","attr":{"dataDirectory":"/tmp/f54m2zn5.l0x0520d32fc74c4f838929_32865/diagnostic.data"}} +{"t":{"$date":"2025-11-25T03:47:00.023+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.replset.oplogTruncateAfterPoint","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"6ebbbc40-875b-448f-ac4d-9c7fe238b5eb"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:47:00.044+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.replset.oplogTruncateAfterPoint","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:47:00.044+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.replset.minvalid","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"603415e4-8dc1-4e82-9f62-0e6d48de7352"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:47:00.061+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.replset.minvalid","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:47:00.062+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.replset.election","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"94ff23a8-11b3-423c-af02-2422e71f24fc"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:47:00.084+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.replset.election","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:47:00.085+00:00"},"s":"I", "c":"REPL", "id":21311, "ctx":"initandlisten","msg":"Did not find local initialized voted for document at startup"} +{"t":{"$date":"2025-11-25T03:47:00.085+00:00"},"s":"I", "c":"REPL", "id":21312, "ctx":"initandlisten","msg":"Did not find local Rollback ID document at startup. Creating one"} +{"t":{"$date":"2025-11-25T03:47:00.085+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"initandlisten","msg":"createCollection","attr":{"namespace":"local.system.rollback.id","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"a2f46637-91c1-4f52-8256-a20611926972"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:47:00.101+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"initandlisten","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.system.rollback.id","index":"_id_","commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:47:00.101+00:00"},"s":"I", "c":"REPL", "id":21531, "ctx":"initandlisten","msg":"Initialized the rollback ID","attr":{"rbid":1}} +{"t":{"$date":"2025-11-25T03:47:00.101+00:00"},"s":"I", "c":"REPL", "id":21313, "ctx":"initandlisten","msg":"Did not find local replica set configuration document at startup","attr":{"error":{"code":47,"codeName":"NoMatchingDocument","errmsg":"Did not find replica set configuration document in local.system.replset"}}} +{"t":{"$date":"2025-11-25T03:47:00.102+00:00"},"s":"I", "c":"CONTROL", "id":20714, "ctx":"LogicalSessionCacheRefresh","msg":"Failed to refresh session cache, will try again at the next refresh interval","attr":{"error":"NotYetInitialized: Replication has not yet been configured"}} +{"t":{"$date":"2025-11-25T03:47:00.103+00:00"},"s":"I", "c":"CONTROL", "id":20712, "ctx":"LogicalSessionCacheReap","msg":"Sessions collection is not set up; waiting until next sessions reap interval","attr":{"error":"NamespaceNotFound: config.system.sessions does not exist"}} +{"t":{"$date":"2025-11-25T03:47:00.103+00:00"},"s":"I", "c":"REPL", "id":40440, "ctx":"initandlisten","msg":"Starting the TopologyVersionObserver"} +{"t":{"$date":"2025-11-25T03:47:00.103+00:00"},"s":"I", "c":"REPL", "id":40445, "ctx":"TopologyVersionObserver","msg":"Started TopologyVersionObserver"} +{"t":{"$date":"2025-11-25T03:47:00.103+00:00"},"s":"I", "c":"NETWORK", "id":23015, "ctx":"listener","msg":"Listening on","attr":{"address":"/tmp/mongodb-32865.sock"}} +{"t":{"$date":"2025-11-25T03:47:00.103+00:00"},"s":"I", "c":"NETWORK", "id":23015, "ctx":"listener","msg":"Listening on","attr":{"address":"127.0.0.1"}} +{"t":{"$date":"2025-11-25T03:47:00.103+00:00"},"s":"I", "c":"NETWORK", "id":23016, "ctx":"listener","msg":"Waiting for connections","attr":{"port":32865,"ssl":"off"}} +{"t":{"$date":"2025-11-25T03:47:00.141+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:32952","connectionId":1,"connectionCount":1}} +{"t":{"$date":"2025-11-25T03:47:00.142+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn1","msg":"client metadata","attr":{"remote":"127.0.0.1:32952","client":"conn1","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:47:00.143+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:32956","connectionId":2,"connectionCount":2}} +{"t":{"$date":"2025-11-25T03:47:00.144+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn2","msg":"client metadata","attr":{"remote":"127.0.0.1:32956","client":"conn2","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:47:00.144+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:32958","connectionId":3,"connectionCount":3}} +{"t":{"$date":"2025-11-25T03:47:00.144+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn3","msg":"client metadata","attr":{"remote":"127.0.0.1:32958","client":"conn3","doc":{"driver":{"name":"mongo-csharp-driver","version":"3.5.0"},"os":{"type":"Linux","name":"Ubuntu 24.04.3 LTS","architecture":"x86_64","version":"24.04.3"},"platform":".NET 10.0.0-rc.2.25502.107"}}} +{"t":{"$date":"2025-11-25T03:47:00.145+00:00"},"s":"I", "c":"REPL", "id":21356, "ctx":"conn2","msg":"replSetInitiate admin command received from client"} +{"t":{"$date":"2025-11-25T03:47:00.145+00:00"},"s":"I", "c":"REPL", "id":21357, "ctx":"conn2","msg":"replSetInitiate config object parses ok","attr":{"numMembers":1}} +{"t":{"$date":"2025-11-25T03:47:00.145+00:00"},"s":"I", "c":"REPL", "id":21251, "ctx":"conn2","msg":"Creating replication oplog","attr":{"oplogSizeMB":48087}} +{"t":{"$date":"2025-11-25T03:47:00.146+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn2","msg":"createCollection","attr":{"namespace":"local.oplog.rs","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"20717f8c-54ff-4b74-a452-ece55bdb7dbb"}},"options":{"capped":true,"size":50423249920.0,"autoIndexId":false}}} +{"t":{"$date":"2025-11-25T03:47:00.155+00:00"},"s":"I", "c":"STORAGE", "id":22383, "ctx":"conn2","msg":"The size storer reports that the oplog contains","attr":{"numRecords":0,"dataSize":0}} +{"t":{"$date":"2025-11-25T03:47:00.155+00:00"},"s":"I", "c":"STORAGE", "id":22382, "ctx":"conn2","msg":"WiredTiger record store oplog processing finished","attr":{"durationMillis":0}} +{"t":{"$date":"2025-11-25T03:47:00.200+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn2","msg":"createCollection","attr":{"namespace":"local.system.replset","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"e76a7051-11f0-41d1-9e22-39b73aa284c2"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:47:00.217+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn2","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.system.replset","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042420,"i":1}}}} +{"t":{"$date":"2025-11-25T03:47:00.218+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn2","msg":"createCollection","attr":{"namespace":"admin.system.version","uuidDisposition":"provided","uuid":{"uuid":{"$uuid":"7ca3ad6a-3e97-4f2c-aef9-b17fe9bd6c74"}},"options":{"uuid":{"$uuid":"7ca3ad6a-3e97-4f2c-aef9-b17fe9bd6c74"}}}} +{"t":{"$date":"2025-11-25T03:47:00.235+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn2","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"admin.system.version","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042420,"i":1}}}} +{"t":{"$date":"2025-11-25T03:47:00.235+00:00"},"s":"I", "c":"COMMAND", "id":20459, "ctx":"conn2","msg":"Setting featureCompatibilityVersion","attr":{"newVersion":"4.4"}} +{"t":{"$date":"2025-11-25T03:47:00.235+00:00"},"s":"I", "c":"NETWORK", "id":22991, "ctx":"conn2","msg":"Skip closing connection for connection","attr":{"connectionId":3}} +{"t":{"$date":"2025-11-25T03:47:00.235+00:00"},"s":"I", "c":"NETWORK", "id":22991, "ctx":"conn2","msg":"Skip closing connection for connection","attr":{"connectionId":2}} +{"t":{"$date":"2025-11-25T03:47:00.235+00:00"},"s":"I", "c":"NETWORK", "id":22991, "ctx":"conn2","msg":"Skip closing connection for connection","attr":{"connectionId":1}} +{"t":{"$date":"2025-11-25T03:47:00.235+00:00"},"s":"I", "c":"REPL", "id":21392, "ctx":"conn2","msg":"New replica set config in use","attr":{"config":{"_id":"singleNodeReplSet","version":1,"term":0,"protocolVersion":1,"writeConcernMajorityJournalDefault":true,"members":[{"_id":0,"host":"127.0.0.1:32865","arbiterOnly":false,"buildIndexes":true,"hidden":false,"priority":1.0,"tags":{},"slaveDelay":0,"votes":1}],"settings":{"chainingAllowed":true,"heartbeatIntervalMillis":2000,"heartbeatTimeoutSecs":10,"electionTimeoutMillis":10000,"catchUpTimeoutMillis":-1,"catchUpTakeoverDelayMillis":30000,"getLastErrorModes":{},"getLastErrorDefaults":{"w":1,"wtimeout":0},"replicaSetId":{"$oid":"692526b48d3d1985c4de93e4"}}}}} +{"t":{"$date":"2025-11-25T03:47:00.235+00:00"},"s":"I", "c":"REPL", "id":21393, "ctx":"conn2","msg":"Found self in config","attr":{"hostAndPort":"127.0.0.1:32865"}} +{"t":{"$date":"2025-11-25T03:47:00.235+00:00"},"s":"I", "c":"REPL", "id":21358, "ctx":"conn2","msg":"Replica set state transition","attr":{"newState":"STARTUP2","oldState":"STARTUP"}} +{"t":{"$date":"2025-11-25T03:47:00.235+00:00"},"s":"I", "c":"REPL", "id":21306, "ctx":"conn2","msg":"Starting replication storage threads"} +{"t":{"$date":"2025-11-25T03:47:00.238+00:00"},"s":"I", "c":"REPL", "id":21358, "ctx":"conn2","msg":"Replica set state transition","attr":{"newState":"RECOVERING","oldState":"STARTUP2"}} +{"t":{"$date":"2025-11-25T03:47:00.238+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn2","msg":"createCollection","attr":{"namespace":"local.replset.initialSyncId","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"1703a7f0-89fd-4541-b1f9-034025c93c97"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:47:00.260+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn2","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"local.replset.initialSyncId","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042420,"i":1}}}} +{"t":{"$date":"2025-11-25T03:47:00.260+00:00"},"s":"I", "c":"REPL", "id":21299, "ctx":"conn2","msg":"Starting replication fetcher thread"} +{"t":{"$date":"2025-11-25T03:47:00.260+00:00"},"s":"I", "c":"REPL", "id":21300, "ctx":"conn2","msg":"Starting replication applier thread"} +{"t":{"$date":"2025-11-25T03:47:00.261+00:00"},"s":"I", "c":"REPL", "id":21301, "ctx":"conn2","msg":"Starting replication reporter thread"} +{"t":{"$date":"2025-11-25T03:47:00.261+00:00"},"s":"I", "c":"REPL", "id":21224, "ctx":"OplogApplier-0","msg":"Starting oplog application"} +{"t":{"$date":"2025-11-25T03:47:00.261+00:00"},"s":"I", "c":"COMMAND", "id":51803, "ctx":"conn2","msg":"Slow query","attr":{"type":"command","ns":"local.system.replset","command":{"replSetInitiate":{"_id":"singleNodeReplSet","members":[{"_id":0,"host":"127.0.0.1:32865"}]},"$db":"admin","lsid":{"id":{"$uuid":"5884425c-dce7-4528-b599-e26f26b5dff1"}}},"numYields":0,"reslen":163,"locks":{"ParallelBatchWriterMode":{"acquireCount":{"r":18}},"ReplicationStateTransition":{"acquireCount":{"w":19}},"Global":{"acquireCount":{"r":11,"w":6,"W":2}},"Database":{"acquireCount":{"r":10,"w":4,"W":2}},"Collection":{"acquireCount":{"r":3,"w":5}},"Mutex":{"acquireCount":{"r":17}},"oplog":{"acquireCount":{"w":1}}},"flowControl":{"acquireCount":5,"timeAcquiringMicros":5},"storage":{},"protocol":"op_msg","durationMillis":115}} +{"t":{"$date":"2025-11-25T03:47:00.261+00:00"},"s":"I", "c":"REPL", "id":21358, "ctx":"OplogApplier-0","msg":"Replica set state transition","attr":{"newState":"SECONDARY","oldState":"RECOVERING"}} +{"t":{"$date":"2025-11-25T03:47:00.261+00:00"},"s":"I", "c":"ELECTION", "id":4615652, "ctx":"OplogApplier-0","msg":"Starting an election, since we've seen no PRIMARY in election timeout period","attr":{"electionTimeoutPeriodMillis":10000}} +{"t":{"$date":"2025-11-25T03:47:00.262+00:00"},"s":"I", "c":"ELECTION", "id":21438, "ctx":"OplogApplier-0","msg":"Conducting a dry run election to see if we could be elected","attr":{"currentTerm":0}} +{"t":{"$date":"2025-11-25T03:47:00.262+00:00"},"s":"I", "c":"ELECTION", "id":21444, "ctx":"ReplCoord-0","msg":"Dry election run succeeded, running for election","attr":{"newTerm":1}} +{"t":{"$date":"2025-11-25T03:47:00.264+00:00"},"s":"I", "c":"ELECTION", "id":21450, "ctx":"ReplCoord-0","msg":"Election succeeded, assuming primary role","attr":{"term":1}} +{"t":{"$date":"2025-11-25T03:47:00.264+00:00"},"s":"I", "c":"REPL", "id":21358, "ctx":"ReplCoord-0","msg":"Replica set state transition","attr":{"newState":"PRIMARY","oldState":"SECONDARY"}} +{"t":{"$date":"2025-11-25T03:47:00.264+00:00"},"s":"I", "c":"REPL", "id":21106, "ctx":"ReplCoord-0","msg":"Resetting sync source to empty","attr":{"previousSyncSource":":27017"}} +{"t":{"$date":"2025-11-25T03:47:00.264+00:00"},"s":"I", "c":"REPL", "id":21359, "ctx":"ReplCoord-0","msg":"Entering primary catch-up mode"} +{"t":{"$date":"2025-11-25T03:47:00.264+00:00"},"s":"I", "c":"REPL", "id":21363, "ctx":"ReplCoord-0","msg":"Exited primary catch-up mode"} +{"t":{"$date":"2025-11-25T03:47:00.264+00:00"},"s":"I", "c":"REPL", "id":21107, "ctx":"ReplCoord-0","msg":"Stopping replication producer"} +{"t":{"$date":"2025-11-25T03:47:00.265+00:00"},"s":"I", "c":"REPL", "id":21239, "ctx":"ReplBatcher","msg":"Oplog buffer has been drained","attr":{"term":1}} +{"t":{"$date":"2025-11-25T03:47:00.265+00:00"},"s":"I", "c":"REPL", "id":21343, "ctx":"RstlKillOpThread","msg":"Starting to kill user operations"} +{"t":{"$date":"2025-11-25T03:47:00.265+00:00"},"s":"I", "c":"REPL", "id":21344, "ctx":"RstlKillOpThread","msg":"Stopped killing user operations"} +{"t":{"$date":"2025-11-25T03:47:00.265+00:00"},"s":"I", "c":"REPL", "id":21340, "ctx":"RstlKillOpThread","msg":"State transition ops metrics","attr":{"metrics":{"lastStateTransition":"stepUp","userOpsKilled":0,"userOpsRunning":1}}} +{"t":{"$date":"2025-11-25T03:47:00.265+00:00"},"s":"I", "c":"REPL", "id":4508103, "ctx":"OplogApplier-0","msg":"Increment the config term via reconfig"} +{"t":{"$date":"2025-11-25T03:47:00.265+00:00"},"s":"I", "c":"REPL", "id":21353, "ctx":"OplogApplier-0","msg":"replSetReconfig config object parses ok","attr":{"numMembers":1}} +{"t":{"$date":"2025-11-25T03:47:00.265+00:00"},"s":"I", "c":"REPL", "id":51814, "ctx":"OplogApplier-0","msg":"Persisting new config to disk"} +{"t":{"$date":"2025-11-25T03:47:00.267+00:00"},"s":"I", "c":"REPL", "id":21392, "ctx":"OplogApplier-0","msg":"New replica set config in use","attr":{"config":{"_id":"singleNodeReplSet","version":1,"term":1,"protocolVersion":1,"writeConcernMajorityJournalDefault":true,"members":[{"_id":0,"host":"127.0.0.1:32865","arbiterOnly":false,"buildIndexes":true,"hidden":false,"priority":1.0,"tags":{},"slaveDelay":0,"votes":1}],"settings":{"chainingAllowed":true,"heartbeatIntervalMillis":2000,"heartbeatTimeoutSecs":10,"electionTimeoutMillis":10000,"catchUpTimeoutMillis":-1,"catchUpTakeoverDelayMillis":30000,"getLastErrorModes":{},"getLastErrorDefaults":{"w":1,"wtimeout":0},"replicaSetId":{"$oid":"692526b48d3d1985c4de93e4"}}}}} +{"t":{"$date":"2025-11-25T03:47:00.267+00:00"},"s":"I", "c":"REPL", "id":21393, "ctx":"OplogApplier-0","msg":"Found self in config","attr":{"hostAndPort":"127.0.0.1:32865"}} +{"t":{"$date":"2025-11-25T03:47:00.267+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"OplogApplier-0","msg":"createCollection","attr":{"namespace":"config.transactions","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"90401e83-c42b-4ce2-a5ad-49a4c81c816b"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:47:00.285+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"OplogApplier-0","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"config.transactions","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042420,"i":3}}}} +{"t":{"$date":"2025-11-25T03:47:00.286+00:00"},"s":"I", "c":"STORAGE", "id":20657, "ctx":"OplogApplier-0","msg":"IndexBuildsCoordinator::onStepUp - this node is stepping up to primary"} +{"t":{"$date":"2025-11-25T03:47:00.286+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"OplogApplier-0","msg":"createCollection","attr":{"namespace":"config.system.indexBuilds","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"e0a50b99-1d2c-4142-ae31-a78f724d5f96"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:47:00.303+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"OplogApplier-0","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"config.system.indexBuilds","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042420,"i":5}}}} +{"t":{"$date":"2025-11-25T03:47:00.303+00:00"},"s":"I", "c":"REPL", "id":21331, "ctx":"OplogApplier-0","msg":"Transition to primary complete; database writes are now permitted"} +{"t":{"$date":"2025-11-25T03:47:00.304+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"monitoring-keys-for-HMAC","msg":"createCollection","attr":{"namespace":"admin.system.keys","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"72c6ddd0-0840-4549-9145-58849a210856"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:47:00.322+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"monitoring-keys-for-HMAC","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"admin.system.keys","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042420,"i":6}}}} +{"t":{"$date":"2025-11-25T03:47:00.323+00:00"},"s":"I", "c":"STORAGE", "id":22310, "ctx":"WTJournalFlusher","msg":"Triggering the first stable checkpoint","attr":{"initialData":{"$timestamp":{"t":1764042420,"i":1}},"prevStable":{"$timestamp":{"t":0,"i":0}},"currStable":{"$timestamp":{"t":1764042420,"i":7}}}} +{"t":{"$date":"2025-11-25T03:47:00.580+00:00"},"s":"I", "c":"COMMAND", "id":518070, "ctx":"conn2","msg":"CMD: drop","attr":{"namespace":"concelier-tests-7e8484ac4f7a4ae08dd2c21f49c49720.jobs"}} +{"t":{"$date":"2025-11-25T03:47:00.762+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn2","msg":"createCollection","attr":{"namespace":"concelier-tests-7e8484ac4f7a4ae08dd2c21f49c49720.jobs","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"a39564e0-a39c-47ce-b7f1-eb7e36a2686a"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:47:00.795+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn2","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier-tests-7e8484ac4f7a4ae08dd2c21f49c49720.jobs","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042420,"i":9}}}} +{"t":{"$date":"2025-11-25T03:47:00.998+00:00"},"s":"I", "c":"COMMAND", "id":518070, "ctx":"conn2","msg":"CMD: drop","attr":{"namespace":"concelier-tests-7e8484ac4f7a4ae08dd2c21f49c49720.jobs"}} +{"t":{"$date":"2025-11-25T03:47:00.998+00:00"},"s":"I", "c":"STORAGE", "id":23879, "ctx":"conn2","msg":"About to abort all index builders","attr":{"namespace":"concelier-tests-7e8484ac4f7a4ae08dd2c21f49c49720.jobs","uuid":{"uuid":{"$uuid":"a39564e0-a39c-47ce-b7f1-eb7e36a2686a"}},"reason":"Collection concelier-tests-7e8484ac4f7a4ae08dd2c21f49c49720.jobs(a39564e0-a39c-47ce-b7f1-eb7e36a2686a) is being dropped"}} +{"t":{"$date":"2025-11-25T03:47:00.998+00:00"},"s":"I", "c":"STORAGE", "id":20314, "ctx":"conn2","msg":"dropCollection: storage engine will take ownership of drop-pending collection","attr":{"namespace":"concelier-tests-7e8484ac4f7a4ae08dd2c21f49c49720.jobs","uuid":{"uuid":{"$uuid":"a39564e0-a39c-47ce-b7f1-eb7e36a2686a"}},"dropOpTime":{"ts":{"$timestamp":{"t":0,"i":0}},"t":-1},"commitTimestamp":{"$timestamp":{"t":0,"i":0}}}} +{"t":{"$date":"2025-11-25T03:47:00.998+00:00"},"s":"I", "c":"STORAGE", "id":20318, "ctx":"conn2","msg":"Finishing collection drop","attr":{"namespace":"concelier-tests-7e8484ac4f7a4ae08dd2c21f49c49720.jobs","uuid":{"uuid":{"$uuid":"a39564e0-a39c-47ce-b7f1-eb7e36a2686a"}}}} +{"t":{"$date":"2025-11-25T03:47:00.998+00:00"},"s":"I", "c":"STORAGE", "id":22206, "ctx":"conn2","msg":"Deferring table drop for index","attr":{"index":"_id_","namespace":"concelier-tests-7e8484ac4f7a4ae08dd2c21f49c49720.jobs","uuid":{"uuid":{"$uuid":"a39564e0-a39c-47ce-b7f1-eb7e36a2686a"}},"ident":"index-24-1032418473798039975","commitTimestamp":{"$timestamp":{"t":1764042420,"i":15}}}} +{"t":{"$date":"2025-11-25T03:47:00.998+00:00"},"s":"I", "c":"STORAGE", "id":22214, "ctx":"conn2","msg":"Deferring table drop for collection","attr":{"namespace":"concelier-tests-7e8484ac4f7a4ae08dd2c21f49c49720.jobs","ident":"collection-23-1032418473798039975","commitTimestamp":{"$timestamp":{"t":1764042420,"i":15}}}} +{"t":{"$date":"2025-11-25T03:47:01.013+00:00"},"s":"I", "c":"COMMAND", "id":518070, "ctx":"conn2","msg":"CMD: drop","attr":{"namespace":"concelier-tests-7e8484ac4f7a4ae08dd2c21f49c49720.jobs"}} +{"t":{"$date":"2025-11-25T03:47:01.018+00:00"},"s":"I", "c":"STORAGE", "id":20320, "ctx":"conn2","msg":"createCollection","attr":{"namespace":"concelier-tests-7e8484ac4f7a4ae08dd2c21f49c49720.jobs","uuidDisposition":"generated","uuid":{"uuid":{"$uuid":"169c4fe9-6a2c-4f20-92cb-ed7517b7d6a2"}},"options":{}}} +{"t":{"$date":"2025-11-25T03:47:01.037+00:00"},"s":"I", "c":"INDEX", "id":20345, "ctx":"conn2","msg":"Index build: done building","attr":{"buildUUID":null,"namespace":"concelier-tests-7e8484ac4f7a4ae08dd2c21f49c49720.jobs","index":"_id_","commitTimestamp":{"$timestamp":{"t":1764042421,"i":1}}}} +[xUnit.net 00:00:03.64] Finished: StellaOps.Concelier.Storage.Mongo.Tests + + + + + Data collector 'Blame' message: All tests finished running, Sequence file will not be generated. + + + + \ No newline at end of file diff --git a/ops/devops/artifacts/ci-110/20251125T034529Z/trx/concelier-storage-orch.trx b/ops/devops/artifacts/ci-110/20251125T034529Z/trx/concelier-storage-orch.trx new file mode 100644 index 000000000..034f7a351 --- /dev/null +++ b/ops/devops/artifacts/ci-110/20251125T034529Z/trx/concelier-storage-orch.trx @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0-rc.2.25502.107) +[xUnit.net 00:00:01.08] Discovering: StellaOps.Concelier.Storage.Mongo.Tests +[xUnit.net 00:00:01.15] Discovered: StellaOps.Concelier.Storage.Mongo.Tests +[xUnit.net 00:00:01.16] Starting: StellaOps.Concelier.Storage.Mongo.Tests +[xUnit.net 00:00:01.18] Finished: StellaOps.Concelier.Storage.Mongo.Tests + + + + + No test matches the given testcase filter `FullyQualifiedName~Orchestrator` in /mnt/e/dev/git.stella-ops.org/src/Concelier/__Tests/StellaOps.Concelier.Storage.Mongo.Tests/bin/Debug/net10.0/StellaOps.Concelier.Storage.Mongo.Tests.dll + + + Data collector 'Blame' message: All tests finished running, Sequence file will not be generated. + + + + \ No newline at end of file diff --git a/ops/devops/artifacts/ci-110/20251125T040900Z/trx/concelier-web-orch.trx b/ops/devops/artifacts/ci-110/20251125T040900Z/trx/concelier-web-orch.trx new file mode 100644 index 000000000..2e8bf3434 --- /dev/null +++ b/ops/devops/artifacts/ci-110/20251125T040900Z/trx/concelier-web-orch.trx @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0-rc.2.25502.107) +[xUnit.net 00:00:00.25] Discovering: StellaOps.Concelier.WebService.Tests +[xUnit.net 00:00:00.31] Discovered: StellaOps.Concelier.WebService.Tests +[xUnit.net 00:00:00.31] Starting: StellaOps.Concelier.WebService.Tests +[xUnit.net 00:00:00.33] Finished: StellaOps.Concelier.WebService.Tests + + + + + No test matches the given testcase filter `FullyQualifiedName~Orchestrator` in /mnt/e/dev/git.stella-ops.org/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/bin/Debug/net10.0/StellaOps.Concelier.WebService.Tests.dll + + + Data collector 'Blame' message: All tests finished running, Sequence file will not be generated. + + + + \ No newline at end of file diff --git a/ops/devops/ci-110-runner/README.md b/ops/devops/ci-110-runner/README.md new file mode 100644 index 000000000..89688a1c9 --- /dev/null +++ b/ops/devops/ci-110-runner/README.md @@ -0,0 +1,32 @@ +CI runner for **DEVOPS-CI-110-001** (Concelier + Excititor smoke) +================================================================== + +Scope +----- +- Warm NuGet cache from `local-nugets`, `.nuget/packages`, and (optionally) NuGet.org. +- Ensure OpenSSL 1.1 is present (installs `libssl1.1` when available via `apt-get`). +- Run lightweight slices: + - Concelier WebService: `HealthAndReadyEndpointsRespond` + - Excititor WebService: `AirgapImportEndpointTests*` +- Emit TRX + logs to `ops/devops/artifacts/ci-110//`. + +Usage +----- +```bash +export NUGET_SOURCES="/mnt/e/dev/git.stella-ops.org/local-nugets;/mnt/e/dev/git.stella-ops.org/.nuget/packages;https://api.nuget.org/v3/index.json" +export TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ) # optional, for reproducible paths +bash ops/devops/ci-110-runner/run-ci-110.sh +``` + +Artifacts +--------- +- TRX: `ops/devops/artifacts/ci-110//trx/` + - `concelier-health.trx` (1 test) + - `excititor-airgapimport.fqn.trx` (2 tests) +- Logs + restores under `ops/devops/artifacts/ci-110//logs/`. + +Notes +----- +- The runner uses `--no-build` on test slices; prior restores are included in the script. +- If OpenSSL 1.1 is not present and `apt-get` cannot install `libssl1.1`, set `LD_LIBRARY_PATH` to a pre-installed OpenSSL 1.1 location before running. +- Extend the runner by adding more `run_test_slice` calls for additional suites; keep filters tight to avoid long hangs on constrained CI. diff --git a/ops/devops/ci-110-runner/run-ci-110.sh b/ops/devops/ci-110-runner/run-ci-110.sh new file mode 100644 index 000000000..68cbc054d --- /dev/null +++ b/ops/devops/ci-110-runner/run-ci-110.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +# CI helper for DEVOPS-CI-110-001 +# - Warms NuGet cache from local sources +# - Ensures OpenSSL 1.1 compatibility if available +# - Runs targeted Concelier and Excititor test slices with TRX output +# - Writes artefacts under ops/devops/artifacts/ci-110// + +set -euo pipefail + +ROOT="${ROOT:-$(git rev-parse --show-toplevel)}" +TIMESTAMP="${TIMESTAMP:-$(date -u +%Y%m%dT%H%M%SZ)}" +ARTIFACT_ROOT="${ARTIFACT_ROOT:-"$ROOT/ops/devops/artifacts/ci-110/$TIMESTAMP"}" +LOG_DIR="$ARTIFACT_ROOT/logs" +TRX_DIR="$ARTIFACT_ROOT/trx" + +NUGET_SOURCES_DEFAULT="$ROOT/local-nugets;$ROOT/.nuget/packages;https://api.nuget.org/v3/index.json" +NUGET_SOURCES="${NUGET_SOURCES:-$NUGET_SOURCES_DEFAULT}" + +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 +export DOTNET_CLI_TELEMETRY_OPTOUT=1 +export DOTNET_RESTORE_DISABLE_PARALLEL="${DOTNET_RESTORE_DISABLE_PARALLEL:-1}" + +mkdir -p "$LOG_DIR" "$TRX_DIR" + +log() { + printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" +} + +ensure_openssl11() { + if openssl version 2>/dev/null | grep -q "1\\.1."; then + log "OpenSSL 1.1 detected: $(openssl version)" + return + fi + + if command -v apt-get >/dev/null 2>&1; then + log "OpenSSL 1.1 not found; attempting install via apt-get (libssl1.1)" + sudo DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null || true + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libssl1.1 || true + if openssl version 2>/dev/null | grep -q "1\\.1."; then + log "OpenSSL 1.1 available after install: $(openssl version)" + return + fi + fi + + log "OpenSSL 1.1 still unavailable. Provide it via LD_LIBRARY_PATH if required." +} + +restore_solution() { + local sln="$1" + log "Restore $sln" + dotnet restore "$sln" --source "$NUGET_SOURCES" --verbosity minimal | tee "$LOG_DIR/restore-$(basename "$sln").log" +} + +run_test_slice() { + local proj="$1" + local filter="$2" + local name="$3" + log "Test $name ($proj, filter='$filter')" + dotnet test "$proj" \ + -c Debug \ + --no-build \ + ${filter:+--filter "$filter"} \ + --logger "trx;LogFileName=${name}.trx" \ + --results-directory "$TRX_DIR" \ + --blame-hang \ + --blame-hang-timeout 8m \ + --blame-hang-dump-type none \ + | tee "$LOG_DIR/test-${name}.log" +} + +main() { + log "Starting CI-110 runner; artefacts -> $ARTIFACT_ROOT" + ensure_openssl11 + + restore_solution "$ROOT/concelier-webservice.slnf" + restore_solution "$ROOT/src/Excititor/StellaOps.Excititor.sln" + + # Concelier: lightweight health slice to validate runner + Mongo wiring + run_test_slice "$ROOT/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj" \ + "HealthAndReadyEndpointsRespond" \ + "concelier-health" + + # Excititor: airgap import surface (chunk-path) smoke + run_test_slice "$ROOT/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj" \ + "FullyQualifiedName~AirgapImportEndpointTests" \ + "excititor-airgapimport" + + log "Done. TRX files in $TRX_DIR" +} + +main "$@" diff --git a/ops/devops/concelier-ci-runner/README.md b/ops/devops/concelier-ci-runner/README.md new file mode 100644 index 000000000..a7cd47013 --- /dev/null +++ b/ops/devops/concelier-ci-runner/README.md @@ -0,0 +1,26 @@ +# Concelier CI Runner Harness (DEVOPS-CONCELIER-CI-24-101) + +Purpose: provide a deterministic, offline-friendly harness that restores, builds, and runs Concelier WebService + Storage Mongo tests with warmed NuGet cache and TRX/binlog artefacts for downstream sprints (Concelier II/III). + +Usage +- From repo root run: `ops/devops/concelier-ci-runner/run-concelier-ci.sh` +- Outputs land in `ops/devops/artifacts/concelier-ci//`: + - `build.binlog` (solution build) + - `tests/webservice.trx`, `tests/storage.trx` (VSTest results) + - per-project `.dmp`/logs if failures occur + - `summary.json` (paths + hashes) + +Environment +- Defaults: `DOTNET_CLI_TELEMETRY_OPTOUT=1`, `DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1`, `NUGET_PACKAGES=$REPO/.nuget/packages`. +- Uses local feed `local-nugets/` first, then NuGet.org (can be overridden via `NUGET_SOURCES`). +- No external services required; Mongo2Go provides ephemeral Mongo for tests. + +What it does +1) Warm NuGet cache from `local-nugets/` into `$NUGET_PACKAGES` for offline/air-gap parity. +2) `dotnet restore` + `dotnet build` on `concelier-webservice.slnf` with `/bl`. +3) Run WebService and Storage.Mongo test projects with TRX output and without rebuild (`--no-build`). +4) Emit a concise `summary.json` listing artefacts and SHA256s for reproducibility. + +Notes +- Keep test filters narrow if you need faster runs; edit `TEST_FILTER` env var (default empty = run all tests). +- Artefacts are timestamped UTC to keep ordering deterministic in pipelines; consumers should sort by path. diff --git a/ops/devops/concelier-ci-runner/run-concelier-ci.sh b/ops/devops/concelier-ci-runner/run-concelier-ci.sh new file mode 100644 index 000000000..806e932e1 --- /dev/null +++ b/ops/devops/concelier-ci-runner/run-concelier-ci.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Concelier CI runner harness (DEVOPS-CONCELIER-CI-24-101) +# Produces warmed-cache restore, build binlog, and TRX outputs for WebService + Storage Mongo tests. + +repo_root="$(cd "$(dirname "$0")/../../.." && pwd)" +ts="$(date -u +%Y%m%dT%H%M%SZ)" +out_dir="$repo_root/ops/devops/artifacts/concelier-ci/$ts" +logs_dir="$out_dir/tests" +mkdir -p "$logs_dir" + +# Deterministic env +export DOTNET_CLI_TELEMETRY_OPTOUT=${DOTNET_CLI_TELEMETRY_OPTOUT:-1} +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=${DOTNET_SKIP_FIRST_TIME_EXPERIENCE:-1} +export NUGET_PACKAGES=${NUGET_PACKAGES:-$repo_root/.nuget/packages} +export NUGET_SOURCES=${NUGET_SOURCES:-"$repo_root/local-nugets;$repo_root/.nuget/packages"} +export TEST_FILTER=${TEST_FILTER:-""} +export DOTNET_RESTORE_DISABLE_PARALLEL=${DOTNET_RESTORE_DISABLE_PARALLEL:-1} + +# Warm NuGet cache from local feed for offline/airgap parity +mkdir -p "$NUGET_PACKAGES" +rsync -a "$repo_root/local-nugets/" "$NUGET_PACKAGES/" >/dev/null 2>&1 || true + +# Restore with deterministic sources +restore_sources=() +IFS=';' read -ra SRC_ARR <<< "$NUGET_SOURCES" +for s in "${SRC_ARR[@]}"; do + [[ -n "$s" ]] && restore_sources+=(--source "$s") +done + +dotnet restore "$repo_root/concelier-webservice.slnf" --ignore-failed-sources "${restore_sources[@]}" + +# Build with binlog +build_binlog="$out_dir/build.binlog" +dotnet build "$repo_root/concelier-webservice.slnf" -c Debug /p:ContinuousIntegrationBuild=true /bl:"$build_binlog" + +common_test_args=( -c Debug --no-build --results-directory "$logs_dir" ) +if [[ -n "$TEST_FILTER" ]]; then + common_test_args+=( --filter "$TEST_FILTER" ) +fi + +# WebService tests +web_trx="webservice.trx" +dotnet test "$repo_root/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj" \ + "${common_test_args[@]}" \ + --logger "trx;LogFileName=$web_trx" + +# Storage Mongo tests +storage_trx="storage.trx" +dotnet test "$repo_root/src/Concelier/__Tests/StellaOps.Concelier.Storage.Mongo.Tests/StellaOps.Concelier.Storage.Mongo.Tests.csproj" \ + "${common_test_args[@]}" \ + --logger "trx;LogFileName=$storage_trx" + +# Summarize artefacts (relative paths to repo root) +summary="$out_dir/summary.json" +{ + printf '{\n' + printf ' "timestamp_utc": "%s",\n' "$ts" + printf ' "build_binlog": "%s",\n' "${build_binlog#${repo_root}/}" + printf ' "tests": [\n' + printf ' {"project": "WebService", "trx": "%s"},\n' "${logs_dir#${repo_root}/}/$web_trx" + printf ' {"project": "Storage.Mongo", "trx": "%s"}\n' "${logs_dir#${repo_root}/}/$storage_trx" + printf ' ],\n' + printf ' "nuget_packages": "%s",\n' "${NUGET_PACKAGES#${repo_root}/}" + printf ' "sources": [\n' + for i in "${!SRC_ARR[@]}"; do + sep=","; [[ $i -eq $((${#SRC_ARR[@]}-1)) ]] && sep="" + printf ' "%s"%s\n' "${SRC_ARR[$i]}" "$sep" + done + printf ' ]\n' + printf '}\n' +} > "$summary" + +echo "Artifacts written to ${out_dir#${repo_root}/}" diff --git a/ops/devops/observability/incident-mode.md b/ops/devops/observability/incident-mode.md new file mode 100644 index 000000000..0e60f7408 --- /dev/null +++ b/ops/devops/observability/incident-mode.md @@ -0,0 +1,49 @@ +# Incident Mode Automation (DEVOPS-OBS-55-001) + +## What it does +- Auto-enables an *incident* feature flag when SLO burn rate crosses a threshold. +- Writes deterministic retention overrides (hours) for downstream storage/ingest. +- Auto-clears after a cooldown once burn is back under the reset threshold. +- Offline-friendly: no external calls; pure file outputs under `out/incident-mode/`. + +## Inputs +- Burn rate multiple (fast-burn): required. +- Thresholds/cooldown/retention configurable via CLI flags or env vars. +- Optional note for audit context. + +## Outputs +- `flag.json` — enabled/disabled + burn rate and note. +- `retention.json` — retention override hours + applied time. +- `last_burn.txt`, `cooldown.txt` — trace for automation/testing. + +## Usage +```bash +# Activate if burn >= 2.5, otherwise decay cooldown; clear after 15 mins <0.4 +scripts/observability/incident-mode.sh \ + --burn-rate 3.2 \ + --threshold 2.5 \ + --reset-threshold 0.4 \ + --cooldown-mins 15 \ + --retention-hours 48 \ + --note "api error burst" + +# Later (burn back to normal): +scripts/observability/incident-mode.sh --burn-rate 0.2 --reset-threshold 0.4 --cooldown-mins 15 +``` +Outputs land in `out/incident-mode/` by default (override with `--state-dir`). + +## Integration hooks +- Prometheus rule should page on SLOBurnRateFast (already in `alerts-slo.yaml`). +- A small runner (cron/workflow) can feed burn rate into this script from PromQL + (`scalar(slo:burn_rate:fast)`), then distribute `flag.json` via configmap/secret. +- Downstream services can read `retention.json` to temporarily raise retention + windows during incident mode. + +## Determinism +- Timestamps are UTC ISO-8601; no network dependencies. +- State is contained under the chosen `state-dir` for reproducible runs. + +## Clearing / reset +- Cooldown counter increments only when burn stays below reset threshold. +- Once cooldown minutes are met, `flag.json` flips `enabled=false` and the script + leaves prior retention files untouched (downstream can prune separately). diff --git a/ops/devops/orchestrator/README.md b/ops/devops/orchestrator/README.md new file mode 100644 index 000000000..d89480cf5 --- /dev/null +++ b/ops/devops/orchestrator/README.md @@ -0,0 +1,36 @@ +# Orchestrator Infra Bootstrap (DEVOPS-ORCH-32-001) + +## Components +- Postgres 16 (state/config) +- Mongo 7 (job ledger history) +- NATS 2.10 JetStream (queue/bus) + +Compose file: `ops/devops/orchestrator/docker-compose.orchestrator.yml` + +## Quick start (offline-friendly) +```bash +# bring up infra +COMPOSE_FILE=ops/devops/orchestrator/docker-compose.orchestrator.yml docker compose up -d + +# smoke check and emit connection strings +scripts/orchestrator/smoke.sh +cat out/orchestrator-smoke/readiness.txt +``` + +Connection strings +- Postgres: `postgres://orch:orchpass@localhost:55432/orchestrator` +- Mongo: `mongodb://localhost:57017` +- NATS: `nats://localhost:4222` + +## Observability +- Alerts: `ops/devops/orchestrator/alerts.yaml` +- Grafana dashboard: `ops/devops/orchestrator/grafana/orchestrator-overview.json` + - Metrics expected: `job_queue_depth`, `job_failures_total`, `lease_extensions_total`, `job_latency_seconds_bucket`. + +## CI hook (suggested) +Add a workflow step (or local cron) to run `scripts/orchestrator/smoke.sh` with `SKIP_UP=1` against existing infra and publish the `readiness.txt` artifact for traceability. + +## Notes +- Uses fixed ports for determinism; adjust via COMPOSE overrides if needed. +- Data volumes: `orch_pg_data`, `orch_mongo_data` (docker volumes). +- No external downloads beyond base images; pin images to specific tags above. diff --git a/ops/devops/orchestrator/alerts.yaml b/ops/devops/orchestrator/alerts.yaml new file mode 100644 index 000000000..660698fcf --- /dev/null +++ b/ops/devops/orchestrator/alerts.yaml @@ -0,0 +1,30 @@ +groups: + - name: orchestrator-core + rules: + - alert: OrchestratorQueueDepthHigh + expr: job_queue_depth > 500 + for: 10m + labels: + severity: warning + service: orchestrator + annotations: + summary: "Queue depth high" + description: "job_queue_depth exceeded 500 for 10m" + - alert: OrchestratorFailuresHigh + expr: rate(job_failures_total[5m]) > 5 + for: 5m + labels: + severity: critical + service: orchestrator + annotations: + summary: "Job failures elevated" + description: "Failure rate above 5/min in last 5m" + - alert: OrchestratorLeaseStall + expr: rate(lease_extensions_total[5m]) == 0 and job_queue_depth > 0 + for: 5m + labels: + severity: critical + service: orchestrator + annotations: + summary: "Leases stalled" + description: "No lease renewals while queue has items" diff --git a/ops/devops/orchestrator/docker-compose.orchestrator.yml b/ops/devops/orchestrator/docker-compose.orchestrator.yml new file mode 100644 index 000000000..0eff14ee0 --- /dev/null +++ b/ops/devops/orchestrator/docker-compose.orchestrator.yml @@ -0,0 +1,49 @@ +version: "3.9" +services: + orchestrator-postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: orch + POSTGRES_PASSWORD: orchpass + POSTGRES_DB: orchestrator + volumes: + - orch_pg_data:/var/lib/postgresql/data + ports: + - "55432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U orch"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + orchestrator-mongo: + image: mongo:7 + command: ["mongod", "--quiet", "--storageEngine=wiredTiger"] + ports: + - "57017:27017" + volumes: + - orch_mongo_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + orchestrator-nats: + image: nats:2.10-alpine + ports: + - "5422:4222" + - "5822:8222" + command: ["-js", "-m", "8222"] + healthcheck: + test: ["CMD", "nats", "--server", "localhost:4222", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + orch_pg_data: + orch_mongo_data: diff --git a/ops/devops/orchestrator/grafana/orchestrator-overview.json b/ops/devops/orchestrator/grafana/orchestrator-overview.json new file mode 100644 index 000000000..455d0546e --- /dev/null +++ b/ops/devops/orchestrator/grafana/orchestrator-overview.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 39, + "title": "Orchestrator Overview", + "panels": [ + { + "type": "stat", + "title": "Queue Depth", + "datasource": "Prometheus", + "fieldConfig": {"defaults": {"unit": "none"}}, + "targets": [{"expr": "sum(job_queue_depth)"}] + }, + { + "type": "timeseries", + "title": "Queue Depth by Job Type", + "datasource": "Prometheus", + "targets": [{"expr": "job_queue_depth"}], + "fieldConfig": {"defaults": {"unit": "none"}} + }, + { + "type": "timeseries", + "title": "Failures per minute", + "datasource": "Prometheus", + "targets": [{"expr": "rate(job_failures_total[5m])"}], + "fieldConfig": {"defaults": {"unit": "short"}} + }, + { + "type": "timeseries", + "title": "Leases per second", + "datasource": "Prometheus", + "targets": [{"expr": "rate(lease_extensions_total[5m])"}], + "fieldConfig": {"defaults": {"unit": "ops"}} + }, + { + "type": "timeseries", + "title": "Job latency p95", + "datasource": "Prometheus", + "targets": [{"expr": "histogram_quantile(0.95, sum(rate(job_latency_seconds_bucket[5m])) by (le))"}], + "fieldConfig": {"defaults": {"unit": "s"}} + } + ], + "time": {"from": "now-6h", "to": "now"} +} diff --git a/scripts/mirror/ci-sign.sh b/scripts/mirror/ci-sign.sh index e0e33bae5..4d38c937e 100644 --- a/scripts/mirror/ci-sign.sh +++ b/scripts/mirror/ci-sign.sh @@ -24,3 +24,25 @@ openssl pkey -in "$KEYFILE" -pubout -out "$KEYDIR/ci-ed25519.pub" >/dev/null 2>& STAGE=${STAGE:-$ROOT/out/mirror/thin/stage-v1} CREATED=${CREATED:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} SIGN_KEY="$KEYFILE" STAGE="$STAGE" CREATED="$CREATED" "$ROOT/src/Mirror/StellaOps.Mirror.Creator/make-thin-v1.sh" + +# Emit milestone summary with hashes for downstream consumers +MANIFEST_PATH="$ROOT/out/mirror/thin/mirror-thin-v1.manifest.json" +TAR_PATH="$ROOT/out/mirror/thin/mirror-thin-v1.tar.gz" +DSSE_PATH="$ROOT/out/mirror/thin/mirror-thin-v1.manifest.dsse.json" +SUMMARY_PATH="$ROOT/out/mirror/thin/milestone.json" + +sha256() { + sha256sum "$1" | awk '{print $1}' +} + +cat > "$SUMMARY_PATH" < [--threshold 2.0] [--reset-threshold 0.5] \ + [--state-dir out/incident-mode] [--retention-hours 24] \ + [--cooldown-mins 30] [--note "text"] + +Environment overrides: + INCIDENT_STATE_DIR default: out/incident-mode + INCIDENT_THRESHOLD default: 2.0 (fast burn multiple) + INCIDENT_RESET_TH default: 0.5 (burn multiple to exit) + INCIDENT_COOLDOWN default: 30 (minutes below reset threshold) + INCIDENT_RETENTION_H default: 24 (hours) + +Outputs (in state dir): + flag.json feature flag payload (enabled/disabled + metadata) + retention.json retention override (hours, applied_at) + last_burn.txt last burn rate observed + cooldown.txt consecutive minutes below reset threshold + +Examples: + incident-mode.sh --burn-rate 3.1 --note "fast burn" # enter incident mode + incident-mode.sh --burn-rate 0.2 # progress cooldown / exit +USAGE +} + +if [[ $# -eq 0 ]]; then usage; exit 1; fi + +BURN_RATE="" +NOTE="" +STATE_DIR=${INCIDENT_STATE_DIR:-out/incident-mode} +THRESHOLD=${INCIDENT_THRESHOLD:-2.0} +RESET_TH=${INCIDENT_RESET_TH:-0.5} +COOLDOWN_MINS=${INCIDENT_COOLDOWN:-30} +RETENTION_H=${INCIDENT_RETENTION_H:-24} + +while [[ $# -gt 0 ]]; do + case "$1" in + --burn-rate) BURN_RATE="$2"; shift 2;; + --threshold) THRESHOLD="$2"; shift 2;; + --reset-threshold) RESET_TH="$2"; shift 2;; + --state-dir) STATE_DIR="$2"; shift 2;; + --retention-hours) RETENTION_H="$2"; shift 2;; + --cooldown-mins) COOLDOWN_MINS="$2"; shift 2;; + --note) NOTE="$2"; shift 2;; + -h|--help) usage; exit 0;; + *) echo "Unknown arg: $1" >&2; usage; exit 1;; + esac +done + +if [[ -z "$BURN_RATE" ]]; then echo "--burn-rate is required" >&2; exit 1; fi +mkdir -p "$STATE_DIR" +FLAG_FILE="$STATE_DIR/flag.json" +RET_FILE="$STATE_DIR/retention.json" +LAST_FILE="$STATE_DIR/last_burn.txt" +COOLDOWN_FILE="$STATE_DIR/cooldown.txt" + +jq_escape() { python - <= $THRESHOLD" | bc -l) )); then + enter_incident=true + cooldown_current=0 +elif (( $(echo "$burn_float <= $RESET_TH" | bc -l) )); then + cooldown_current=$((cooldown_current + 1)) + if (( cooldown_current >= COOLDOWN_MINS )); then + exit_incident=true + fi +else + cooldown_current=0 +fi + +echo "$burn_float" > "$LAST_FILE" +echo "$cooldown_current" > "$COOLDOWN_FILE" + +write_flag() { + local enabled="$1" + cat > "$FLAG_FILE" < "$RET_FILE" <&2 +elif $exit_incident; then + write_flag false + echo "incident-mode: cleared after cooldown (burn_rate=$burn_float)" >&2 +else + # no change; preserve prior flag if exists + if [[ ! -f "$FLAG_FILE" ]]; then + write_flag false + fi + echo "incident-mode: steady (burn_rate=$burn_float, cooldown=$cooldown_current/$COOLDOWN_MINS)" >&2 +fi + +exit 0 diff --git a/scripts/orchestrator/smoke.sh b/scripts/orchestrator/smoke.sh new file mode 100644 index 000000000..b4fbad4b9 --- /dev/null +++ b/scripts/orchestrator/smoke.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +COMPOSE_FILE="${COMPOSE_FILE:-$ROOT/devops/orchestrator/docker-compose.orchestrator.yml}" +STATE_DIR="${STATE_DIR:-$ROOT/out/orchestrator-smoke}" + +usage() { + cat <<'USAGE' +Orchestrator infra smoke test +- Starts postgres + mongo + nats via docker-compose +- Verifies basic connectivity and prints ready endpoints + +Env/flags: + COMPOSE_FILE path to compose file (default: ops/devops/orchestrator/docker-compose.orchestrator.yml) + STATE_DIR path for logs (default: out/orchestrator-smoke) + SKIP_UP set to 1 to skip compose up (assumes already running) +USAGE +} + +if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then usage; exit 0; fi +mkdir -p "$STATE_DIR" + +if [[ "${SKIP_UP:-0}" != "1" ]]; then + docker compose -f "$COMPOSE_FILE" up -d +fi + +log() { echo "[smoke] $*"; } + +log "waiting for postgres..." +for i in {1..12}; do + if docker compose -f "$COMPOSE_FILE" exec -T orchestrator-postgres pg_isready -U orch >/dev/null 2>&1; then break; fi + sleep 5; +done + +log "waiting for mongo..." +for i in {1..12}; do + if docker compose -f "$COMPOSE_FILE" exec -T orchestrator-mongo mongosh --quiet --eval "db.adminCommand('ping')" >/dev/null 2>&1; then break; fi + sleep 5; +done + +log "waiting for nats..." +for i in {1..12}; do + if docker compose -f "$COMPOSE_FILE" exec -T orchestrator-nats nats --server localhost:4222 ping >/dev/null 2>&1; then break; fi + sleep 5; +done + +log "postgres DSN: postgres://orch:orchpass@localhost:55432/orchestrator" +log "mongo uri: mongodb://localhost:57017" +log "nats uri: nats://localhost:4222" + +# Write readiness summary +cat > "$STATE_DIR/readiness.txt" < +/// Minimal, deterministic writer for Concelier air-gap bundles. Intended as the +/// first increment for CONCELIER-AIRGAP-56-001; produces a stable NDJSON file +/// from link-not-merge cache items without external dependencies. +/// +public sealed class AirgapBundleBuilder +{ + private const string BundleFileName = "concelier-airgap.ndjson"; + private const string ManifestFileName = "bundle.manifest.json"; + private const string EntryTraceFileName = "bundle.entry-trace.json"; + + public async Task BuildAsync( + IEnumerable cacheItems, + string outputDirectory, + DateTimeOffset? createdUtc = null, + CancellationToken cancellationToken = default) + { + if (cacheItems is null) throw new ArgumentNullException(nameof(cacheItems)); + if (string.IsNullOrWhiteSpace(outputDirectory)) throw new ArgumentException("Output directory is required", nameof(outputDirectory)); + + Directory.CreateDirectory(outputDirectory); + + var ordered = cacheItems + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item.Trim()) + .OrderBy(item => item, StringComparer.Ordinal) + .ToArray(); + + var bundlePath = Path.Combine(outputDirectory, BundleFileName); + await WriteNdjsonAsync(bundlePath, ordered, cancellationToken).ConfigureAwait(false); + + var bundleSha = ComputeSha256FromPath(bundlePath); + + var entries = ordered + .Select((value, index) => new AirgapBundleEntry + { + LineNumber = index + 1, + Sha256 = ComputeSha256(value) + }) + .ToArray(); + + var manifestCreated = createdUtc ?? DateTimeOffset.UnixEpoch; + var manifest = new AirgapBundleManifest + { + Items = ordered, + Entries = entries, + BundleSha256 = bundleSha, + CreatedUtc = manifestCreated, + Count = ordered.Length + }; + + var manifestPath = Path.Combine(outputDirectory, ManifestFileName); + await WriteManifest(manifestPath, manifest, cancellationToken).ConfigureAwait(false); + + var entryTracePath = Path.Combine(outputDirectory, EntryTraceFileName); + await WriteEntryTrace(entryTracePath, entries, cancellationToken).ConfigureAwait(false); + + return new AirgapBundleResult(bundlePath, manifestPath, entryTracePath, bundleSha, ordered.Length); + } + + private static async Task WriteNdjsonAsync(string bundlePath, IReadOnlyList orderedItems, CancellationToken cancellationToken) + { + await using var stream = new FileStream(bundlePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true); + await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + + foreach (var item in orderedItems) + { + cancellationToken.ThrowIfCancellationRequested(); + await writer.WriteLineAsync(item).ConfigureAwait(false); + } + } + + private static async Task WriteManifest(string manifestPath, AirgapBundleManifest manifest, CancellationToken cancellationToken) + { + await using var stream = new FileStream(manifestPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true); + await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + var payload = System.Text.Json.JsonSerializer.Serialize(manifest, new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + WriteIndented = false + }); + cancellationToken.ThrowIfCancellationRequested(); + await writer.WriteAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false); + } + + private static async Task WriteEntryTrace(string entryTracePath, IReadOnlyList entries, CancellationToken cancellationToken) + { + await using var stream = new FileStream(entryTracePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true); + await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + var payload = System.Text.Json.JsonSerializer.Serialize(entries, new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + WriteIndented = false + }); + cancellationToken.ThrowIfCancellationRequested(); + await writer.WriteAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false); + } + + private static string ComputeSha256FromPath(string path) + { + using var sha = SHA256.Create(); + using var stream = File.OpenRead(path); + var hashBytes = sha.ComputeHash(stream); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + + private static string ComputeSha256(string content) + { + using var sha = SHA256.Create(); + var bytes = Encoding.UTF8.GetBytes(content); + var hashBytes = sha.ComputeHash(bytes); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } +} + +public sealed record AirgapBundleResult(string BundlePath, string ManifestPath, string EntryTracePath, string Sha256, int ItemCount); + +public sealed record AirgapBundleManifest +{ + [JsonPropertyName("items")] + public string[] Items { get; init; } = Array.Empty(); + + [JsonPropertyName("entries")] + public AirgapBundleEntry[] Entries { get; init; } = Array.Empty(); + + [JsonPropertyName("bundleSha256")] + public string BundleSha256 { get; init; } = string.Empty; + + [JsonPropertyName("createdUtc")] + public DateTimeOffset CreatedUtc { get; init; } + + [JsonPropertyName("count")] + public int Count { get; init; } +} + +public sealed record AirgapBundleEntry +{ + [JsonPropertyName("lineNumber")] + public int LineNumber { get; init; } + + [JsonPropertyName("sha256")] + public string Sha256 { get; init; } = string.Empty; +} diff --git a/src/Concelier/StellaOps.Concelier.WebService/AirGap/AirgapBundleValidator.cs b/src/Concelier/StellaOps.Concelier.WebService/AirGap/AirgapBundleValidator.cs new file mode 100644 index 000000000..f9ade85b2 --- /dev/null +++ b/src/Concelier/StellaOps.Concelier.WebService/AirGap/AirgapBundleValidator.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Concelier.WebService.AirGap; + +public sealed class AirgapBundleValidator +{ + public async Task ValidateAsync( + string bundlePath, + string manifestPath, + string? entryTracePath = null, + CancellationToken cancellationToken = default) + { + var errors = new List(); + + if (!File.Exists(bundlePath)) + { + errors.Add($"Bundle file missing: {bundlePath}"); + return new AirgapBundleValidationResult(false, errors); + } + + if (!File.Exists(manifestPath)) + { + errors.Add($"Manifest file missing: {manifestPath}"); + return new AirgapBundleValidationResult(false, errors); + } + + AirgapBundleManifest? manifest = null; + try + { + var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false); + manifest = JsonSerializer.Deserialize(manifestJson); + } + catch (Exception ex) + { + errors.Add($"Manifest parse error: {ex.Message}"); + } + + var lines = await File.ReadAllLinesAsync(bundlePath, cancellationToken).ConfigureAwait(false); + var bundleSha = ComputeSha256FromFile(bundlePath); + + if (manifest is null) + { + return new AirgapBundleValidationResult(false, errors); + } + + if (!string.Equals(bundleSha, manifest.BundleSha256, StringComparison.OrdinalIgnoreCase)) + { + errors.Add("Bundle hash mismatch"); + } + + if (manifest.Count != lines.Length) + { + errors.Add($"Manifest count {manifest.Count} != bundle lines {lines.Length}"); + } + + var ordered = lines.ToArray(); + if (!manifest.Items.SequenceEqual(ordered)) + { + errors.Add("Manifest items differ from bundle payload"); + } + + // If entry trace exists (either provided or embedded in manifest), verify per-line hashes. + AirgapBundleEntry[] entries = manifest.Entries ?? Array.Empty(); + if (!string.IsNullOrWhiteSpace(entryTracePath) && File.Exists(entryTracePath)) + { + try + { + var traceJson = await File.ReadAllTextAsync(entryTracePath!, cancellationToken).ConfigureAwait(false); + var traceEntries = JsonSerializer.Deserialize(traceJson); + if (traceEntries is not null) + { + entries = traceEntries; + } + } + catch (Exception ex) + { + errors.Add($"Entry trace parse error: {ex.Message}"); + } + } + + if (entries.Length > 0) + { + if (entries.Length != lines.Length) + { + errors.Add($"Entry trace length {entries.Length} != bundle lines {lines.Length}"); + } + else + { + for (var i = 0; i < lines.Length; i++) + { + var expectedHash = ComputeSha256(lines[i]); + if (!string.Equals(entries[i].Sha256, expectedHash, StringComparison.OrdinalIgnoreCase)) + { + errors.Add($"Entry trace hash mismatch at line {i + 1}"); + break; + } + } + } + } + + return new AirgapBundleValidationResult(errors.Count == 0, errors); + } + + private static string ComputeSha256(string content) + { + using var sha = SHA256.Create(); + var bytes = Encoding.UTF8.GetBytes(content); + var hashBytes = sha.ComputeHash(bytes); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + + private static string ComputeSha256FromFile(string path) + { + using var sha = SHA256.Create(); + using var stream = File.OpenRead(path); + var hashBytes = sha.ComputeHash(stream); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } +} + +public sealed record AirgapBundleValidationResult(bool IsValid, IReadOnlyList Errors); diff --git a/src/Concelier/StellaOps.Concelier.WebService/Contracts/AttestationContracts.cs b/src/Concelier/StellaOps.Concelier.WebService/Contracts/AttestationContracts.cs new file mode 100644 index 000000000..5323a6b5c --- /dev/null +++ b/src/Concelier/StellaOps.Concelier.WebService/Contracts/AttestationContracts.cs @@ -0,0 +1,22 @@ +namespace StellaOps.Concelier.WebService; + +public sealed record VerifyAttestationRequest( + string? BundlePath, + string? ManifestPath, + string? TransparencyPath, + string? PipelineVersion); + +public readonly record struct EvidencePathResolutionResult( + bool IsValid, + string? BundlePath, + string? ManifestPath, + string? TransparencyPath, + string? Error, + string? ErrorDetails) +{ + public static EvidencePathResolutionResult Valid(string bundlePath, string manifestPath, string? transparencyPath) => + new(true, bundlePath, manifestPath, transparencyPath, null, null); + + public static EvidencePathResolutionResult Invalid(string error, string? details = null) => + new(false, null, null, null, error, details); +} diff --git a/src/Concelier/StellaOps.Concelier.WebService/Contracts/EvidenceBatchContracts.cs b/src/Concelier/StellaOps.Concelier.WebService/Contracts/EvidenceBatchContracts.cs new file mode 100644 index 000000000..ad2e08d82 --- /dev/null +++ b/src/Concelier/StellaOps.Concelier.WebService/Contracts/EvidenceBatchContracts.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using StellaOps.Concelier.Core.Linksets; +using StellaOps.Concelier.Core.Observations; +using StellaOps.Concelier.Models.Observations; + +namespace StellaOps.Concelier.WebService.Contracts; + +public sealed record EvidenceBatchRequest( + IReadOnlyCollection Items, + int? ObservationLimit, + int? LinksetLimit); + +public sealed record EvidenceBatchItemRequest( + string? ComponentId, + IReadOnlyCollection? Purls, + IReadOnlyCollection? Aliases); + +public sealed record EvidenceBatchItemResponse( + string ComponentId, + IReadOnlyCollection Observations, + IReadOnlyCollection Linksets, + bool HasMore, + DateTimeOffset RetrievedAt); + +public sealed record EvidenceBatchResponse( + IReadOnlyCollection Items); diff --git a/src/Concelier/StellaOps.Concelier.WebService/Contracts/EvidenceSnapshotContracts.cs b/src/Concelier/StellaOps.Concelier.WebService/Contracts/EvidenceSnapshotContracts.cs new file mode 100644 index 000000000..e5b077e57 --- /dev/null +++ b/src/Concelier/StellaOps.Concelier.WebService/Contracts/EvidenceSnapshotContracts.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace StellaOps.Concelier.WebService; + +public sealed record EvidenceSnapshotResponse( + [property: JsonPropertyName("advisoryKey")] string AdvisoryKey, + [property: JsonPropertyName("tenant")] string Tenant, + [property: JsonPropertyName("manifestPath")] string ManifestPath, + [property: JsonPropertyName("manifestHash")] string ManifestHash, + [property: JsonPropertyName("transparencyPath")] string? TransparencyPath, + [property: JsonPropertyName("pipelineVersion")] string? PipelineVersion); + +public sealed record AttestationStatusResponse( + [property: JsonPropertyName("advisoryKey")] string AdvisoryKey, + [property: JsonPropertyName("tenant")] string Tenant, + [property: JsonPropertyName("claims")] AttestationClaims Claims, + [property: JsonPropertyName("bundlePath")] string BundlePath, + [property: JsonPropertyName("manifestPath")] string ManifestPath, + [property: JsonPropertyName("transparencyPath")] string? TransparencyPath, + [property: JsonPropertyName("pipelineVersion")] string? PipelineVersion); diff --git a/src/Concelier/StellaOps.Concelier.WebService/Contracts/IncidentContracts.cs b/src/Concelier/StellaOps.Concelier.WebService/Contracts/IncidentContracts.cs new file mode 100644 index 000000000..a55da388b --- /dev/null +++ b/src/Concelier/StellaOps.Concelier.WebService/Contracts/IncidentContracts.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace StellaOps.Concelier.WebService; + +public sealed record IncidentUpsertRequest( + [property: JsonPropertyName("reason")] string? Reason, + [property: JsonPropertyName("cooldownMinutes")] int? CooldownMinutes); + +public sealed record IncidentStatusResponse( + [property: JsonPropertyName("advisoryKey")] string AdvisoryKey, + [property: JsonPropertyName("tenant")] string Tenant, + [property: JsonPropertyName("reason")] string Reason, + [property: JsonPropertyName("activatedAt")] string ActivatedAt, + [property: JsonPropertyName("cooldownUntil")] string CooldownUntil, + [property: JsonPropertyName("pipelineVersion")] string? PipelineVersion, + [property: JsonPropertyName("active")] bool Active); diff --git a/src/Concelier/StellaOps.Concelier.WebService/Program.cs b/src/Concelier/StellaOps.Concelier.WebService/Program.cs index ac3d50552..d5265189d 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Program.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Program.cs @@ -1,37 +1,42 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Globalization; -using System.Linq; -using System.Security.Claims; -using System.Text; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.IdentityModel.Tokens; -using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -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 Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; -using MongoDB.Bson; -using MongoDB.Driver; -using StellaOps.Concelier.Core.Events; -using StellaOps.Concelier.Core.Jobs; -using StellaOps.Concelier.Core.Observations; -using StellaOps.Concelier.Core.Linksets; -using StellaOps.Concelier.Models; -using StellaOps.Concelier.WebService.Diagnostics; -using Serilog; -using StellaOps.Concelier.Merge; -using StellaOps.Concelier.Merge.Services; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +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 Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using MongoDB.Bson; +using MongoDB.Driver; +using StellaOps.Concelier.Core.Events; +using StellaOps.Concelier.Core.Jobs; +using StellaOps.Concelier.Core.Observations; +using StellaOps.Concelier.Core.Linksets; +using StellaOps.Concelier.Models; +using StellaOps.Concelier.WebService.Diagnostics; +using Serilog; +using StellaOps.Concelier.Merge; +using StellaOps.Concelier.Merge.Services; using StellaOps.Concelier.WebService.Extensions; +using StellaOps.Concelier.WebService.Services; using StellaOps.Concelier.WebService.Jobs; using StellaOps.Concelier.WebService.Options; using StellaOps.Concelier.WebService.Filters; @@ -59,348 +64,409 @@ using StellaOps.Concelier.Core.Attestation; using StellaOps.Concelier.Storage.Mongo.Orchestrator; using System.Security.Cryptography; using System.Diagnostics.Metrics; -using StellaOps.Concelier.WebService.Contracts; -using StellaOps.Concelier.WebService.Telemetry; +using StellaOps.Concelier.Models.Observations; + +namespace StellaOps.Concelier.WebService +{ + +public partial class Program +{ + private const string JobsPolicyName = "Concelier.Jobs.Trigger"; + private const string ObservationsPolicyName = "Concelier.Observations.Read"; + private const string AdvisoryIngestPolicyName = "Concelier.Advisories.Ingest"; + private const string AdvisoryReadPolicyName = "Concelier.Advisories.Read"; + private const string AocVerifyPolicyName = "Concelier.Aoc.Verify"; + public const string TenantHeaderName = "X-Stella-Tenant"; + + public static async Task Main(string[] args) + { var builder = WebApplication.CreateBuilder(args); - -const string JobsPolicyName = "Concelier.Jobs.Trigger"; -const string ObservationsPolicyName = "Concelier.Observations.Read"; -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")); - }; -}); - -var contentRootPath = builder.Environment.ContentRootPath; - -var concelierOptions = builder.Configuration.BindOptions(postConfigure: (opts, _) => -{ - ConcelierOptionsPostConfigure.Apply(opts, contentRootPath); - ConcelierOptionsValidator.Validate(opts); -}); -builder.Services.AddOptions() - .Bind(builder.Configuration) - .PostConfigure(options => - { - ConcelierOptionsPostConfigure.Apply(options, contentRootPath); - ConcelierOptionsValidator.Validate(options); - }) - .ValidateOnStart(); - -builder.Services.AddStellaOpsCrypto(concelierOptions.Crypto); - -builder.ConfigureConcelierTelemetry(concelierOptions); - -builder.Services.TryAddSingleton(_ => TimeProvider.System); -builder.Services.AddMemoryCache(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -builder.Services.AddMongoStorage(storageOptions => -{ - storageOptions.ConnectionString = concelierOptions.Storage.Dsn; - storageOptions.DatabaseName = concelierOptions.Storage.Database; - storageOptions.CommandTimeout = TimeSpan.FromSeconds(concelierOptions.Storage.CommandTimeoutSeconds); -}); -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection("advisoryObservationEvents")) - .PostConfigure(options => - { - options.Subject ??= "concelier.advisory.observation.updated.v1"; - options.Stream ??= "CONCELIER_OBS"; - options.Transport = string.IsNullOrWhiteSpace(options.Transport) ? "mongo" : options.Transport; - }) - .ValidateOnStart(); + +// For test/CI runs, allow injecting a minimal config before options bind. +#pragma warning disable ASP0013 // permitted here for test-only override path +builder.Host.ConfigureAppConfiguration((context, cfg) => +{ + if (context.HostingEnvironment.IsEnvironment("Testing")) + { + cfg.AddInMemoryCollection(new Dictionary + { + {"Concelier:Storage:Dsn", Environment.GetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN") ?? "mongodb://localhost:27017/test-health"}, + {"Concelier:Storage:Driver", "mongo"}, + {"Concelier:Storage:CommandTimeoutSeconds", "30"}, + {"Concelier:Telemetry:Enabled", "false"} + }); + } +}); +#pragma warning restore ASP0013 + +var JsonOptions = CreateJsonOptions(); + +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; + +// For Testing we allow pre-bound options injected via DI to override BindOptions. +ConcelierOptions concelierOptions; + +if (builder.Environment.IsEnvironment("Testing")) +{ + // Allow a fully pre-bound options instance to be supplied by the test host. + #pragma warning disable ASP0000 // test-only: create provider to fetch pre-bound options + using var tempProvider = builder.Services.BuildServiceProvider(); + #pragma warning restore ASP0000 + concelierOptions = tempProvider.GetService>()?.Value ?? new ConcelierOptions + { + Storage = new ConcelierOptions.StorageOptions + { + Dsn = Environment.GetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN") ?? "mongodb://localhost:27017/test-health", + Driver = "mongo", + CommandTimeoutSeconds = 30 + }, + Telemetry = new ConcelierOptions.TelemetryOptions + { + Enabled = false + } + }; + + ConcelierOptionsPostConfigure.Apply(concelierOptions, contentRootPath); + // Skip validation in Testing to allow factory-provided wiring. +} +else +{ + concelierOptions = builder.Configuration.BindOptions(postConfigure: (opts, _) => + { + var testDsn = Environment.GetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN"); + if (string.IsNullOrWhiteSpace(opts.Storage.Dsn) && !string.IsNullOrWhiteSpace(testDsn)) + { + opts.Storage.Dsn = testDsn; + } + + ConcelierOptionsPostConfigure.Apply(opts, contentRootPath); + var skipValidation = string.Equals(Environment.GetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION"), "1", StringComparison.OrdinalIgnoreCase); + if (!skipValidation) + { + ConcelierOptionsValidator.Validate(opts); + } + }); +} + +// Register the chosen options instance so downstream services/tests share it. +builder.Services.AddSingleton(concelierOptions); +builder.Services.AddSingleton>(_ => Microsoft.Extensions.Options.Options.Create(concelierOptions)); + +builder.Services.AddStellaOpsCrypto(concelierOptions.Crypto); + +builder.ConfigureConcelierTelemetry(concelierOptions); + +builder.Services.TryAddSingleton(_ => TimeProvider.System); +builder.Services.AddMemoryCache(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddMongoStorage(storageOptions => +{ + storageOptions.ConnectionString = concelierOptions.Storage.Dsn; + storageOptions.DatabaseName = concelierOptions.Storage.Database; + storageOptions.CommandTimeout = TimeSpan.FromSeconds(concelierOptions.Storage.CommandTimeoutSeconds); +}); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("advisoryObservationEvents")) + .PostConfigure(options => + { + options.Subject ??= "concelier.advisory.observation.updated.v1"; + options.Stream ??= "CONCELIER_OBS"; + options.Transport = string.IsNullOrWhiteSpace(options.Transport) ? "mongo" : options.Transport; + }) + .ValidateOnStart(); builder.Services.AddConcelierAocGuards(); builder.Services.AddConcelierLinksetMappers(); builder.Services.TryAddSingleton(); builder.Services.AddSingleton(); builder.Services.AddAdvisoryRawServices(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); var features = concelierOptions.Features ?? new ConcelierOptions.FeaturesOptions(); - -if (!features.NoMergeEnabled) -{ -#pragma warning disable CS0618, CONCELIER0001, CONCELIER0002 // Legacy merge service is intentionally supported behind a feature toggle. - builder.Services.AddMergeModule(builder.Configuration); -#pragma warning restore CS0618, CONCELIER0001, CONCELIER0002 -} - -builder.Services.AddJobScheduler(); -builder.Services.AddBuiltInConcelierJobs(); -builder.Services.PostConfigure(options => -{ - if (features.NoMergeEnabled) - { - options.Definitions.Remove("merge:reconcile"); - return; - } - - if (features.MergeJobAllowlist is { Count: > 0 }) - { - var allowMergeJob = features.MergeJobAllowlist.Any(value => - string.Equals(value, "merge:reconcile", StringComparison.OrdinalIgnoreCase)); - - if (!allowMergeJob) - { - options.Definitions.Remove("merge:reconcile"); - } - } -}); -builder.Services.AddSingleton(); - -builder.Services.AddSingleton(sp => new ServiceStatus(sp.GetRequiredService())); -builder.Services.AddAocGuard(); - -var authorityConfigured = concelierOptions.Authority is { Enabled: true }; - - -if (authorityConfigured) -{ - builder.Services.AddStellaOpsAuthClient(clientOptions => - { - clientOptions.Authority = concelierOptions.Authority.Issuer; - clientOptions.ClientId = concelierOptions.Authority.ClientId ?? string.Empty; - clientOptions.ClientSecret = concelierOptions.Authority.ClientSecret; - clientOptions.HttpTimeout = TimeSpan.FromSeconds(concelierOptions.Authority.BackchannelTimeoutSeconds); - - clientOptions.DefaultScopes.Clear(); - foreach (var scope in concelierOptions.Authority.ClientScopes) - { - clientOptions.DefaultScopes.Add(scope); - } - - var resilience = concelierOptions.Authority.Resilience ?? new ConcelierOptions.AuthorityOptions.ResilienceOptions(); - if (resilience.EnableRetries.HasValue) - { - clientOptions.EnableRetries = resilience.EnableRetries.Value; - } - - if (resilience.RetryDelays is { Count: > 0 }) - { - clientOptions.RetryDelays.Clear(); - foreach (var delay in resilience.RetryDelays) - { - clientOptions.RetryDelays.Add(delay); - } - } - - if (resilience.AllowOfflineCacheFallback.HasValue) - { - clientOptions.AllowOfflineCacheFallback = resilience.AllowOfflineCacheFallback.Value; - } - - if (resilience.OfflineCacheTolerance.HasValue) - { - clientOptions.OfflineCacheTolerance = resilience.OfflineCacheTolerance.Value; - } - }); - - if (string.IsNullOrWhiteSpace(concelierOptions.Authority.TestSigningSecret)) - { - builder.Services.AddStellaOpsResourceServerAuthentication( - builder.Configuration, - configurationSection: null, - configure: resourceOptions => - { - resourceOptions.Authority = concelierOptions.Authority.Issuer; - resourceOptions.RequireHttpsMetadata = concelierOptions.Authority.RequireHttpsMetadata; - resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(concelierOptions.Authority.BackchannelTimeoutSeconds); - resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(concelierOptions.Authority.TokenClockSkewSeconds); - - if (!string.IsNullOrWhiteSpace(concelierOptions.Authority.MetadataAddress)) - { - resourceOptions.MetadataAddress = concelierOptions.Authority.MetadataAddress; - } - - foreach (var audience in concelierOptions.Authority.Audiences) - { - resourceOptions.Audiences.Add(audience); - } - - foreach (var scope in concelierOptions.Authority.RequiredScopes) - { - resourceOptions.RequiredScopes.Add(scope); - } - - foreach (var network in concelierOptions.Authority.BypassNetworks) - { - resourceOptions.BypassNetworks.Add(network); - } - }); - } - else - { - builder.Services - .AddAuthentication(StellaOpsAuthenticationDefaults.AuthenticationScheme) - .AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme, options => - { - options.RequireHttpsMetadata = concelierOptions.Authority.RequireHttpsMetadata; - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(concelierOptions.Authority.TestSigningSecret!)), - ValidateIssuer = true, - ValidIssuer = concelierOptions.Authority.Issuer, - ValidateAudience = concelierOptions.Authority.Audiences.Count > 0, - ValidAudiences = concelierOptions.Authority.Audiences, - ValidateLifetime = true, - ClockSkew = TimeSpan.FromSeconds(concelierOptions.Authority.TokenClockSkewSeconds), - NameClaimType = StellaOpsClaimTypes.Subject, - RoleClaimType = ClaimTypes.Role - }; - options.Events = new JwtBearerEvents - { - OnMessageReceived = context => - { - var logger = context.HttpContext.RequestServices.GetRequiredService>(); - string? token = null; - if (context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authorizationValues)) - { - var authorization = authorizationValues.ToString(); - if (!string.IsNullOrWhiteSpace(authorization) && - authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) && - authorization.Length > 7) - { - token = authorization.Substring("Bearer ".Length).Trim(); - } - } - - if (string.IsNullOrEmpty(token)) - { - token = context.Token; - } - - if (!string.IsNullOrWhiteSpace(token)) - { - var parts = token.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length > 0) - { - token = parts[^1]; - } - - token = token.Trim().Trim('"'); - } - - if (string.IsNullOrWhiteSpace(token)) - { - logger.LogWarning("JWT token missing from request to {Path}", context.HttpContext.Request.Path); - return Task.CompletedTask; - } - - context.Token = token; - - return Task.CompletedTask; - } - }; - }); - } -} - -builder.Services.AddAuthorization(options => -{ - options.AddStellaOpsScopePolicy(JobsPolicyName, concelierOptions.Authority.RequiredScopes.ToArray()); - options.AddStellaOpsScopePolicy(ObservationsPolicyName, StellaOpsScopes.VulnView); - options.AddStellaOpsScopePolicy(AdvisoryIngestPolicyName, StellaOpsScopes.AdvisoryIngest); - options.AddStellaOpsScopePolicy(AdvisoryReadPolicyName, StellaOpsScopes.AdvisoryRead); - options.AddStellaOpsScopePolicy(AocVerifyPolicyName, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.AocVerify); - }); - -var pluginHostOptions = BuildPluginOptions(concelierOptions, builder.Environment.ContentRootPath); -builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions); - -builder.Services.AddEndpointsApiExplorer(); - -var app = builder.Build(); - -app.Logger.LogWarning("Authority enabled: {AuthorityEnabled}, test signing secret configured: {HasTestSecret}", authorityConfigured, !string.IsNullOrWhiteSpace(concelierOptions.Authority?.TestSigningSecret)); - -if (features.NoMergeEnabled) -{ - app.Logger.LogWarning("Legacy merge module disabled via concelier:features:noMergeEnabled; Link-Not-Merge mode active."); -} - -var resolvedConcelierOptions = app.Services.GetRequiredService>().Value; -var resolvedAuthority = resolvedConcelierOptions.Authority ?? new ConcelierOptions.AuthorityOptions(); -authorityConfigured = resolvedAuthority.Enabled; -var enforceAuthority = resolvedAuthority.Enabled && !resolvedAuthority.AllowAnonymousFallback; -var requiredTenants = (resolvedAuthority.RequiredTenants ?? Array.Empty()) - .Select(static tenant => tenant?.Trim().ToLowerInvariant()) - .Where(static tenant => !string.IsNullOrWhiteSpace(tenant)) - .Distinct(StringComparer.Ordinal) - .ToImmutableHashSet(StringComparer.Ordinal); -var enforceTenantAllowlist = !requiredTenants.IsEmpty; - -if (resolvedAuthority.Enabled && resolvedAuthority.AllowAnonymousFallback) -{ - app.Logger.LogWarning( - "Authority authentication is configured but anonymous fallback remains enabled. Set authority.allowAnonymousFallback to false before 2025-12-31 to complete the rollout."); -} - -if (authorityConfigured) -{ - app.UseAuthentication(); - app.UseAuthorization(); -} - -app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority); - + +if (!features.NoMergeEnabled) +{ +#pragma warning disable CS0618, CONCELIER0001, CONCELIER0002 // Legacy merge service is intentionally supported behind a feature toggle. + builder.Services.AddMergeModule(builder.Configuration); +#pragma warning restore CS0618, CONCELIER0001, CONCELIER0002 +} + +builder.Services.AddJobScheduler(); +builder.Services.AddBuiltInConcelierJobs(); +builder.Services.PostConfigure(options => +{ + if (features.NoMergeEnabled) + { + options.Definitions.Remove("merge:reconcile"); + return; + } + + if (features.MergeJobAllowlist is { Count: > 0 }) + { + var allowMergeJob = features.MergeJobAllowlist.Any(value => + string.Equals(value, "merge:reconcile", StringComparison.OrdinalIgnoreCase)); + + if (!allowMergeJob) + { + options.Definitions.Remove("merge:reconcile"); + } + } +}); +builder.Services.AddSingleton(); + +builder.Services.AddSingleton(sp => new ServiceStatus(sp.GetRequiredService())); +builder.Services.AddAocGuard(); + +var authorityConfigured = concelierOptions.Authority is { Enabled: true }; + + +if (authorityConfigured) +{ + builder.Services.AddStellaOpsAuthClient(clientOptions => + { + clientOptions.Authority = concelierOptions.Authority.Issuer; + clientOptions.ClientId = concelierOptions.Authority.ClientId ?? string.Empty; + clientOptions.ClientSecret = concelierOptions.Authority.ClientSecret; + clientOptions.HttpTimeout = TimeSpan.FromSeconds(concelierOptions.Authority.BackchannelTimeoutSeconds); + + clientOptions.DefaultScopes.Clear(); + foreach (var scope in concelierOptions.Authority.ClientScopes) + { + clientOptions.DefaultScopes.Add(scope); + } + + var resilience = concelierOptions.Authority.Resilience ?? new ConcelierOptions.AuthorityOptions.ResilienceOptions(); + if (resilience.EnableRetries.HasValue) + { + clientOptions.EnableRetries = resilience.EnableRetries.Value; + } + + if (resilience.RetryDelays is { Count: > 0 }) + { + clientOptions.RetryDelays.Clear(); + foreach (var delay in resilience.RetryDelays) + { + clientOptions.RetryDelays.Add(delay); + } + } + + if (resilience.AllowOfflineCacheFallback.HasValue) + { + clientOptions.AllowOfflineCacheFallback = resilience.AllowOfflineCacheFallback.Value; + } + + if (resilience.OfflineCacheTolerance.HasValue) + { + clientOptions.OfflineCacheTolerance = resilience.OfflineCacheTolerance.Value; + } + }); + + if (string.IsNullOrWhiteSpace(concelierOptions.Authority.TestSigningSecret)) + { + builder.Services.AddStellaOpsResourceServerAuthentication( + builder.Configuration, + configurationSection: null, + configure: resourceOptions => + { + resourceOptions.Authority = concelierOptions.Authority.Issuer; + resourceOptions.RequireHttpsMetadata = concelierOptions.Authority.RequireHttpsMetadata; + resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(concelierOptions.Authority.BackchannelTimeoutSeconds); + resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(concelierOptions.Authority.TokenClockSkewSeconds); + + if (!string.IsNullOrWhiteSpace(concelierOptions.Authority.MetadataAddress)) + { + resourceOptions.MetadataAddress = concelierOptions.Authority.MetadataAddress; + } + + foreach (var audience in concelierOptions.Authority.Audiences) + { + resourceOptions.Audiences.Add(audience); + } + + foreach (var scope in concelierOptions.Authority.RequiredScopes) + { + resourceOptions.RequiredScopes.Add(scope); + } + + foreach (var network in concelierOptions.Authority.BypassNetworks) + { + resourceOptions.BypassNetworks.Add(network); + } + }); + } + else + { + builder.Services + .AddAuthentication(StellaOpsAuthenticationDefaults.AuthenticationScheme) + .AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme, options => + { + options.RequireHttpsMetadata = concelierOptions.Authority.RequireHttpsMetadata; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(concelierOptions.Authority.TestSigningSecret!)), + ValidateIssuer = true, + ValidIssuer = concelierOptions.Authority.Issuer, + ValidateAudience = concelierOptions.Authority.Audiences.Count > 0, + ValidAudiences = concelierOptions.Authority.Audiences, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(concelierOptions.Authority.TokenClockSkewSeconds), + NameClaimType = StellaOpsClaimTypes.Subject, + RoleClaimType = ClaimTypes.Role + }; + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + string? token = null; + if (context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authorizationValues)) + { + var authorization = authorizationValues.ToString(); + if (!string.IsNullOrWhiteSpace(authorization) && + authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) && + authorization.Length > 7) + { + token = authorization.Substring("Bearer ".Length).Trim(); + } + } + + if (string.IsNullOrEmpty(token)) + { + token = context.Token; + } + + if (!string.IsNullOrWhiteSpace(token)) + { + var parts = token.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 0) + { + token = parts[^1]; + } + + token = token.Trim().Trim('"'); + } + + if (string.IsNullOrWhiteSpace(token)) + { + logger.LogWarning("JWT token missing from request to {Path}", context.HttpContext.Request.Path); + return Task.CompletedTask; + } + + context.Token = token; + + return Task.CompletedTask; + } + }; + }); + } +} + +builder.Services.AddAuthorization(options => +{ + options.AddStellaOpsScopePolicy(JobsPolicyName, concelierOptions.Authority.RequiredScopes.ToArray()); + options.AddStellaOpsScopePolicy(ObservationsPolicyName, StellaOpsScopes.VulnView); + options.AddStellaOpsScopePolicy(AdvisoryIngestPolicyName, StellaOpsScopes.AdvisoryIngest); + options.AddStellaOpsScopePolicy(AdvisoryReadPolicyName, StellaOpsScopes.AdvisoryRead); + options.AddStellaOpsScopePolicy(AocVerifyPolicyName, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.AocVerify); + }); + +var pluginHostOptions = BuildPluginOptions(concelierOptions, builder.Environment.ContentRootPath); +builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions); + +builder.Services.AddEndpointsApiExplorer(); + +var app = builder.Build(); + +app.Logger.LogWarning("Authority enabled: {AuthorityEnabled}, test signing secret configured: {HasTestSecret}", authorityConfigured, !string.IsNullOrWhiteSpace(concelierOptions.Authority?.TestSigningSecret)); + +if (features.NoMergeEnabled) +{ + app.Logger.LogWarning("Legacy merge module disabled via concelier:features:noMergeEnabled; Link-Not-Merge mode active."); +} + +var resolvedConcelierOptions = app.Services.GetRequiredService>().Value; +var resolvedAuthority = resolvedConcelierOptions.Authority ?? new ConcelierOptions.AuthorityOptions(); +authorityConfigured = resolvedAuthority.Enabled; +var enforceAuthority = resolvedAuthority.Enabled && !resolvedAuthority.AllowAnonymousFallback; +var requiredTenants = (resolvedAuthority.RequiredTenants ?? Array.Empty()) + .Select(static tenant => tenant?.Trim().ToLowerInvariant()) + .Where(static tenant => !string.IsNullOrWhiteSpace(tenant)) + .Distinct(StringComparer.Ordinal) + .ToImmutableHashSet(StringComparer.Ordinal); +var enforceTenantAllowlist = !requiredTenants.IsEmpty; + +if (resolvedAuthority.Enabled && resolvedAuthority.AllowAnonymousFallback) +{ + app.Logger.LogWarning( + "Authority authentication is configured but anonymous fallback remains enabled. Set authority.allowAnonymousFallback to false before 2025-12-31 to complete the rollout."); +} + +if (authorityConfigured) +{ + app.UseAuthentication(); + app.UseAuthorization(); +} + +app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority); + app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvider provider, HttpContext context) => { - var (payload, etag) = provider.GetDocument(); - - if (context.Request.Headers.IfNoneMatch.Count > 0) - { - foreach (var candidate in context.Request.Headers.IfNoneMatch) - { - if (Matches(candidate, etag)) - { - context.Response.Headers.ETag = etag; - context.Response.Headers.CacheControl = "public, max-age=300, immutable"; - return Results.StatusCode(StatusCodes.Status304NotModified); - } - } - } - - context.Response.Headers.ETag = etag; - context.Response.Headers.CacheControl = "public, max-age=300, immutable"; - return Results.Text(payload, "application/vnd.oai.openapi+json;version=3.1"); - - static bool Matches(string? candidate, string expected) - { - if (string.IsNullOrWhiteSpace(candidate)) - { - return false; - } - - var trimmed = candidate.Trim(); - if (string.Equals(trimmed, expected, StringComparison.Ordinal)) - { - return true; - } - - if (trimmed.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) - { - var weakValue = trimmed[2..].TrimStart(); - return string.Equals(weakValue, expected, StringComparison.Ordinal); - } - - return false; - } + var (payload, etag) = provider.GetDocument(); + + if (context.Request.Headers.IfNoneMatch.Count > 0) + { + foreach (var candidate in context.Request.Headers.IfNoneMatch) + { + if (Matches(candidate, etag)) + { + context.Response.Headers.ETag = etag; + context.Response.Headers.CacheControl = "public, max-age=300, immutable"; + return Results.StatusCode(StatusCodes.Status304NotModified); + } + } + } + + context.Response.Headers.ETag = etag; + context.Response.Headers.CacheControl = "public, max-age=300, immutable"; + return Results.Text(payload, "application/vnd.oai.openapi+json;version=3.1"); + + static bool Matches(string? candidate, string expected) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + return false; + } + + var trimmed = candidate.Trim(); + if (string.Equals(trimmed, expected, StringComparison.Ordinal)) + { + return true; + } + + if (trimmed.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) + { + var weakValue = trimmed[2..].TrimStart(); + return string.Equals(weakValue, expected, StringComparison.Ordinal); + } + + return false; + } }).WithName("GetConcelierOpenApiDocument"); var orchestratorGroup = app.MapGroup("/internal/orch"); @@ -559,83 +625,82 @@ orchestratorGroup.MapGet("/commands", async ( return Results.Ok(commands); }).WithName("GetOrchestratorCommands"); var observationsEndpoint = app.MapGet("/concelier/observations", async ( - HttpContext context, - [FromQuery(Name = "observationId")] string[]? observationIds, - [FromQuery(Name = "alias")] string[]? aliases, - [FromQuery(Name = "purl")] string[]? purls, - [FromQuery(Name = "cpe")] string[]? cpes, - [FromQuery(Name = "limit")] int? limit, - [FromQuery(Name = "cursor")] string? cursor, - [FromServices] IAdvisoryObservationQueryService queryService, - CancellationToken cancellationToken) => -{ - ApplyNoCache(context.Response); - - if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) - { - return tenantError; - } - - var authorizationError = EnsureTenantAuthorized(context, tenant); - if (authorizationError is not null) - { - return authorizationError; - } - - var normalizedTenant = tenant; - - var options = new AdvisoryObservationQueryOptions( - normalizedTenant, - observationIds, - aliases, - purls, - cpes, - limit, - cursor); - + HttpContext context, + [FromQuery(Name = "observationId")] string[]? observationIds, + [FromQuery(Name = "alias")] string[]? aliases, + [FromQuery(Name = "purl")] string[]? purls, + [FromQuery(Name = "cpe")] string[]? cpes, + [FromQuery(Name = "limit")] int? limit, + [FromQuery(Name = "cursor")] string? cursor, + [FromServices] IAdvisoryObservationQueryService queryService, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError; + } + + var authorizationError = EnsureTenantAuthorized(context, tenant); + if (authorizationError is not null) + { + return authorizationError; + } + + var normalizedTenant = tenant; + + var options = new AdvisoryObservationQueryOptions( + normalizedTenant, + observationIds, + aliases, + purls, + cpes, + limit, + cursor); + + var stopwatch = Stopwatch.StartNew(); AdvisoryObservationQueryResult result; try { result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false); } catch (FormatException ex) - { - return Results.BadRequest(ex.Message); - } - - IngestObservability.IngestLatencySeconds.Record(result.Duration.TotalSeconds, new TagList - { - {"tenant", normalizedTenant}, - {"source", result.Source ?? string.Empty}, - {"stage", "ingest"} - }); - - if (!result.Success && !string.IsNullOrWhiteSpace(result.ErrorCode)) { IngestObservability.IngestErrorsTotal.Add(1, new TagList { {"tenant", normalizedTenant}, - {"source", result.Source ?? string.Empty}, - {"reason", result.ErrorCode} + {"source", "mixed"}, + {"reason", "format"}, + {"stage", "ingest"} }); + return Results.BadRequest(ex.Message); } - var response = new AdvisoryObservationQueryResponse( - result.Observations, - new AdvisoryObservationLinksetAggregateResponse( - result.Linkset.Aliases, - result.Linkset.Purls, - result.Linkset.Cpes, - result.Linkset.References, - result.Linkset.Scopes, - result.Linkset.Relationships, - result.Linkset.Confidence, - result.Linkset.Conflicts), - result.NextCursor, - result.HasMore); - - return Results.Ok(response); -}).WithName("GetConcelierObservations"); - + var elapsed = stopwatch.Elapsed; + + IngestObservability.IngestLatencySeconds.Record(elapsed.TotalSeconds, new TagList + { + {"tenant", normalizedTenant}, + {"source", "mixed"}, + {"stage", "ingest"} + }); + var response = new AdvisoryObservationQueryResponse( + result.Observations, + new AdvisoryObservationLinksetAggregateResponse( + result.Linkset.Aliases, + result.Linkset.Purls, + result.Linkset.Cpes, + result.Linkset.References, + result.Linkset.Scopes, + result.Linkset.Relationships, + result.Linkset.Confidence, + result.Linkset.Conflicts), + result.NextCursor, + result.HasMore); + + return Results.Ok(response); +}).WithName("GetConcelierObservations"); + const int DefaultLnmPageSize = 50; const int MaxLnmPageSize = 200; @@ -647,6 +712,7 @@ app.MapGet("/v1/lnm/linksets", async ( [FromQuery(Name = "pageSize")] int? pageSize, [FromQuery(Name = "includeConflicts")] bool? includeConflicts, [FromServices] IAdvisoryLinksetQueryService queryService, + [FromServices] IAdvisoryObservationQueryService observationQueryService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); @@ -677,9 +743,12 @@ app.MapGet("/v1/lnm/linksets", async ( resolvedPageSize, cancellationToken).ConfigureAwait(false); - var items = result.Items - .Select(linkset => ToLnmResponse(linkset, includeConflicts.GetValueOrDefault(true), includeTimeline: false, includeObservations: false)) - .ToArray(); + var items = new List(result.Items.Length); + foreach (var linkset in result.Items) + { + var summary = await BuildObservationSummaryAsync(observationQueryService, tenant!, linkset, cancellationToken).ConfigureAwait(false); + items.Add(ToLnmResponse(linkset, includeConflicts.GetValueOrDefault(true), includeTimeline: false, includeObservations: false, summary)); + } return Results.Ok(new LnmLinksetPage(items, resolvedPage, resolvedPageSize, result.Total)); }).WithName("ListLnmLinksets"); @@ -688,6 +757,7 @@ app.MapPost("/v1/lnm/linksets/search", async ( HttpContext context, [FromBody] LnmLinksetSearchRequest request, [FromServices] IAdvisoryLinksetQueryService queryService, + [FromServices] IAdvisoryObservationQueryService observationQueryService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); @@ -718,13 +788,17 @@ app.MapPost("/v1/lnm/linksets/search", async ( resolvedPageSize, cancellationToken).ConfigureAwait(false); - var items = result.Items - .Select(linkset => ToLnmResponse( + var items = new List(result.Items.Length); + foreach (var linkset in result.Items) + { + var summary = await BuildObservationSummaryAsync(observationQueryService, tenant!, linkset, cancellationToken).ConfigureAwait(false); + items.Add(ToLnmResponse( linkset, includeConflicts: true, includeTimeline: request.IncludeTimeline, - includeObservations: request.IncludeObservations)) - .ToArray(); + includeObservations: request.IncludeObservations, + summary)); + } return Results.Ok(new LnmLinksetPage(items, resolvedPage, resolvedPageSize, result.Total)); }).WithName("SearchLnmLinksets"); @@ -734,6 +808,7 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async ( string advisoryId, [FromQuery(Name = "source")] string? source, [FromServices] IAdvisoryLinksetQueryService queryService, + [FromServices] IAdvisoryObservationQueryService observationQueryService, [FromServices] LinksetCacheTelemetry telemetry, CancellationToken cancellationToken, [FromQuery(Name = "includeConflicts")] bool includeConflicts = true, @@ -771,7 +846,8 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async ( } var linkset = result.Linksets[0]; - var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations); + var summary = await BuildObservationSummaryAsync(observationQueryService, tenant!, linkset, cancellationToken).ConfigureAwait(false); + var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations, summary); telemetry.RecordHit(tenant, linkset.Source); telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds); @@ -817,316 +893,316 @@ app.MapGet("/linksets", async ( return Results.Ok(payload); }).WithName("ListLinksetsLegacy"); - -if (authorityConfigured) -{ - observationsEndpoint.RequireAuthorization(ObservationsPolicyName); -} - -var advisoryIngestEndpoint = app.MapPost("/ingest/advisory", async ( - HttpContext context, - AdvisoryIngestRequest request, - [FromServices] IAdvisoryRawService rawService, - [FromServices] TimeProvider timeProvider, - [FromServices] ILogger logger, - CancellationToken cancellationToken) => -{ - ApplyNoCache(context.Response); - - var ingestRequest = request; - - if (ingestRequest is null || ingestRequest.Source is null || ingestRequest.Upstream is null || ingestRequest.Content is null || ingestRequest.Identifiers is null) - { - return Problem(context, "Invalid request", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "source, upstream, content, and identifiers sections are required."); - } - - if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) - { - return tenantError; - } - - var authorizationError = EnsureTenantAuthorized(context, tenant); - if (authorizationError is not null) - { - return authorizationError; - } - - using var ingestScope = logger.BeginScope(new Dictionary(StringComparer.Ordinal) - { - ["tenant"] = tenant, - ["source.vendor"] = ingestRequest.Source.Vendor, - ["upstream.upstreamId"] = ingestRequest.Upstream.UpstreamId, - ["contentHash"] = ingestRequest.Upstream.ContentHash ?? "(null)" - }); - - AdvisoryRawDocument document; - try - { - logger.LogWarning( - "Binding advisory ingest request hash={Hash}", - ingestRequest.Upstream.ContentHash ?? "(null)"); - - document = AdvisoryRawRequestMapper.Map(ingestRequest, tenant, timeProvider); - logger.LogWarning( - "Mapped advisory_raw document hash={Hash}", - string.IsNullOrWhiteSpace(document.Upstream.ContentHash) ? "(empty)" : document.Upstream.ContentHash); - } - catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) - { - return Problem(context, "Invalid advisory payload", StatusCodes.Status400BadRequest, ProblemTypes.Validation, ex.Message); - } - - try - { - var result = await rawService.IngestAsync(document, cancellationToken).ConfigureAwait(false); - - var response = new AdvisoryIngestResponse( - result.Record.Id, - result.Inserted, - result.Record.Document.Tenant, - result.Record.Document.Upstream.ContentHash, - result.Record.Document.Supersedes, - result.Record.IngestedAt, - result.Record.CreatedAt); - - var statusCode = result.Inserted ? StatusCodes.Status201Created : StatusCodes.Status200OK; - if (result.Inserted) - { - context.Response.Headers.Location = $"/advisories/raw/{Uri.EscapeDataString(result.Record.Id)}"; - } - - IngestionMetrics.IngestionWriteCounter.Add( - 1, - IngestionMetrics.BuildWriteTags( - tenant, - ingestRequest.Source.Vendor ?? "(unknown)", - result.Inserted ? "inserted" : "duplicate")); - - return JsonResult(response, statusCode); - } - catch (ConcelierAocGuardException guardException) - { - logger.LogWarning( - guardException, - "AOC guard rejected advisory ingest tenant={Tenant} upstream={UpstreamId} requestHash={RequestHash} documentHash={DocumentHash} codes={Codes}", - tenant, - document.Upstream.UpstreamId, - request!.Upstream?.ContentHash ?? "(null)", - string.IsNullOrWhiteSpace(document.Upstream.ContentHash) ? "(empty)" : document.Upstream.ContentHash, - string.Join(',', guardException.Violations.Select(static violation => violation.ErrorCode))); - - IngestionMetrics.IngestionWriteCounter.Add( - 1, - IngestionMetrics.BuildWriteTags( - tenant, - ingestRequest.Source.Vendor ?? "(unknown)", - "rejected")); - - return MapAocGuardException(context, guardException); - } -}); - -var advisoryIngestGuardOptions = AocGuardOptions.Default with -{ - RequireTenant = false, - RequiredTopLevelFields = AocGuardOptions.Default.RequiredTopLevelFields.Remove("tenant") -}; - -advisoryIngestEndpoint.RequireAocGuard(request => -{ - if (request?.Source is null || request.Upstream is null || request.Content is null || request.Identifiers is null) - { - return Array.Empty(); - } - - var guardDocument = AdvisoryRawRequestMapper.Map(request, "guard-tenant", TimeProvider.System); - return new object?[] { guardDocument }; -}, guardOptions: advisoryIngestGuardOptions); - -if (authorityConfigured) -{ - advisoryIngestEndpoint.RequireAuthorization(AdvisoryIngestPolicyName); -} - -var advisoryRawListEndpoint = app.MapGet("/advisories/raw", async ( - HttpContext context, - [FromServices] IAdvisoryRawService rawService, - CancellationToken cancellationToken) => -{ - ApplyNoCache(context.Response); - - if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) - { - return tenantError; - } - - var authorizationError = EnsureTenantAuthorized(context, tenant); - if (authorizationError is not null) - { - return authorizationError; - } - - var query = context.Request.Query; - - var options = new AdvisoryRawQueryOptions(tenant); - - if (query.TryGetValue("vendor", out var vendorValues)) - { - options = options with { Vendors = AdvisoryRawRequestMapper.NormalizeStrings(vendorValues) }; - } - - if (query.TryGetValue("upstreamId", out var upstreamValues)) - { - options = options with { UpstreamIds = AdvisoryRawRequestMapper.NormalizeStrings(upstreamValues) }; - } - - if (query.TryGetValue("alias", out var aliasValues)) - { - options = options with { Aliases = AdvisoryRawRequestMapper.NormalizeStrings(aliasValues) }; - } - - if (query.TryGetValue("purl", out var purlValues)) - { - options = options with { PackageUrls = AdvisoryRawRequestMapper.NormalizeStrings(purlValues) }; - } - - if (query.TryGetValue("hash", out var hashValues)) - { - options = options with { ContentHashes = AdvisoryRawRequestMapper.NormalizeStrings(hashValues) }; - } - - if (query.TryGetValue("since", out var sinceValues)) - { - var since = ParseDateTime(sinceValues.FirstOrDefault()); - if (since.HasValue) - { - options = options with { Since = since }; - } - } - - if (query.TryGetValue("limit", out var limitValues) && int.TryParse(limitValues.FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLimit)) - { - options = options with { Limit = parsedLimit }; - } - - if (query.TryGetValue("cursor", out var cursorValues)) - { - var cursor = cursorValues.FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(cursor)) - { - options = options with { Cursor = cursor }; - } - } - - var result = await rawService.QueryAsync(options, cancellationToken).ConfigureAwait(false); - - var records = result.Records - .Select(record => new AdvisoryRawRecordResponse( - record.Id, - record.Document.Tenant, - record.IngestedAt, - record.CreatedAt, - record.Document)) - .ToArray(); - - var response = new AdvisoryRawListResponse(records, result.NextCursor, result.HasMore); - return JsonResult(response); -}); -if (authorityConfigured) -{ - advisoryRawListEndpoint.RequireAuthorization(AdvisoryReadPolicyName); -} - -var advisoryRawGetEndpoint = app.MapGet("/advisories/raw/{id}", async ( - string id, - HttpContext context, - [FromServices] IAdvisoryRawService rawService, - CancellationToken cancellationToken) => -{ - ApplyNoCache(context.Response); - - if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) - { - return tenantError; - } - - var authorizationError = EnsureTenantAuthorized(context, tenant); - if (authorizationError is not null) - { - return authorizationError; - } - - if (string.IsNullOrWhiteSpace(id)) - { - return Problem(context, "id is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); - } - - var record = await rawService.FindByIdAsync(tenant, id.Trim(), cancellationToken).ConfigureAwait(false); - if (record is null) - { - return Results.NotFound(); - } - - var response = new AdvisoryRawRecordResponse( - record.Id, - record.Document.Tenant, - record.IngestedAt, - record.CreatedAt, - record.Document); - - return JsonResult(response); -}); -if (authorityConfigured) -{ - advisoryRawGetEndpoint.RequireAuthorization(AdvisoryReadPolicyName); -} - -var advisoryRawProvenanceEndpoint = app.MapGet("/advisories/raw/{id}/provenance", async ( - string id, - HttpContext context, - [FromServices] IAdvisoryRawService rawService, - CancellationToken cancellationToken) => -{ - ApplyNoCache(context.Response); - - if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) - { - return tenantError; - } - - var authorizationError = EnsureTenantAuthorized(context, tenant); - if (authorizationError is not null) - { - return authorizationError; - } - - if (string.IsNullOrWhiteSpace(id)) - { - return Problem(context, "id is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); - } - - var record = await rawService.FindByIdAsync(tenant, id.Trim(), cancellationToken).ConfigureAwait(false); - if (record is null) - { - return Results.NotFound(); - } - - var response = new AdvisoryRawProvenanceResponse( - record.Id, - record.Document.Tenant, - record.Document.Source, - record.Document.Upstream, - record.Document.Supersedes, - record.IngestedAt, - record.CreatedAt); - - return JsonResult(response); -}); -if (authorityConfigured) -{ - advisoryRawProvenanceEndpoint.RequireAuthorization(AdvisoryReadPolicyName); -} - + +if (authorityConfigured) +{ + observationsEndpoint.RequireAuthorization(ObservationsPolicyName); +} + +var advisoryIngestEndpoint = app.MapPost("/ingest/advisory", async ( + HttpContext context, + AdvisoryIngestRequest request, + [FromServices] IAdvisoryRawService rawService, + [FromServices] TimeProvider timeProvider, + [FromServices] ILogger logger, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + var ingestRequest = request; + + if (ingestRequest is null || ingestRequest.Source is null || ingestRequest.Upstream is null || ingestRequest.Content is null || ingestRequest.Identifiers is null) + { + return Problem(context, "Invalid request", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "source, upstream, content, and identifiers sections are required."); + } + + if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) + { + return tenantError; + } + + var authorizationError = EnsureTenantAuthorized(context, tenant); + if (authorizationError is not null) + { + return authorizationError; + } + + using var ingestScope = logger.BeginScope(new Dictionary(StringComparer.Ordinal) + { + ["tenant"] = tenant, + ["source.vendor"] = ingestRequest.Source.Vendor, + ["upstream.upstreamId"] = ingestRequest.Upstream.UpstreamId, + ["contentHash"] = ingestRequest.Upstream.ContentHash ?? "(null)" + }); + + AdvisoryRawDocument document; + try + { + logger.LogWarning( + "Binding advisory ingest request hash={Hash}", + ingestRequest.Upstream.ContentHash ?? "(null)"); + + document = AdvisoryRawRequestMapper.Map(ingestRequest, tenant, timeProvider); + logger.LogWarning( + "Mapped advisory_raw document hash={Hash}", + string.IsNullOrWhiteSpace(document.Upstream.ContentHash) ? "(empty)" : document.Upstream.ContentHash); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return Problem(context, "Invalid advisory payload", StatusCodes.Status400BadRequest, ProblemTypes.Validation, ex.Message); + } + + try + { + var result = await rawService.IngestAsync(document, cancellationToken).ConfigureAwait(false); + + var response = new AdvisoryIngestResponse( + result.Record.Id, + result.Inserted, + result.Record.Document.Tenant, + result.Record.Document.Upstream.ContentHash, + result.Record.Document.Supersedes, + result.Record.IngestedAt, + result.Record.CreatedAt); + + var statusCode = result.Inserted ? StatusCodes.Status201Created : StatusCodes.Status200OK; + if (result.Inserted) + { + context.Response.Headers.Location = $"/advisories/raw/{Uri.EscapeDataString(result.Record.Id)}"; + } + + IngestionMetrics.IngestionWriteCounter.Add( + 1, + IngestionMetrics.BuildWriteTags( + tenant, + ingestRequest.Source.Vendor ?? "(unknown)", + result.Inserted ? "inserted" : "duplicate")); + + return JsonResult(response, statusCode); + } + catch (ConcelierAocGuardException guardException) + { + logger.LogWarning( + guardException, + "AOC guard rejected advisory ingest tenant={Tenant} upstream={UpstreamId} requestHash={RequestHash} documentHash={DocumentHash} codes={Codes}", + tenant, + document.Upstream.UpstreamId, + request!.Upstream?.ContentHash ?? "(null)", + string.IsNullOrWhiteSpace(document.Upstream.ContentHash) ? "(empty)" : document.Upstream.ContentHash, + string.Join(',', guardException.Violations.Select(static violation => violation.ErrorCode))); + + IngestionMetrics.IngestionWriteCounter.Add( + 1, + IngestionMetrics.BuildWriteTags( + tenant, + ingestRequest.Source.Vendor ?? "(unknown)", + "rejected")); + + return MapAocGuardException(context, guardException); + } +}); + +var advisoryIngestGuardOptions = AocGuardOptions.Default with +{ + RequireTenant = false, + RequiredTopLevelFields = AocGuardOptions.Default.RequiredTopLevelFields.Remove("tenant") +}; + +advisoryIngestEndpoint.RequireAocGuard(request => +{ + if (request?.Source is null || request.Upstream is null || request.Content is null || request.Identifiers is null) + { + return Array.Empty(); + } + + var guardDocument = AdvisoryRawRequestMapper.Map(request, "guard-tenant", TimeProvider.System); + return new object?[] { guardDocument }; +}, guardOptions: advisoryIngestGuardOptions); + +if (authorityConfigured) +{ + advisoryIngestEndpoint.RequireAuthorization(AdvisoryIngestPolicyName); +} + +var advisoryRawListEndpoint = app.MapGet("/advisories/raw", async ( + HttpContext context, + [FromServices] IAdvisoryRawService rawService, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError; + } + + var authorizationError = EnsureTenantAuthorized(context, tenant); + if (authorizationError is not null) + { + return authorizationError; + } + + var query = context.Request.Query; + + var options = new AdvisoryRawQueryOptions(tenant); + + if (query.TryGetValue("vendor", out var vendorValues)) + { + options = options with { Vendors = AdvisoryRawRequestMapper.NormalizeStrings(vendorValues) }; + } + + if (query.TryGetValue("upstreamId", out var upstreamValues)) + { + options = options with { UpstreamIds = AdvisoryRawRequestMapper.NormalizeStrings(upstreamValues) }; + } + + if (query.TryGetValue("alias", out var aliasValues)) + { + options = options with { Aliases = AdvisoryRawRequestMapper.NormalizeStrings(aliasValues) }; + } + + if (query.TryGetValue("purl", out var purlValues)) + { + options = options with { PackageUrls = AdvisoryRawRequestMapper.NormalizeStrings(purlValues) }; + } + + if (query.TryGetValue("hash", out var hashValues)) + { + options = options with { ContentHashes = AdvisoryRawRequestMapper.NormalizeStrings(hashValues) }; + } + + if (query.TryGetValue("since", out var sinceValues)) + { + var since = ParseDateTime(sinceValues.FirstOrDefault()); + if (since.HasValue) + { + options = options with { Since = since }; + } + } + + if (query.TryGetValue("limit", out var limitValues) && int.TryParse(limitValues.FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLimit)) + { + options = options with { Limit = parsedLimit }; + } + + if (query.TryGetValue("cursor", out var cursorValues)) + { + var cursor = cursorValues.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(cursor)) + { + options = options with { Cursor = cursor }; + } + } + + var result = await rawService.QueryAsync(options, cancellationToken).ConfigureAwait(false); + + var records = result.Records + .Select(record => new AdvisoryRawRecordResponse( + record.Id, + record.Document.Tenant, + record.IngestedAt, + record.CreatedAt, + record.Document)) + .ToArray(); + + var response = new AdvisoryRawListResponse(records, result.NextCursor, result.HasMore); + return JsonResult(response); +}); +if (authorityConfigured) +{ + advisoryRawListEndpoint.RequireAuthorization(AdvisoryReadPolicyName); +} + +var advisoryRawGetEndpoint = app.MapGet("/advisories/raw/{id}", async ( + string id, + HttpContext context, + [FromServices] IAdvisoryRawService rawService, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError; + } + + var authorizationError = EnsureTenantAuthorized(context, tenant); + if (authorizationError is not null) + { + return authorizationError; + } + + if (string.IsNullOrWhiteSpace(id)) + { + return Problem(context, "id is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); + } + + var record = await rawService.FindByIdAsync(tenant, id.Trim(), cancellationToken).ConfigureAwait(false); + if (record is null) + { + return Results.NotFound(); + } + + var response = new AdvisoryRawRecordResponse( + record.Id, + record.Document.Tenant, + record.IngestedAt, + record.CreatedAt, + record.Document); + + return JsonResult(response); +}); +if (authorityConfigured) +{ + advisoryRawGetEndpoint.RequireAuthorization(AdvisoryReadPolicyName); +} + +var advisoryRawProvenanceEndpoint = app.MapGet("/advisories/raw/{id}/provenance", async ( + string id, + HttpContext context, + [FromServices] IAdvisoryRawService rawService, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError; + } + + var authorizationError = EnsureTenantAuthorized(context, tenant); + if (authorizationError is not null) + { + return authorizationError; + } + + if (string.IsNullOrWhiteSpace(id)) + { + return Problem(context, "id is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); + } + + var record = await rawService.FindByIdAsync(tenant, id.Trim(), cancellationToken).ConfigureAwait(false); + if (record is null) + { + return Results.NotFound(); + } + + var response = new AdvisoryRawProvenanceResponse( + record.Id, + record.Document.Tenant, + record.Document.Source, + record.Document.Upstream, + record.Document.Supersedes, + record.IngestedAt, + record.CreatedAt); + + return JsonResult(response); +}); +if (authorityConfigured) +{ + advisoryRawProvenanceEndpoint.RequireAuthorization(AdvisoryReadPolicyName); +} + var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKey}", async ( string advisoryKey, HttpContext context, @@ -1136,37 +1212,37 @@ var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKe CancellationToken cancellationToken) => { ApplyNoCache(context.Response); - - if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) - { - return tenantError; - } - - var authorizationError = EnsureTenantAuthorized(context, tenant); - if (authorizationError is not null) - { - return authorizationError; - } - - if (string.IsNullOrWhiteSpace(advisoryKey)) - { - return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); - } - - var normalizedKey = advisoryKey.Trim(); - var canonicalKey = normalizedKey.ToUpperInvariant(); - var vendorFilter = AdvisoryRawRequestMapper.NormalizeStrings(context.Request.Query["vendor"]); - var records = await rawService.FindByAdvisoryKeyAsync( - tenant, - canonicalKey, - vendorFilter, - cancellationToken).ConfigureAwait(false); - - if (records.Count == 0) - { - return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No evidence available for {normalizedKey}."); - } - + + if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError; + } + + var authorizationError = EnsureTenantAuthorized(context, tenant); + if (authorizationError is not null) + { + return authorizationError; + } + + if (string.IsNullOrWhiteSpace(advisoryKey)) + { + return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); + } + + var normalizedKey = advisoryKey.Trim(); + var canonicalKey = normalizedKey.ToUpperInvariant(); + var vendorFilter = AdvisoryRawRequestMapper.NormalizeStrings(context.Request.Query["vendor"]); + var records = await rawService.FindByAdvisoryKeyAsync( + tenant, + canonicalKey, + vendorFilter, + cancellationToken).ConfigureAwait(false); + + if (records.Count == 0) + { + return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No evidence available for {normalizedKey}."); + } + var recordResponses = records .Select(record => new AdvisoryRawRecordResponse( record.Id, @@ -1188,100 +1264,346 @@ var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKe var response = new AdvisoryEvidenceResponse(responseKey, recordResponses, attestation); return JsonResult(response); }); -if (authorityConfigured) -{ - advisoryEvidenceEndpoint.RequireAuthorization(AdvisoryReadPolicyName); -} - -var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", async ( - string advisoryKey, - HttpContext context, - [FromServices] IAdvisoryObservationQueryService observationService, - [FromServices] AdvisoryChunkBuilder chunkBuilder, - [FromServices] IAdvisoryChunkCache chunkCache, - [FromServices] IAdvisoryStore advisoryStore, - [FromServices] IAliasStore aliasStore, - [FromServices] IAdvisoryAiTelemetry telemetry, - [FromServices] TimeProvider timeProvider, - CancellationToken cancellationToken) => -{ - ApplyNoCache(context.Response); - - var requestStart = timeProvider.GetTimestamp(); - - if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) - { - telemetry.TrackChunkFailure(null, advisoryKey ?? string.Empty, "tenant_unresolved", "validation_error"); - return tenantError; - } - - var authorizationError = EnsureTenantAuthorized(context, tenant); - if (authorizationError is not null) - { - var failureResult = authorizationError switch - { - UnauthorizedHttpResult => "unauthorized", - _ => "forbidden" - }; - - telemetry.TrackChunkFailure(tenant, advisoryKey ?? string.Empty, "tenant_not_authorized", failureResult); - return authorizationError; - } - - if (string.IsNullOrWhiteSpace(advisoryKey)) - { - telemetry.TrackChunkFailure(tenant, string.Empty, "missing_key", "validation_error"); - return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); - } - - var normalizedKey = advisoryKey.Trim(); - var chunkSettings = resolvedConcelierOptions.AdvisoryChunks ?? new ConcelierOptions.AdvisoryChunkOptions(); - var chunkLimit = ResolveBoundedInt(context.Request.Query["limit"], chunkSettings.DefaultChunkLimit, 1, chunkSettings.MaxChunkLimit); - var observationLimit = ResolveBoundedInt(context.Request.Query["observations"], chunkSettings.DefaultObservationLimit, 1, chunkSettings.MaxObservationLimit); - var minimumLength = ResolveBoundedInt(context.Request.Query["minLength"], chunkSettings.DefaultMinimumLength, 16, chunkSettings.MaxMinimumLength); - - var sectionFilter = BuildFilterSet(context.Request.Query["section"]); - var formatFilter = BuildFilterSet(context.Request.Query["format"]); - - var resolution = await ResolveAdvisoryAsync( - tenant, - normalizedKey, - advisoryStore, - aliasStore, - cancellationToken).ConfigureAwait(false); - if (resolution is null) - { - telemetry.TrackChunkFailure(tenant, normalizedKey, "advisory_not_found", "not_found"); - return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No advisory found for {normalizedKey}."); - } - - var (advisory, aliasList, fingerprint) = resolution.Value; - var aliasCandidates = aliasList.IsDefaultOrEmpty - ? ImmutableArray.Create(advisory.AdvisoryKey) - : aliasList; - - var queryOptions = new AdvisoryObservationQueryOptions( - tenant, - aliases: aliasCandidates, - limit: observationLimit); - - var observationResult = await observationService.QueryAsync(queryOptions, cancellationToken).ConfigureAwait(false); - if (observationResult.Observations.IsDefaultOrEmpty || observationResult.Observations.Length == 0) - { - telemetry.TrackChunkFailure(tenant, advisory.AdvisoryKey, "advisory_not_found", "not_found"); - return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No observations available for {advisory.AdvisoryKey}."); - } - - var observations = observationResult.Observations.ToArray(); - var buildOptions = new AdvisoryChunkBuildOptions( - advisory.AdvisoryKey, - fingerprint, - chunkLimit, - observationLimit, - sectionFilter, - formatFilter, - minimumLength); - +if (authorityConfigured) +{ + advisoryEvidenceEndpoint.RequireAuthorization(AdvisoryReadPolicyName); +} + +var attestationVerifyEndpoint = app.MapPost("/internal/attestations/verify", async ( + VerifyAttestationRequest request, + HttpContext context, + [FromServices] EvidenceBundleAttestationBuilder attestationBuilder, + [FromServices] IOptions concelierOptions, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (request is null) + { + return Problem(context, "Request body required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide bundle/manifest paths."); + } + + var evidenceOptions = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions(); + + var resolved = ResolveEvidencePaths(request, evidenceOptions.RootAbsolute, evidenceOptions); + if (!resolved.IsValid) + { + return Problem(context, resolved.Error!, StatusCodes.Status400BadRequest, ProblemTypes.Validation, resolved.ErrorDetails ?? string.Empty); + } + + try + { + var claims = await attestationBuilder.BuildAsync( + new EvidenceBundleAttestationRequest( + resolved.BundlePath!, + resolved.ManifestPath!, + resolved.TransparencyPath, + request.PipelineVersion ?? evidenceOptions.PipelineVersion ?? "git:unknown"), + cancellationToken).ConfigureAwait(false); + + return Results.Json(claims); + } + catch (Exception ex) + { + return Problem(context, "Attestation verification failed", StatusCodes.Status400BadRequest, ProblemTypes.Validation, ex.Message); + } +}); +if (authorityConfigured) +{ + attestationVerifyEndpoint.RequireAuthorization(AdvisoryReadPolicyName); +} + +// Evidence snapshot (manifest-only) endpoint for Console/VEX consumers +var evidenceSnapshotEndpoint = app.MapGet("/obs/evidence/advisories/{advisoryKey}", async ( + string advisoryKey, + HttpContext context, + [FromServices] IOptions concelierOptions, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) + { + return tenantError; + } + + if (string.IsNullOrWhiteSpace(advisoryKey)) + { + return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); + } + + var options = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions(); + var baseDir = Path.Combine(options.RootAbsolute ?? options.Root ?? string.Empty, tenant, advisoryKey.Trim()); + var manifestPath = Path.Combine(baseDir, options.DefaultManifestFileName ?? "manifest.json"); + var transparencyPath = Path.Combine(baseDir, options.DefaultTransparencyFileName ?? "transparency.json"); + + if (!File.Exists(manifestPath)) + { + return Problem(context, "Manifest not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No manifest for {advisoryKey} in tenant {tenant}."); + } + + await using var manifestStream = File.OpenRead(manifestPath); + var hash = await ComputeSha256Async(manifestStream, cancellationToken).ConfigureAwait(false); + + var response = new EvidenceSnapshotResponse( + advisoryKey: advisoryKey.Trim(), + Tenant: tenant, + ManifestPath: manifestPath, + ManifestHash: hash, + TransparencyPath: File.Exists(transparencyPath) ? transparencyPath : null, + PipelineVersion: options.PipelineVersion); + + return Results.Json(response); +}); +if (authorityConfigured) +{ + evidenceSnapshotEndpoint.RequireAuthorization(AdvisoryReadPolicyName); +} + +// Attestation status endpoint (evidence locker proxy) +var evidenceAttestationEndpoint = app.MapGet("/obs/attestations/advisories/{advisoryKey}", async ( + string advisoryKey, + HttpContext context, + [FromServices] IOptions concelierOptions, + [FromServices] EvidenceBundleAttestationBuilder attestationBuilder, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) + { + return tenantError; + } + + if (string.IsNullOrWhiteSpace(advisoryKey)) + { + return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); + } + + var options = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions(); + var baseDir = Path.Combine(options.RootAbsolute ?? options.Root ?? string.Empty, tenant, advisoryKey.Trim()); + if (!Directory.Exists(baseDir)) + { + return Problem(context, "Evidence directory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No evidence for {advisoryKey} in tenant {tenant}."); + } + + var bundlePath = Directory.EnumerateFiles(baseDir, "*.tar*", SearchOption.TopDirectoryOnly).FirstOrDefault(); + if (bundlePath is null) + { + return Problem(context, "Bundle missing", StatusCodes.Status404NotFound, ProblemTypes.NotFound, "No bundle archive found in evidence directory."); + } + + var manifestPath = Path.Combine(baseDir, options.DefaultManifestFileName ?? "manifest.json"); + var transparencyPath = Path.Combine(baseDir, options.DefaultTransparencyFileName ?? "transparency.json"); + if (!File.Exists(manifestPath)) + { + return Problem(context, "Manifest missing", StatusCodes.Status404NotFound, ProblemTypes.NotFound, "Manifest required to build attestation claims."); + } + + var claims = await attestationBuilder.BuildAsync( + new EvidenceBundleAttestationRequest( + bundlePath, + manifestPath, + File.Exists(transparencyPath) ? transparencyPath : null, + options.PipelineVersion ?? "git:unknown"), + cancellationToken).ConfigureAwait(false); + + var response = new AttestationStatusResponse( + AdvisoryKey: advisoryKey.Trim(), + Tenant: tenant, + Claims: claims, + BundlePath: bundlePath, + ManifestPath: manifestPath, + TransparencyPath: File.Exists(transparencyPath) ? transparencyPath : null, + PipelineVersion: options.PipelineVersion); + + return Results.Json(response); +}); +if (authorityConfigured) +{ + evidenceAttestationEndpoint.RequireAuthorization(AdvisoryReadPolicyName); +} + +// Incident-mode (ingest pause) endpoints +var incidentGetEndpoint = app.MapGet("/obs/incidents/advisories/{advisoryKey}", async ( + string advisoryKey, + HttpContext context, + [FromServices] IOptions concelierOptions, + [FromServices] TimeProvider timeProvider, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) + { + return tenantError; + } + + var evidenceOptions = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions(); + var status = await IncidentFileStore.ReadAsync(evidenceOptions, tenant!, advisoryKey, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false); + if (status is null) + { + return Problem(context, "Incident not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, "No incident marker present."); + } + + return Results.Json(status); +}); +if (authorityConfigured) +{ + incidentGetEndpoint.RequireAuthorization(AdvisoryReadPolicyName); +} + +var incidentUpsertEndpoint = app.MapPost("/obs/incidents/advisories/{advisoryKey}", async ( + string advisoryKey, + IncidentUpsertRequest request, + HttpContext context, + [FromServices] IOptions concelierOptions, + [FromServices] TimeProvider timeProvider, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) + { + return tenantError; + } + + if (request is null) + { + return Problem(context, "Request body required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide reason/cooldownMinutes."); + } + + var cooldownMinutes = request.CooldownMinutes is null or <= 0 ? 60 : request.CooldownMinutes.Value; + var evidenceOptions = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions(); + await IncidentFileStore.WriteAsync( + evidenceOptions, + tenant!, + advisoryKey, + request.Reason ?? "unspecified", + cooldownMinutes, + evidenceOptions.PipelineVersion, + timeProvider.GetUtcNow(), + cancellationToken).ConfigureAwait(false); + + var status = await IncidentFileStore.ReadAsync(evidenceOptions, tenant!, advisoryKey, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false); + return Results.Json(status); +}); +if (authorityConfigured) +{ + incidentUpsertEndpoint.RequireAuthorization(AdvisoryReadPolicyName); +} + +var incidentDeleteEndpoint = app.MapDelete("/obs/incidents/advisories/{advisoryKey}", async ( + string advisoryKey, + HttpContext context, + [FromServices] IOptions concelierOptions, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) + { + return tenantError; + } + + var evidenceOptions = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions(); + await IncidentFileStore.DeleteAsync(evidenceOptions, tenant!, advisoryKey, cancellationToken).ConfigureAwait(false); + return Results.NoContent(); +}); +if (authorityConfigured) +{ + incidentDeleteEndpoint.RequireAuthorization(AdvisoryReadPolicyName); +} + +var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", async ( + string advisoryKey, + HttpContext context, + [FromServices] IAdvisoryObservationQueryService observationService, + [FromServices] AdvisoryChunkBuilder chunkBuilder, + [FromServices] IAdvisoryChunkCache chunkCache, + [FromServices] IAdvisoryStore advisoryStore, + [FromServices] IAliasStore aliasStore, + [FromServices] IAdvisoryAiTelemetry telemetry, + [FromServices] TimeProvider timeProvider, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + var requestStart = timeProvider.GetTimestamp(); + + if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) + { + telemetry.TrackChunkFailure(null, advisoryKey ?? string.Empty, "tenant_unresolved", "validation_error"); + return tenantError; + } + + var authorizationError = EnsureTenantAuthorized(context, tenant); + if (authorizationError is not null) + { + var failureResult = authorizationError switch + { + UnauthorizedHttpResult => "unauthorized", + _ => "forbidden" + }; + + telemetry.TrackChunkFailure(tenant, advisoryKey ?? string.Empty, "tenant_not_authorized", failureResult); + return authorizationError; + } + + if (string.IsNullOrWhiteSpace(advisoryKey)) + { + telemetry.TrackChunkFailure(tenant, string.Empty, "missing_key", "validation_error"); + return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); + } + + var normalizedKey = advisoryKey.Trim(); + var chunkSettings = resolvedConcelierOptions.AdvisoryChunks ?? new ConcelierOptions.AdvisoryChunkOptions(); + var chunkLimit = ResolveBoundedInt(context.Request.Query["limit"], chunkSettings.DefaultChunkLimit, 1, chunkSettings.MaxChunkLimit); + var observationLimit = ResolveBoundedInt(context.Request.Query["observations"], chunkSettings.DefaultObservationLimit, 1, chunkSettings.MaxObservationLimit); + var minimumLength = ResolveBoundedInt(context.Request.Query["minLength"], chunkSettings.DefaultMinimumLength, 16, chunkSettings.MaxMinimumLength); + + var sectionFilter = BuildFilterSet(context.Request.Query["section"]); + var formatFilter = BuildFilterSet(context.Request.Query["format"]); + + var resolution = await ResolveAdvisoryAsync( + tenant, + normalizedKey, + advisoryStore, + aliasStore, + cancellationToken).ConfigureAwait(false); + if (resolution is null) + { + telemetry.TrackChunkFailure(tenant, normalizedKey, "advisory_not_found", "not_found"); + return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No advisory found for {normalizedKey}."); + } + + var (advisory, aliasList, fingerprint) = resolution.Value; + var aliasCandidates = aliasList.IsDefaultOrEmpty + ? ImmutableArray.Create(advisory.AdvisoryKey) + : aliasList; + + var queryOptions = new AdvisoryObservationQueryOptions( + tenant, + aliases: aliasCandidates, + limit: observationLimit); + + var observationResult = await observationService.QueryAsync(queryOptions, cancellationToken).ConfigureAwait(false); + if (observationResult.Observations.IsDefaultOrEmpty || observationResult.Observations.Length == 0) + { + telemetry.TrackChunkFailure(tenant, advisory.AdvisoryKey, "advisory_not_found", "not_found"); + return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No observations available for {advisory.AdvisoryKey}."); + } + + var observations = observationResult.Observations.ToArray(); + var buildOptions = new AdvisoryChunkBuildOptions( + advisory.AdvisoryKey, + fingerprint, + chunkLimit, + observationLimit, + sectionFilter, + formatFilter, + minimumLength); + var cacheDuration = chunkSettings.CacheDurationSeconds > 0 ? TimeSpan.FromSeconds(chunkSettings.CacheDurationSeconds) : TimeSpan.Zero; @@ -1301,12 +1623,12 @@ var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", asyn cacheHit = true; } else - { - buildResult = chunkBuilder.Build(buildOptions, advisory, observations); - chunkCache.Set(cacheKey, buildResult, cacheDuration); - } - } - else + { + buildResult = chunkBuilder.Build(buildOptions, advisory, observations); + chunkCache.Set(cacheKey, buildResult, cacheDuration); + } + } + else { buildResult = chunkBuilder.Build(buildOptions, advisory, observations); } @@ -1320,22 +1642,22 @@ var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", asyn var duration = timeProvider.GetElapsedTime(requestStart); var guardrailCounts = buildResult.Telemetry.GuardrailCounts ?? ImmutableDictionary.Empty; - - telemetry.TrackChunkResult(new AdvisoryAiChunkRequestTelemetry( - tenant, - advisory.AdvisoryKey, - "ok", - buildResult.Response.Truncated, - cacheHit, - observations.Length, - buildResult.Telemetry.SourceCount, - buildResult.Response.Entries.Count, - duration, - guardrailCounts)); - - return JsonResult(buildResult.Response); -}); - + + telemetry.TrackChunkResult(new AdvisoryAiChunkRequestTelemetry( + tenant, + advisory.AdvisoryKey, + "ok", + buildResult.Response.Truncated, + cacheHit, + observations.Length, + buildResult.Telemetry.SourceCount, + buildResult.Response.Entries.Count, + duration, + guardrailCounts)); + + return JsonResult(buildResult.Response); +}); + if (authorityConfigured) { advisoryChunksEndpoint.RequireAuthorization(AdvisoryReadPolicyName); @@ -1424,6 +1746,74 @@ var advisorySummaryEndpoint = app.MapGet("/advisories/summary", async ( return Results.Ok(response); }).WithName("GetAdvisoriesSummary"); +// Evidence batch (component-centric) endpoint for graph overlays / evidence exports. +app.MapPost("/v1/evidence/batch", async ( + HttpContext context, + [FromBody] EvidenceBatchRequest request, + [FromServices] IAdvisoryObservationQueryService observationService, + [FromServices] IAdvisoryLinksetQueryService linksetService, + [FromServices] TimeProvider timeProvider, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) + { + return tenantError; + } + + if (request?.Items is null || request.Items.Count == 0) + { + return Problem(context, "At least one batch item is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide items with aliases/purls."); + } + + var resolvedObservationLimit = request.ObservationLimit is > 0 and <= 200 ? request.ObservationLimit.Value : 50; + var resolvedLinksetLimit = request.LinksetLimit is > 0 and <= 200 ? request.LinksetLimit.Value : 50; + + var responses = new List(request.Items.Count); + foreach (var item in request.Items) + { + var componentId = string.IsNullOrWhiteSpace(item.ComponentId) ? "(unnamed)" : item.ComponentId.Trim(); + var aliases = item.Aliases?.Where(a => !string.IsNullOrWhiteSpace(a)).Select(a => a.Trim()).ToArray(); + var purls = item.Purls?.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p.Trim()).ToArray(); + + AdvisoryObservationQueryResult observationResult = new( + ImmutableArray.Empty, + new AdvisoryObservationLinksetAggregate( + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty), + NextCursor: null, + HasMore: false); + + AdvisoryLinksetQueryResult linksetResult = new( + ImmutableArray.Empty, + NextCursor: null, + HasMore: false); + + if ((aliases?.Length ?? 0) > 0 || (purls?.Length ?? 0) > 0) + { + var obsOptions = new AdvisoryObservationQueryOptions(tenant, aliases: aliases, purls: purls, limit: resolvedObservationLimit); + observationResult = await observationService.QueryAsync(obsOptions, cancellationToken).ConfigureAwait(false); + + var linksetOptions = new AdvisoryLinksetQueryOptions(tenant, aliases, null, resolvedLinksetLimit); + linksetResult = await linksetService.QueryAsync(linksetOptions, cancellationToken).ConfigureAwait(false); + } + + var responseItem = new EvidenceBatchItemResponse( + componentId, + observationResult.Observations, + linksetResult.Linksets, + observationResult.HasMore || linksetResult.HasMore, + timeProvider.GetUtcNow()); + + responses.Add(responseItem); + } + + return Results.Ok(new EvidenceBatchResponse(responses)); +}).WithName("GetEvidenceBatch"); + if (authorityConfigured) { advisorySummaryEndpoint.RequireAuthorization(AdvisoryReadPolicyName); @@ -1432,254 +1822,254 @@ if (authorityConfigured) var aocVerifyEndpoint = app.MapPost("/aoc/verify", async ( HttpContext context, AocVerifyRequest request, - [FromServices] IAdvisoryRawService rawService, - [FromServices] TimeProvider timeProvider, - CancellationToken cancellationToken) => -{ - ApplyNoCache(context.Response); - - if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) - { - return tenantError; - } - - var authorizationError = EnsureTenantAuthorized(context, tenant); - if (authorizationError is not null) - { - return authorizationError; - } - - var now = timeProvider.GetUtcNow(); - var windowStart = (request?.Since ?? now.AddHours(-24)).ToUniversalTime(); - var windowEnd = (request?.Until ?? now).ToUniversalTime(); - - if (windowEnd < windowStart) - { - return Problem(context, "Invalid verification window", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "'until' must be greater than 'since'."); - } - - var limit = request?.Limit ?? 20; - if (limit < 0) - { - limit = 0; - } - - var sources = AdvisoryRawRequestMapper.NormalizeStrings(request?.Sources); - var codes = AdvisoryRawRequestMapper.NormalizeStrings(request?.Codes); - - var verificationRequest = new AdvisoryRawVerificationRequest( - tenant, - windowStart, - windowEnd, - limit, - sources, - codes); - - var result = await rawService.VerifyAsync(verificationRequest, cancellationToken).ConfigureAwait(false); - - var violationResponses = result.Violations - .Select(violation => new AocVerifyViolation( - violation.Code, - violation.Count, - violation.Examples.Select(example => new AocVerifyViolationExample( - example.SourceVendor, - example.DocumentId, - example.ContentHash, - example.Path)).ToArray())) - .ToArray(); - - var metrics = new AocVerifyMetrics(result.CheckedCount, result.Violations.Sum(v => v.Count)); - - var response = new AocVerifyResponse( - result.Tenant, - new AocVerifyWindow(result.WindowStart, result.WindowEnd), - new AocVerifyChecked(result.CheckedCount, 0), - violationResponses, - metrics, - result.Truncated); - var verificationOutcome = response.Truncated - ? "truncated" - : (violationResponses.Length == 0 ? "ok" : "violations"); - IngestionMetrics.VerificationCounter.Add( - 1, - IngestionMetrics.BuildVerifyTags(tenant, verificationOutcome)); - - return JsonResult(response); -}); -if (authorityConfigured) -{ - aocVerifyEndpoint.RequireAuthorization(AocVerifyPolicyName); -} - -app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async ( - string vulnerabilityKey, - DateTimeOffset? asOf, - [FromServices] IAdvisoryEventLog eventLog, - CancellationToken cancellationToken) => -{ - if (string.IsNullOrWhiteSpace(vulnerabilityKey)) - { - return Results.BadRequest("vulnerabilityKey must be provided."); - } - - var replay = await eventLog.ReplayAsync(vulnerabilityKey.Trim(), asOf, cancellationToken).ConfigureAwait(false); - if (replay.Statements.Length == 0 && replay.Conflicts.Length == 0) - { - return Results.NotFound(); - } - - var response = new - { - replay.VulnerabilityKey, - replay.AsOf, - Statements = replay.Statements.Select(statement => new - { - statement.StatementId, - statement.VulnerabilityKey, - statement.AdvisoryKey, - statement.Advisory, - StatementHash = Convert.ToHexString(statement.StatementHash.ToArray()), - statement.AsOf, - statement.RecordedAt, - InputDocumentIds = statement.InputDocumentIds - }).ToArray(), - Conflicts = replay.Conflicts.Select(conflict => new - { - conflict.ConflictId, - conflict.VulnerabilityKey, - conflict.StatementIds, - ConflictHash = Convert.ToHexString(conflict.ConflictHash.ToArray()), - conflict.AsOf, - conflict.RecordedAt, - Details = conflict.CanonicalJson, - Explainer = MergeConflictExplainerPayload.FromCanonicalJson(conflict.CanonicalJson) - }).ToArray() - }; - - return JsonResult(response); -}); - -var statementProvenanceEndpoint = app.MapPost("/events/statements/{statementId:guid}/provenance", async ( - Guid statementId, - HttpContext context, - [FromServices] IAdvisoryEventLog eventLog, - CancellationToken cancellationToken) => -{ - if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) - { - return tenantError; - } - - var authorizationError = EnsureTenantAuthorized(context, tenant); - if (authorizationError is not null) - { - return authorizationError; - } - - try - { - using var document = await JsonDocument.ParseAsync(context.Request.Body, cancellationToken: cancellationToken).ConfigureAwait(false); - var (dsse, trust) = ProvenanceJsonParser.Parse(document.RootElement); - - if (!trust.Verified) - { - return Problem(context, "Unverified provenance", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "trust.verified must be true."); - } - - await eventLog.AttachStatementProvenanceAsync(statementId, dsse, trust, cancellationToken).ConfigureAwait(false); - } - catch (JsonException ex) - { - return Problem(context, "Invalid provenance payload", StatusCodes.Status400BadRequest, ProblemTypes.Validation, ex.Message); - } - catch (InvalidOperationException ex) - { - return Problem(context, "Statement not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, ex.Message); - } - - return Results.Accepted($"/events/statements/{statementId}"); -}); - -if (authorityConfigured) -{ - statementProvenanceEndpoint.RequireAuthorization(AdvisoryIngestPolicyName); -} - -var loggingEnabled = concelierOptions.Telemetry?.EnableLogging ?? true; - -if (loggingEnabled) -{ - app.UseSerilogRequestLogging(options => - { - options.IncludeQueryInRequestPath = true; - options.GetLevel = (httpContext, elapsedMs, exception) => exception is null ? LogEventLevel.Information : LogEventLevel.Error; - options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => - { - diagnosticContext.Set("RequestId", httpContext.TraceIdentifier); - diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString()); - if (Activity.Current is { TraceId: var traceId } && traceId != default) - { - diagnosticContext.Set("TraceId", traceId.ToString()); - } - }; - }); -} - -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); - }); -}); - + [FromServices] IAdvisoryRawService rawService, + [FromServices] TimeProvider timeProvider, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError; + } + + var authorizationError = EnsureTenantAuthorized(context, tenant); + if (authorizationError is not null) + { + return authorizationError; + } + + var now = timeProvider.GetUtcNow(); + var windowStart = (request?.Since ?? now.AddHours(-24)).ToUniversalTime(); + var windowEnd = (request?.Until ?? now).ToUniversalTime(); + + if (windowEnd < windowStart) + { + return Problem(context, "Invalid verification window", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "'until' must be greater than 'since'."); + } + + var limit = request?.Limit ?? 20; + if (limit < 0) + { + limit = 0; + } + + var sources = AdvisoryRawRequestMapper.NormalizeStrings(request?.Sources); + var codes = AdvisoryRawRequestMapper.NormalizeStrings(request?.Codes); + + var verificationRequest = new AdvisoryRawVerificationRequest( + tenant, + windowStart, + windowEnd, + limit, + sources, + codes); + + var result = await rawService.VerifyAsync(verificationRequest, cancellationToken).ConfigureAwait(false); + + var violationResponses = result.Violations + .Select(violation => new AocVerifyViolation( + violation.Code, + violation.Count, + violation.Examples.Select(example => new AocVerifyViolationExample( + example.SourceVendor, + example.DocumentId, + example.ContentHash, + example.Path)).ToArray())) + .ToArray(); + + var metrics = new AocVerifyMetrics(result.CheckedCount, result.Violations.Sum(v => v.Count)); + + var response = new AocVerifyResponse( + result.Tenant, + new AocVerifyWindow(result.WindowStart, result.WindowEnd), + new AocVerifyChecked(result.CheckedCount, 0), + violationResponses, + metrics, + result.Truncated); + var verificationOutcome = response.Truncated + ? "truncated" + : (violationResponses.Length == 0 ? "ok" : "violations"); + IngestionMetrics.VerificationCounter.Add( + 1, + IngestionMetrics.BuildVerifyTags(tenant, verificationOutcome)); + + return JsonResult(response); +}); +if (authorityConfigured) +{ + aocVerifyEndpoint.RequireAuthorization(AocVerifyPolicyName); +} + +app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async ( + string vulnerabilityKey, + DateTimeOffset? asOf, + [FromServices] IAdvisoryEventLog eventLog, + CancellationToken cancellationToken) => +{ + if (string.IsNullOrWhiteSpace(vulnerabilityKey)) + { + return Results.BadRequest("vulnerabilityKey must be provided."); + } + + var replay = await eventLog.ReplayAsync(vulnerabilityKey.Trim(), asOf, cancellationToken).ConfigureAwait(false); + if (replay.Statements.Length == 0 && replay.Conflicts.Length == 0) + { + return Results.NotFound(); + } + + var response = new + { + replay.VulnerabilityKey, + replay.AsOf, + Statements = replay.Statements.Select(statement => new + { + statement.StatementId, + statement.VulnerabilityKey, + statement.AdvisoryKey, + statement.Advisory, + StatementHash = Convert.ToHexString(statement.StatementHash.ToArray()), + statement.AsOf, + statement.RecordedAt, + InputDocumentIds = statement.InputDocumentIds + }).ToArray(), + Conflicts = replay.Conflicts.Select(conflict => new + { + conflict.ConflictId, + conflict.VulnerabilityKey, + conflict.StatementIds, + ConflictHash = Convert.ToHexString(conflict.ConflictHash.ToArray()), + conflict.AsOf, + conflict.RecordedAt, + Details = conflict.CanonicalJson, + Explainer = MergeConflictExplainerPayload.FromCanonicalJson(conflict.CanonicalJson) + }).ToArray() + }; + + return JsonResult(response); +}); + +var statementProvenanceEndpoint = app.MapPost("/events/statements/{statementId:guid}/provenance", async ( + Guid statementId, + HttpContext context, + [FromServices] IAdvisoryEventLog eventLog, + CancellationToken cancellationToken) => +{ + if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) + { + return tenantError; + } + + var authorizationError = EnsureTenantAuthorized(context, tenant); + if (authorizationError is not null) + { + return authorizationError; + } + + try + { + using var document = await JsonDocument.ParseAsync(context.Request.Body, cancellationToken: cancellationToken).ConfigureAwait(false); + var (dsse, trust) = ProvenanceJsonParser.Parse(document.RootElement); + + if (!trust.Verified) + { + return Problem(context, "Unverified provenance", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "trust.verified must be true."); + } + + await eventLog.AttachStatementProvenanceAsync(statementId, dsse, trust, cancellationToken).ConfigureAwait(false); + } + catch (JsonException ex) + { + return Problem(context, "Invalid provenance payload", StatusCodes.Status400BadRequest, ProblemTypes.Validation, ex.Message); + } + catch (InvalidOperationException ex) + { + return Problem(context, "Statement not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, ex.Message); + } + + return Results.Accepted($"/events/statements/{statementId}"); +}); + +if (authorityConfigured) +{ + statementProvenanceEndpoint.RequireAuthorization(AdvisoryIngestPolicyName); +} + +var loggingEnabled = concelierOptions.Telemetry?.EnableLogging ?? true; + +if (loggingEnabled) +{ + app.UseSerilogRequestLogging(options => + { + options.IncludeQueryInRequestPath = true; + options.GetLevel = (httpContext, elapsedMs, exception) => exception is null ? LogEventLevel.Information : LogEventLevel.Error; + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("RequestId", httpContext.TraceIdentifier); + diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString()); + if (Activity.Current is { TraceId: var traceId } && traceId != default) + { + diagnosticContext.Set("TraceId", traceId.ToString()); + } + }; + }); +} + +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); + }); +}); + if (authorityConfigured) { app.Use(async (context, next) => - { - await next().ConfigureAwait(false); - - if (!context.Request.Path.StartsWithSegments("/jobs", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (context.Response.StatusCode != StatusCodes.Status401Unauthorized) - { - return; - } - - var optionsMonitor = context.RequestServices.GetRequiredService>().Value.Authority; - if (optionsMonitor is null || !optionsMonitor.Enabled) - { - return; - } - - var logger = context.RequestServices - .GetRequiredService() - .CreateLogger(JobAuthorizationAuditFilter.LoggerName); - - var matcher = new NetworkMaskMatcher(optionsMonitor.BypassNetworks); - var remote = context.Connection.RemoteIpAddress; - var bypassAllowed = matcher.IsAllowed(remote); - + { + await next().ConfigureAwait(false); + + if (!context.Request.Path.StartsWithSegments("/jobs", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (context.Response.StatusCode != StatusCodes.Status401Unauthorized) + { + return; + } + + var optionsMonitor = context.RequestServices.GetRequiredService>().Value.Authority; + if (optionsMonitor is null || !optionsMonitor.Enabled) + { + return; + } + + var logger = context.RequestServices + .GetRequiredService() + .CreateLogger(JobAuthorizationAuditFilter.LoggerName); + + var matcher = new NetworkMaskMatcher(optionsMonitor.BypassNetworks); + var remote = context.Connection.RemoteIpAddress; + var bypassAllowed = matcher.IsAllowed(remote); + logger.LogWarning( "Concelier authorization denied route={Route} remote={RemoteAddress} bypassAllowed={BypassAllowed} hasPrincipal={HasPrincipal}", context.Request.Path.Value ?? string.Empty, @@ -1749,12 +2139,13 @@ LnmLinksetResponse ToLnmResponse( AdvisoryLinkset linkset, bool includeConflicts, bool includeTimeline, - bool includeObservations) + bool includeObservations, + LinksetObservationSummary summary) { var normalized = linkset.Normalized; - var severity = normalized?.Severities?.FirstOrDefault() is { } severityDict + var severity = summary.Severity ?? (normalized?.Severities?.FirstOrDefault() is { } severityDict ? ExtractSeverity(severityDict) - : null; + : null); var conflicts = includeConflicts ? (linkset.Conflicts ?? Array.Empty()).Select(c => new LnmLinksetConflict( @@ -1767,13 +2158,7 @@ LnmLinksetResponse ToLnmResponse( : Array.Empty(); var timeline = includeTimeline - ? new[] - { - new LnmLinksetTimeline( - Event: "created", - At: linkset.CreatedAt, - EvidenceHash: linkset.Provenance?.ObservationHashes?.FirstOrDefault()) - } + ? BuildTimeline(linkset, summary) : Array.Empty(); var provenance = linkset.Provenance is null @@ -1800,8 +2185,8 @@ LnmLinksetResponse ToLnmResponse( normalized?.Purls ?? Array.Empty(), normalized?.Cpes ?? Array.Empty(), Summary: null, - PublishedAt: linkset.CreatedAt, - ModifiedAt: linkset.CreatedAt, + PublishedAt: summary.PublishedAt ?? linkset.CreatedAt, + ModifiedAt: summary.ModifiedAt ?? linkset.CreatedAt, Severity: severity, Status: "fact-only", provenance, @@ -1834,309 +2219,389 @@ string? ExtractSeverity(IReadOnlyDictionary severityDict) return null; } +async Task BuildObservationSummaryAsync( + IAdvisoryObservationQueryService observationQueryService, + string tenant, + AdvisoryLinkset linkset, + CancellationToken cancellationToken) +{ + if (linkset.ObservationIds.Length == 0) + { + return LinksetObservationSummary.Empty; + } + + var options = new AdvisoryObservationQueryOptions( + tenant, + observationIds: linkset.ObservationIds, + limit: linkset.ObservationIds.Length); + + var result = await observationQueryService.QueryAsync(options, cancellationToken).ConfigureAwait(false); + if (result.Observations.IsDefaultOrEmpty) + { + return LinksetObservationSummary.Empty; + } + + var published = result.Observations + .Where(o => o.Published.HasValue) + .Select(o => o.Published!.Value) + .OrderBy(p => p) + .FirstOrDefault(); + + var modified = result.Observations + .Where(o => o.Modified.HasValue) + .Select(o => o.Modified!.Value) + .OrderByDescending(p => p) + .FirstOrDefault(); + + var severity = result.Observations + .SelectMany(o => o.Severities) + .OrderByDescending(s => s.Score) + .FirstOrDefault(); + + var severityText = severity is null ? null : $"{severity.System}:{severity.Score:0.0}"; + var evidenceHash = result.Observations + .Select(o => o.Provenance.SourceArtifactSha) + .FirstOrDefault(); + + return new LinksetObservationSummary( + PublishedAt: published == default ? null : published, + ModifiedAt: modified == default ? null : modified, + Severity: severityText, + EvidenceHash: evidenceHash); +} + +IReadOnlyList BuildTimeline(AdvisoryLinkset linkset, LinksetObservationSummary summary) +{ + var timeline = new List(3) + { + new("created", linkset.CreatedAt, linkset.Provenance?.ObservationHashes?.FirstOrDefault()), + }; + + if (summary.PublishedAt.HasValue) + { + timeline.Add(new LnmLinksetTimeline("published", summary.PublishedAt, summary.EvidenceHash)); + } + + if (summary.ModifiedAt.HasValue) + { + timeline.Add(new LnmLinksetTimeline("modified", summary.ModifiedAt, summary.EvidenceHash)); + } + + return timeline; +} + +readonly record struct LinksetObservationSummary( + DateTimeOffset? PublishedAt, + DateTimeOffset? ModifiedAt, + string? Severity, + string? EvidenceHash) +{ + public static LinksetObservationSummary Empty { get; } = new(null, null, null, null); +} + IResult JsonResult(T value, int? statusCode = null) { - var payload = JsonSerializer.Serialize(value, Program.JsonOptions); + 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; - extensions ??= new Dictionary(StringComparer.Ordinal) - { - ["traceId"] = traceId, - }; - - if (!extensions.ContainsKey("traceId")) - { - extensions["traceId"] = traceId; - } - - var problemDetails = new ProblemDetails - { - Type = type, - Title = title, - Detail = detail, - Status = statusCode, - Instance = context.Request.Path - }; - - foreach (var entry in extensions) - { - problemDetails.Extensions[entry.Key] = entry.Value; - } - - var payload = JsonSerializer.Serialize(problemDetails, Program.JsonOptions); + +IResult Problem(HttpContext context, string title, int statusCode, string type, string? detail = null, IDictionary? extensions = null) +{ + var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier; + extensions ??= new Dictionary(StringComparer.Ordinal) + { + ["traceId"] = traceId, + }; + + if (!extensions.ContainsKey("traceId")) + { + extensions["traceId"] = traceId; + } + + var problemDetails = new ProblemDetails + { + Type = type, + Title = title, + Detail = detail, + Status = statusCode, + Instance = context.Request.Path + }; + + foreach (var entry in extensions) + { + problemDetails.Extensions[entry.Key] = entry.Value; + } + + var payload = JsonSerializer.Serialize(problemDetails, JsonOptions); return Results.Content(payload, "application/problem+json", Encoding.UTF8, statusCode); } - -bool TryResolveTenant(HttpContext context, bool requireHeader, out string tenant, out IResult? error) -{ - tenant = string.Empty; - error = null; - - var headerTenant = context.Request.Headers[TenantHeaderName].FirstOrDefault(); - var queryTenant = context.Request.Query.TryGetValue("tenant", out var tenantValues) ? tenantValues.FirstOrDefault() : null; - - if (requireHeader && string.IsNullOrWhiteSpace(headerTenant)) - { - error = Problem(context, "Tenant header required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, $"Header '{TenantHeaderName}' must be provided."); - return false; - } - - if (!string.IsNullOrWhiteSpace(headerTenant) && !string.IsNullOrWhiteSpace(queryTenant) && - !string.Equals(headerTenant.Trim(), queryTenant.Trim(), StringComparison.OrdinalIgnoreCase)) - { - error = Problem(context, "Tenant mismatch", StatusCodes.Status400BadRequest, ProblemTypes.Validation, $"Values for '{TenantHeaderName}' and 'tenant' query parameter must match."); - return false; - } - - var resolved = !string.IsNullOrWhiteSpace(headerTenant) ? headerTenant : queryTenant; - if (string.IsNullOrWhiteSpace(resolved)) - { - error = Problem(context, "Tenant required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, $"Specify the tenant via '{TenantHeaderName}' header or 'tenant' query parameter."); - return false; - } - - tenant = resolved.Trim().ToLowerInvariant(); - return true; -} - -IResult? EnsureTenantAuthorized(HttpContext context, string tenant) -{ - if (!authorityConfigured) - { - return null; - } - - if (enforceTenantAllowlist && !requiredTenants.Contains(tenant)) - { - return Results.Forbid(); - } - - var principal = context.User; - - if (enforceAuthority && (principal?.Identity?.IsAuthenticated != true)) - { - return Results.Unauthorized(); - } - - if (principal?.Identity?.IsAuthenticated == true) - { - var tenantClaim = principal.FindFirstValue(StellaOpsClaimTypes.Tenant); - if (string.IsNullOrWhiteSpace(tenantClaim)) - { - return Results.Forbid(); - } - - var normalizedClaim = tenantClaim.Trim().ToLowerInvariant(); - if (!string.Equals(normalizedClaim, tenant, StringComparison.Ordinal)) - { - return Results.Forbid(); - } - - if (enforceTenantAllowlist && !requiredTenants.Contains(normalizedClaim)) - { - return Results.Forbid(); - } - } - - return null; -} - -async Task<(Advisory Advisory, ImmutableArray Aliases, string Fingerprint)?> ResolveAdvisoryAsync( - string tenant, - string advisoryKey, - IAdvisoryStore advisoryStore, - IAliasStore aliasStore, - CancellationToken cancellationToken) -{ - if (string.IsNullOrWhiteSpace(tenant)) - { - return null; - } - - ArgumentNullException.ThrowIfNull(advisoryStore); - ArgumentNullException.ThrowIfNull(aliasStore); - - var directCandidates = new List(); - if (!string.IsNullOrWhiteSpace(advisoryKey)) - { - var trimmed = advisoryKey.Trim(); - if (!string.IsNullOrWhiteSpace(trimmed)) - { - directCandidates.Add(trimmed); - var upper = trimmed.ToUpperInvariant(); - if (!string.Equals(upper, trimmed, StringComparison.Ordinal)) - { - directCandidates.Add(upper); - } - } - } - - foreach (var candidate in directCandidates.Distinct(StringComparer.OrdinalIgnoreCase)) - { - var advisory = await advisoryStore.FindAsync(candidate, cancellationToken).ConfigureAwait(false); - if (advisory is not null) - { - return CreateResolution(advisory); - } - } - - var aliasMatches = new List(); - foreach (var (scheme, value) in BuildAliasLookups(advisoryKey)) - { - var records = await aliasStore.GetByAliasAsync(scheme, value, cancellationToken).ConfigureAwait(false); - if (records.Count > 0) - { - aliasMatches.AddRange(records); - } - } - - if (aliasMatches.Count == 0) - { - return null; - } - - foreach (var candidate in aliasMatches - .OrderByDescending(record => record.UpdatedAt) - .ThenBy(record => record.AdvisoryKey, StringComparer.Ordinal) - .Select(record => record.AdvisoryKey) - .Distinct(StringComparer.OrdinalIgnoreCase)) - { - var advisory = await advisoryStore.FindAsync(candidate, cancellationToken).ConfigureAwait(false); - if (advisory is not null) - { - return CreateResolution(advisory); - } - } - - return null; -} - -static (Advisory Advisory, ImmutableArray Aliases, string Fingerprint) CreateResolution(Advisory advisory) -{ - var fingerprint = AdvisoryFingerprint.Compute(advisory); - var aliases = BuildAliasQuery(advisory); - return (advisory, aliases, fingerprint); -} - -static ImmutableArray BuildAliasQuery(Advisory advisory) -{ - var set = new HashSet(StringComparer.OrdinalIgnoreCase); - - if (!string.IsNullOrWhiteSpace(advisory.AdvisoryKey)) - { - set.Add(advisory.AdvisoryKey.Trim()); - } - - foreach (var alias in advisory.Aliases) - { - if (!string.IsNullOrWhiteSpace(alias)) - { - set.Add(alias.Trim()); - } - } - - if (set.Count == 0) - { - return ImmutableArray.Empty; - } - - var ordered = set - .OrderBy(static value => value, StringComparer.OrdinalIgnoreCase) - .ToList(); - - var canonical = advisory.AdvisoryKey?.Trim(); - if (!string.IsNullOrWhiteSpace(canonical)) - { - ordered.RemoveAll(value => string.Equals(value, canonical, StringComparison.OrdinalIgnoreCase)); - ordered.Insert(0, canonical); - } - - return ordered.ToImmutableArray(); -} - -static IReadOnlyList<(string Scheme, string Value)> BuildAliasLookups(string? candidate) -{ - var pairs = new List<(string Scheme, string Value)>(); - var seen = new HashSet(StringComparer.Ordinal); - - void Add(string scheme, string? value) - { - if (string.IsNullOrWhiteSpace(scheme) || string.IsNullOrWhiteSpace(value)) - { - return; - } - - var trimmed = value.Trim(); - if (trimmed.Length == 0) - { - return; - } - - var key = $"{scheme}\u0001{trimmed}"; - if (seen.Add(key)) - { - pairs.Add((scheme, trimmed)); - } - } - - if (AliasSchemeRegistry.TryNormalize(candidate, out var normalized, out var scheme)) - { - Add(scheme, normalized); - } - - Add(AliasStoreConstants.UnscopedScheme, candidate); - Add(AliasStoreConstants.PrimaryScheme, candidate); - - return pairs; -} - -ImmutableHashSet BuildFilterSet(StringValues values) -{ - if (values.Count == 0) - { - return ImmutableHashSet.Empty; - } - - var builder = ImmutableHashSet.CreateBuilder(StringComparer.OrdinalIgnoreCase); - foreach (var value in values) - { - if (string.IsNullOrWhiteSpace(value)) - { - continue; - } - - var segments = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (segments.Length == 0) - { - builder.Add(value.Trim()); - continue; - } - - foreach (var segment in segments) - { - if (!string.IsNullOrWhiteSpace(segment)) - { - builder.Add(segment.Trim()); - } - } - } - - return builder.ToImmutable(); -} - + +bool TryResolveTenant(HttpContext context, bool requireHeader, out string tenant, out IResult? error) +{ + tenant = string.Empty; + error = null; + + var headerTenant = context.Request.Headers[TenantHeaderName].FirstOrDefault(); + var queryTenant = context.Request.Query.TryGetValue("tenant", out var tenantValues) ? tenantValues.FirstOrDefault() : null; + + if (requireHeader && string.IsNullOrWhiteSpace(headerTenant)) + { + error = Problem(context, "Tenant header required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, $"Header '{TenantHeaderName}' must be provided."); + return false; + } + + if (!string.IsNullOrWhiteSpace(headerTenant) && !string.IsNullOrWhiteSpace(queryTenant) && + !string.Equals(headerTenant.Trim(), queryTenant.Trim(), StringComparison.OrdinalIgnoreCase)) + { + error = Problem(context, "Tenant mismatch", StatusCodes.Status400BadRequest, ProblemTypes.Validation, $"Values for '{TenantHeaderName}' and 'tenant' query parameter must match."); + return false; + } + + var resolved = !string.IsNullOrWhiteSpace(headerTenant) ? headerTenant : queryTenant; + if (string.IsNullOrWhiteSpace(resolved)) + { + error = Problem(context, "Tenant required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, $"Specify the tenant via '{TenantHeaderName}' header or 'tenant' query parameter."); + return false; + } + + tenant = resolved.Trim().ToLowerInvariant(); + return true; +} + +IResult? EnsureTenantAuthorized(HttpContext context, string tenant) +{ + if (!authorityConfigured) + { + return null; + } + + if (enforceTenantAllowlist && !requiredTenants.Contains(tenant)) + { + return Results.Forbid(); + } + + var principal = context.User; + + if (enforceAuthority && (principal?.Identity?.IsAuthenticated != true)) + { + return Results.Unauthorized(); + } + + if (principal?.Identity?.IsAuthenticated == true) + { + var tenantClaim = principal.FindFirstValue(StellaOpsClaimTypes.Tenant); + if (string.IsNullOrWhiteSpace(tenantClaim)) + { + return Results.Forbid(); + } + + var normalizedClaim = tenantClaim.Trim().ToLowerInvariant(); + if (!string.Equals(normalizedClaim, tenant, StringComparison.Ordinal)) + { + return Results.Forbid(); + } + + if (enforceTenantAllowlist && !requiredTenants.Contains(normalizedClaim)) + { + return Results.Forbid(); + } + } + + return null; +} + +async Task<(Advisory Advisory, ImmutableArray Aliases, string Fingerprint)?> ResolveAdvisoryAsync( + string tenant, + string advisoryKey, + IAdvisoryStore advisoryStore, + IAliasStore aliasStore, + CancellationToken cancellationToken) +{ + if (string.IsNullOrWhiteSpace(tenant)) + { + return null; + } + + ArgumentNullException.ThrowIfNull(advisoryStore); + ArgumentNullException.ThrowIfNull(aliasStore); + + var directCandidates = new List(); + if (!string.IsNullOrWhiteSpace(advisoryKey)) + { + var trimmed = advisoryKey.Trim(); + if (!string.IsNullOrWhiteSpace(trimmed)) + { + directCandidates.Add(trimmed); + var upper = trimmed.ToUpperInvariant(); + if (!string.Equals(upper, trimmed, StringComparison.Ordinal)) + { + directCandidates.Add(upper); + } + } + } + + foreach (var candidate in directCandidates.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var advisory = await advisoryStore.FindAsync(candidate, cancellationToken).ConfigureAwait(false); + if (advisory is not null) + { + return CreateResolution(advisory); + } + } + + var aliasMatches = new List(); + foreach (var (scheme, value) in BuildAliasLookups(advisoryKey)) + { + var records = await aliasStore.GetByAliasAsync(scheme, value, cancellationToken).ConfigureAwait(false); + if (records.Count > 0) + { + aliasMatches.AddRange(records); + } + } + + if (aliasMatches.Count == 0) + { + return null; + } + + foreach (var candidate in aliasMatches + .OrderByDescending(record => record.UpdatedAt) + .ThenBy(record => record.AdvisoryKey, StringComparer.Ordinal) + .Select(record => record.AdvisoryKey) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + var advisory = await advisoryStore.FindAsync(candidate, cancellationToken).ConfigureAwait(false); + if (advisory is not null) + { + return CreateResolution(advisory); + } + } + + return null; +} + +static (Advisory Advisory, ImmutableArray Aliases, string Fingerprint) CreateResolution(Advisory advisory) +{ + var fingerprint = AdvisoryFingerprint.Compute(advisory); + var aliases = BuildAliasQuery(advisory); + return (advisory, aliases, fingerprint); +} + +static ImmutableArray BuildAliasQuery(Advisory advisory) +{ + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (!string.IsNullOrWhiteSpace(advisory.AdvisoryKey)) + { + set.Add(advisory.AdvisoryKey.Trim()); + } + + foreach (var alias in advisory.Aliases) + { + if (!string.IsNullOrWhiteSpace(alias)) + { + set.Add(alias.Trim()); + } + } + + if (set.Count == 0) + { + return ImmutableArray.Empty; + } + + var ordered = set + .OrderBy(static value => value, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var canonical = advisory.AdvisoryKey?.Trim(); + if (!string.IsNullOrWhiteSpace(canonical)) + { + ordered.RemoveAll(value => string.Equals(value, canonical, StringComparison.OrdinalIgnoreCase)); + ordered.Insert(0, canonical); + } + + return ordered.ToImmutableArray(); +} + +static IReadOnlyList<(string Scheme, string Value)> BuildAliasLookups(string? candidate) +{ + var pairs = new List<(string Scheme, string Value)>(); + var seen = new HashSet(StringComparer.Ordinal); + + void Add(string scheme, string? value) + { + if (string.IsNullOrWhiteSpace(scheme) || string.IsNullOrWhiteSpace(value)) + { + return; + } + + var trimmed = value.Trim(); + if (trimmed.Length == 0) + { + return; + } + + var key = $"{scheme}\u0001{trimmed}"; + if (seen.Add(key)) + { + pairs.Add((scheme, trimmed)); + } + } + + if (AliasSchemeRegistry.TryNormalize(candidate, out var normalized, out var scheme)) + { + Add(scheme, normalized); + } + + Add(AliasStoreConstants.UnscopedScheme, candidate); + Add(AliasStoreConstants.PrimaryScheme, candidate); + + return pairs; +} + +ImmutableHashSet BuildFilterSet(StringValues values) +{ + if (values.Count == 0) + { + return ImmutableHashSet.Empty; + } + + var builder = ImmutableHashSet.CreateBuilder(StringComparer.OrdinalIgnoreCase); + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + var segments = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length == 0) + { + builder.Add(value.Trim()); + continue; + } + + foreach (var segment in segments) + { + if (!string.IsNullOrWhiteSpace(segment)) + { + builder.Add(segment.Trim()); + } + } + } + + return builder.ToImmutable(); +} + int ResolveBoundedInt(StringValues values, int fallback, int minValue, int maxValue) { foreach (var value in values) { if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) - { - return Math.Clamp(parsed, minValue, maxValue); - } - } + { + return Math.Clamp(parsed, minValue, maxValue); + } + } return Math.Clamp(fallback, minValue, maxValue); } @@ -2181,19 +2646,27 @@ static DateTimeOffset? ParseDateTime(string? value) if (string.IsNullOrWhiteSpace(value)) { return null; - } - - return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) - ? parsed.ToUniversalTime() - : null; -} - -IResult MapAocGuardException(HttpContext context, ConcelierAocGuardException exception) -{ - var guardException = new AocGuardException(exception.Result); - return AocHttpResults.Problem(context, guardException); -} - + } + + return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) + ? parsed.ToUniversalTime() + : null; +} + +static async Task ComputeSha256Async(Stream stream, CancellationToken cancellationToken) +{ + stream.Seek(0, SeekOrigin.Begin); + using var sha = SHA256.Create(); + var hash = await sha.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false); + return Convert.ToHexString(hash).ToLowerInvariant(); +} + +IResult MapAocGuardException(HttpContext context, ConcelierAocGuardException exception) +{ + var guardException = new AocGuardException(exception.Result); + return AocHttpResults.Problem(context, guardException); +} + static KeyValuePair[] BuildJobMetricTags(string jobKind, string trigger, string outcome) => new[] { @@ -2278,17 +2751,19 @@ static string? ResolveEvidencePath(string candidate, string root) return null; } + var effectiveRoot = root ?? string.Empty; + var path = candidate; - if (!Path.IsPathRooted(path)) + if (!Path.IsPathRooted(path) && !string.IsNullOrWhiteSpace(effectiveRoot)) { - path = Path.Combine(root, path); + path = Path.Combine(effectiveRoot, path); } var fullPath = Path.GetFullPath(path); - if (!string.IsNullOrWhiteSpace(root)) + if (!string.IsNullOrWhiteSpace(effectiveRoot)) { - var rootPath = Path.GetFullPath(root) + var rootPath = Path.GetFullPath(effectiveRoot) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase)) @@ -2300,6 +2775,35 @@ static string? ResolveEvidencePath(string candidate, string root) return fullPath; } +static EvidencePathResolutionResult ResolveEvidencePaths( + VerifyAttestationRequest request, + string root, + ConcelierOptions.EvidenceBundleOptions evidenceOptions) +{ + var effectiveRoot = string.IsNullOrWhiteSpace(root) ? string.Empty : root; + + var bundlePath = ResolveEvidencePath(request.BundlePath ?? string.Empty, effectiveRoot); + if (string.IsNullOrWhiteSpace(bundlePath) || !File.Exists(bundlePath)) + { + return EvidencePathResolutionResult.Invalid("Bundle path not found", request.BundlePath); + } + + var manifestPath = string.IsNullOrWhiteSpace(request.ManifestPath) + ? ResolveSibling(bundlePath, evidenceOptions.DefaultManifestFileName) + : ResolveEvidencePath(request.ManifestPath!, effectiveRoot); + + if (string.IsNullOrWhiteSpace(manifestPath) || !File.Exists(manifestPath)) + { + return EvidencePathResolutionResult.Invalid("Manifest path not found", request.ManifestPath); + } + + var transparencyPath = string.IsNullOrWhiteSpace(request.TransparencyPath) + ? ResolveSibling(bundlePath, evidenceOptions.DefaultTransparencyFileName) + : ResolveEvidencePath(request.TransparencyPath!, effectiveRoot); + + return EvidencePathResolutionResult.Valid(bundlePath!, manifestPath!, transparencyPath); +} + static string? ResolveSibling(string? bundlePath, string? fileName) { if (string.IsNullOrWhiteSpace(bundlePath) || string.IsNullOrWhiteSpace(fileName)) @@ -2321,338 +2825,338 @@ 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); - - var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 200); - var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false); - var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); - return JsonResult(payload); -}).AddEndpointFilter(); -if (enforceAuthority) -{ - jobsListEndpoint.RequireAuthorization(JobsPolicyName); -} - -var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => -{ - ApplyNoCache(context.Response); - - var run = await coordinator.GetRunAsync(runId, cancellationToken).ConfigureAwait(false); - if (run is null) - { - return Problem(context, "Job run not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job run '{runId}' was not found."); - } - - return JsonResult(JobRunResponse.FromSnapshot(run)); -}).AddEndpointFilter(); -if (enforceAuthority) -{ - jobByIdEndpoint.RequireAuthorization(JobsPolicyName); -} - -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)); - } - - return JsonResult(responses); -}).AddEndpointFilter(); -if (enforceAuthority) -{ - jobDefinitionsEndpoint.RequireAuthorization(JobsPolicyName); -} - -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); - - var response = JobDefinitionResponse.FromDefinition(definition, lastRun); - return JsonResult(response); -}).AddEndpointFilter(); -if (enforceAuthority) -{ - jobDefinitionEndpoint.RequireAuthorization(JobsPolicyName); -} - -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."); - } - - 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(); - return JsonResult(payload); -}).AddEndpointFilter(); -if (enforceAuthority) -{ - jobDefinitionRunsEndpoint.RequireAuthorization(JobsPolicyName); -} - -var activeJobsEndpoint = app.MapGet("/jobs/active", async ([FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => -{ - ApplyNoCache(context.Response); - - var runs = await coordinator.GetActiveRunsAsync(cancellationToken).ConfigureAwait(false); - var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); - return JsonResult(payload); -}).AddEndpointFilter(); -if (enforceAuthority) -{ - activeJobsEndpoint.RequireAuthorization(JobsPolicyName); -} - -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) - { - case JobTriggerOutcome.Accepted: - JobMetrics.TriggerCounter.Add(1, tags); - if (result.Run is null) - { - return Results.StatusCode(StatusCodes.Status202Accepted); - } - - 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); - } - - default: - JobMetrics.TriggerFailureCounter.Add(1, tags); - return Problem(context, "Unexpected job outcome", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, $"Job '{jobKind}' returned outcome '{outcome}'."); - } -}).AddEndpointFilter(); + } + + 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); + + var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 200); + var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false); + var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); + return JsonResult(payload); +}).AddEndpointFilter(); +if (enforceAuthority) +{ + jobsListEndpoint.RequireAuthorization(JobsPolicyName); +} + +var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + var run = await coordinator.GetRunAsync(runId, cancellationToken).ConfigureAwait(false); + if (run is null) + { + return Problem(context, "Job run not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job run '{runId}' was not found."); + } + + return JsonResult(JobRunResponse.FromSnapshot(run)); +}).AddEndpointFilter(); +if (enforceAuthority) +{ + jobByIdEndpoint.RequireAuthorization(JobsPolicyName); +} + +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)); + } + + return JsonResult(responses); +}).AddEndpointFilter(); +if (enforceAuthority) +{ + jobDefinitionsEndpoint.RequireAuthorization(JobsPolicyName); +} + +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); + + var response = JobDefinitionResponse.FromDefinition(definition, lastRun); + return JsonResult(response); +}).AddEndpointFilter(); +if (enforceAuthority) +{ + jobDefinitionEndpoint.RequireAuthorization(JobsPolicyName); +} + +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."); + } + + 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(); + return JsonResult(payload); +}).AddEndpointFilter(); +if (enforceAuthority) +{ + jobDefinitionRunsEndpoint.RequireAuthorization(JobsPolicyName); +} + +var activeJobsEndpoint = app.MapGet("/jobs/active", async ([FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + var runs = await coordinator.GetActiveRunsAsync(cancellationToken).ConfigureAwait(false); + var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); + return JsonResult(payload); +}).AddEndpointFilter(); +if (enforceAuthority) +{ + activeJobsEndpoint.RequireAuthorization(JobsPolicyName); +} + +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) + { + case JobTriggerOutcome.Accepted: + JobMetrics.TriggerCounter.Add(1, tags); + if (result.Run is null) + { + return Results.StatusCode(StatusCodes.Status202Accepted); + } + + 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); + } + + default: + JobMetrics.TriggerFailureCounter.Add(1, tags); + return Problem(context, "Unexpected job outcome", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, $"Job '{jobKind}' returned outcome '{outcome}'."); + } +}).AddEndpointFilter(); if (enforceAuthority) { triggerJobEndpoint.RequireAuthorization(JobsPolicyName); @@ -2746,69 +3250,69 @@ var concelierTimelineEndpoint = app.MapGet("/obs/concelier/timeline", async ( }); 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 -{ - public static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + } - private static JsonSerializerOptions CreateJsonOptions() + static JsonSerializerOptions CreateJsonOptions() +{ + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; +} + + static PluginHostOptions BuildPluginOptions(ConcelierOptions options, string contentRoot) +{ + var pluginOptions = new PluginHostOptions { - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); - options.Converters.Add(new JsonStringEnumConverter()); - return options; + 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; } } + +} + +} diff --git a/src/Concelier/StellaOps.Concelier.WebService/Services/IncidentFileStore.cs b/src/Concelier/StellaOps.Concelier.WebService/Services/IncidentFileStore.cs new file mode 100644 index 000000000..6137cf168 --- /dev/null +++ b/src/Concelier/StellaOps.Concelier.WebService/Services/IncidentFileStore.cs @@ -0,0 +1,104 @@ +using System.Text.Json; + +namespace StellaOps.Concelier.WebService.Services; + +internal static class IncidentFileStore +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + public static string GetIncidentFilePath(ConcelierOptions.EvidenceBundleOptions evidenceOptions, string tenant, string advisoryKey) + { + ArgumentNullException.ThrowIfNull(evidenceOptions); + ArgumentNullException.ThrowIfNull(tenant); + ArgumentNullException.ThrowIfNull(advisoryKey); + + var root = evidenceOptions.RootAbsolute ?? evidenceOptions.Root ?? string.Empty; + return Path.Combine(root, tenant.Trim(), advisoryKey.Trim(), "incident.json"); + } + + public static async Task WriteAsync( + ConcelierOptions.EvidenceBundleOptions evidenceOptions, + string tenant, + string advisoryKey, + string reason, + int cooldownMinutes, + string? pipelineVersion, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + var activatedAt = now.ToUniversalTime(); + var cooldownUntil = activatedAt.AddMinutes(cooldownMinutes); + + var payload = new IncidentFile + { + AdvisoryKey = advisoryKey.Trim(), + Tenant = tenant.Trim(), + Reason = string.IsNullOrWhiteSpace(reason) ? "unspecified" : reason.Trim(), + ActivatedAt = activatedAt, + CooldownUntil = cooldownUntil, + PipelineVersion = pipelineVersion, + }; + + var json = JsonSerializer.Serialize(payload, SerializerOptions); + await File.WriteAllTextAsync(path, json, cancellationToken).ConfigureAwait(false); + } + + public static async Task ReadAsync( + ConcelierOptions.EvidenceBundleOptions evidenceOptions, + string tenant, + string advisoryKey, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey); + if (!File.Exists(path)) + { + return null; + } + + await using var stream = File.OpenRead(path); + var payload = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken).ConfigureAwait(false); + if (payload is null) + { + return null; + } + + var active = payload.CooldownUntil > now.ToUniversalTime(); + return new IncidentStatusResponse( + payload.AdvisoryKey, + payload.Tenant, + payload.Reason, + payload.ActivatedAt.ToUniversalTime().ToString("O"), + payload.CooldownUntil.ToUniversalTime().ToString("O"), + payload.PipelineVersion, + active); + } + + public static Task DeleteAsync(ConcelierOptions.EvidenceBundleOptions evidenceOptions, string tenant, string advisoryKey, CancellationToken cancellationToken) + { + var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey); + if (File.Exists(path)) + { + File.Delete(path); + } + + return Task.CompletedTask; + } + + private sealed record IncidentFile + { + public string AdvisoryKey { get; init; } = string.Empty; + public string Tenant { get; init; } = string.Empty; + public string Reason { get; init; } = "unspecified"; + public DateTimeOffset ActivatedAt { get; init; } + public DateTimeOffset CooldownUntil { get; init; } + public string? PipelineVersion { get; init; } + } +} diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/Internal/CccsMapper.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/Internal/CccsMapper.cs index a23cadc5c..5c09328ff 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/Internal/CccsMapper.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/Internal/CccsMapper.cs @@ -1,10 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using StellaOps.Concelier.Models; -using StellaOps.Concelier.Storage.Mongo.Documents; - -namespace StellaOps.Concelier.Connector.Cccs.Internal; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using StellaOps.Concelier.Models; +using StellaOps.Concelier.Storage.Mongo.Documents; +using StellaOps.Concelier.Normalization.SemVer; + +namespace StellaOps.Concelier.Connector.Cccs.Internal; internal static class CccsMapper { @@ -108,44 +110,149 @@ internal static class CccsMapper .ToArray(); } - private static IReadOnlyList BuildPackages(CccsAdvisoryDto dto, DateTimeOffset recordedAt) - { - if (dto.Products.Count == 0) - { - return Array.Empty(); - } - - var packages = new List(dto.Products.Count); - foreach (var product in dto.Products) - { - if (string.IsNullOrWhiteSpace(product)) - { - continue; - } - - var identifier = product.Trim(); - var provenance = new AdvisoryProvenance( - CccsConnectorPlugin.SourceName, - "package", - identifier, - recordedAt, - new[] { ProvenanceFieldMasks.AffectedPackages }); - - packages.Add(new AffectedPackage( - AffectedPackageTypes.Vendor, - identifier, - platform: null, - versionRanges: Array.Empty(), - statuses: Array.Empty(), - provenance: new[] { provenance }, - normalizedVersions: Array.Empty())); - } - - return packages.Count == 0 - ? Array.Empty() - : packages - .DistinctBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase) - .OrderBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase) - .ToArray(); - } -} + private static IReadOnlyList BuildPackages(CccsAdvisoryDto dto, DateTimeOffset recordedAt) + { + if (dto.Products.Count == 0) + { + return Array.Empty(); + } + + var packages = new List(dto.Products.Count); + for (var index = 0; index < dto.Products.Count; index++) + { + var product = dto.Products[index]; + if (string.IsNullOrWhiteSpace(product)) + { + continue; + } + + var identifier = product.Trim(); + var provenance = new AdvisoryProvenance( + CccsConnectorPlugin.SourceName, + "package", + identifier, + recordedAt, + new[] { ProvenanceFieldMasks.AffectedPackages }); + + var rangeAnchor = $"cccs:{dto.SerialNumber}:{index}"; + var versionRanges = BuildVersionRanges(product, rangeAnchor, recordedAt); + var normalizedVersions = BuildNormalizedVersions(versionRanges, rangeAnchor); + + packages.Add(new AffectedPackage( + AffectedPackageTypes.Vendor, + identifier, + platform: null, + versionRanges: versionRanges, + statuses: Array.Empty(), + provenance: new[] { provenance }, + normalizedVersions: normalizedVersions)); + } + + return packages.Count == 0 + ? Array.Empty() + : packages + .DistinctBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase) + .OrderBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static IReadOnlyList BuildVersionRanges(string productText, string rangeAnchor, DateTimeOffset recordedAt) + { + var versionText = ExtractFirstVersionToken(productText); + if (string.IsNullOrWhiteSpace(versionText)) + { + return Array.Empty(); + } + + var provenance = new AdvisoryProvenance( + CccsConnectorPlugin.SourceName, + "range", + rangeAnchor, + recordedAt, + new[] { ProvenanceFieldMasks.VersionRanges }); + + var vendorExtensions = new Dictionary + { + ["cccs.version.raw"] = versionText!, + ["cccs.anchor"] = rangeAnchor, + }; + + var semVerResults = SemVerRangeRuleBuilder.Build(versionText!, patchedVersion: null, provenanceNote: rangeAnchor); + if (semVerResults.Count > 0) + { + return semVerResults.Select(result => + new AffectedVersionRange( + rangeKind: NormalizedVersionSchemes.SemVer, + introducedVersion: result.Primitive.Introduced, + fixedVersion: result.Primitive.Fixed, + lastAffectedVersion: result.Primitive.LastAffected, + rangeExpression: result.Expression ?? versionText!, + provenance: provenance, + primitives: new RangePrimitives( + result.Primitive, + Nevra: null, + Evr: null, + VendorExtensions: vendorExtensions))) + .ToArray(); + } + + var primitives = new RangePrimitives( + new SemVerPrimitive( + Introduced: versionText, + IntroducedInclusive: true, + Fixed: null, + FixedInclusive: false, + LastAffected: null, + LastAffectedInclusive: true, + ConstraintExpression: null, + ExactValue: versionText), + Nevra: null, + Evr: null, + VendorExtensions: vendorExtensions); + + return new[] + { + new AffectedVersionRange( + rangeKind: NormalizedVersionSchemes.SemVer, + introducedVersion: null, + fixedVersion: null, + lastAffectedVersion: null, + rangeExpression: versionText, + provenance: provenance, + primitives: primitives), + }; + } + + private static IReadOnlyList BuildNormalizedVersions( + IReadOnlyList ranges, + string rangeAnchor) + { + if (ranges.Count == 0) + { + return Array.Empty(); + } + + var rules = new List(ranges.Count); + foreach (var range in ranges) + { + var rule = range.ToNormalizedVersionRule(rangeAnchor); + if (rule is not null) + { + rules.Add(rule); + } + } + + return rules.Count == 0 ? Array.Empty() : rules.ToArray(); + } + + private static string? ExtractFirstVersionToken(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var match = Regex.Match(value, @"\d+(?:\.\d+){0,3}(?:[A-Za-z0-9\-_]*)?"); + return match.Success ? match.Value : null; + } +} diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.CertBund/Internal/CertBundMapper.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.CertBund/Internal/CertBundMapper.cs index 540e96608..c24718a09 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.CertBund/Internal/CertBundMapper.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.CertBund/Internal/CertBundMapper.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using StellaOps.Concelier.Models; using StellaOps.Concelier.Storage.Mongo.Documents; +using StellaOps.Concelier.Normalization.SemVer; namespace StellaOps.Concelier.Connector.CertBund.Internal; @@ -116,23 +118,9 @@ internal static class CertBundMapper recordedAt, new[] { ProvenanceFieldMasks.AffectedPackages }); - var ranges = string.IsNullOrWhiteSpace(product.Versions) - ? Array.Empty() - : new[] - { - new AffectedVersionRange( - rangeKind: "string", - introducedVersion: null, - fixedVersion: null, - lastAffectedVersion: null, - rangeExpression: product.Versions, - provenance: new AdvisoryProvenance( - CertBundConnectorPlugin.SourceName, - "package-range", - product.Versions, - recordedAt, - new[] { ProvenanceFieldMasks.VersionRanges })) - }; + var anchor = $"certbund:{dto.AdvisoryId}:{vendor.ToLowerInvariant().Replace(' ', '-')}"; + var ranges = BuildVersionRanges(product.Versions, anchor, recordedAt); + var normalized = BuildNormalizedVersions(ranges, anchor); packages.Add(new AffectedPackage( AffectedPackageTypes.Vendor, @@ -141,7 +129,7 @@ internal static class CertBundMapper versionRanges: ranges, statuses: Array.Empty(), provenance: new[] { provenance }, - normalizedVersions: Array.Empty())); + normalizedVersions: normalized)); } return packages @@ -150,6 +138,87 @@ internal static class CertBundMapper .ToArray(); } + private static IReadOnlyList BuildVersionRanges(string? versions, string anchor, DateTimeOffset recordedAt) + { + if (string.IsNullOrWhiteSpace(versions) + || string.Equals(versions.Trim(), "alle", StringComparison.OrdinalIgnoreCase)) + { + return Array.Empty(); + } + + var tokens = Regex.Matches(versions, @"\d+(?:\.\d+){0,3}(?:[A-Za-z0-9\-_]*)?") + .Select(match => match.Value) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToList(); + + if (tokens.Count == 0) + { + return Array.Empty(); + } + + var introduced = tokens.First(); + var fixedVersion = tokens.Count > 1 ? tokens.Last() : null; + + var vendorExtensions = new Dictionary + { + ["certbund.version.raw"] = versions!, + ["certbund.anchor"] = anchor, + }; + + var semVer = new SemVerPrimitive( + Introduced: introduced, + IntroducedInclusive: true, + Fixed: fixedVersion, + FixedInclusive: true, + LastAffected: null, + LastAffectedInclusive: true, + ConstraintExpression: null, + ExactValue: tokens.Count == 1 ? introduced : null); + + var rangeProvenance = new AdvisoryProvenance( + CertBundConnectorPlugin.SourceName, + "package-range", + anchor, + recordedAt, + new[] { ProvenanceFieldMasks.VersionRanges }); + + var primitives = new RangePrimitives(semVer, Nevra: null, Evr: null, VendorExtensions: vendorExtensions); + + return new[] + { + new AffectedVersionRange( + rangeKind: NormalizedVersionSchemes.SemVer, + introducedVersion: introduced, + fixedVersion: fixedVersion, + lastAffectedVersion: null, + rangeExpression: versions!, + provenance: rangeProvenance, + primitives: primitives), + }; + } + + private static IReadOnlyList BuildNormalizedVersions( + IReadOnlyList ranges, + string anchor) + { + if (ranges.Count == 0) + { + return Array.Empty(); + } + + var rules = new List(ranges.Count); + foreach (var range in ranges) + { + var rule = range.ToNormalizedVersionRule(anchor); + if (rule is not null) + { + rules.Add(rule); + } + } + + return rules.Count == 0 ? Array.Empty() : rules.ToArray(); + } + private static string? MapSeverity(string? severity) { if (string.IsNullOrWhiteSpace(severity)) diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Cccs.Tests/Internal/CccsMapperTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Cccs.Tests/Internal/CccsMapperTests.cs index 8dca2117b..03413e7a5 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Cccs.Tests/Internal/CccsMapperTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Cccs.Tests/Internal/CccsMapperTests.cs @@ -1,10 +1,11 @@ -using System; -using FluentAssertions; -using StellaOps.Concelier.Connector.Cccs.Internal; -using StellaOps.Concelier.Connector.Common; -using StellaOps.Concelier.Connector.Common.Html; -using StellaOps.Concelier.Storage.Mongo.Documents; -using Xunit; +using System; +using FluentAssertions; +using StellaOps.Concelier.Connector.Cccs.Internal; +using StellaOps.Concelier.Connector.Common; +using StellaOps.Concelier.Connector.Common.Html; +using StellaOps.Concelier.Models; +using StellaOps.Concelier.Storage.Mongo.Documents; +using Xunit; namespace StellaOps.Concelier.Connector.Cccs.Tests.Internal; @@ -35,9 +36,17 @@ public sealed class CccsMapperTests advisory.AdvisoryKey.Should().Be("TEST-001"); advisory.Title.Should().Be(dto.Title); advisory.Aliases.Should().Contain(new[] { "TEST-001", "CVE-2020-1234", "CVE-2021-9999" }); - advisory.References.Should().Contain(reference => reference.Url == dto.CanonicalUrl && reference.Kind == "details"); - advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details"); - advisory.AffectedPackages.Should().HaveCount(2); - advisory.Provenance.Should().ContainSingle(p => p.Source == CccsConnectorPlugin.SourceName && p.Kind == "advisory"); - } -} + advisory.References.Should().Contain(reference => reference.Url == dto.CanonicalUrl && reference.Kind == "details"); + advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details"); + advisory.AffectedPackages.Should().HaveCount(2); + advisory.Provenance.Should().ContainSingle(p => p.Source == CccsConnectorPlugin.SourceName && p.Kind == "advisory"); + + var first = advisory.AffectedPackages[0]; + first.VersionRanges.Should().ContainSingle(range => range.RangeKind == NormalizedVersionSchemes.SemVer && range.RangeExpression == "1.0"); + first.NormalizedVersions.Should().ContainSingle(rule => rule.Notes == "cccs:TEST-001:0" && rule.Value == "1.0"); + + var second = advisory.AffectedPackages[1]; + second.VersionRanges.Should().ContainSingle(range => range.RangeKind == NormalizedVersionSchemes.SemVer && range.RangeExpression == "2.0"); + second.NormalizedVersions.Should().ContainSingle(rule => rule.Notes == "cccs:TEST-001:1" && rule.Value == "2.0"); + } +} diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertBund.Tests/CertBundConnectorTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertBund.Tests/CertBundConnectorTests.cs index 93dc5fd7c..6b5604f12 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertBund.Tests/CertBundConnectorTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertBund.Tests/CertBundConnectorTests.cs @@ -12,13 +12,14 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using MongoDB.Bson; using StellaOps.Concelier.Connector.CertBund.Configuration; -using StellaOps.Concelier.Connector.Common.Http; -using StellaOps.Concelier.Connector.Common; -using StellaOps.Concelier.Connector.Common.Fetch; -using StellaOps.Concelier.Connector.Common.Testing; -using StellaOps.Concelier.Storage.Mongo; -using StellaOps.Concelier.Storage.Mongo.Advisories; -using StellaOps.Concelier.Storage.Mongo.Documents; +using StellaOps.Concelier.Connector.Common.Http; +using StellaOps.Concelier.Connector.Common; +using StellaOps.Concelier.Connector.Common.Fetch; +using StellaOps.Concelier.Connector.Common.Testing; +using StellaOps.Concelier.Models; +using StellaOps.Concelier.Storage.Mongo; +using StellaOps.Concelier.Storage.Mongo.Advisories; +using StellaOps.Concelier.Storage.Mongo.Documents; using StellaOps.Concelier.Storage.Mongo.Dtos; using StellaOps.Concelier.Testing; using Xunit; @@ -57,16 +58,27 @@ public sealed class CertBundConnectorTests : IAsyncLifetime advisories.Should().HaveCount(1); var advisory = advisories[0]; - advisory.AdvisoryKey.Should().Be("WID-SEC-2025-2264"); - advisory.Aliases.Should().Contain("CVE-2025-1234"); - advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("Ivanti")); - advisory.References.Should().Contain(reference => reference.Url == DetailUri.ToString()); - advisory.Language.Should().Be("de"); - - var stateRepository = provider.GetRequiredService(); - var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None); - state.Should().NotBeNull(); - state!.Cursor.Should().NotBeNull(); + advisory.AdvisoryKey.Should().Be("WID-SEC-2025-2264"); + advisory.Aliases.Should().Contain("CVE-2025-1234"); + advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("Ivanti")); + advisory.References.Should().Contain(reference => reference.Url == DetailUri.ToString()); + advisory.Language.Should().Be("de"); + + var endpoint = advisory.AffectedPackages.Should().ContainSingle(p => p.Identifier.Contains("Endpoint Manager") && !p.Identifier.Contains("Cloud")) + .Subject; + endpoint.VersionRanges.Should().ContainSingle(range => + range.RangeKind == NormalizedVersionSchemes.SemVer && + range.IntroducedVersion == "2023.1" && + range.FixedVersion == "2024.2"); + endpoint.NormalizedVersions.Should().ContainSingle(rule => + rule.Min == "2023.1" && + rule.Max == "2024.2" && + rule.Notes == "certbund:WID-SEC-2025-2264:ivanti"); + + var stateRepository = provider.GetRequiredService(); + var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None); + state.Should().NotBeNull(); + state!.Cursor.Should().NotBeNull(); state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue(); pendingDocs!.AsBsonArray.Should().BeEmpty(); state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue(); diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Attestation/EvidenceBundleAttestationBuilderTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Attestation/EvidenceBundleAttestationBuilderTests.cs index ce5f96276..ecb000126 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Attestation/EvidenceBundleAttestationBuilderTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Attestation/EvidenceBundleAttestationBuilderTests.cs @@ -8,6 +8,7 @@ public sealed class EvidenceBundleAttestationBuilderTests Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "..")); [Fact] + [Trait("Category", "Attestation")] public async Task BuildAsync_ProducesClaimsFromSampleBundle() { var sampleDir = Path.Combine(RepoRoot, "docs", "samples", "evidence-bundle"); @@ -22,7 +23,7 @@ public sealed class EvidenceBundleAttestationBuilderTests tarPath, manifestPath, transparencyPath, - pipelineVersion: "git:test-sha"), + "git:test-sha"), CancellationToken.None); Assert.Equal("evidence-bundle-m0", claims.SubjectName); @@ -38,6 +39,7 @@ public sealed class EvidenceBundleAttestationBuilderTests } [Fact] + [Trait("Category", "Attestation")] public async Task BuildAsync_EnforcesLowercaseTenant() { var tempManifest = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json"); @@ -64,4 +66,30 @@ public sealed class EvidenceBundleAttestationBuilderTests Assert.Contains("Tenant must be lowercase", ex.Message); } + + [Fact] + [Trait("Category", "Attestation")] + public async Task BuildAsync_RequiresTenant() + { + var tempManifest = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json"); + var manifest = """ + { + "bundle_id": "test-bundle", + "version": "1.0.0", + "created": "2025-11-19T00:00:00Z", + "scope": "vex" + } + """; + await File.WriteAllTextAsync(tempManifest, manifest); + + var tempTar = Path.Combine(Path.GetTempPath(), $"bundle-{Guid.NewGuid():N}.tar.gz"); + await File.WriteAllTextAsync(tempTar, "dummy"); + + var builder = new EvidenceBundleAttestationBuilder(); + + var ex = await Assert.ThrowsAsync(() => + builder.BuildAsync(new EvidenceBundleAttestationRequest(tempTar, tempManifest, null, "git:test"), CancellationToken.None)); + + Assert.Contains("Tenant must be present", ex.Message); + } } diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Attestation/EvidenceBundleAttestationValidator.cs b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Attestation/EvidenceBundleAttestationValidator.cs new file mode 100644 index 000000000..8565cfed6 --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Attestation/EvidenceBundleAttestationValidator.cs @@ -0,0 +1,23 @@ +using System.IO; +using System.Threading.Tasks; +using StellaOps.Concelier.Core.Attestation; +using Xunit; + +namespace StellaOps.Concelier.Core.Tests.Attestation; + +public class EvidenceBundleAttestationValidator +{ + [Fact] + public async Task BuildAsync_RejectsMissingTenant() + { + var bundle = Path.GetTempFileName(); + var manifest = Path.GetTempFileName(); + await File.WriteAllTextAsync(bundle, "dummy"); + await File.WriteAllTextAsync(manifest, "{\"tenant\":\"ACME\"}"); + + var builder = new EvidenceBundleAttestationBuilder(); + + await Assert.ThrowsAsync(() => + builder.BuildAsync(new EvidenceBundleAttestationRequest(bundle, manifest, null, "git:test"))); + } +} diff --git a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/AirGap/AirgapBundleBuilderTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/AirGap/AirgapBundleBuilderTests.cs new file mode 100644 index 000000000..c75bf3267 --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/AirGap/AirgapBundleBuilderTests.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using StellaOps.Concelier.WebService.AirGap; +using Xunit; + +namespace StellaOps.Concelier.WebService.Tests.AirGap; + +public class AirgapBundleBuilderTests +{ + [Fact] + public async Task BuildAsync_WritesDeterministicNdjson() + { + var builder = new AirgapBundleBuilder(); + var created = DateTimeOffset.Parse("2025-11-01T00:00:00Z"); + var items = new[] + { + "b:2", + "a:1", + "c:3", + "a:1" // duplicate should still appear twice to preserve raw cache content + }; + + var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-test"); + + try + { + var result = await builder.BuildAsync(items, tempDir.FullName, created); + + var lines = await File.ReadAllLinesAsync(result.BundlePath); + + Assert.Equal(4, lines.Length); + Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }, lines); + Assert.False(string.IsNullOrWhiteSpace(result.Sha256)); + Assert.Equal(4, result.ItemCount); + Assert.True(File.Exists(result.ManifestPath)); + + var manifestJson = await File.ReadAllTextAsync(result.ManifestPath); + var manifest = System.Text.Json.JsonSerializer.Deserialize(manifestJson)!; + Assert.Equal(result.Sha256, manifest.BundleSha256); + Assert.Equal(4, manifest.Count); + Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }, manifest.Items); + Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }.Select(v => v.GetDeterministicHash()), manifest.Entries.Select(e => e.Sha256)); + Assert.Equal(created, manifest.CreatedUtc); + + var manifestJsonFirstRun = manifestJson; + var entryTraceJsonFirstRun = await File.ReadAllTextAsync(result.EntryTracePath); + + // Second run should produce identical hash + var result2 = await builder.BuildAsync(items, tempDir.FullName, created); + Assert.Equal(result.Sha256, result2.Sha256); + Assert.Equal(result.ManifestPath, result2.ManifestPath); // paths stable in same directory + + var manifestJsonSecondRun = await File.ReadAllTextAsync(result2.ManifestPath); + var entryTraceJsonSecondRun = await File.ReadAllTextAsync(result2.EntryTracePath); + Assert.Equal(manifestJsonFirstRun, manifestJsonSecondRun); + Assert.Equal(entryTraceJsonFirstRun, entryTraceJsonSecondRun); + } + finally + { + tempDir.Delete(recursive: true); + } + } +} + +internal static class HashTestExtensions +{ + public static string GetDeterministicHash(this string content) + { + using var sha = System.Security.Cryptography.SHA256.Create(); + var bytes = System.Text.Encoding.UTF8.GetBytes(content); + return System.Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant(); + } +} diff --git a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/AirGap/AirgapBundleValidatorTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/AirGap/AirgapBundleValidatorTests.cs new file mode 100644 index 000000000..68d718913 --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/AirGap/AirgapBundleValidatorTests.cs @@ -0,0 +1,60 @@ +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using StellaOps.Concelier.WebService.AirGap; +using Xunit; + +namespace StellaOps.Concelier.WebService.Tests.AirGap; + +public class AirgapBundleValidatorTests +{ + [Fact] + public async Task ValidateAsync_Succeeds_ForBuilderOutput() + { + var builder = new AirgapBundleBuilder(); + var validator = new AirgapBundleValidator(); + var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-validator"); + + try + { + var items = new[] { "b:2", "a:1" }; + var result = await builder.BuildAsync(items, tempDir.FullName); + + var validation = await validator.ValidateAsync(result.BundlePath, result.ManifestPath, result.EntryTracePath); + + Assert.True(validation.IsValid, string.Join(";", validation.Errors)); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public async Task ValidateAsync_Fails_WhenManifestTampered() + { + var builder = new AirgapBundleBuilder(); + var validator = new AirgapBundleValidator(); + var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-validator-bad"); + + try + { + var items = new[] { "b:2", "a:1" }; + var result = await builder.BuildAsync(items, tempDir.FullName); + + // Tamper manifest count + var manifest = await File.ReadAllTextAsync(result.ManifestPath); + manifest = manifest.Replace("\"count\":2", "\"count\":3"); + await File.WriteAllTextAsync(result.ManifestPath, manifest); + + var validation = await validator.ValidateAsync(result.BundlePath, result.ManifestPath, result.EntryTracePath); + + Assert.False(validation.IsValid); + Assert.Contains(validation.Errors, e => e.Contains("count", System.StringComparison.OrdinalIgnoreCase)); + } + finally + { + tempDir.Delete(recursive: true); + } + } +} diff --git a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/ConcelierHealthEndpointTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/ConcelierHealthEndpointTests.cs index 68a7e24b1..1d0efea38 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/ConcelierHealthEndpointTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/ConcelierHealthEndpointTests.cs @@ -1,20 +1,99 @@ using System.Net; using System.Net.Http.Json; using FluentAssertions; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using StellaOps.Concelier.WebService.Options; using Xunit; namespace StellaOps.Concelier.WebService.Tests; -public class ConcelierHealthEndpointTests : IClassFixture> +public sealed class HealthWebAppFactory : WebApplicationFactory { - private readonly WebApplicationFactory _factory; - - public ConcelierHealthEndpointTests(WebApplicationFactory factory) + public HealthWebAppFactory() { - _factory = factory.WithWebHostBuilder(_ => { }); + // Ensure options binder sees required storage values before Program.Main executes. + Environment.SetEnvironmentVariable("CONCELIER__STORAGE__DSN", "mongodb://localhost:27017/test-health"); + Environment.SetEnvironmentVariable("CONCELIER__STORAGE__DRIVER", "mongo"); + Environment.SetEnvironmentVariable("CONCELIER__STORAGE__COMMANDTIMEOUTSECONDS", "30"); + Environment.SetEnvironmentVariable("CONCELIER__TELEMETRY__ENABLED", "false"); + Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1"); + Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "mongodb://localhost:27017/test-health"); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing"); + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing"); } + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((_, config) => + { + var overrides = new Dictionary + { + {"Storage:Dsn", "mongodb://localhost:27017/test-health"}, + {"Storage:Driver", "mongo"}, + {"Storage:CommandTimeoutSeconds", "30"}, + {"Telemetry:Enabled", "false"} + }; + + config.AddInMemoryCollection(overrides); + }); + + builder.UseSetting("CONCELIER__STORAGE__DSN", "mongodb://localhost:27017/test-health"); + builder.UseSetting("CONCELIER__STORAGE__DRIVER", "mongo"); + builder.UseSetting("CONCELIER__STORAGE__COMMANDTIMEOUTSECONDS", "30"); + builder.UseSetting("CONCELIER__TELEMETRY__ENABLED", "false"); + + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + services.AddSingleton(new ConcelierOptions + { + Storage = new ConcelierOptions.StorageOptions + { + Dsn = "mongodb://localhost:27017/test-health", + Driver = "mongo", + CommandTimeoutSeconds = 30 + }, + Telemetry = new ConcelierOptions.TelemetryOptions + { + Enabled = false + } + }); + + services.AddSingleton>(sp => new ConfigureOptions(opts => + { + opts.Storage ??= new ConcelierOptions.StorageOptions(); + opts.Storage.Driver = "mongo"; + opts.Storage.Dsn = "mongodb://localhost:27017/test-health"; + opts.Storage.CommandTimeoutSeconds = 30; + + opts.Telemetry ??= new ConcelierOptions.TelemetryOptions(); + opts.Telemetry.Enabled = false; + })); + services.PostConfigure(opts => + { + opts.Storage ??= new ConcelierOptions.StorageOptions(); + opts.Storage.Driver = "mongo"; + opts.Storage.Dsn = "mongodb://localhost:27017/test-health"; + opts.Storage.CommandTimeoutSeconds = 30; + + opts.Telemetry ??= new ConcelierOptions.TelemetryOptions(); + opts.Telemetry.Enabled = false; + }); + }); + } +} + +public class ConcelierHealthEndpointTests : IClassFixture +{ + private readonly HealthWebAppFactory _factory; + + public ConcelierHealthEndpointTests(HealthWebAppFactory factory) => _factory = factory; + [Fact] public async Task Health_requires_tenant_header() { diff --git a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/Services/IncidentFileStoreTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/Services/IncidentFileStoreTests.cs new file mode 100644 index 000000000..73056d5a0 --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/Services/IncidentFileStoreTests.cs @@ -0,0 +1,43 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using StellaOps.Concelier.WebService.Services; +using StellaOps.Concelier.WebService; +using Xunit; + +namespace StellaOps.Concelier.WebService.Tests.Services; + +public sealed class IncidentFileStoreTests +{ + [Fact] + public async Task WriteReadDelete_RoundTripsIncident() + { + var temp = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "incident-store-tests", Path.GetRandomFileName())); + var options = new ConcelierOptions + { + Evidence = new ConcelierOptions.EvidenceBundleOptions + { + RootAbsolute = temp.FullName, + DefaultManifestFileName = "manifest.json", + DefaultTransparencyFileName = "transparency.json", + PipelineVersion = "git:test", + }, + }; + + var now = new DateTimeOffset(2025, 11, 25, 12, 0, 0, TimeSpan.Zero); + await IncidentFileStore.WriteAsync(options.Evidence!, "tenant-a", "ADV-1", "test-reason", 30, options.Evidence!.PipelineVersion, now, CancellationToken.None); + + var status = await IncidentFileStore.ReadAsync(options.Evidence!, "tenant-a", "ADV-1", now, CancellationToken.None); + status.Should().NotBeNull(); + status!.Reason.Should().Be("test-reason"); + status.Active.Should().BeTrue(); + status.Tenant.Should().Be("tenant-a"); + status.AdvisoryKey.Should().Be("ADV-1"); + status.PipelineVersion.Should().Be("git:test"); + + await IncidentFileStore.DeleteAsync(options.Evidence!, "tenant-a", "ADV-1", CancellationToken.None); + var afterDelete = await IncidentFileStore.ReadAsync(options.Evidence!, "tenant-a", "ADV-1", now, CancellationToken.None); + afterDelete.Should().BeNull(); + } +} diff --git a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj index f225e50c8..1c3895eda 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj +++ b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj @@ -12,6 +12,7 @@ true + diff --git a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs index 4094b7ca3..bc99be76c 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs @@ -27,6 +27,8 @@ using Mongo2Go; using MongoDB.Bson; using MongoDB.Bson.IO; using MongoDB.Driver; +using StellaOps.Concelier.Core.Attestation; +using static StellaOps.Concelier.WebService.Program; using StellaOps.Concelier.Core.Events; using StellaOps.Concelier.Core.Jobs; using StellaOps.Concelier.Models; @@ -39,6 +41,7 @@ using StellaOps.Concelier.Core.Raw; using StellaOps.Concelier.WebService.Jobs; using StellaOps.Concelier.WebService.Options; using StellaOps.Concelier.WebService.Contracts; +using StellaOps.Concelier.WebService; using Xunit.Sdk; using StellaOps.Auth.Abstractions; using StellaOps.Auth.Client; @@ -73,7 +76,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime public Task InitializeAsync() { PrepareMongoEnvironment(); - if (TryStartExternalMongo(out var externalConnectionString)) + if (TryStartExternalMongo(out var externalConnectionString) && !string.IsNullOrWhiteSpace(externalConnectionString)) { _factory = new ConcelierApplicationFactory(externalConnectionString); } @@ -381,6 +384,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime Assert.Equal("ADV-002", firstItem.GetProperty("advisoryId").GetString()); Assert.Contains("pkg:npm/demo@2.0.0", firstItem.GetProperty("purl").EnumerateArray().Select(x => x.GetString())); Assert.True(firstItem.GetProperty("conflicts").EnumerateArray().Count() >= 0); + Assert.Equal("created", firstItem.GetProperty("timeline").EnumerateArray().First().GetProperty("event").GetString()); + Assert.Equal(DateTime.Parse("2025-01-06T00:00:00Z"), firstItem.GetProperty("publishedAt").GetDateTime()); var detailResponse = await client.GetAsync("/v1/lnm/linksets/ADV-001?source=osv&includeObservations=true"); detailResponse.EnsureSuccessStatusCode(); @@ -390,6 +395,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime Assert.Equal("osv", detailPayload.GetProperty("source").GetString()); Assert.Contains("pkg:npm/demo@1.0.0", detailPayload.GetProperty("purl").EnumerateArray().Select(x => x.GetString())); Assert.Contains("obs-1", detailPayload.GetProperty("observations").EnumerateArray().Select(x => x.GetString())); + Assert.Equal(DateTime.Parse("2025-01-05T00:00:00Z"), detailPayload.GetProperty("publishedAt").GetDateTime()); } [Fact] @@ -713,6 +719,66 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime Assert.Equal(tarPath, evidence.Attestation.EvidenceBundlePath); } + [Fact] + [Trait("Category", "Attestation")] + public async Task InternalAttestationVerify_ReturnsClaims() + { + var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "..")); + var sampleDir = Path.Combine(repoRoot, "docs", "samples", "evidence-bundle"); + var tarPath = Path.Combine(sampleDir, "evidence-bundle-m0.tar.gz"); + var manifestPath = Path.Combine(sampleDir, "manifest.json"); + var transparencyPath = Path.Combine(sampleDir, "transparency.json"); + + using var scope = _factory.Services.CreateScope(); + var concOptions = scope.ServiceProvider.GetRequiredService>().Value; + _output.WriteLine($"EvidenceRoot={concOptions.Evidence.RootAbsolute}"); + Assert.StartsWith(concOptions.Evidence.RootAbsolute, tarPath, StringComparison.OrdinalIgnoreCase); + + using var client = _factory.CreateClient(); + var request = new VerifyAttestationRequest(tarPath, manifestPath, transparencyPath, "git:test-sha"); + + var response = await client.PostAsJsonAsync("/internal/attestations/verify?tenant=demo", request); + + var responseBody = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"Attestation verify failed: {(int)response.StatusCode} {response.StatusCode} · {responseBody}"); + + var claims = JsonSerializer.Deserialize( + responseBody, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + Assert.NotNull(claims); + Assert.Equal("evidence-bundle-m0", claims!.SubjectName); + Assert.Equal("git:test-sha", claims.PipelineVersion); + Assert.Equal(tarPath, claims.EvidenceBundlePath); + } + + [Fact] + public async Task EvidenceBatch_ReturnsEmptyCollectionsWhenUnknown() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add(TenantHeaderName, "demo"); + + var request = new EvidenceBatchRequest( + new[] + { + new EvidenceBatchItemRequest("component-a", new[] { "pkg:purl/example@1.0.0" }, new[] { "ALIAS-1" }) + }, + ObservationLimit: 5, + LinksetLimit: 5); + + var response = await client.PostAsJsonAsync("/v1/evidence/batch", request); + + response.EnsureSuccessStatusCode(); + var payload = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(payload); + var item = Assert.Single(payload!.Items); + Assert.Equal("component-a", item.ComponentId); + Assert.Empty(item.Observations); + Assert.Empty(item.Linksets); + Assert.False(item.HasMore); + } + [Fact] public async Task AdvisoryEvidenceEndpoint_FiltersByVendor() { @@ -1300,7 +1366,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime Assert.Equal(HttpStatusCode.OK, response.StatusCode); var payload = await response.Content.ReadFromJsonAsync(); Assert.NotNull(payload); - var conflict = Assert.Single(payload!.Conflicts); + var conflicts = payload!.Conflicts ?? throw new XunitException("Conflicts was null"); + var conflict = Assert.Single(conflicts); Assert.Equal(conflictId, conflict.ConflictId); Assert.Equal("severity", conflict.Explainer.Type); Assert.Equal("mismatch", conflict.Explainer.Reason); @@ -1977,6 +2044,17 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime _previousTelemetryLogging = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING"); _previousTelemetryTracing = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING"); _previousTelemetryMetrics = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS"); + + var opensslPath = ResolveOpenSsl11Path(); + if (!string.IsNullOrEmpty(opensslPath)) + { + var currentLd = Environment.GetEnvironmentVariable("LD_LIBRARY_PATH"); + var merged = string.IsNullOrWhiteSpace(currentLd) + ? opensslPath + : string.Join(':', opensslPath, currentLd); + Environment.SetEnvironmentVariable("LD_LIBRARY_PATH", merged); + } + Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DSN", connectionString); Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DRIVER", "mongo"); Environment.SetEnvironmentVariable("CONCELIER_STORAGE__COMMANDTIMEOUTSECONDS", "30"); @@ -1984,6 +2062,10 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING", "false"); Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING", "false"); Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS", "false"); + const string EvidenceRootKey = "CONCELIER_EVIDENCE__ROOT"; + var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "..")); + _additionalPreviousEnvironment[EvidenceRootKey] = Environment.GetEnvironmentVariable(EvidenceRootKey); + Environment.SetEnvironmentVariable(EvidenceRootKey, repoRoot); const string TestSecretKey = "CONCELIER_AUTHORITY__TESTSIGNINGSECRET"; if (environmentOverrides is null || !environmentOverrides.ContainsKey(TestSecretKey)) { @@ -2002,6 +2084,23 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime } } + private static string? ResolveOpenSsl11Path() + { + var current = AppContext.BaseDirectory; + for (var i = 0; i < 8; i++) + { + var candidate = Path.GetFullPath(Path.Combine(current, "tests", "native", "openssl-1.1", "linux-x64")); + if (Directory.Exists(candidate)) + { + return candidate; + } + + current = Path.GetFullPath(Path.Combine(current, "..")); + } + + return null; + } + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureAppConfiguration((context, configurationBuilder) => @@ -2035,7 +2134,17 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime options.Telemetry.EnableMetrics = false; options.Authority ??= new ConcelierOptions.AuthorityOptions(); _authorityConfigure?.Invoke(options.Authority); + + // Point evidence root at the repo so sample bundles under docs/samples/evidence-bundle resolve without 400. + var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "..")); + options.Evidence.Root = repoRoot; + options.Evidence.RootAbsolute = repoRoot; }); + + // Ensure content root + wwwroot exist so host startup does not throw when WebService bin output isn't present. + var contentRoot = AppContext.BaseDirectory; + var wwwroot = Path.Combine(contentRoot, "wwwroot"); + Directory.CreateDirectory(wwwroot); }); builder.ConfigureTestServices(services => @@ -3093,4 +3202,5 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime return Task.FromResult>(map); } } + } diff --git a/src/Excititor/AGENTS.md b/src/Excititor/AGENTS.md new file mode 100644 index 000000000..ee58b894b --- /dev/null +++ b/src/Excititor/AGENTS.md @@ -0,0 +1,44 @@ +# Excititor · AGENTS Charter (Air-Gap & Trust Connectors) + +## Module Scope & Working Directory +- Working directory: `src/Excititor/**` (WebService, Worker, __Libraries, __Tests, connectors, scripts). No cross-module edits unless explicitly noted in sprint Decisions & Risks. +- Mission (current sprint): air-gap parity for evidence chunks, trust connector wiring, and attestation verification aligned to Evidence Locker contract. + +## Roles +- **Backend engineer (ASP.NET Core / Mongo):** chunk ingestion/export, attestation verifier, trust connector. +- **Air-Gap/Platform engineer:** sealed-mode switches, offline bundles, deterministic cache/path handling. +- **QA automation:** WebApplicationFactory + Mongo2Go tests for chunk APIs, attestations, and trust connector; deterministic ordering/hashes. +- **Docs/Schema steward:** keep chunk API, attestation plan, and trust connector docs in sync with behavior; update schemas and samples. + +## Required Reading (treat as read before DOING) +- `docs/README.md` +- `docs/07_HIGH_LEVEL_ARCHITECTURE.md` +- `docs/modules/platform/architecture-overview.md` +- `docs/modules/excititor/architecture.md` +- `docs/modules/excititor/attestation-plan.md` +- `docs/modules/excititor/operations/chunk-api-user-guide.md` +- `docs/modules/excititor/schemas/vex-chunk-api.yaml` +- `docs/modules/evidence-locker/attestation-contract.md` + +## Working Agreements +- Determinism: canonical JSON ordering; stable pagination; UTC ISO-8601 timestamps; sort chunk edges deterministically. +- Offline-first: default sealed-mode must not reach external networks; connectors obey allowlist; feature flags default safe. +- Attestation: DSSE/Envelope per contract; always include tenant/source identifiers; validation fixtures required. +- Tenant safety: enforce tenant headers/guards on every API; no cross-tenant leakage. +- Logging/metrics: structured logs; meters under `StellaOps.Excititor.*`; tag `tenant`, `source`, `result`. +- Cross-module edits: require sprint note; otherwise, stay within Excititor working dir. + +## Testing Rules +- Use Mongo2Go/in-memory fixtures; avoid network. +- API tests in `StellaOps.Excititor.WebService.Tests`; worker/connectors in `StellaOps.Excititor.Worker.Tests`; shared fixtures in `__Tests`. +- Tests must assert determinism (ordering/hashes), tenant enforcement, and sealed-mode behavior. + +## Delivery Discipline +- Update sprint tracker status (`TODO → DOING → DONE/BLOCKED`) for each task; mirror changes in Execution Log and Decisions & Risks. +- When changing contracts (API/attestation schemas), update docs and samples and link from sprint Decisions & Risks. +- If a decision is needed, mark the task BLOCKED and record the decision ask—do not pause work. + +## Tooling/Env Notes +- .NET 10 with preview features enabled; Mongo driver ≥ 3.x. +- Signing/verifier hooks rely on Evidence Locker contract fixtures under `docs/modules/evidence-locker/`. +- Sealed-mode tests should run with `EXCITITOR_SEALED=1` (env var) to enforce offline code paths. diff --git a/src/Excititor/StellaOps.Excititor.WebService/Contracts/AirgapImportRequest.cs b/src/Excititor/StellaOps.Excititor.WebService/Contracts/AirgapImportRequest.cs index 2a1773ab7..914bc6c85 100644 --- a/src/Excititor/StellaOps.Excititor.WebService/Contracts/AirgapImportRequest.cs +++ b/src/Excititor/StellaOps.Excititor.WebService/Contracts/AirgapImportRequest.cs @@ -21,6 +21,9 @@ public sealed class AirgapImportRequest [JsonPropertyName("publisher")] public string? Publisher { get; init; } + [JsonPropertyName("tenantId")] + public string? TenantId { get; init; } + [JsonPropertyName("payloadHash")] public string? PayloadHash { get; init; } diff --git a/src/Excititor/StellaOps.Excititor.WebService/Options/AirgapOptions.cs b/src/Excititor/StellaOps.Excititor.WebService/Options/AirgapOptions.cs new file mode 100644 index 000000000..0715f3c8a --- /dev/null +++ b/src/Excititor/StellaOps.Excititor.WebService/Options/AirgapOptions.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace StellaOps.Excititor.WebService.Options; + +internal sealed class AirgapOptions +{ + public const string SectionName = "Excititor:Airgap"; + + /// + /// Enables sealed-mode enforcement for air-gapped imports. + /// When true, external payload URLs are rejected and publisher allowlist is applied. + /// + public bool SealedMode { get; set; } = false; + + /// + /// When true, imports must originate from mirror/offline sources (no HTTP/HTTPS URLs). + /// + public bool MirrorOnly { get; set; } = true; + + /// + /// Optional allowlist of publishers that may submit bundles while sealed mode is enabled. + /// Empty list means allow all. + /// + public List TrustedPublishers { get; } = new(); +} diff --git a/src/Excititor/StellaOps.Excititor.WebService/Program.cs b/src/Excititor/StellaOps.Excititor.WebService/Program.cs index 24f8c2aee..eee64212b 100644 --- a/src/Excititor/StellaOps.Excititor.WebService/Program.cs +++ b/src/Excititor/StellaOps.Excititor.WebService/Program.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Linq; using System.Collections.Immutable; -using System.Globalization; using System.Diagnostics; using System.Reflection; using System.Text; @@ -54,8 +53,10 @@ services.AddCycloneDxNormalizer(); services.AddOpenVexNormalizer(); services.AddSingleton(); // TODO: replace NoopVexSignatureVerifier with hardened verifier once portable bundle signatures are finalized. +services.Configure(configuration.GetSection(AirgapOptions.SectionName)); services.AddSingleton(); services.AddSingleton(); +services.AddSingleton(); services.AddSingleton(); services.AddMemoryCache(); services.AddScoped(); @@ -185,7 +186,7 @@ app.MapGet("/openapi/excititor.json", () => get = new { summary = "Service status (aggregation-only metadata)", - responses = new + responses = new Dictionary { ["200"] = new { @@ -219,7 +220,7 @@ app.MapGet("/openapi/excititor.json", () => get = new { summary = "Health check", - responses = new + responses = new Dictionary { ["200"] = new { @@ -254,7 +255,7 @@ app.MapGet("/openapi/excititor.json", () => new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false, description = "Numeric cursor or Last-Event-ID" }, new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false } }, - responses = new + responses = new Dictionary { ["200"] = new { @@ -331,7 +332,7 @@ app.MapGet("/openapi/excititor.json", () => } } }, - responses = new + responses = new Dictionary { ["200"] = new { description = "Accepted" }, ["400"] = new @@ -448,16 +449,47 @@ app.MapGet("/openapi/excititor.json", () => app.MapPost("/airgap/v1/vex/import", async ( [FromServices] AirgapImportValidator validator, [FromServices] AirgapSignerTrustService trustService, + [FromServices] AirgapModeEnforcer modeEnforcer, [FromServices] IAirgapImportStore store, + [FromServices] ILoggerFactory loggerFactory, [FromServices] TimeProvider timeProvider, [FromBody] AirgapImportRequest request, CancellationToken cancellationToken) => { + var logger = loggerFactory.CreateLogger("AirgapImport"); var nowUtc = timeProvider.GetUtcNow(); + var tenantId = string.IsNullOrWhiteSpace(request.TenantId) + ? "default" + : request.TenantId!.Trim().ToLowerInvariant(); + var stalenessSeconds = request.SignedAt is null + ? (int?)null + : (int)Math.Round((nowUtc - request.SignedAt.Value).TotalSeconds); + + var timeline = new List(); + void RecordEvent(string eventType, string? code = null, string? message = null) + { + var entry = new AirgapTimelineEntry + { + EventType = eventType, + CreatedAt = nowUtc, + TenantId = tenantId, + BundleId = request.BundleId ?? string.Empty, + MirrorGeneration = request.MirrorGeneration ?? string.Empty, + StalenessSeconds = stalenessSeconds, + ErrorCode = code, + Message = message + }; + timeline.Add(entry); + logger.LogInformation("Airgap timeline event {EventType} bundle={BundleId} gen={Gen} tenant={Tenant} code={Code}", eventType, entry.BundleId, entry.MirrorGeneration, tenantId, code); + } + + RecordEvent("airgap.import.started"); + var errors = validator.Validate(request, nowUtc); if (errors.Count > 0) { var first = errors[0]; + RecordEvent("airgap.import.failed", first.Code, first.Message); return Results.BadRequest(new { error = new @@ -468,8 +500,22 @@ app.MapPost("/airgap/v1/vex/import", async ( }); } + if (!modeEnforcer.Validate(request, out var sealedCode, out var sealedMessage)) + { + RecordEvent("airgap.import.failed", sealedCode, sealedMessage); + return Results.Json(new + { + error = new + { + code = sealedCode, + message = sealedMessage + } + }, statusCode: StatusCodes.Status403Forbidden); + } + if (!trustService.Validate(request, out var trustCode, out var trustMessage)) { + RecordEvent("airgap.import.failed", trustCode, trustMessage); return Results.Json(new { error = new @@ -480,9 +526,16 @@ app.MapPost("/airgap/v1/vex/import", async ( }, statusCode: StatusCodes.Status403Forbidden); } + var manifestPath = $"mirror/{request.BundleId}/{request.MirrorGeneration}/manifest.json"; + var evidenceLockerPath = $"evidence/{request.BundleId}/{request.MirrorGeneration}/bundle.ndjson"; + var manifestHash = ComputeSha256($"{request.BundleId}:{request.MirrorGeneration}:{request.PayloadHash}"); + + RecordEvent("airgap.import.completed"); + var record = new AirgapImportRecord { Id = $"{request.BundleId}:{request.MirrorGeneration}", + TenantId = tenantId, BundleId = request.BundleId!, MirrorGeneration = request.MirrorGeneration!, SignedAt = request.SignedAt!.Value, @@ -491,7 +544,11 @@ app.MapPost("/airgap/v1/vex/import", async ( PayloadUrl = request.PayloadUrl, Signature = request.Signature!, TransparencyLog = request.TransparencyLog, - ImportedAt = nowUtc + ImportedAt = nowUtc, + PortableManifestPath = manifestPath, + PortableManifestHash = manifestHash, + EvidenceLockerPath = evidenceLockerPath, + Timeline = timeline }; try @@ -500,6 +557,7 @@ app.MapPost("/airgap/v1/vex/import", async ( } catch (DuplicateAirgapImportException dup) { + RecordEvent("airgap.import.failed", "AIRGAP_IMPORT_DUPLICATE", dup.Message); return Results.Conflict(new { error = new @@ -513,10 +571,20 @@ app.MapPost("/airgap/v1/vex/import", async ( return Results.Accepted($"/airgap/v1/vex/import/{request.BundleId}", new { bundleId = request.BundleId, - generation = request.MirrorGeneration + generation = request.MirrorGeneration, + manifest = manifestPath, + evidence = evidenceLockerPath, + manifestSha256 = manifestHash }); }); +static string ComputeSha256(string value) +{ + var bytes = Encoding.UTF8.GetBytes(value); + var hash = System.Security.Cryptography.SHA256.HashData(bytes); + return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant(); +} + app.MapPost("/v1/attestations/verify", async ( [FromServices] IVexAttestationClient attestationClient, [FromBody] AttestationVerifyRequest request, @@ -1548,6 +1616,15 @@ app.MapGet("/v1/vex/linksets", async (HttpContext _, CancellationToken __) => app.Run(); +internal sealed record ExcititorTimelineEvent( + string Type, + string Tenant, + string Source, + int Count, + int Errors, + string? TraceId, + string OccurredAt); + public partial class Program; internal sealed record StatusResponse(DateTimeOffset UtcNow, string MongoBucket, int InlineThreshold, string[] ArtifactStores); diff --git a/src/Excititor/StellaOps.Excititor.WebService/Services/AirgapModeEnforcer.cs b/src/Excititor/StellaOps.Excititor.WebService/Services/AirgapModeEnforcer.cs new file mode 100644 index 000000000..2252f10a1 --- /dev/null +++ b/src/Excititor/StellaOps.Excititor.WebService/Services/AirgapModeEnforcer.cs @@ -0,0 +1,65 @@ +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Excititor.WebService.Contracts; +using StellaOps.Excititor.WebService.Options; + +namespace StellaOps.Excititor.WebService.Services; + +internal sealed class AirgapModeEnforcer +{ + private readonly AirgapOptions _options; + private readonly ILogger _logger; + + public AirgapModeEnforcer(IOptions options, ILogger logger) + { + _options = options.Value; + _logger = logger; + } + + public bool Validate(AirgapImportRequest request, out string? errorCode, out string? message) + { + errorCode = null; + message = null; + + if (!_options.SealedMode) + { + return true; + } + + if (_options.MirrorOnly && !string.IsNullOrWhiteSpace(request.PayloadUrl) && LooksLikeExternal(request.PayloadUrl)) + { + errorCode = "AIRGAP_EGRESS_BLOCKED"; + message = "Sealed mode forbids external payload URLs; stage bundle via mirror/portable media."; + _logger.LogWarning("Blocked airgap import because payloadUrl points to external location: {Url}", request.PayloadUrl); + return false; + } + + if (_options.TrustedPublishers.Count > 0 && !string.IsNullOrWhiteSpace(request.Publisher)) + { + var allowed = _options.TrustedPublishers.Any(p => string.Equals(p, request.Publisher, StringComparison.OrdinalIgnoreCase)); + if (!allowed) + { + errorCode = "AIRGAP_SOURCE_UNTRUSTED"; + message = $"Publisher '{request.Publisher}' is not allowlisted for sealed-mode imports."; + _logger.LogWarning("Blocked airgap import because publisher {Publisher} is not allowlisted.", request.Publisher); + return false; + } + } + + return true; + } + + private static bool LooksLikeExternal(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + return url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + || url.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/AirgapTimelineEntry.cs b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/AirgapTimelineEntry.cs new file mode 100644 index 000000000..0a90f0129 --- /dev/null +++ b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/AirgapTimelineEntry.cs @@ -0,0 +1,27 @@ +using System; +using MongoDB.Bson.Serialization.Attributes; + +namespace StellaOps.Excititor.Storage.Mongo; + +[BsonIgnoreExtraElements] +public sealed class AirgapTimelineEntry +{ + public string EventType { get; set; } = string.Empty; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public string TenantId { get; set; } = "default"; + + public string BundleId { get; set; } = string.Empty; + + public string MirrorGeneration { get; set; } = string.Empty; + + public int? StalenessSeconds { get; set; } + = null; + + public string? ErrorCode { get; set; } + = null; + + public string? Message { get; set; } + = null; +} diff --git a/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/VexMongoModels.cs b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/VexMongoModels.cs index 782387e00..706ff52c8 100644 --- a/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/VexMongoModels.cs +++ b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/VexMongoModels.cs @@ -316,6 +316,8 @@ public sealed class AirgapImportRecord [BsonId] public string Id { get; set; } = default!; + public string TenantId { get; set; } = "default"; + public string BundleId { get; set; } = default!; public string MirrorGeneration { get; set; } = default!; @@ -333,6 +335,14 @@ public sealed class AirgapImportRecord public string? TransparencyLog { get; set; } = null; public DateTimeOffset ImportedAt { get; set; } = DateTimeOffset.UtcNow; + + public string PortableManifestPath { get; set; } = string.Empty; + + public string PortableManifestHash { get; set; } = string.Empty; + + public string EvidenceLockerPath { get; set; } = string.Empty; + + public List Timeline { get; set; } = new(); } [BsonIgnoreExtraElements] diff --git a/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/AirgapImportValidatorTests.cs b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/AirgapImportValidatorTests.cs index 75cf04fa5..4472a82d9 100644 --- a/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/AirgapImportValidatorTests.cs +++ b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/AirgapImportValidatorTests.cs @@ -31,8 +31,15 @@ public sealed class AirgapImportValidatorTests [Fact] public void Validate_InvalidHash_ReturnsError() { - var req = Valid(); - req.PayloadHash = "not-a-hash"; + var req = new AirgapImportRequest + { + BundleId = "bundle-123", + MirrorGeneration = "5", + Publisher = "stellaops", + PayloadHash = "not-a-hash", + Signature = Convert.ToBase64String(new byte[] { 5, 6, 7 }), + SignedAt = _now + }; var result = _validator.Validate(req, _now); @@ -42,8 +49,15 @@ public sealed class AirgapImportValidatorTests [Fact] public void Validate_InvalidSignature_ReturnsError() { - var req = Valid(); - req.Signature = "???"; + var req = new AirgapImportRequest + { + BundleId = "bundle-123", + MirrorGeneration = "5", + Publisher = "stellaops", + PayloadHash = "sha256:" + new string('b', 64), + Signature = "???", + SignedAt = _now + }; var result = _validator.Validate(req, _now); @@ -53,8 +67,15 @@ public sealed class AirgapImportValidatorTests [Fact] public void Validate_MirrorGenerationNonNumeric_ReturnsError() { - var req = Valid(); - req.MirrorGeneration = "abc"; + var req = new AirgapImportRequest + { + BundleId = "bundle-123", + MirrorGeneration = "abc", + Publisher = "stellaops", + PayloadHash = "sha256:" + new string('b', 64), + Signature = Convert.ToBase64String(new byte[] { 5, 6, 7 }), + SignedAt = _now + }; var result = _validator.Validate(req, _now); @@ -64,8 +85,15 @@ public sealed class AirgapImportValidatorTests [Fact] public void Validate_SignedAtTooOld_ReturnsError() { - var req = Valid(); - req.SignedAt = _now.AddSeconds(-10); + var req = new AirgapImportRequest + { + BundleId = "bundle-123", + MirrorGeneration = "5", + Publisher = "stellaops", + PayloadHash = "sha256:" + new string('b', 64), + Signature = Convert.ToBase64String(new byte[] { 5, 6, 7 }), + SignedAt = _now.AddSeconds(-10) + }; var result = _validator.Validate(req, _now); diff --git a/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/AirgapModeEnforcerTests.cs b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/AirgapModeEnforcerTests.cs new file mode 100644 index 000000000..0d5665ed6 --- /dev/null +++ b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/AirgapModeEnforcerTests.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StellaOps.Excititor.WebService.Contracts; +using StellaOps.Excititor.WebService.Options; +using StellaOps.Excititor.WebService.Services; +using Xunit; + +namespace StellaOps.Excititor.WebService.Tests; + +public class AirgapModeEnforcerTests +{ + [Fact] + public void Validate_Allows_WhenNotSealed() + { + var enforcer = new AirgapModeEnforcer(Microsoft.Extensions.Options.Options.Create(new AirgapOptions { SealedMode = false }), NullLogger.Instance); + var ok = enforcer.Validate(new AirgapImportRequest { PayloadUrl = "https://example.com" }, out var code, out var message); + + Assert.True(ok); + Assert.Null(code); + Assert.Null(message); + } + + [Fact] + public void Validate_Blocks_ExternalUrl_WhenSealed() + { + var enforcer = new AirgapModeEnforcer(Microsoft.Extensions.Options.Options.Create(new AirgapOptions { SealedMode = true, MirrorOnly = true }), NullLogger.Instance); + var ok = enforcer.Validate(new AirgapImportRequest { PayloadUrl = "https://example.com" }, out var code, out var message); + + Assert.False(ok); + Assert.Equal("AIRGAP_EGRESS_BLOCKED", code); + Assert.NotNull(message); + } + + [Fact] + public void Validate_Blocks_Untrusted_Publisher_WhenAllowlistSet() + { + var enforcer = new AirgapModeEnforcer(Microsoft.Extensions.Options.Options.Create(new AirgapOptions { SealedMode = true, TrustedPublishers = { "mirror-a" } }), NullLogger.Instance); + var ok = enforcer.Validate(new AirgapImportRequest { Publisher = "mirror-b" }, out var code, out var message); + + Assert.False(ok); + Assert.Equal("AIRGAP_SOURCE_UNTRUSTED", code); + Assert.NotNull(message); + } +} diff --git a/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj index 4f1835632..3b48a1cc3 100644 --- a/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj +++ b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj @@ -29,6 +29,8 @@ + + diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/AttestationEventEndpointTests.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/AttestationEventEndpointTests.cs new file mode 100644 index 000000000..e9fd0a2cd --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/AttestationEventEndpointTests.cs @@ -0,0 +1,69 @@ +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using StellaOps.Notifier.Tests.Support; +using StellaOps.Notifier.WebService.Contracts; +using StellaOps.Notify.Queue; +using Xunit; + +namespace StellaOps.Notifier.Tests; + +public sealed class AttestationEventEndpointTests : IClassFixture +{ + private readonly NotifierApplicationFactory _factory; + + public AttestationEventEndpointTests(NotifierApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Attestation_event_is_published_to_queue() + { + var recordingQueue = new RecordingNotifyEventQueue(); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(recordingQueue); + }); + }).CreateClient(); + + var request = new AttestationEventRequest + { + EventId = Guid.NewGuid(), + Kind = "authority.keys.rotated", + Actor = "authority", + Timestamp = DateTimeOffset.Parse("2025-11-24T00:00:00Z"), + Payload = new System.Text.Json.Nodes.JsonObject + { + ["rotation"] = new System.Text.Json.Nodes.JsonObject + { + ["batchId"] = "batch-42", + ["executedAt"] = "2025-11-24T00:00:00Z" + } + } + }; + + var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/attestation-events") + { + Content = JsonContent.Create(request) + }; + message.Headers.Add("X-StellaOps-Tenant", "tenant-sample"); + + var response = await client.SendAsync(message, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Single(recordingQueue.Published); + + var published = recordingQueue.Published.Single(); + Assert.Equal("authority.keys.rotated", published.Event.Kind); + Assert.Equal("tenant-sample", published.Event.Tenant); + Assert.Equal("notify:events", published.Stream); + } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/AttestationTemplateSeederTests.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/AttestationTemplateSeederTests.cs new file mode 100644 index 000000000..5ae140a44 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/AttestationTemplateSeederTests.cs @@ -0,0 +1,62 @@ +using System.Linq; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.Notifier.Tests.Support; +using StellaOps.Notifier.WebService.Setup; +using Xunit; + +namespace StellaOps.Notifier.Tests; + +public sealed class AttestationTemplateSeederTests +{ + [Fact] + public async Task SeedTemplates_and_routing_load_from_offline_bundle() + { + var templateRepo = new InMemoryTemplateRepository(); + var channelRepo = new InMemoryChannelRepository(); + var ruleRepo = new InMemoryRuleRepository(); + var logger = NullLogger.Instance; + + var contentRoot = LocateRepoRoot(); + + var seededTemplates = await AttestationTemplateSeeder.SeedTemplatesAsync( + templateRepo, + contentRoot, + logger, + TestContext.Current.CancellationToken); + + var seededRouting = await AttestationTemplateSeeder.SeedRoutingAsync( + channelRepo, + ruleRepo, + contentRoot, + logger, + TestContext.Current.CancellationToken); + + Assert.True(seededTemplates >= 6, "Expected attestation templates to be seeded."); + Assert.True(seededRouting >= 3, "Expected attestation routing seed to create channels and rules."); + + var templates = await templateRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken); + Assert.Contains(templates, t => t.Key == "tmpl-attest-key-rotation"); + Assert.Contains(templates, t => t.Key == "tmpl-attest-transparency-anomaly"); + + var rules = await ruleRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken); + Assert.Contains(rules, r => r.Match.EventKinds.Contains("authority.keys.rotated")); + Assert.Contains(rules, r => r.Match.EventKinds.Contains("attestor.transparency.anomaly")); + } + + private static string LocateRepoRoot() + { + var directory = AppContext.BaseDirectory; + while (directory != null) + { + if (File.Exists(Path.Combine(directory, "StellaOps.sln")) || + File.Exists(Path.Combine(directory, "StellaOps.Notifier.sln"))) + { + return directory; + } + + directory = Directory.GetParent(directory)?.FullName; + } + + throw new InvalidOperationException("Unable to locate repository root."); + } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/RiskEventEndpointTests.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/RiskEventEndpointTests.cs new file mode 100644 index 000000000..8eb041ec4 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/RiskEventEndpointTests.cs @@ -0,0 +1,70 @@ +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using StellaOps.Notifier.Tests.Support; +using StellaOps.Notifier.WebService.Contracts; +using StellaOps.Notify.Queue; +using Xunit; + +namespace StellaOps.Notifier.Tests; + +public sealed class RiskEventEndpointTests : IClassFixture +{ + private readonly NotifierApplicationFactory _factory; + + public RiskEventEndpointTests(NotifierApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Risk_event_is_published_to_queue() + { + var recordingQueue = new RecordingNotifyEventQueue(); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(recordingQueue); + }); + }).CreateClient(); + + var request = new RiskEventRequest + { + EventId = Guid.NewGuid(), + Kind = "risk.profile.severity.changed", + Actor = "risk-engine", + Timestamp = DateTimeOffset.Parse("2025-11-24T00:00:00Z"), + Payload = new System.Text.Json.Nodes.JsonObject + { + ["profile"] = new System.Text.Json.Nodes.JsonObject + { + ["id"] = "stellaops://risk/profile/example@2025.11", + ["version"] = "2025.11" + }, + ["previous"] = new System.Text.Json.Nodes.JsonObject { ["severity"] = "medium" }, + ["current"] = new System.Text.Json.Nodes.JsonObject { ["severity"] = "high" } + } + }; + + var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/risk-events") + { + Content = JsonContent.Create(request) + }; + message.Headers.Add("X-StellaOps-Tenant", "tenant-sample"); + + var response = await client.SendAsync(message, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Single(recordingQueue.Published); + + var published = recordingQueue.Published.Single(); + Assert.Equal("risk.profile.severity.changed", published.Event.Kind); + Assert.Equal("tenant-sample", published.Event.Tenant); + Assert.Equal("notify:events", published.Stream); + } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/RiskTemplateSeederTests.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/RiskTemplateSeederTests.cs new file mode 100644 index 000000000..887406d40 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/RiskTemplateSeederTests.cs @@ -0,0 +1,62 @@ +using System.Linq; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.Notifier.Tests.Support; +using StellaOps.Notifier.WebService.Setup; +using Xunit; + +namespace StellaOps.Notifier.Tests; + +public sealed class RiskTemplateSeederTests +{ + [Fact] + public async Task SeedTemplates_and_routing_load_from_offline_bundle() + { + var templateRepo = new InMemoryTemplateRepository(); + var channelRepo = new InMemoryChannelRepository(); + var ruleRepo = new InMemoryRuleRepository(); + var logger = NullLogger.Instance; + + var contentRoot = LocateRepoRoot(); + + var seededTemplates = await RiskTemplateSeeder.SeedTemplatesAsync( + templateRepo, + contentRoot, + logger, + TestContext.Current.CancellationToken); + + var seededRouting = await RiskTemplateSeeder.SeedRoutingAsync( + channelRepo, + ruleRepo, + contentRoot, + logger, + TestContext.Current.CancellationToken); + + Assert.True(seededTemplates >= 4, "Expected risk templates to be seeded."); + Assert.True(seededRouting >= 4, "Expected risk routing seed to create channels and rules."); + + var templates = await templateRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken); + Assert.Contains(templates, t => t.Key == "tmpl-risk-severity-change"); + Assert.Contains(templates, t => t.Key == "tmpl-risk-profile-state"); + + var rules = await ruleRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken); + Assert.Contains(rules, r => r.Match.EventKinds.Contains("risk.profile.severity.changed")); + Assert.Contains(rules, r => r.Match.EventKinds.Contains("risk.profile.published")); + } + + private static string LocateRepoRoot() + { + var directory = AppContext.BaseDirectory; + while (directory != null) + { + if (File.Exists(Path.Combine(directory, "StellaOps.sln")) || + File.Exists(Path.Combine(directory, "StellaOps.Notifier.sln"))) + { + return directory; + } + + directory = Directory.GetParent(directory)?.FullName; + } + + throw new InvalidOperationException("Unable to locate repository root."); + } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/Support/RecordingNotifyEventQueue.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/Support/RecordingNotifyEventQueue.cs new file mode 100644 index 000000000..ea79fed71 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/Support/RecordingNotifyEventQueue.cs @@ -0,0 +1,21 @@ +using StellaOps.Notify.Queue; + +namespace StellaOps.Notifier.Tests.Support; + +internal sealed class RecordingNotifyEventQueue : INotifyEventQueue +{ + private readonly List _messages = new(); + + public IReadOnlyList Published => _messages; + + public ValueTask>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default) + => ValueTask.FromResult>>(Array.Empty>()); + + public ValueTask PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default) + { + _messages.Add(message); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/AttestationEventRequest.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/AttestationEventRequest.cs new file mode 100644 index 000000000..adebcd76f --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/AttestationEventRequest.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace StellaOps.Notifier.WebService.Contracts; + +public sealed record AttestationEventRequest +{ + public Guid EventId { get; init; } + + /// + /// Event kind, e.g. authority.keys.rotated, authority.keys.revoked, attestor.transparency.anomaly. + /// + public string? Kind { get; init; } + + public string? Actor { get; init; } + + public DateTimeOffset? Timestamp { get; init; } + + public JsonObject? Payload { get; init; } + + public IDictionary? Attributes { get; init; } + + public string? ResumeToken { get; init; } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/RiskEventRequest.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/RiskEventRequest.cs new file mode 100644 index 000000000..cdc614637 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/RiskEventRequest.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace StellaOps.Notifier.WebService.Contracts; + +public sealed record RiskEventRequest +{ + public Guid EventId { get; init; } + + /// + /// risk.profile.severity.changed | risk.profile.published | risk.profile.deprecated | risk.profile.thresholds.changed + /// + public string? Kind { get; init; } + + public string? Actor { get; init; } + + public DateTimeOffset? Timestamp { get; init; } + + public JsonObject? Payload { get; init; } + + public IDictionary? Attributes { get; init; } + + public string? ResumeToken { get; init; } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Program.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Program.cs index 7808e88d9..31c178a8d 100644 --- a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Program.cs +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Program.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -31,6 +32,8 @@ if (!isTesting) builder.Services.AddNotifyMongoStorage(mongoSection); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); } // Fallback no-op event queue for environments that do not configure a real backend. @@ -173,6 +176,122 @@ app.MapPost("/api/v1/notify/pack-approvals", async ( return Results.Accepted(); }); +app.MapPost("/api/v1/notify/attestation-events", async ( + HttpContext context, + AttestationEventRequest request, + INotifyEventQueue? eventQueue, + TimeProvider timeProvider) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + if (string.IsNullOrWhiteSpace(request.Kind)) + { + return Results.BadRequest(Error("invalid_request", "kind is required.", context)); + } + + var eventId = request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid(); + var ts = request.Timestamp is { } tsValue && tsValue != default ? tsValue : timeProvider.GetUtcNow(); + + if (eventQueue is not null) + { + var payload = request.Payload ?? new JsonObject(); + + var notifyEvent = NotifyEvent.Create( + eventId: eventId, + kind: request.Kind!, + tenant: tenantId, + ts: ts, + payload: payload, + attributes: request.Attributes ?? new Dictionary(), + actor: request.Actor, + version: "1"); + + var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString(); + if (string.IsNullOrWhiteSpace(idempotencyKey)) + { + idempotencyKey = $"attestation|{tenantId}|{notifyEvent.Kind}|{notifyEvent.EventId}"; + } + + await eventQueue.PublishAsync( + new NotifyQueueEventMessage( + notifyEvent, + stream: "notify:events", + idempotencyKey: idempotencyKey, + partitionKey: tenantId, + traceId: context.TraceIdentifier), + context.RequestAborted).ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(request.ResumeToken)) + { + context.Response.Headers["X-Resume-After"] = request.ResumeToken; + } + + return Results.Accepted(); +}); + +app.MapPost("/api/v1/notify/risk-events", async ( + HttpContext context, + RiskEventRequest request, + INotifyEventQueue? eventQueue, + TimeProvider timeProvider) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + if (string.IsNullOrWhiteSpace(request.Kind)) + { + return Results.BadRequest(Error("invalid_request", "kind is required.", context)); + } + + var eventId = request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid(); + var ts = request.Timestamp is { } tsValue && tsValue != default ? tsValue : timeProvider.GetUtcNow(); + + if (eventQueue is not null) + { + var payload = request.Payload ?? new JsonObject(); + + var notifyEvent = NotifyEvent.Create( + eventId: eventId, + kind: request.Kind!, + tenant: tenantId, + ts: ts, + payload: payload, + attributes: request.Attributes ?? new Dictionary(), + actor: request.Actor, + version: "1"); + + var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString(); + if (string.IsNullOrWhiteSpace(idempotencyKey)) + { + idempotencyKey = $"risk|{tenantId}|{notifyEvent.Kind}|{notifyEvent.EventId}"; + } + + await eventQueue.PublishAsync( + new NotifyQueueEventMessage( + notifyEvent, + stream: "notify:events", + idempotencyKey: idempotencyKey, + partitionKey: tenantId, + traceId: context.TraceIdentifier), + context.RequestAborted).ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(request.ResumeToken)) + { + context.Response.Headers["X-Resume-After"] = request.ResumeToken; + } + + return Results.Accepted(); +}); + app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async ( HttpContext context, string packId, diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Setup/AttestationTemplateSeeder.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Setup/AttestationTemplateSeeder.cs new file mode 100644 index 000000000..8931fbfa7 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Setup/AttestationTemplateSeeder.cs @@ -0,0 +1,256 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using StellaOps.Notify.Models; +using StellaOps.Notify.Storage.Mongo.Repositories; + +namespace StellaOps.Notifier.WebService.Setup; + +/// +/// Seeds attestation templates and default routing for dev/test/bootstrap scenarios. +/// +public sealed class AttestationTemplateSeeder : IHostedService +{ + private readonly IServiceProvider _services; + private readonly IHostEnvironment _environment; + private readonly ILogger _logger; + + public AttestationTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger logger) + { + _services = services ?? throw new ArgumentNullException(nameof(services)); + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = _services.CreateScope(); + var templateRepo = scope.ServiceProvider.GetService(); + var channelRepo = scope.ServiceProvider.GetService(); + var ruleRepo = scope.ServiceProvider.GetService(); + + if (templateRepo is null) + { + _logger.LogWarning("Template repository not registered; skipping attestation template seed."); + return; + } + + var contentRoot = _environment.ContentRootPath; + var templatesSeeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false); + if (templatesSeeded > 0) + { + _logger.LogInformation("Seeded {TemplateCount} attestation templates from offline bundle.", templatesSeeded); + } + + if (channelRepo is null || ruleRepo is null) + { + _logger.LogWarning("Channel or rule repository not registered; skipping attestation routing seed."); + return; + } + + var routingSeeded = await SeedRoutingAsync(channelRepo, ruleRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false); + if (routingSeeded > 0) + { + _logger.LogInformation("Seeded default attestation routing (channels + rules)."); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public static async Task SeedTemplatesAsync( + INotifyTemplateRepository repository, + string contentRootPath, + ILogger logger, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(logger); + + var templateDir = LocateAttestationTemplatesPath(contentRootPath); + if (templateDir is null) + { + logger.LogWarning("Attestation templates directory not found under {ContentRoot}; skipping seed.", contentRootPath); + return 0; + } + + var count = 0; + foreach (var file in Directory.EnumerateFiles(templateDir, "*.template.json", SearchOption.TopDirectoryOnly)) + { + try + { + var template = await ToTemplateAsync(file, cancellationToken).ConfigureAwait(false); + await repository.UpsertAsync(template, cancellationToken).ConfigureAwait(false); + count++; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to seed template from {File}.", file); + } + } + + return count; + } + + public static async Task SeedRoutingAsync( + INotifyChannelRepository channelRepository, + INotifyRuleRepository ruleRepository, + string contentRootPath, + ILogger logger, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(channelRepository); + ArgumentNullException.ThrowIfNull(ruleRepository); + ArgumentNullException.ThrowIfNull(logger); + + var samplePath = LocateAttestationRulesPath(contentRootPath); + if (samplePath is null) + { + logger.LogWarning("Attestation rules sample not found under {ContentRoot}; skipping routing seed.", contentRootPath); + return 0; + } + + using var stream = File.OpenRead(samplePath); + var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + + var tenant = "bootstrap"; + var channelsElement = doc.RootElement.GetProperty("channels"); + var rulesElement = doc.RootElement.GetProperty("rules"); + + var channels = channelsElement.EnumerateArray() + .Select(ToChannel) + .ToArray(); + + foreach (var channel in channels) + { + await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false); + } + + foreach (var rule in rulesElement.EnumerateArray()) + { + var model = ToRule(rule, tenant); + await ruleRepository.UpsertAsync(model, cancellationToken).ConfigureAwait(false); + } + + return channels.Length + rulesElement.GetArrayLength(); + } + + private static NotifyRule ToRule(JsonElement element, string tenant) + { + var ruleId = element.GetProperty("ruleId").GetString() ?? throw new InvalidOperationException("ruleId missing"); + var name = element.GetProperty("name").GetString() ?? ruleId; + var enabled = element.GetProperty("enabled").GetBoolean(); + var matchKinds = element.GetProperty("match").GetProperty("eventKinds").EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray(); + + var actions = element.GetProperty("actions").EnumerateArray().Select(action => + NotifyRuleAction.Create( + actionId: action.GetProperty("actionId").GetString() ?? throw new InvalidOperationException("actionId missing"), + channel: action.GetProperty("channel").GetString() ?? string.Empty, + template: action.GetProperty("template").GetString() ?? string.Empty, + enabled: action.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true)).ToArray(); + + return NotifyRule.Create( + ruleId: ruleId, + tenantId: tenant, + name: name, + match: NotifyRuleMatch.Create(eventKinds: matchKinds), + actions: actions, + enabled: enabled, + description: "Seeded attestation routing rule."); + } + + private static NotifyChannel ToChannel(JsonElement element) + { + var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing"); + var type = ParseEnum(element.GetProperty("type").GetString(), NotifyChannelType.Custom); + var name = element.GetProperty("name").GetString() ?? channelId; + var target = element.TryGetProperty("target", out var t) ? t.GetString() : null; + var endpoint = element.TryGetProperty("endpoint", out var e) ? e.GetString() : null; + var secretRef = element.GetProperty("secretRef").GetString() ?? string.Empty; + + var config = NotifyChannelConfig.Create( + secretRef: secretRef, + endpoint: endpoint, + target: target); + + return NotifyChannel.Create( + channelId: channelId, + tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap", + name: name, + type: type, + config: config, + description: element.TryGetProperty("description", out var d) ? d.GetString() : null); + } + + private static async Task ToTemplateAsync(string path, CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(path); + var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + var root = doc.RootElement; + + var templateId = root.GetProperty("templateId").GetString() ?? Path.GetFileNameWithoutExtension(path); + var tenantId = root.GetProperty("tenantId").GetString() ?? "bootstrap"; + var channelType = ParseEnum(root.GetProperty("channelType").GetString(), NotifyChannelType.Custom); + var key = root.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing"); + var locale = root.GetProperty("locale").GetString() ?? "en-US"; + var renderMode = ParseEnum(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown); + var format = ParseEnum(root.GetProperty("format").GetString(), NotifyDeliveryFormat.Json); + var description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null; + var body = root.GetProperty("body").GetString() ?? string.Empty; + + var metadata = Enumerable.Empty>(); + if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object) + { + metadata = meta.EnumerateObject().Select(p => new KeyValuePair(p.Name, p.Value.GetString() ?? string.Empty)); + } + + return NotifyTemplate.Create( + templateId: templateId, + tenantId: tenantId, + channelType: channelType, + key: key, + locale: locale, + body: body, + renderMode: renderMode, + format: format, + description: description, + metadata: metadata, + createdBy: "seed:attestation"); + } + + private static string? LocateAttestationTemplatesPath(string contentRootPath) + { + var candidates = new[] + { + Path.Combine(contentRootPath, "offline", "notifier", "templates", "attestation"), + Path.Combine(contentRootPath, "..", "offline", "notifier", "templates", "attestation") + }; + + return candidates.FirstOrDefault(Directory.Exists); + } + + private static string? LocateAttestationRulesPath(string contentRootPath) + { + var candidates = new[] + { + Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "attestation-rules.sample.json"), + Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "attestation-rules.sample.json"), + Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "docs", "attestation-rules.sample.json") + }; + + return candidates.FirstOrDefault(File.Exists); + } + + private static TEnum ParseEnum(string? value, TEnum fallback) where TEnum : struct + { + if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + return fallback; + } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Setup/RiskTemplateSeeder.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Setup/RiskTemplateSeeder.cs new file mode 100644 index 000000000..b63533741 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Setup/RiskTemplateSeeder.cs @@ -0,0 +1,258 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Xml; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using StellaOps.Notify.Models; +using StellaOps.Notify.Storage.Mongo.Repositories; + +namespace StellaOps.Notifier.WebService.Setup; + +/// +/// Seeds risk templates and default routing for dev/test/bootstrap scenarios. +/// +public sealed class RiskTemplateSeeder : IHostedService +{ + private readonly IServiceProvider _services; + private readonly IHostEnvironment _environment; + private readonly ILogger _logger; + + public RiskTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger logger) + { + _services = services ?? throw new ArgumentNullException(nameof(services)); + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = _services.CreateScope(); + var templateRepo = scope.ServiceProvider.GetService(); + var channelRepo = scope.ServiceProvider.GetService(); + var ruleRepo = scope.ServiceProvider.GetService(); + + if (templateRepo is null) + { + _logger.LogWarning("Template repository not registered; skipping risk template seed."); + return; + } + + var contentRoot = _environment.ContentRootPath; + var templatesSeeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false); + if (templatesSeeded > 0) + { + _logger.LogInformation("Seeded {TemplateCount} risk templates from offline bundle.", templatesSeeded); + } + + if (channelRepo is null || ruleRepo is null) + { + _logger.LogWarning("Channel or rule repository not registered; skipping risk routing seed."); + return; + } + + var routingSeeded = await SeedRoutingAsync(channelRepo, ruleRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false); + if (routingSeeded > 0) + { + _logger.LogInformation("Seeded default risk routing (channels + rules)."); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public static async Task SeedTemplatesAsync( + INotifyTemplateRepository repository, + string contentRootPath, + ILogger logger, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(logger); + + var templateDir = LocateRiskTemplatesPath(contentRootPath); + if (templateDir is null) + { + logger.LogWarning("Risk templates directory not found under {ContentRoot}; skipping seed.", contentRootPath); + return 0; + } + + var count = 0; + foreach (var file in Directory.EnumerateFiles(templateDir, "*.template.json", SearchOption.TopDirectoryOnly)) + { + try + { + var template = await ToTemplateAsync(file, cancellationToken).ConfigureAwait(false); + await repository.UpsertAsync(template, cancellationToken).ConfigureAwait(false); + count++; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to seed template from {File}.", file); + } + } + + return count; + } + + public static async Task SeedRoutingAsync( + INotifyChannelRepository channelRepository, + INotifyRuleRepository ruleRepository, + string contentRootPath, + ILogger logger, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(channelRepository); + ArgumentNullException.ThrowIfNull(ruleRepository); + ArgumentNullException.ThrowIfNull(logger); + + var samplePath = LocateRiskRulesPath(contentRootPath); + if (samplePath is null) + { + logger.LogWarning("Risk rules sample not found under {ContentRoot}; skipping routing seed.", contentRootPath); + return 0; + } + + await using var stream = File.OpenRead(samplePath); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + + var tenant = "bootstrap"; + var channelsElement = doc.RootElement.GetProperty("channels"); + var rulesElement = doc.RootElement.GetProperty("rules"); + + var channels = channelsElement.EnumerateArray() + .Select(ToChannel) + .ToArray(); + + foreach (var channel in channels) + { + await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false); + } + + foreach (var rule in rulesElement.EnumerateArray()) + { + var model = ToRule(rule, tenant); + await ruleRepository.UpsertAsync(model, cancellationToken).ConfigureAwait(false); + } + + return channels.Length + rulesElement.GetArrayLength(); + } + + private static NotifyRule ToRule(JsonElement element, string tenant) + { + var ruleId = element.GetProperty("ruleId").GetString() ?? throw new InvalidOperationException("ruleId missing"); + var name = element.GetProperty("name").GetString() ?? ruleId; + var enabled = element.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true; + var matchKinds = element.GetProperty("match").GetProperty("eventKinds").EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray(); + + var actions = element.GetProperty("actions").EnumerateArray().Select(action => + NotifyRuleAction.Create( + actionId: action.GetProperty("actionId").GetString() ?? throw new InvalidOperationException("actionId missing"), + channel: action.GetProperty("channel").GetString() ?? string.Empty, + template: action.GetProperty("template").GetString() ?? string.Empty, + locale: action.TryGetProperty("locale", out var loc) ? loc.GetString() : null, + throttle: action.TryGetProperty("throttle", out var throttle) ? XmlConvert.ToTimeSpan(throttle.GetString() ?? string.Empty) : default, + enabled: action.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true)).ToArray(); + + return NotifyRule.Create( + ruleId: ruleId, + tenantId: tenant, + name: name, + match: NotifyRuleMatch.Create(eventKinds: matchKinds), + actions: actions, + enabled: enabled, + description: "Seeded risk routing rule."); + } + + private static NotifyChannel ToChannel(JsonElement element) + { + var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing"); + var type = ParseEnum(element.GetProperty("type").GetString(), NotifyChannelType.Custom); + var name = element.GetProperty("name").GetString() ?? channelId; + var target = element.TryGetProperty("target", out var t) ? t.GetString() : null; + var endpoint = element.TryGetProperty("endpoint", out var e) ? e.GetString() : null; + var secretRef = element.GetProperty("secretRef").GetString() ?? string.Empty; + + var config = NotifyChannelConfig.Create( + secretRef: secretRef, + endpoint: endpoint, + target: target); + + return NotifyChannel.Create( + channelId: channelId, + tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap", + name: name, + type: type, + config: config, + description: element.TryGetProperty("description", out var d) ? d.GetString() : null); + } + + private static async Task ToTemplateAsync(string path, CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(path); + var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + var root = doc.RootElement; + + var templateId = root.GetProperty("templateId").GetString() ?? Path.GetFileNameWithoutExtension(path); + var tenantId = root.GetProperty("tenantId").GetString() ?? "bootstrap"; + var channelType = ParseEnum(root.GetProperty("channelType").GetString(), NotifyChannelType.Custom); + var key = root.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing"); + var locale = root.GetProperty("locale").GetString() ?? "en-US"; + var renderMode = ParseEnum(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown); + var format = ParseEnum(root.GetProperty("format").GetString(), NotifyDeliveryFormat.Json); + var description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null; + var body = root.GetProperty("body").GetString() ?? string.Empty; + + var metadata = Enumerable.Empty>(); + if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object) + { + metadata = meta.EnumerateObject().Select(p => new KeyValuePair(p.Name, p.Value.GetString() ?? string.Empty)); + } + + return NotifyTemplate.Create( + templateId: templateId, + tenantId: tenantId, + channelType: channelType, + key: key, + locale: locale, + body: body, + renderMode: renderMode, + format: format, + description: description, + metadata: metadata, + createdBy: "seed:risk"); + } + + private static string? LocateRiskTemplatesPath(string contentRootPath) + { + var candidates = new[] + { + Path.Combine(contentRootPath, "offline", "notifier", "templates", "risk"), + Path.Combine(contentRootPath, "..", "offline", "notifier", "templates", "risk") + }; + + return candidates.FirstOrDefault(Directory.Exists); + } + + private static string? LocateRiskRulesPath(string contentRootPath) + { + var candidates = new[] + { + Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "risk-rules.sample.json"), + Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "risk-rules.sample.json"), + Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "StellaOps.Notifier.docs", "risk-rules.sample.json") + }; + + return candidates.FirstOrDefault(File.Exists); + } + + private static TEnum ParseEnum(string? value, TEnum fallback) where TEnum : struct + { + if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + return fallback; + } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.docs/risk-rules.sample.json b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.docs/risk-rules.sample.json new file mode 100644 index 000000000..812aa329e --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.docs/risk-rules.sample.json @@ -0,0 +1,80 @@ +{ + "rules": [ + { + "ruleId": "risk-severity-change", + "name": "Risk severity escalation/downgrade", + "enabled": true, + "tenantId": "bootstrap", + "match": { + "eventKinds": [ + "risk.profile.severity.changed" + ] + }, + "actions": [ + { + "actionId": "act-risk-severity-slack", + "enabled": true, + "channel": "slack-risk", + "template": "tmpl-risk-severity-change", + "locale": "en-us", + "throttle": "PT5M" + }, + { + "actionId": "act-risk-severity-email", + "enabled": true, + "channel": "email-risk", + "template": "tmpl-risk-severity-change", + "locale": "en-us", + "throttle": "PT10M" + } + ] + }, + { + "ruleId": "risk-profile-state", + "name": "Risk profile published/deprecated", + "enabled": true, + "tenantId": "bootstrap", + "match": { + "eventKinds": [ + "risk.profile.published", + "risk.profile.deprecated", + "risk.profile.thresholds.changed" + ] + }, + "actions": [ + { + "actionId": "act-risk-profile-slack", + "enabled": true, + "channel": "slack-risk", + "template": "tmpl-risk-profile-state", + "locale": "en-us" + }, + { + "actionId": "act-risk-profile-email", + "enabled": true, + "channel": "email-risk", + "template": "tmpl-risk-profile-state", + "locale": "en-us" + } + ] + } + ], + "channels": [ + { + "channelId": "slack-risk", + "type": "slack", + "tenantId": "bootstrap", + "name": "Slack · Risk", + "endpoint": "https://hooks.slack.local/services/T000/B000/RISK", + "secretRef": "ref://notify/channels/slack/risk" + }, + { + "channelId": "email-risk", + "type": "email", + "tenantId": "bootstrap", + "name": "Email · Risk", + "target": "risk-team@example.com", + "secretRef": "ref://notify/channels/email/risk" + } + ] +} diff --git a/src/Notifier/StellaOps.Notifier/TASKS.md b/src/Notifier/StellaOps.Notifier/TASKS.md index d02bc880f..82007023a 100644 --- a/src/Notifier/StellaOps.Notifier/TASKS.md +++ b/src/Notifier/StellaOps.Notifier/TASKS.md @@ -3,13 +3,13 @@ | ID | Status | Owner(s) | Notes | | --- | --- | --- | --- | | NOTIFY-ATTEST-74-001 | DONE (2025-11-16) | Notifications Service Guild | Attestation template suite complete; Slack expiry template added; coverage tests guard required channels. | -| NOTIFY-ATTEST-74-002 | TODO | Notifications Service Guild · KMS Guild | Wire notifications to key rotation/revocation events + transparency witness failures (depends on 74-001). | +| NOTIFY-ATTEST-74-002 | DONE (2025-11-24) | Notifications Service Guild · KMS Guild | Attestation event ingestion endpoint + seed routing/templates for key rotation, revocation, and transparency witness failures. | | NOTIFY-OAS-61-001 | DONE (2025-11-17) | Notifications Service Guild · API Contracts Guild | OAS updated with rules/templates/incidents/quiet hours and standard error envelope. | | NOTIFY-OAS-61-002 | DONE (2025-11-17) | Notifications Service Guild | `.well-known/openapi` discovery endpoint with scope metadata implemented. | | NOTIFY-OAS-62-001 | DONE (2025-11-17) | Notifications Service Guild · SDK Generator Guild | SDK usage examples + smoke tests (depends on 61-002). | | NOTIFY-OAS-63-001 | DONE (2025-11-17) | Notifications Service Guild · API Governance Guild | Deprecation headers + template notices for retiring APIs (depends on 62-001). | | NOTIFY-OBS-51-001 | DONE (2025-11-22) | Notifications Service Guild · Observability Guild | SLO webhook sink validated (`HttpEgressSloSinkTests`, `EventProcessorTests`); TRX: `StellaOps.Notifier.Tests/TestResults/notifier-slo-tests.trx`. | | NOTIFY-OBS-55-001 | DONE (2025-11-22) | Notifications Service Guild · Ops Guild | Incident mode start/stop notifications; templates + importable rules with quiet-hour overrides and legal logging metadata. | -| NOTIFY-RISK-66-001 | TODO | Notifications Service Guild · Risk Engine Guild | Trigger risk severity escalation/downgrade notifications (waiting on Policy export). | -| NOTIFY-RISK-67-001 | TODO | Notifications Service Guild · Policy Guild | Notify when risk profiles publish/deprecate/threshold-change (depends on 66-001). | -| NOTIFY-RISK-68-001 | TODO | Notifications Service Guild | Per-profile routing rules + quiet hours for risk alerts (depends on 67-001). | +| NOTIFY-RISK-66-001 | DONE (2025-11-24) | Notifications Service Guild · Risk Engine Guild | Added risk-events endpoint + templates/rules for severity change notifications. | +| NOTIFY-RISK-67-001 | DONE (2025-11-24) | Notifications Service Guild · Policy Guild | Added routing/templates for risk profile publish/deprecate/threshold change. | +| NOTIFY-RISK-68-001 | DONE (2025-11-24) | Notifications Service Guild | Default routing seeds with throttles/locales for risk alerts. | diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/AGENTS.md b/src/PacksRegistry/StellaOps.PacksRegistry/AGENTS.md index 3f05fa5db..a21d17cbc 100644 --- a/src/PacksRegistry/StellaOps.PacksRegistry/AGENTS.md +++ b/src/PacksRegistry/StellaOps.PacksRegistry/AGENTS.md @@ -26,3 +26,5 @@ Host signed Task Pack bundles with provenance and RBAC for Epic 12. Ensure pac - 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations. - 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change. - 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context. +- 6. Registry API expectations: require `X-API-Key` when configured and tenant scoping via `X-StellaOps-Tenant` (or `tenantId` on upload). Content/provenance downloads must emit digest headers (`X-Content-Digest`, `X-Provenance-Digest`) and respect tenant allowlists. +- 7. Lifecycle/parity/signature rotation endpoints require tenant headers; offline seed export supports per-tenant filtering and deterministic zip output. All mutating calls emit audit log entries (file `audit.ndjson` or Mongo `packs_audit_log`). diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Class1.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Class1.cs deleted file mode 100644 index fe0664dee..000000000 --- a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StellaOps.PacksRegistry.Core; - -public class Class1 -{ - -} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IAttestationRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IAttestationRepository.cs new file mode 100644 index 000000000..601e2a232 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IAttestationRepository.cs @@ -0,0 +1,12 @@ +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Core.Contracts; + +public interface IAttestationRepository +{ + Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default); + Task GetAsync(string packId, string type, CancellationToken cancellationToken = default); + Task> ListAsync(string packId, CancellationToken cancellationToken = default); + Task GetContentAsync(string packId, string type, CancellationToken cancellationToken = default); +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IAuditRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IAuditRepository.cs new file mode 100644 index 000000000..3fee93a5d --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IAuditRepository.cs @@ -0,0 +1,11 @@ +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Core.Contracts; + +public interface IAuditRepository +{ + Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default); + + Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default); +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/ILifecycleRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/ILifecycleRepository.cs new file mode 100644 index 000000000..af4183f08 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/ILifecycleRepository.cs @@ -0,0 +1,12 @@ +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Core.Contracts; + +public interface ILifecycleRepository +{ + Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default); + + Task GetAsync(string packId, CancellationToken cancellationToken = default); + + Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default); +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IMirrorRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IMirrorRepository.cs new file mode 100644 index 000000000..bb96d62c2 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IMirrorRepository.cs @@ -0,0 +1,11 @@ +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Core.Contracts; + +public interface IMirrorRepository +{ + Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default); + Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default); + Task GetAsync(string id, CancellationToken cancellationToken = default); +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IPackRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IPackRepository.cs new file mode 100644 index 000000000..081e7eb4a --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IPackRepository.cs @@ -0,0 +1,16 @@ +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Core.Contracts; + +public interface IPackRepository +{ + Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default); + + Task GetAsync(string packId, CancellationToken cancellationToken = default); + + Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default); + + Task GetContentAsync(string packId, CancellationToken cancellationToken = default); + + Task GetProvenanceAsync(string packId, CancellationToken cancellationToken = default); +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IPackSignatureVerifier.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IPackSignatureVerifier.cs new file mode 100644 index 000000000..6b6794e42 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IPackSignatureVerifier.cs @@ -0,0 +1,6 @@ +namespace StellaOps.PacksRegistry.Core.Contracts; + +public interface IPackSignatureVerifier +{ + Task VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default); +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IParityRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IParityRepository.cs new file mode 100644 index 000000000..e52af909e --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Contracts/IParityRepository.cs @@ -0,0 +1,12 @@ +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Core.Contracts; + +public interface IParityRepository +{ + Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default); + + Task GetAsync(string packId, CancellationToken cancellationToken = default); + + Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default); +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/AttestationRecord.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/AttestationRecord.cs new file mode 100644 index 000000000..226bdcad1 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/AttestationRecord.cs @@ -0,0 +1,10 @@ +namespace StellaOps.PacksRegistry.Core.Models; + +public sealed record AttestationRecord( + string PackId, + string TenantId, + string Type, + string Digest, + DateTimeOffset CreatedAtUtc, + string? Notes = null); + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/AuditRecord.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/AuditRecord.cs new file mode 100644 index 000000000..79461d9f2 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/AuditRecord.cs @@ -0,0 +1,13 @@ +namespace StellaOps.PacksRegistry.Core.Models; + +/// +/// Immutable audit event emitted for registry actions. +/// +public sealed record AuditRecord( + string? PackId, + string TenantId, + string Event, + DateTimeOffset OccurredAtUtc, + string? Actor = null, + string? Notes = null); + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/LifecycleRecord.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/LifecycleRecord.cs new file mode 100644 index 000000000..ac7c3cdad --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/LifecycleRecord.cs @@ -0,0 +1,8 @@ +namespace StellaOps.PacksRegistry.Core.Models; + +public sealed record LifecycleRecord( + string PackId, + string TenantId, + string State, + string? Notes, + DateTimeOffset UpdatedAtUtc); diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/MirrorSourceRecord.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/MirrorSourceRecord.cs new file mode 100644 index 000000000..277affdee --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/MirrorSourceRecord.cs @@ -0,0 +1,12 @@ +namespace StellaOps.PacksRegistry.Core.Models; + +public sealed record MirrorSourceRecord( + string Id, + string TenantId, + Uri UpstreamUri, + bool Enabled, + string Status, + DateTimeOffset UpdatedAtUtc, + string? Notes = null, + DateTimeOffset? LastSuccessfulSyncUtc = null); + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/PackPolicyOptions.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/PackPolicyOptions.cs new file mode 100644 index 000000000..fe916bc12 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/PackPolicyOptions.cs @@ -0,0 +1,7 @@ +namespace StellaOps.PacksRegistry.Core.Models; + +public sealed class PackPolicyOptions +{ + public bool RequireSignature { get; set; } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/PackRecord.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/PackRecord.cs new file mode 100644 index 000000000..b6f585fac --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/PackRecord.cs @@ -0,0 +1,16 @@ +namespace StellaOps.PacksRegistry.Core.Models; + +/// +/// Canonical pack metadata stored by the registry. +/// +public sealed record PackRecord( + string PackId, + string Name, + string Version, + string TenantId, + string Digest, + string? Signature, + string? ProvenanceUri, + string? ProvenanceDigest, + DateTimeOffset CreatedAtUtc, + IReadOnlyDictionary? Metadata); diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/ParityRecord.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/ParityRecord.cs new file mode 100644 index 000000000..b61b20d3b --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Models/ParityRecord.cs @@ -0,0 +1,8 @@ +namespace StellaOps.PacksRegistry.Core.Models; + +public sealed record ParityRecord( + string PackId, + string TenantId, + string Status, + string? Notes, + DateTimeOffset UpdatedAtUtc); diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/AttestationService.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/AttestationService.cs new file mode 100644 index 000000000..ed063e71d --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/AttestationService.cs @@ -0,0 +1,71 @@ +using System.Security.Cryptography; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Core.Services; + +public sealed class AttestationService +{ + private readonly IPackRepository _packRepository; + private readonly IAttestationRepository _attestationRepository; + private readonly IAuditRepository _auditRepository; + private readonly TimeProvider _timeProvider; + + public AttestationService(IPackRepository packRepository, IAttestationRepository attestationRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null) + { + _packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository)); + _attestationRepository = attestationRepository ?? throw new ArgumentNullException(nameof(attestationRepository)); + _auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository)); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public async Task UploadAsync(string packId, string tenantId, string type, byte[] content, string? notes, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packId); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(type); + ArgumentNullException.ThrowIfNull(content); + + var pack = await _packRepository.GetAsync(packId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Pack {packId} not found."); + + if (!string.Equals(pack.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Tenant mismatch for attestation upload."); + } + + var digest = ComputeSha256(content); + var record = new AttestationRecord(packId.Trim(), tenantId.Trim(), type.Trim(), digest, _timeProvider.GetUtcNow(), notes?.Trim()); + await _attestationRepository.UpsertAsync(record, content, cancellationToken).ConfigureAwait(false); + await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "attestation.uploaded", record.CreatedAtUtc, null, notes), cancellationToken).ConfigureAwait(false); + return record; + } + + public Task GetAsync(string packId, string type, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packId); + ArgumentException.ThrowIfNullOrWhiteSpace(type); + return _attestationRepository.GetAsync(packId.Trim(), type.Trim(), cancellationToken); + } + + public Task> ListAsync(string packId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packId); + return _attestationRepository.ListAsync(packId.Trim(), cancellationToken); + } + + public Task GetContentAsync(string packId, string type, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packId); + ArgumentException.ThrowIfNullOrWhiteSpace(type); + return _attestationRepository.GetContentAsync(packId.Trim(), type.Trim(), cancellationToken); + } + + private static string ComputeSha256(byte[] bytes) + { + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(bytes); + return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant(); + } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/ComplianceService.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/ComplianceService.cs new file mode 100644 index 000000000..30c7a066d --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/ComplianceService.cs @@ -0,0 +1,36 @@ +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Core.Services; + +public sealed class ComplianceService +{ + private readonly IPackRepository _packRepository; + private readonly IParityRepository _parityRepository; + private readonly ILifecycleRepository _lifecycleRepository; + + public ComplianceService(IPackRepository packRepository, IParityRepository parityRepository, ILifecycleRepository lifecycleRepository) + { + _packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository)); + _parityRepository = parityRepository ?? throw new ArgumentNullException(nameof(parityRepository)); + _lifecycleRepository = lifecycleRepository ?? throw new ArgumentNullException(nameof(lifecycleRepository)); + } + + public async Task SummarizeAsync(string? tenantId, CancellationToken cancellationToken = default) + { + var packs = await _packRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); + var parity = await _parityRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); + var lifecycle = await _lifecycleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); + + var total = packs.Count; + var unsigned = packs.Count(p => string.IsNullOrWhiteSpace(p.Signature)); + var promoted = lifecycle.Count(l => string.Equals(l.State, "promoted", StringComparison.OrdinalIgnoreCase)); + var deprecated = lifecycle.Count(l => string.Equals(l.State, "deprecated", StringComparison.OrdinalIgnoreCase)); + var parityReady = parity.Count(p => string.Equals(p.Status, "ready", StringComparison.OrdinalIgnoreCase)); + + return new ComplianceSummary(total, unsigned, promoted, deprecated, parityReady); + } +} + +public sealed record ComplianceSummary(int TotalPacks, int UnsignedPacks, int PromotedPacks, int DeprecatedPacks, int ParityReadyPacks); + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/ExportService.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/ExportService.cs new file mode 100644 index 000000000..eb07389d1 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/ExportService.cs @@ -0,0 +1,104 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Core.Services; + +public sealed class ExportService +{ + private static readonly DateTimeOffset ZipEpoch = new DateTimeOffset(1980, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private readonly IPackRepository _packRepository; + private readonly IParityRepository _parityRepository; + private readonly ILifecycleRepository _lifecycleRepository; + private readonly IAuditRepository _auditRepository; + private readonly TimeProvider _timeProvider; + private readonly JsonSerializerOptions _jsonOptions; + + public ExportService( + IPackRepository packRepository, + IParityRepository parityRepository, + ILifecycleRepository lifecycleRepository, + IAuditRepository auditRepository, + TimeProvider? timeProvider = null) + { + _packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository)); + _parityRepository = parityRepository ?? throw new ArgumentNullException(nameof(parityRepository)); + _lifecycleRepository = lifecycleRepository ?? throw new ArgumentNullException(nameof(lifecycleRepository)); + _auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository)); + _timeProvider = timeProvider ?? TimeProvider.System; + _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + } + + public async Task ExportOfflineSeedAsync(string? tenantId, bool includeContent, bool includeProvenance, CancellationToken cancellationToken = default) + { + var packs = await _packRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); + var parity = await _parityRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); + var lifecycle = await _lifecycleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); + var audits = await _auditRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); + + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteNdjson(archive, "packs.ndjson", packs.OrderBy(p => p.PackId, StringComparer.Ordinal)); + WriteNdjson(archive, "parity.ndjson", parity.OrderBy(p => p.PackId, StringComparer.Ordinal)); + WriteNdjson(archive, "lifecycle.ndjson", lifecycle.OrderBy(l => l.PackId, StringComparer.Ordinal)); + WriteNdjson(archive, "audit.ndjson", audits.OrderBy(a => a.OccurredAtUtc).ThenBy(a => a.PackId, StringComparer.Ordinal)); + + if (includeContent) + { + foreach (var pack in packs.OrderBy(p => p.PackId, StringComparer.Ordinal)) + { + var content = await _packRepository.GetContentAsync(pack.PackId, cancellationToken).ConfigureAwait(false); + if (content is null || content.Length == 0) + { + continue; + } + + var entry = archive.CreateEntry($"content/{pack.PackId}.bin", CompressionLevel.Optimal); + entry.LastWriteTime = ZipEpoch; + await using var entryStream = entry.Open(); + await entryStream.WriteAsync(content.AsMemory(0, content.Length), cancellationToken).ConfigureAwait(false); + } + } + + if (includeProvenance) + { + foreach (var pack in packs.OrderBy(p => p.PackId, StringComparer.Ordinal)) + { + var provenance = await _packRepository.GetProvenanceAsync(pack.PackId, cancellationToken).ConfigureAwait(false); + if (provenance is null || provenance.Length == 0) + { + continue; + } + + var entry = archive.CreateEntry($"provenance/{pack.PackId}.json", CompressionLevel.Optimal); + entry.LastWriteTime = ZipEpoch; + await using var entryStream = entry.Open(); + await entryStream.WriteAsync(provenance.AsMemory(0, provenance.Length), cancellationToken).ConfigureAwait(false); + } + } + } + + stream.Position = 0; + + var auditTenant = string.IsNullOrWhiteSpace(tenantId) ? "*" : tenantId.Trim(); + await _auditRepository.AppendAsync(new AuditRecord(null, auditTenant, "offline.seed.exported", _timeProvider.GetUtcNow(), null, includeContent ? "with-content" : null), cancellationToken).ConfigureAwait(false); + return stream; + } + + private void WriteNdjson(ZipArchive archive, string entryName, IEnumerable records) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + entry.LastWriteTime = ZipEpoch; + using var stream = entry.Open(); + using var writer = new StreamWriter(stream); + foreach (var record in records) + { + var json = JsonSerializer.Serialize(record, _jsonOptions); + writer.WriteLine(json); + } + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/LifecycleService.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/LifecycleService.cs new file mode 100644 index 000000000..3ac83e890 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/LifecycleService.cs @@ -0,0 +1,62 @@ +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Core.Services; + +public sealed class LifecycleService +{ + private readonly ILifecycleRepository _lifecycleRepository; + private readonly IPackRepository _packRepository; + private readonly IAuditRepository _auditRepository; + private readonly TimeProvider _timeProvider; + + private static readonly HashSet AllowedStates = new(StringComparer.OrdinalIgnoreCase) + { + "promoted", "deprecated", "draft" + }; + + public LifecycleService(ILifecycleRepository lifecycleRepository, IPackRepository packRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null) + { + _lifecycleRepository = lifecycleRepository ?? throw new ArgumentNullException(nameof(lifecycleRepository)); + _packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository)); + _auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository)); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public async Task SetStateAsync(string packId, string tenantId, string state, string? notes, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packId); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(state); + + if (!AllowedStates.Contains(state)) + { + throw new InvalidOperationException($"State '{state}' is not allowed (use: {string.Join(',', AllowedStates)})."); + } + + var pack = await _packRepository.GetAsync(packId, cancellationToken).ConfigureAwait(false); + if (pack is null) + { + throw new InvalidOperationException($"Pack {packId} not found."); + } + + if (!string.Equals(pack.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Tenant mismatch for lifecycle update."); + } + + var record = new LifecycleRecord(packId.Trim(), tenantId.Trim(), state.Trim(), notes?.Trim(), _timeProvider.GetUtcNow()); + await _lifecycleRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false); + await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "lifecycle.updated", record.UpdatedAtUtc, null, notes), cancellationToken).ConfigureAwait(false); + return record; + } + + public Task GetAsync(string packId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packId); + return _lifecycleRepository.GetAsync(packId.Trim(), cancellationToken); + } + + public Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + => _lifecycleRepository.ListAsync(tenantId?.Trim(), cancellationToken); +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/MirrorService.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/MirrorService.cs new file mode 100644 index 000000000..438714f94 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/MirrorService.cs @@ -0,0 +1,59 @@ +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Core.Services; + +public sealed class MirrorService +{ + private readonly IMirrorRepository _mirrorRepository; + private readonly IAuditRepository _auditRepository; + private readonly TimeProvider _timeProvider; + + public MirrorService(IMirrorRepository mirrorRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null) + { + _mirrorRepository = mirrorRepository ?? throw new ArgumentNullException(nameof(mirrorRepository)); + _auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository)); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public async Task UpsertAsync(string id, string tenantId, Uri upstreamUri, bool enabled, string? notes, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(upstreamUri); + + var record = new MirrorSourceRecord(id.Trim(), tenantId.Trim(), upstreamUri, enabled, enabled ? "enabled" : "disabled", _timeProvider.GetUtcNow(), notes?.Trim(), null); + await _mirrorRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false); + await _auditRepository.AppendAsync(new AuditRecord(null, tenantId, "mirror.upserted", record.UpdatedAtUtc, null, upstreamUri.ToString()), cancellationToken).ConfigureAwait(false); + return record; + } + + public Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + => _mirrorRepository.ListAsync(tenantId?.Trim(), cancellationToken); + + public async Task MarkSyncAsync(string id, string tenantId, string status, string? notes, CancellationToken cancellationToken = default) + { + var existing = await _mirrorRepository.GetAsync(id.Trim(), cancellationToken).ConfigureAwait(false); + if (existing is null) + { + return null; + } + if (!string.Equals(existing.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Tenant mismatch for mirror sync update."); + } + + var updated = existing with + { + Status = status.Trim(), + UpdatedAtUtc = _timeProvider.GetUtcNow(), + LastSuccessfulSyncUtc = string.Equals(status, "synced", StringComparison.OrdinalIgnoreCase) ? _timeProvider.GetUtcNow() : existing.LastSuccessfulSyncUtc, + Notes = notes ?? existing.Notes + }; + + await _mirrorRepository.UpsertAsync(updated, cancellationToken).ConfigureAwait(false); + await _auditRepository.AppendAsync(new AuditRecord(null, tenantId, "mirror.sync", updated.UpdatedAtUtc, null, status), cancellationToken).ConfigureAwait(false); + return updated; + } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/PackService.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/PackService.cs new file mode 100644 index 000000000..e06e12e70 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/PackService.cs @@ -0,0 +1,149 @@ +using System.Security.Cryptography; +using System.Text; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Core.Services; + +public sealed class PackService +{ + private readonly IPackRepository _repository; + private readonly IPackSignatureVerifier _signatureVerifier; + private readonly IAuditRepository _auditRepository; + private readonly TimeProvider _timeProvider; + private readonly PackPolicyOptions _policy; + + public PackService(IPackRepository repository, IPackSignatureVerifier signatureVerifier, IAuditRepository auditRepository, PackPolicyOptions? policy = null, TimeProvider? timeProvider = null) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier)); + _auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository)); + _policy = policy ?? new PackPolicyOptions(); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public async Task UploadAsync( + string name, + string version, + string tenantId, + byte[] content, + string? signature, + string? provenanceUri, + byte[]? provenanceContent, + IReadOnlyDictionary? metadata, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(version); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(content); + + var digest = ComputeSha256(content); + + if (_policy.RequireSignature && string.IsNullOrWhiteSpace(signature)) + { + throw new InvalidOperationException("Signature is required by policy."); + } + + var valid = await _signatureVerifier.VerifyAsync(content, digest, signature, cancellationToken).ConfigureAwait(false); + if (!valid) + { + throw new InvalidOperationException("Signature validation failed for uploaded pack."); + } + + string? provenanceDigest = null; + if (provenanceContent is { Length: > 0 }) + { + provenanceDigest = ComputeSha256(provenanceContent); + } + + var packId = BuildPackId(name, version); + var record = new PackRecord( + PackId: packId, + Name: name.Trim(), + Version: version.Trim(), + TenantId: tenantId.Trim(), + Digest: digest, + Signature: signature, + ProvenanceUri: provenanceUri, + ProvenanceDigest: provenanceDigest, + CreatedAtUtc: _timeProvider.GetUtcNow(), + Metadata: metadata); + + await _repository.UpsertAsync(record, content, provenanceContent, cancellationToken).ConfigureAwait(false); + await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "pack.uploaded", record.CreatedAtUtc, null, provenanceUri), cancellationToken).ConfigureAwait(false); + return record; + } + + public Task GetAsync(string packId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packId); + return _repository.GetAsync(packId.Trim(), cancellationToken); + } + + public Task GetContentAsync(string packId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packId); + return _repository.GetContentAsync(packId.Trim(), cancellationToken); + } + + public Task GetProvenanceAsync(string packId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packId); + return _repository.GetProvenanceAsync(packId.Trim(), cancellationToken); + } + + public Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + => _repository.ListAsync(tenantId?.Trim(), cancellationToken); + + public async Task RotateSignatureAsync( + string packId, + string tenantId, + string newSignature, + IPackSignatureVerifier? verifierOverride = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packId); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(newSignature); + + var record = await _repository.GetAsync(packId.Trim(), cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Pack {packId} not found."); + + if (!string.Equals(record.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Tenant mismatch for signature rotation."); + } + + var content = await _repository.GetContentAsync(packId.Trim(), cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("Pack content missing; cannot rotate signature."); + + var digest = ComputeSha256(content); + var verifier = verifierOverride ?? _signatureVerifier; + var valid = await verifier.VerifyAsync(content, digest, newSignature, cancellationToken).ConfigureAwait(false); + if (!valid) + { + throw new InvalidOperationException("Signature validation failed during rotation."); + } + + var provenance = await _repository.GetProvenanceAsync(packId.Trim(), cancellationToken).ConfigureAwait(false); + var updated = record with { Signature = newSignature, Digest = digest }; + await _repository.UpsertAsync(updated, content, provenance, cancellationToken).ConfigureAwait(false); + await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "signature.rotated", _timeProvider.GetUtcNow(), null, null), cancellationToken).ConfigureAwait(false); + return updated; + } + + private static string ComputeSha256(byte[] bytes) + { + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(bytes); + return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static string BuildPackId(string name, string version) + { + var cleanName = name.Trim().ToLowerInvariant().Replace(' ', '-'); + var cleanVersion = version.Trim().ToLowerInvariant(); + return $"{cleanName}@{cleanVersion}"; + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/ParityService.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/ParityService.cs new file mode 100644 index 000000000..7892c6ab5 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/ParityService.cs @@ -0,0 +1,52 @@ +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Core.Services; + +public sealed class ParityService +{ + private readonly IParityRepository _parityRepository; + private readonly IPackRepository _packRepository; + private readonly IAuditRepository _auditRepository; + private readonly TimeProvider _timeProvider; + + public ParityService(IParityRepository parityRepository, IPackRepository packRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null) + { + _parityRepository = parityRepository ?? throw new ArgumentNullException(nameof(parityRepository)); + _packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository)); + _auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository)); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public async Task SetStatusAsync(string packId, string tenantId, string status, string? notes, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packId); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(status); + + var pack = await _packRepository.GetAsync(packId, cancellationToken).ConfigureAwait(false); + if (pack is null) + { + throw new InvalidOperationException($"Pack {packId} not found."); + } + + if (!string.Equals(pack.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Tenant mismatch for parity update."); + } + + var record = new ParityRecord(packId.Trim(), tenantId.Trim(), status.Trim(), notes?.Trim(), _timeProvider.GetUtcNow()); + await _parityRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false); + await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "parity.updated", record.UpdatedAtUtc, null, notes), cancellationToken).ConfigureAwait(false); + return record; + } + + public Task GetAsync(string packId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packId); + return _parityRepository.GetAsync(packId.Trim(), cancellationToken); + } + + public Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + => _parityRepository.ListAsync(tenantId?.Trim(), cancellationToken); +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Class1.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Class1.cs deleted file mode 100644 index cb007f4fd..000000000 --- a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StellaOps.PacksRegistry.Infrastructure; - -public class Class1 -{ - -} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileAttestationRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileAttestationRepository.cs new file mode 100644 index 000000000..82daf46e4 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileAttestationRepository.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Infrastructure.FileSystem; + +public sealed class FileAttestationRepository : IAttestationRepository +{ + private readonly string _indexPath; + private readonly string _contentPath; + private readonly JsonSerializerOptions _jsonOptions; + private readonly SemaphoreSlim _mutex = new(1, 1); + + public FileAttestationRepository(string rootPath) + { + var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath); + Directory.CreateDirectory(root); + _indexPath = Path.Combine(root, "attestations.ndjson"); + _contentPath = Path.Combine(root, "attestations"); + Directory.CreateDirectory(_contentPath); + _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + } + + public async Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(content); + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await using var stream = new FileStream(_indexPath, FileMode.Append, FileAccess.Write, FileShare.Read); + await using var writer = new StreamWriter(stream); + var json = JsonSerializer.Serialize(record, _jsonOptions); + await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); + + var fileName = GetFileName(record.PackId, record.Type); + await File.WriteAllBytesAsync(fileName, content, cancellationToken).ConfigureAwait(false); + } + finally + { + _mutex.Release(); + } + } + + public async Task GetAsync(string packId, string type, CancellationToken cancellationToken = default) + { + var list = await ListAsync(packId, cancellationToken).ConfigureAwait(false); + return list.LastOrDefault(r => string.Equals(r.Type, type, StringComparison.OrdinalIgnoreCase)); + } + + public async Task> ListAsync(string packId, CancellationToken cancellationToken = default) + { + if (!File.Exists(_indexPath)) + { + return Array.Empty(); + } + + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var lines = await File.ReadAllLinesAsync(_indexPath, cancellationToken).ConfigureAwait(false); + return lines + .Where(l => !string.IsNullOrWhiteSpace(l)) + .Select(l => JsonSerializer.Deserialize(l, _jsonOptions)) + .Where(r => r is not null && string.Equals(r!.PackId, packId, StringComparison.OrdinalIgnoreCase)) + .Cast() + .OrderBy(r => r.Type, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + finally + { + _mutex.Release(); + } + } + + public Task GetContentAsync(string packId, string type, CancellationToken cancellationToken = default) + { + var file = GetFileName(packId, type); + if (!File.Exists(file)) + { + return Task.FromResult(null); + } + return File.ReadAllBytesAsync(file, cancellationToken).ContinueWith(t => (byte[]?)t.Result, cancellationToken); + } + + private string GetFileName(string packId, string type) + { + var safe = packId.Replace('/', '_').Replace('@', '_'); + var safeType = type.Replace('/', '_'); + return Path.Combine(_contentPath, $"{safe}_{safeType}.bin"); + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileAuditRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileAuditRepository.cs new file mode 100644 index 000000000..ce8c5d3d1 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileAuditRepository.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Infrastructure.FileSystem; + +public sealed class FileAuditRepository : IAuditRepository +{ + private readonly string _path; + private readonly JsonSerializerOptions _jsonOptions; + private readonly SemaphoreSlim _mutex = new(1, 1); + + public FileAuditRepository(string rootPath) + { + var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath); + Directory.CreateDirectory(root); + _path = Path.Combine(root, "audit.ndjson"); + _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + } + + public async Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read); + await using var writer = new StreamWriter(stream); + var json = JsonSerializer.Serialize(record, _jsonOptions); + await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); + } + finally + { + _mutex.Release(); + } + } + + public async Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + if (!File.Exists(_path)) + { + return Array.Empty(); + } + + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false); + IEnumerable records = lines + .Where(l => !string.IsNullOrWhiteSpace(l)) + .Select(l => JsonSerializer.Deserialize(l, _jsonOptions)) + .Where(r => r is not null)! + .Cast(); + + if (!string.IsNullOrWhiteSpace(tenantId)) + { + records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)); + } + + return records + .OrderBy(r => r.OccurredAtUtc) + .ThenBy(r => r.PackId, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + finally + { + _mutex.Release(); + } + } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileLifecycleRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileLifecycleRepository.cs new file mode 100644 index 000000000..62beeda03 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileLifecycleRepository.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Infrastructure.FileSystem; + +public sealed class FileLifecycleRepository : ILifecycleRepository +{ + private readonly string _path; + private readonly JsonSerializerOptions _jsonOptions; + private readonly SemaphoreSlim _mutex = new(1, 1); + + public FileLifecycleRepository(string rootPath) + { + var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath); + Directory.CreateDirectory(root); + _path = Path.Combine(root, "lifecycle.ndjson"); + _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + } + + public async Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read); + await using var writer = new StreamWriter(stream); + var json = JsonSerializer.Serialize(record, _jsonOptions); + await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); + } + finally + { + _mutex.Release(); + } + } + + public async Task GetAsync(string packId, CancellationToken cancellationToken = default) + { + if (!File.Exists(_path)) + { + return null; + } + + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false); + return lines + .Where(l => !string.IsNullOrWhiteSpace(l)) + .Select(l => JsonSerializer.Deserialize(l, _jsonOptions)) + .Where(r => r is not null) + .Cast() + .LastOrDefault(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase)); + } + finally + { + _mutex.Release(); + } + } + + public async Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + if (!File.Exists(_path)) + { + return Array.Empty(); + } + + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false); + IEnumerable records = lines + .Where(l => !string.IsNullOrWhiteSpace(l)) + .Select(l => JsonSerializer.Deserialize(l, _jsonOptions)) + .Where(r => r is not null)! + .Cast(); + + if (!string.IsNullOrWhiteSpace(tenantId)) + { + records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)); + } + + return records.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList(); + } + finally + { + _mutex.Release(); + } + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileMirrorRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileMirrorRepository.cs new file mode 100644 index 000000000..74711e5b2 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileMirrorRepository.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Infrastructure.FileSystem; + +public sealed class FileMirrorRepository : IMirrorRepository +{ + private readonly string _path; + private readonly JsonSerializerOptions _jsonOptions; + private readonly SemaphoreSlim _mutex = new(1, 1); + + public FileMirrorRepository(string rootPath) + { + var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath); + Directory.CreateDirectory(root); + _path = Path.Combine(root, "mirrors.ndjson"); + _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + } + + public async Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read); + await using var writer = new StreamWriter(stream); + var json = JsonSerializer.Serialize(record, _jsonOptions); + await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); + } + finally + { + _mutex.Release(); + } + } + + public async Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + if (!File.Exists(_path)) + { + return Array.Empty(); + } + + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false); + IEnumerable records = lines + .Where(l => !string.IsNullOrWhiteSpace(l)) + .Select(l => JsonSerializer.Deserialize(l, _jsonOptions)) + .Where(r => r is not null)! + .Cast(); + + if (!string.IsNullOrWhiteSpace(tenantId)) + { + records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)); + } + + return records.OrderBy(r => r.Id, StringComparer.OrdinalIgnoreCase).ToList(); + } + finally + { + _mutex.Release(); + } + } + + public async Task GetAsync(string id, CancellationToken cancellationToken = default) + { + var list = await ListAsync(null, cancellationToken).ConfigureAwait(false); + return list.LastOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); + } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FilePackRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FilePackRepository.cs new file mode 100644 index 000000000..206f139de --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FilePackRepository.cs @@ -0,0 +1,134 @@ +using System.Text.Json; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Infrastructure.FileSystem; + +public sealed class FilePackRepository : IPackRepository +{ + private readonly string _root; + private readonly string _indexPath; + private readonly JsonSerializerOptions _jsonOptions; + private readonly SemaphoreSlim _mutex = new(1, 1); + + public FilePackRepository(string root) + { + _root = string.IsNullOrWhiteSpace(root) ? Path.GetFullPath("data/packs") : Path.GetFullPath(root); + _indexPath = Path.Combine(_root, "index.ndjson"); + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + Directory.CreateDirectory(_root); + Directory.CreateDirectory(Path.Combine(_root, "blobs")); + } + + public async Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(content); + + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var blobPath = Path.Combine(_root, "blobs", record.Digest.Replace(':', '_')); + await File.WriteAllBytesAsync(blobPath, content, cancellationToken).ConfigureAwait(false); + + if (provenance is { Length: > 0 } && record.ProvenanceDigest is not null) + { + var provPath = Path.Combine(_root, "provenance", record.ProvenanceDigest.Replace(':', '_')); + Directory.CreateDirectory(Path.GetDirectoryName(provPath)!); + await File.WriteAllBytesAsync(provPath, provenance, cancellationToken).ConfigureAwait(false); + } + + await using var stream = new FileStream(_indexPath, FileMode.Append, FileAccess.Write, FileShare.Read); + await using var writer = new StreamWriter(stream); + var json = JsonSerializer.Serialize(record, _jsonOptions); + await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); + } + finally + { + _mutex.Release(); + } + } + + public async Task GetAsync(string packId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packId); + var records = await ReadAllAsync(cancellationToken).ConfigureAwait(false); + return records.LastOrDefault(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase)); + } + + public async Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + var records = await ReadAllAsync(cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(tenantId)) + { + records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)); + } + + return records + .OrderBy(r => r.TenantId, StringComparer.OrdinalIgnoreCase) + .ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(r => r.Version, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private async Task> ReadAllAsync(CancellationToken cancellationToken) + { + if (!File.Exists(_indexPath)) + { + return Array.Empty(); + } + + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var lines = await File.ReadAllLinesAsync(_indexPath, cancellationToken).ConfigureAwait(false); + return lines + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Select(line => JsonSerializer.Deserialize(line, _jsonOptions)) + .Where(r => r is not null)!; + } + finally + { + _mutex.Release(); + } + } + + public async Task GetContentAsync(string packId, CancellationToken cancellationToken = default) + { + var record = await GetAsync(packId, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return null; + } + + var blobPath = Path.Combine(_root, "blobs", record.Digest.Replace(':', '_')); + if (!File.Exists(blobPath)) + { + return null; + } + + return await File.ReadAllBytesAsync(blobPath, cancellationToken).ConfigureAwait(false); + } + + public async Task GetProvenanceAsync(string packId, CancellationToken cancellationToken = default) + { + var record = await GetAsync(packId, cancellationToken).ConfigureAwait(false); + if (record?.ProvenanceDigest is null) + { + return null; + } + + var provPath = Path.Combine(_root, "provenance", record.ProvenanceDigest.Replace(':', '_')); + if (!File.Exists(provPath)) + { + return null; + } + + return await File.ReadAllBytesAsync(provPath, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileParityRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileParityRepository.cs new file mode 100644 index 000000000..b9be904c4 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FileParityRepository.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Infrastructure.FileSystem; + +public sealed class FileParityRepository : IParityRepository +{ + private readonly string _path; + private readonly JsonSerializerOptions _jsonOptions; + private readonly SemaphoreSlim _mutex = new(1, 1); + + public FileParityRepository(string rootPath) + { + var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath); + Directory.CreateDirectory(root); + _path = Path.Combine(root, "parity.ndjson"); + _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + } + + public async Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // naive append; last write wins on read + await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read); + await using var writer = new StreamWriter(stream); + var json = JsonSerializer.Serialize(record, _jsonOptions); + await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); + } + finally + { + _mutex.Release(); + } + } + + public async Task GetAsync(string packId, CancellationToken cancellationToken = default) + { + if (!File.Exists(_path)) + { + return null; + } + + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false); + return lines + .Where(l => !string.IsNullOrWhiteSpace(l)) + .Select(l => JsonSerializer.Deserialize(l, _jsonOptions)) + .Where(r => r is not null) + .Cast() + .LastOrDefault(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase)); + } + finally + { + _mutex.Release(); + } + } + + public async Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + if (!File.Exists(_path)) + { + return Array.Empty(); + } + + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false); + IEnumerable records = lines + .Where(l => !string.IsNullOrWhiteSpace(l)) + .Select(l => JsonSerializer.Deserialize(l, _jsonOptions)) + .Where(r => r is not null)! + .Cast(); + + if (!string.IsNullOrWhiteSpace(tenantId)) + { + records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)); + } + + return records.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList(); + } + finally + { + _mutex.Release(); + } + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryAttestationRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryAttestationRepository.cs new file mode 100644 index 000000000..77aab4315 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryAttestationRepository.cs @@ -0,0 +1,41 @@ +using System.Collections.Concurrent; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Infrastructure.InMemory; + +public sealed class InMemoryAttestationRepository : IAttestationRepository +{ + private readonly ConcurrentDictionary<(string PackId, string Type), AttestationRecord> _records = new(); + private readonly ConcurrentDictionary<(string PackId, string Type), byte[]> _content = new(); + + public Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(content); + _records[(record.PackId, record.Type)] = record; + _content[(record.PackId, record.Type)] = content.ToArray(); + return Task.CompletedTask; + } + + public Task GetAsync(string packId, string type, CancellationToken cancellationToken = default) + { + _records.TryGetValue((packId, type), out var record); + return Task.FromResult(record); + } + + public Task> ListAsync(string packId, CancellationToken cancellationToken = default) + { + var result = _records.Values.Where(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase)) + .OrderBy(r => r.Type, StringComparer.OrdinalIgnoreCase) + .ToList(); + return Task.FromResult>(result); + } + + public Task GetContentAsync(string packId, string type, CancellationToken cancellationToken = default) + { + _content.TryGetValue((packId, type), out var bytes); + return Task.FromResult(bytes?.ToArray()); + } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryAuditRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryAuditRepository.cs new file mode 100644 index 000000000..aec0e361b --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryAuditRepository.cs @@ -0,0 +1,34 @@ +using System.Collections.Concurrent; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Infrastructure.InMemory; + +public sealed class InMemoryAuditRepository : IAuditRepository +{ + private readonly ConcurrentBag _events = new(); + + public Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + _events.Add(record); + return Task.CompletedTask; + } + + public Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + IEnumerable result = _events; + if (!string.IsNullOrWhiteSpace(tenantId)) + { + result = result.Where(e => string.Equals(e.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)); + } + + var ordered = result + .OrderBy(e => e.OccurredAtUtc) + .ThenBy(e => e.PackId, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return Task.FromResult>(ordered); + } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryLifecycleRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryLifecycleRepository.cs new file mode 100644 index 000000000..c0f585c0f --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryLifecycleRepository.cs @@ -0,0 +1,35 @@ +using System.Collections.Concurrent; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Infrastructure.InMemory; + +public sealed class InMemoryLifecycleRepository : ILifecycleRepository +{ + private readonly ConcurrentDictionary _records = new(StringComparer.OrdinalIgnoreCase); + + public Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + _records[record.PackId] = record; + return Task.CompletedTask; + } + + public Task GetAsync(string packId, CancellationToken cancellationToken = default) + { + _records.TryGetValue(packId, out var record); + return Task.FromResult(record); + } + + public Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + IEnumerable result = _records.Values; + if (!string.IsNullOrWhiteSpace(tenantId)) + { + result = result.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)); + } + + var ordered = result.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList(); + return Task.FromResult>(ordered); + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryMirrorRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryMirrorRepository.cs new file mode 100644 index 000000000..7474ee013 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryMirrorRepository.cs @@ -0,0 +1,35 @@ +using System.Collections.Concurrent; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Infrastructure.InMemory; + +public sealed class InMemoryMirrorRepository : IMirrorRepository +{ + private readonly ConcurrentDictionary _records = new(StringComparer.OrdinalIgnoreCase); + + public Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + _records[record.Id] = record; + return Task.CompletedTask; + } + + public Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + IEnumerable result = _records.Values; + if (!string.IsNullOrWhiteSpace(tenantId)) + { + result = result.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)); + } + + return Task.FromResult>(result.OrderBy(r => r.Id, StringComparer.OrdinalIgnoreCase).ToList()); + } + + public Task GetAsync(string id, CancellationToken cancellationToken = default) + { + _records.TryGetValue(id, out var record); + return Task.FromResult(record); + } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryPackRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryPackRepository.cs new file mode 100644 index 000000000..c93139f4f --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryPackRepository.cs @@ -0,0 +1,71 @@ +using System.Collections.Concurrent; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Infrastructure.InMemory; + +/// +/// Simple in-memory repository for early development and tests. +/// +public sealed class InMemoryPackRepository : IPackRepository +{ + private readonly ConcurrentDictionary _packs = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _content = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _provenance = new(StringComparer.OrdinalIgnoreCase); + + public Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(content); + _packs[record.PackId] = record; + _content[record.Digest] = content.ToArray(); + if (provenance is { Length: > 0 } && !string.IsNullOrWhiteSpace(record.ProvenanceDigest)) + { + _provenance[record.ProvenanceDigest!] = provenance.ToArray(); + } + return Task.CompletedTask; + } + + public Task GetAsync(string packId, CancellationToken cancellationToken = default) + { + _packs.TryGetValue(packId, out var record); + return Task.FromResult(record); + } + + public Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + IEnumerable result = _packs.Values; + if (!string.IsNullOrWhiteSpace(tenantId)) + { + result = result.Where(p => string.Equals(p.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)); + } + + var ordered = result + .OrderBy(p => p.TenantId, StringComparer.OrdinalIgnoreCase) + .ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(p => p.Version, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return Task.FromResult>(ordered); + } + + public Task GetContentAsync(string packId, CancellationToken cancellationToken = default) + { + if (_packs.TryGetValue(packId, out var record) && _content.TryGetValue(record.Digest, out var bytes)) + { + return Task.FromResult(bytes.ToArray()); + } + + return Task.FromResult(null); + } + + public Task GetProvenanceAsync(string packId, CancellationToken cancellationToken = default) + { + if (_packs.TryGetValue(packId, out var record) && record.ProvenanceDigest is not null && _provenance.TryGetValue(record.ProvenanceDigest, out var bytes)) + { + return Task.FromResult(bytes.ToArray()); + } + + return Task.FromResult(null); + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryParityRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryParityRepository.cs new file mode 100644 index 000000000..acd0416b5 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/InMemory/InMemoryParityRepository.cs @@ -0,0 +1,35 @@ +using System.Collections.Concurrent; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.Infrastructure.InMemory; + +public sealed class InMemoryParityRepository : IParityRepository +{ + private readonly ConcurrentDictionary _parity = new(StringComparer.OrdinalIgnoreCase); + + public Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + _parity[record.PackId] = record; + return Task.CompletedTask; + } + + public Task GetAsync(string packId, CancellationToken cancellationToken = default) + { + _parity.TryGetValue(packId, out var record); + return Task.FromResult(record); + } + + public Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + IEnumerable result = _parity.Values; + if (!string.IsNullOrWhiteSpace(tenantId)) + { + result = result.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)); + } + + var ordered = result.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList(); + return Task.FromResult>(ordered); + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoAttestationRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoAttestationRepository.cs new file mode 100644 index 000000000..8c162205c --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoAttestationRepository.cs @@ -0,0 +1,84 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; +using StellaOps.PacksRegistry.Infrastructure.Options; + +namespace StellaOps.PacksRegistry.Infrastructure.Mongo; + +public sealed class MongoAttestationRepository : IAttestationRepository +{ + private readonly IMongoCollection _index; + private readonly IMongoCollection _blobs; + + public MongoAttestationRepository(IMongoDatabase database, MongoOptions options) + { + _index = database.GetCollection(options.AttestationCollection ?? "packs_attestations"); + _blobs = database.GetCollection(options.AttestationBlobsCollection ?? "packs_attestation_blobs"); + _index.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.PackId).Ascending(x => x.Type), new CreateIndexOptions { Unique = true })); + _blobs.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.Digest), new CreateIndexOptions { Unique = true })); + } + + public async Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default) + { + var doc = AttestationDocument.From(record); + await _index.ReplaceOneAsync(x => x.PackId == record.PackId && x.Type == record.Type, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false); + + var blob = new AttestationBlob { Digest = record.Digest, Content = content }; + await _blobs.ReplaceOneAsync(x => x.Digest == blob.Digest, blob, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false); + } + + public async Task GetAsync(string packId, string type, CancellationToken cancellationToken = default) + { + var doc = await _index.Find(x => x.PackId == packId && x.Type == type).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + return doc?.ToModel(); + } + + public async Task> ListAsync(string packId, CancellationToken cancellationToken = default) + { + var docs = await _index.Find(x => x.PackId == packId).SortBy(x => x.Type).ToListAsync(cancellationToken).ConfigureAwait(false); + return docs.Select(d => d.ToModel()).ToList(); + } + + public async Task GetContentAsync(string packId, string type, CancellationToken cancellationToken = default) + { + var record = await GetAsync(packId, type, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return null; + } + + var blob = await _blobs.Find(x => x.Digest == record.Digest).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + return blob?.Content; + } + + private sealed class AttestationDocument + { + public ObjectId Id { get; set; } + public string PackId { get; set; } = default!; + public string TenantId { get; set; } = default!; + public string Type { get; set; } = default!; + public string Digest { get; set; } = default!; + public DateTimeOffset CreatedAtUtc { get; set; } + public string? Notes { get; set; } + + public AttestationRecord ToModel() => new(PackId, TenantId, Type, Digest, CreatedAtUtc, Notes); + public static AttestationDocument From(AttestationRecord record) => new() + { + PackId = record.PackId, + TenantId = record.TenantId, + Type = record.Type, + Digest = record.Digest, + CreatedAtUtc = record.CreatedAtUtc, + Notes = record.Notes + }; + } + + private sealed class AttestationBlob + { + public ObjectId Id { get; set; } + public string Digest { get; set; } = default!; + public byte[] Content { get; set; } = default!; + } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoAuditRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoAuditRepository.cs new file mode 100644 index 000000000..9ab996688 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoAuditRepository.cs @@ -0,0 +1,66 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; +using StellaOps.PacksRegistry.Infrastructure.Options; + +namespace StellaOps.PacksRegistry.Infrastructure.Mongo; + +public sealed class MongoAuditRepository : IAuditRepository +{ + private readonly IMongoCollection _collection; + + public MongoAuditRepository(IMongoDatabase database, MongoOptions options) + { + _collection = database.GetCollection(options.AuditCollection ?? "packs_audit_log"); + var indexKeys = Builders.IndexKeys + .Ascending(x => x.TenantId) + .Ascending(x => x.PackId) + .Ascending(x => x.OccurredAtUtc); + _collection.Indexes.CreateOne(new CreateIndexModel(indexKeys)); + } + + public async Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default) + { + var doc = AuditDocument.From(record); + await _collection.InsertOneAsync(doc, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + var filter = string.IsNullOrWhiteSpace(tenantId) + ? Builders.Filter.Empty + : Builders.Filter.Eq(x => x.TenantId, tenantId); + + var docs = await _collection.Find(filter) + .SortBy(x => x.OccurredAtUtc) + .ThenBy(x => x.PackId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return docs.Select(d => d.ToModel()).ToList(); + } + + private sealed class AuditDocument + { + public ObjectId Id { get; set; } + public string? PackId { get; set; } + public string TenantId { get; set; } = default!; + public string Event { get; set; } = default!; + public DateTimeOffset OccurredAtUtc { get; set; } + public string? Actor { get; set; } + public string? Notes { get; set; } + + public AuditRecord ToModel() => new(PackId, TenantId, Event, OccurredAtUtc, Actor, Notes); + + public static AuditDocument From(AuditRecord record) => new() + { + PackId = record.PackId, + TenantId = record.TenantId, + Event = record.Event, + OccurredAtUtc = record.OccurredAtUtc, + Actor = record.Actor, + Notes = record.Notes + }; + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoLifecycleRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoLifecycleRepository.cs new file mode 100644 index 000000000..7a166d925 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoLifecycleRepository.cs @@ -0,0 +1,64 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; +using StellaOps.PacksRegistry.Infrastructure.Options; + +namespace StellaOps.PacksRegistry.Infrastructure.Mongo; + +public sealed class MongoLifecycleRepository : ILifecycleRepository +{ + private readonly IMongoCollection _collection; + + public MongoLifecycleRepository(IMongoDatabase database, MongoOptions options) + { + _collection = database.GetCollection(options.LifecycleCollection ?? "packs_lifecycle"); + _collection.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.PackId), new CreateIndexOptions { Unique = true })); + } + + public async Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default) + { + var doc = LifecycleDocument.From(record); + await _collection.ReplaceOneAsync(x => x.PackId == record.PackId, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false); + } + + public async Task GetAsync(string packId, CancellationToken cancellationToken = default) + { + var doc = await _collection.Find(x => x.PackId == packId).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + return doc?.ToModel(); + } + + public async Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + var filter = string.IsNullOrWhiteSpace(tenantId) + ? Builders.Filter.Empty + : Builders.Filter.Eq(x => x.TenantId, tenantId); + + var docs = await _collection.Find(filter) + .SortBy(x => x.PackId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return docs.Select(d => d.ToModel()).ToList(); + } + + private sealed class LifecycleDocument + { + public ObjectId Id { get; set; } + public string PackId { get; set; } = default!; + public string TenantId { get; set; } = default!; + public string State { get; set; } = default!; + public string? Notes { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } + + public LifecycleRecord ToModel() => new(PackId, TenantId, State, Notes, UpdatedAtUtc); + public static LifecycleDocument From(LifecycleRecord record) => new() + { + PackId = record.PackId, + TenantId = record.TenantId, + State = record.State, + Notes = record.Notes, + UpdatedAtUtc = record.UpdatedAtUtc + }; + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoMirrorRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoMirrorRepository.cs new file mode 100644 index 000000000..d2ec4cb1d --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoMirrorRepository.cs @@ -0,0 +1,67 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; +using StellaOps.PacksRegistry.Infrastructure.Options; + +namespace StellaOps.PacksRegistry.Infrastructure.Mongo; + +public sealed class MongoMirrorRepository : IMirrorRepository +{ + private readonly IMongoCollection _collection; + + public MongoMirrorRepository(IMongoDatabase database, MongoOptions options) + { + _collection = database.GetCollection(options.MirrorCollection ?? "packs_mirrors"); + _collection.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.Id), new CreateIndexOptions { Unique = true })); + } + + public async Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default) + { + var doc = MirrorDocument.From(record); + await _collection.ReplaceOneAsync(x => x.Id == record.Id, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false); + } + + public async Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + var filter = string.IsNullOrWhiteSpace(tenantId) + ? Builders.Filter.Empty + : Builders.Filter.Eq(x => x.TenantId, tenantId); + + var docs = await _collection.Find(filter).SortBy(x => x.Id).ToListAsync(cancellationToken).ConfigureAwait(false); + return docs.Select(d => d.ToModel()).ToList(); + } + + public async Task GetAsync(string id, CancellationToken cancellationToken = default) + { + var doc = await _collection.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + return doc?.ToModel(); + } + + private sealed class MirrorDocument + { + public ObjectId InternalId { get; set; } + public string Id { get; set; } = default!; + public string TenantId { get; set; } = default!; + public string Upstream { get; set; } = default!; + public bool Enabled { get; set; } + public string Status { get; set; } = default!; + public DateTimeOffset UpdatedAtUtc { get; set; } + public string? Notes { get; set; } + public DateTimeOffset? LastSuccessfulSyncUtc { get; set; } + + public MirrorSourceRecord ToModel() => new(Id, TenantId, new Uri(Upstream), Enabled, Status, UpdatedAtUtc, Notes, LastSuccessfulSyncUtc); + public static MirrorDocument From(MirrorSourceRecord record) => new() + { + Id = record.Id, + TenantId = record.TenantId, + Upstream = record.UpstreamUri.ToString(), + Enabled = record.Enabled, + Status = record.Status, + UpdatedAtUtc = record.UpdatedAtUtc, + Notes = record.Notes, + LastSuccessfulSyncUtc = record.LastSuccessfulSyncUtc + }; + } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoPackRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoPackRepository.cs new file mode 100644 index 000000000..6dc0d64bc --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoPackRepository.cs @@ -0,0 +1,123 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; +using StellaOps.PacksRegistry.Infrastructure.Options; + +namespace StellaOps.PacksRegistry.Infrastructure.Mongo; + +public sealed class MongoPackRepository : IPackRepository +{ + private readonly IMongoCollection _packs; + private readonly IMongoCollection _contents; + + public MongoPackRepository(IMongoDatabase database, MongoOptions options) + { + ArgumentNullException.ThrowIfNull(database); + _packs = database.GetCollection(options.PacksCollection); + _contents = database.GetCollection(options.BlobsCollection); + + _packs.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.PackId), new CreateIndexOptions { Unique = true })); + _packs.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.TenantId).Ascending(x => x.Name).Ascending(x => x.Version))); + _contents.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.Digest), new CreateIndexOptions { Unique = true })); + } + + public async Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(content); + + var packDoc = PackDocument.From(record); + await _packs.ReplaceOneAsync(x => x.PackId == record.PackId, packDoc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false); + + var blob = new PackContentDocument + { + Digest = record.Digest, + Content = content, + ProvenanceDigest = record.ProvenanceDigest, + Provenance = provenance + }; + + await _contents.ReplaceOneAsync(x => x.Digest == record.Digest, blob, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false); + } + + public async Task GetAsync(string packId, CancellationToken cancellationToken = default) + { + var doc = await _packs.Find(x => x.PackId == packId).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + return doc?.ToModel(); + } + + public async Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + var filter = string.IsNullOrWhiteSpace(tenantId) + ? Builders.Filter.Empty + : Builders.Filter.Eq(x => x.TenantId, tenantId); + + var docs = await _packs.Find(filter).SortBy(x => x.TenantId).ThenBy(x => x.Name).ThenBy(x => x.Version).ToListAsync(cancellationToken).ConfigureAwait(false); + return docs.Select(d => d.ToModel()).ToArray(); + } + + public async Task GetContentAsync(string packId, CancellationToken cancellationToken = default) + { + var pack = await GetAsync(packId, cancellationToken).ConfigureAwait(false); + if (pack is null) + { + return null; + } + + var blob = await _contents.Find(x => x.Digest == pack.Digest).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + return blob?.Content; + } + + public async Task GetProvenanceAsync(string packId, CancellationToken cancellationToken = default) + { + var pack = await GetAsync(packId, cancellationToken).ConfigureAwait(false); + if (pack is null || string.IsNullOrWhiteSpace(pack.ProvenanceDigest)) + { + return null; + } + + var blob = await _contents.Find(x => x.Digest == pack.Digest).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + return blob?.Provenance; + } + + private sealed class PackDocument + { + public ObjectId Id { get; set; } + public string PackId { get; set; } = default!; + public string Name { get; set; } = default!; + public string Version { get; set; } = default!; + public string TenantId { get; set; } = default!; + public string Digest { get; set; } = default!; + public string? Signature { get; set; } + public string? ProvenanceUri { get; set; } + public string? ProvenanceDigest { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } + public Dictionary? Metadata { get; set; } + + public PackRecord ToModel() => new(PackId, Name, Version, TenantId, Digest, Signature, ProvenanceUri, ProvenanceDigest, CreatedAtUtc, Metadata); + + public static PackDocument From(PackRecord model) => new() + { + PackId = model.PackId, + Name = model.Name, + Version = model.Version, + TenantId = model.TenantId, + Digest = model.Digest, + Signature = model.Signature, + ProvenanceUri = model.ProvenanceUri, + ProvenanceDigest = model.ProvenanceDigest, + CreatedAtUtc = model.CreatedAtUtc, + Metadata = model.Metadata?.ToDictionary(kv => kv.Key, kv => kv.Value) + }; + } + + private sealed class PackContentDocument + { + public ObjectId Id { get; set; } + public string Digest { get; set; } = default!; + public byte[] Content { get; set; } = Array.Empty(); + public string? ProvenanceDigest { get; set; } + public byte[]? Provenance { get; set; } + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoParityRepository.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoParityRepository.cs new file mode 100644 index 000000000..9918ca901 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/MongoParityRepository.cs @@ -0,0 +1,64 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; +using StellaOps.PacksRegistry.Infrastructure.Options; + +namespace StellaOps.PacksRegistry.Infrastructure.Mongo; + +public sealed class MongoParityRepository : IParityRepository +{ + private readonly IMongoCollection _collection; + + public MongoParityRepository(IMongoDatabase database, MongoOptions options) + { + _collection = database.GetCollection(options.ParityCollection ?? "packs_parity_matrix"); + _collection.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.PackId), new CreateIndexOptions { Unique = true })); + } + + public async Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default) + { + var doc = ParityDocument.From(record); + await _collection.ReplaceOneAsync(x => x.PackId == record.PackId, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false); + } + + public async Task GetAsync(string packId, CancellationToken cancellationToken = default) + { + var doc = await _collection.Find(x => x.PackId == packId).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + return doc?.ToModel(); + } + + public async Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) + { + var filter = string.IsNullOrWhiteSpace(tenantId) + ? Builders.Filter.Empty + : Builders.Filter.Eq(x => x.TenantId, tenantId); + + var docs = await _collection.Find(filter) + .SortBy(x => x.PackId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return docs.Select(d => d.ToModel()).ToList(); + } + + private sealed class ParityDocument + { + public ObjectId Id { get; set; } + public string PackId { get; set; } = default!; + public string TenantId { get; set; } = default!; + public string Status { get; set; } = default!; + public string? Notes { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } + + public ParityRecord ToModel() => new(PackId, TenantId, Status, Notes, UpdatedAtUtc); + public static ParityDocument From(ParityRecord record) => new() + { + PackId = record.PackId, + TenantId = record.TenantId, + Status = record.Status, + Notes = record.Notes, + UpdatedAtUtc = record.UpdatedAtUtc + }; + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/PacksMongoInitializer.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/PacksMongoInitializer.cs new file mode 100644 index 000000000..00620bb7a --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Mongo/PacksMongoInitializer.cs @@ -0,0 +1,109 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MongoDB.Bson; +using MongoDB.Driver; +using StellaOps.PacksRegistry.Infrastructure.Options; + +namespace StellaOps.PacksRegistry.Infrastructure.Mongo; + +/// +/// Ensures Mongo collections and indexes exist for packs, blobs, and parity matrix. +/// +public sealed class PacksMongoInitializer : IHostedService +{ + private readonly IMongoDatabase _database; + private readonly MongoOptions _options; + private readonly ILogger _logger; + + public PacksMongoInitializer(IMongoDatabase database, MongoOptions options, ILogger logger) + { + _database = database ?? throw new ArgumentNullException(nameof(database)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + await EnsurePacksIndexAsync(cancellationToken).ConfigureAwait(false); + await EnsureBlobsIndexAsync(cancellationToken).ConfigureAwait(false); + await EnsureParityMatrixAsync(cancellationToken).ConfigureAwait(false); + await EnsureLifecycleAsync(cancellationToken).ConfigureAwait(false); + await EnsureAuditAsync(cancellationToken).ConfigureAwait(false); + await EnsureAttestationsAsync(cancellationToken).ConfigureAwait(false); + await EnsureMirrorsAsync(cancellationToken).ConfigureAwait(false); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private async Task EnsurePacksIndexAsync(CancellationToken cancellationToken) + { + var packs = _database.GetCollection(_options.PacksCollection); + var indexKeys = Builders.IndexKeys.Ascending("packId"); + await packs.Indexes.CreateOneAsync(new CreateIndexModel(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false); + + var secondary = Builders.IndexKeys.Ascending("tenantId").Ascending("name").Ascending("version"); + await packs.Indexes.CreateOneAsync(new CreateIndexModel(secondary), cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private async Task EnsureBlobsIndexAsync(CancellationToken cancellationToken) + { + var blobs = _database.GetCollection(_options.BlobsCollection); + var indexKeys = Builders.IndexKeys.Ascending("digest"); + await blobs.Indexes.CreateOneAsync(new CreateIndexModel(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private async Task EnsureParityMatrixAsync(CancellationToken cancellationToken) + { + var parityName = _options.ParityCollection ?? "packs_parity_matrix"; + var parity = _database.GetCollection(parityName); + var indexKeys = Builders.IndexKeys.Ascending("packId"); + await parity.Indexes.CreateOneAsync(new CreateIndexModel(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Mongo collections ensured: {Packs}, {Blobs}, {Parity}", _options.PacksCollection, _options.BlobsCollection, parityName); + } + + private async Task EnsureLifecycleAsync(CancellationToken cancellationToken) + { + var lifecycleName = _options.LifecycleCollection ?? "packs_lifecycle"; + var lifecycle = _database.GetCollection(lifecycleName); + var indexKeys = Builders.IndexKeys.Ascending("packId"); + await lifecycle.Indexes.CreateOneAsync(new CreateIndexModel(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Mongo lifecycle collection ensured: {Lifecycle}", lifecycleName); + } + + private async Task EnsureAuditAsync(CancellationToken cancellationToken) + { + var auditName = _options.AuditCollection ?? "packs_audit_log"; + var audit = _database.GetCollection(auditName); + var indexKeys = Builders.IndexKeys + .Ascending("tenantId") + .Ascending("packId") + .Ascending("occurredAtUtc"); + await audit.Indexes.CreateOneAsync(new CreateIndexModel(indexKeys), cancellationToken: cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Mongo audit collection ensured: {Audit}", auditName); + } + + private async Task EnsureAttestationsAsync(CancellationToken cancellationToken) + { + var attestName = _options.AttestationCollection ?? "packs_attestations"; + var attest = _database.GetCollection(attestName); + var indexKeys = Builders.IndexKeys.Ascending("packId").Ascending("type"); + await attest.Indexes.CreateOneAsync(new CreateIndexModel(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false); + + var blobsName = _options.AttestationBlobsCollection ?? "packs_attestation_blobs"; + var blobs = _database.GetCollection(blobsName); + var blobIndex = Builders.IndexKeys.Ascending("digest"); + await blobs.Indexes.CreateOneAsync(new CreateIndexModel(blobIndex, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Mongo attestation collections ensured: {Attest} / {AttestBlobs}", attestName, blobsName); + } + + private async Task EnsureMirrorsAsync(CancellationToken cancellationToken) + { + var mirrorName = _options.MirrorCollection ?? "packs_mirrors"; + var mirrors = _database.GetCollection(mirrorName); + var indexKeys = Builders.IndexKeys.Ascending("id"); + await mirrors.Indexes.CreateOneAsync(new CreateIndexModel(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Mongo mirror collection ensured: {Mirror}", mirrorName); + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Options/MongoOptions.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Options/MongoOptions.cs new file mode 100644 index 000000000..b93ba2fca --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Options/MongoOptions.cs @@ -0,0 +1,15 @@ +namespace StellaOps.PacksRegistry.Infrastructure.Options; + +public sealed class MongoOptions +{ + public string? ConnectionString { get; set; } + public string Database { get; set; } = "packs_registry"; + public string PacksCollection { get; set; } = "packs_index"; + public string BlobsCollection { get; set; } = "packs_blobs"; + public string? ParityCollection { get; set; } = "packs_parity_matrix"; + public string? LifecycleCollection { get; set; } = "packs_lifecycle"; + public string? AuditCollection { get; set; } = "packs_audit_log"; + public string? AttestationCollection { get; set; } = "packs_attestations"; + public string? AttestationBlobsCollection { get; set; } = "packs_attestation_blobs"; + public string? MirrorCollection { get; set; } = "packs_mirrors"; +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/StellaOps.PacksRegistry.Infrastructure.csproj b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/StellaOps.PacksRegistry.Infrastructure.csproj index 0db5ce09c..b03f7f4be 100644 --- a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/StellaOps.PacksRegistry.Infrastructure.csproj +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/StellaOps.PacksRegistry.Infrastructure.csproj @@ -3,14 +3,22 @@ - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Verification/RsaSignatureVerifier.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Verification/RsaSignatureVerifier.cs new file mode 100644 index 000000000..06545a2ea --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Verification/RsaSignatureVerifier.cs @@ -0,0 +1,50 @@ +using System.Security.Cryptography; +using System.Text; +using StellaOps.PacksRegistry.Core.Contracts; + +namespace StellaOps.PacksRegistry.Infrastructure.Verification; + +/// +/// Verifies signatures over the digest string using an RSA public key (PEM, PKCS#8 or PKCS#1), SHA-256. +/// Signature input is expected to be base64 of the raw RSA signature over UTF-8 digest text (e.g. "sha256:abcd..."). +/// +public sealed class RsaSignatureVerifier : IPackSignatureVerifier +{ + private readonly RSA _rsa; + + public RsaSignatureVerifier(string publicKeyPem) + { + if (string.IsNullOrWhiteSpace(publicKeyPem)) + { + throw new ArgumentException("Public key PEM is required for RSA verification.", nameof(publicKeyPem)); + } + + _rsa = RSA.Create(); + _rsa.ImportFromPem(publicKeyPem); + } + + public Task VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(content); + ArgumentException.ThrowIfNullOrWhiteSpace(digest); + + if (string.IsNullOrWhiteSpace(signature)) + { + return Task.FromResult(false); + } + + byte[] sigBytes; + try + { + sigBytes = Convert.FromBase64String(signature); + } + catch + { + return Task.FromResult(false); + } + + var data = Encoding.UTF8.GetBytes(digest); + var valid = _rsa.VerifyData(data, sigBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return Task.FromResult(valid); + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Verification/SimpleSignatureVerifier.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Verification/SimpleSignatureVerifier.cs new file mode 100644 index 000000000..1660c9061 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/Verification/SimpleSignatureVerifier.cs @@ -0,0 +1,46 @@ +using System.Security.Cryptography; +using System.Text; +using StellaOps.PacksRegistry.Core.Contracts; + +namespace StellaOps.PacksRegistry.Infrastructure.Verification; + +/// +/// Accepts either no signature or a signature that matches the computed SHA-256 digest (hex or base64 of digest string). +/// Replace with real signature verification when keys/attestations are available. +/// +public sealed class SimpleSignatureVerifier : IPackSignatureVerifier +{ + public Task VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(content); + ArgumentException.ThrowIfNullOrWhiteSpace(digest); + + using var sha = SHA256.Create(); + var computed = "sha256:" + Convert.ToHexString(sha.ComputeHash(content)).ToLowerInvariant(); + + if (string.IsNullOrWhiteSpace(signature)) + { + return Task.FromResult(string.Equals(computed, digest, StringComparison.OrdinalIgnoreCase)); + } + + if (string.Equals(signature, computed, StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(true); + } + + try + { + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(signature)); + if (string.Equals(decoded, computed, StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(true); + } + } + catch + { + // ignore decode errors + } + + return Task.FromResult(false); + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/ExportServiceTests.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/ExportServiceTests.cs new file mode 100644 index 000000000..d820c12b0 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/ExportServiceTests.cs @@ -0,0 +1,41 @@ +using System.IO.Compression; +using StellaOps.PacksRegistry.Core.Services; +using StellaOps.PacksRegistry.Infrastructure.InMemory; +using StellaOps.PacksRegistry.Infrastructure.Verification; + +namespace StellaOps.PacksRegistry.Tests; + +public sealed class ExportServiceTests +{ + [Fact] + public async Task Offline_seed_includes_metadata_and_content_when_requested() + { + var ct = TestContext.Current.CancellationToken; + var packRepo = new InMemoryPackRepository(); + var parityRepo = new InMemoryParityRepository(); + var lifecycleRepo = new InMemoryLifecycleRepository(); + var auditRepo = new InMemoryAuditRepository(); + var verifier = new SimpleSignatureVerifier(); + + var packService = new PackService(packRepo, verifier, auditRepo, null, TimeProvider.System); + var parityService = new ParityService(parityRepo, packRepo, auditRepo, TimeProvider.System); + var lifecycleService = new LifecycleService(lifecycleRepo, packRepo, auditRepo, TimeProvider.System); + var exportService = new ExportService(packRepo, parityRepo, lifecycleRepo, auditRepo, TimeProvider.System); + + var content = System.Text.Encoding.UTF8.GetBytes("export-pack"); + var provenance = System.Text.Encoding.UTF8.GetBytes("{\"p\":1}"); + var record = await packService.UploadAsync("demo", "1.2.3", "tenant-1", content, null, null, provenance, null, ct); + await parityService.SetStatusAsync(record.PackId, record.TenantId, "ready", "seed", ct); + await lifecycleService.SetStateAsync(record.PackId, record.TenantId, "promoted", "seed", ct); + + var archiveStream = await exportService.ExportOfflineSeedAsync(record.TenantId, includeContent: true, includeProvenance: true, cancellationToken: ct); + using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read); + + Assert.NotNull(archive.GetEntry("packs.ndjson")); + Assert.NotNull(archive.GetEntry("parity.ndjson")); + Assert.NotNull(archive.GetEntry("lifecycle.ndjson")); + Assert.NotNull(archive.GetEntry("audit.ndjson")); + Assert.NotNull(archive.GetEntry($"content/{record.PackId}.bin")); + Assert.NotNull(archive.GetEntry($"provenance/{record.PackId}.json")); + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/FilePackRepositoryTests.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/FilePackRepositoryTests.cs new file mode 100644 index 000000000..dd41d04dc --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/FilePackRepositoryTests.cs @@ -0,0 +1,45 @@ +using StellaOps.PacksRegistry.Core.Models; +using StellaOps.PacksRegistry.Infrastructure.FileSystem; + +namespace StellaOps.PacksRegistry.Tests; + +public sealed class FilePackRepositoryTests +{ + [Fact] + public async Task Upsert_and_List_round_trip() + { + var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempPath); + try + { + var ct = TestContext.Current.CancellationToken; + var repo = new FilePackRepository(tempPath); + + var record = new PackRecord( + PackId: "demo@1.0.0", + Name: "demo", + Version: "1.0.0", + TenantId: "t1", + Digest: "sha256:abc", + Signature: null, + ProvenanceUri: null, + ProvenanceDigest: null, + CreatedAtUtc: DateTimeOffset.Parse("2025-11-24T00:00:00Z"), + Metadata: new Dictionary { ["lang"] = "csharp" }); + + await repo.UpsertAsync(record, new byte[] { 1, 2, 3 }, null, ct); + + var listed = await repo.ListAsync("t1", ct); + Assert.Single(listed); + Assert.Equal(record.PackId, listed[0].PackId); + + var fetched = await repo.GetAsync("demo@1.0.0", ct); + Assert.NotNull(fetched); + Assert.Equal(record.Digest, fetched!.Digest); + } + finally + { + Directory.Delete(tempPath, recursive: true); + } + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PackServiceTests.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PackServiceTests.cs new file mode 100644 index 000000000..1987e6ecc --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PackServiceTests.cs @@ -0,0 +1,95 @@ +using StellaOps.PacksRegistry.Core.Services; +using StellaOps.PacksRegistry.Infrastructure.InMemory; +using StellaOps.PacksRegistry.Infrastructure.Verification; + +namespace StellaOps.PacksRegistry.Tests; + +public sealed class PackServiceTests +{ + private static byte[] SampleContent => System.Text.Encoding.UTF8.GetBytes("sample-pack-content"); + + [Fact] + public async Task Upload_persists_pack_with_digest() + { + var ct = TestContext.Current.CancellationToken; + var repo = new InMemoryPackRepository(); + var verifier = new SimpleSignatureVerifier(); + var service = new PackService(repo, verifier, new InMemoryAuditRepository(), null, TimeProvider.System); + + var record = await service.UploadAsync( + name: "demo-pack", + version: "1.0.0", + tenantId: "tenant-1", + content: SampleContent, + signature: null, + provenanceUri: "https://example/manifest.json", + provenanceContent: null, + metadata: new Dictionary { ["lang"] = "csharp" }, + cancellationToken: ct); + + Assert.Equal("demo-pack@1.0.0", record.PackId); + Assert.NotNull(record.Digest); + + var listed = await service.ListAsync("tenant-1", ct); + Assert.Single(listed); + Assert.Equal(record.PackId, listed[0].PackId); + } + + [Fact] + public async Task Upload_rejects_when_digest_mismatch() + { + var ct = TestContext.Current.CancellationToken; + var repo = new InMemoryPackRepository(); + var verifier = new AlwaysFailSignatureVerifier(); + var service = new PackService(repo, verifier, new InMemoryAuditRepository(), null, TimeProvider.System); + + await Assert.ThrowsAsync(() => + service.UploadAsync( + name: "demo-pack", + version: "1.0.0", + tenantId: "tenant-1", + content: SampleContent, + signature: "bogus", + provenanceUri: null, + provenanceContent: null, + metadata: null, + cancellationToken: ct)); + } + + [Fact] + public async Task Rotate_signature_updates_record_and_audits() + { + var ct = TestContext.Current.CancellationToken; + var repo = new InMemoryPackRepository(); + var audit = new InMemoryAuditRepository(); + var verifier = new SimpleSignatureVerifier(); + var service = new PackService(repo, verifier, audit, null, TimeProvider.System); + + var record = await service.UploadAsync( + name: "demo-pack", + version: "1.0.0", + tenantId: "tenant-1", + content: SampleContent, + signature: null, + provenanceUri: null, + provenanceContent: null, + metadata: null, + cancellationToken: ct); + + var digest = record.Digest; + var newSignature = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(digest)); + + var rotated = await service.RotateSignatureAsync(record.PackId, record.TenantId, newSignature, cancellationToken: ct); + + Assert.Equal(newSignature, rotated.Signature); + + var auditEvents = await audit.ListAsync(record.TenantId, ct); + Assert.Contains(auditEvents, a => a.Event == "signature.rotated" && a.PackId == record.PackId); + } + + private sealed class AlwaysFailSignatureVerifier : StellaOps.PacksRegistry.Core.Contracts.IPackSignatureVerifier + { + public Task VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default) + => Task.FromResult(false); + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksApiTests.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksApiTests.cs new file mode 100644 index 000000000..d40c6aef8 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksApiTests.cs @@ -0,0 +1,127 @@ +using System.Net; +using System.Net.Http.Json; +using System.IO.Compression; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Services; +using StellaOps.PacksRegistry.Infrastructure.InMemory; +using StellaOps.PacksRegistry.WebService.Contracts; + +namespace StellaOps.PacksRegistry.Tests; + +public sealed class PacksApiTests : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + public PacksApiTests(WebApplicationFactory factory) + { + _factory = factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(TimeProvider.System); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }); + }); + } + + [Fact] + public async Task Upload_and_download_round_trip() + { + var ct = TestContext.Current.CancellationToken; + var client = _factory.CreateClient(); + + client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "t1"); + var auth = _factory.Services.GetRequiredService(); + if (!string.IsNullOrWhiteSpace(auth.ApiKey)) + { + client.DefaultRequestHeaders.Add("X-API-Key", auth.ApiKey); + } + + var payload = new PackUploadRequest + { + Name = "demo", + Version = "1.0.0", + TenantId = "t1", + Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("hello")), + ProvenanceUri = "https://example/provenance.json", + ProvenanceContent = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{\"provenance\":true}")) + }; + + var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/packs") + { + Content = JsonContent.Create(payload) + }; + + var response = await client.SendAsync(message, ct); + if (response.StatusCode != HttpStatusCode.Created) + { + var body = await response.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException($"Upload failed with {response.StatusCode}: {body}"); + } + + var created = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + Assert.NotNull(created); + Assert.Equal("demo", created!.Name); + Assert.Equal("1.0.0", created.Version); + + var get = await client.GetAsync($"/api/v1/packs/{created.PackId}", ct); + Assert.Equal(HttpStatusCode.OK, get.StatusCode); + + var content = await client.GetAsync($"/api/v1/packs/{created.PackId}/content", ct); + Assert.Equal(HttpStatusCode.OK, content.StatusCode); + var bytes = await content.Content.ReadAsByteArrayAsync(ct); + Assert.Equal("hello", System.Text.Encoding.UTF8.GetString(bytes)); + Assert.True(content.Headers.Contains("X-Content-Digest")); + + var prov = await client.GetAsync($"/api/v1/packs/{created.PackId}/provenance", ct); + Assert.Equal(HttpStatusCode.OK, prov.StatusCode); + var provBytes = await prov.Content.ReadAsByteArrayAsync(ct); + Assert.Contains("provenance", System.Text.Encoding.UTF8.GetString(provBytes)); + Assert.True(prov.Headers.Contains("X-Provenance-Digest")); + + var manifest = await client.GetFromJsonAsync($"/api/v1/packs/{created.PackId}/manifest", ct); + Assert.NotNull(manifest); + Assert.Equal(created.PackId, manifest!.PackId); + Assert.True(manifest.ContentLength > 0); + Assert.True(manifest.ProvenanceLength > 0); + + // parity status + var parityResponse = await client.PostAsJsonAsync($"/api/v1/packs/{created.PackId}/parity", new ParityRequest { Status = "ready", Notes = "tests" }, ct); + Assert.Equal(HttpStatusCode.OK, parityResponse.StatusCode); + + var parity = await client.GetFromJsonAsync($"/api/v1/packs/{created.PackId}/parity", ct); + Assert.NotNull(parity); + Assert.Equal("ready", parity!.Status); + + var newSignature = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(created.Digest)); + var rotationResponse = await client.PostAsJsonAsync($"/api/v1/packs/{created.PackId}/signature", new RotateSignatureRequest { Signature = newSignature }, ct); + Assert.Equal(HttpStatusCode.OK, rotationResponse.StatusCode); + var rotated = await rotationResponse.Content.ReadFromJsonAsync(cancellationToken: ct); + Assert.Equal(newSignature, rotated!.Signature); + + var offlineSeed = await client.PostAsJsonAsync("/api/v1/export/offline-seed", new OfflineSeedRequest { TenantId = "t1", IncludeContent = true, IncludeProvenance = true }, ct); + Assert.Equal(HttpStatusCode.OK, offlineSeed.StatusCode); + var bytesZip = await offlineSeed.Content.ReadAsByteArrayAsync(ct); + using var archive = new ZipArchive(new MemoryStream(bytesZip)); + Assert.NotNull(archive.GetEntry("packs.ndjson")); + Assert.NotNull(archive.GetEntry($"content/{created.PackId}.bin")); + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/RsaSignatureVerifierTests.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/RsaSignatureVerifierTests.cs new file mode 100644 index 000000000..37c5f124a --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/RsaSignatureVerifierTests.cs @@ -0,0 +1,48 @@ +using System.Security.Cryptography; +using System.Text; +using StellaOps.PacksRegistry.Infrastructure.Verification; + +namespace StellaOps.PacksRegistry.Tests; + +public sealed class RsaSignatureVerifierTests +{ + [Fact] + public async Task Verify_succeeds_when_signature_matches_digest() + { + var ct = TestContext.Current.CancellationToken; + using var rsa = RSA.Create(2048); + var publicPem = ExportPublicPem(rsa); + + const string digest = "sha256:deadbeef"; + var sig = Convert.ToBase64String(rsa.SignData(Encoding.UTF8.GetBytes(digest), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); + + var verifier = new RsaSignatureVerifier(publicPem); + var ok = await verifier.VerifyAsync(Array.Empty(), digest, sig, ct); + + Assert.True(ok); + } + + [Fact] + public async Task Verify_fails_on_invalid_signature() + { + var ct = TestContext.Current.CancellationToken; + using var rsa = RSA.Create(2048); + var publicPem = ExportPublicPem(rsa); + const string digest = "sha256:deadbeef"; + var sig = Convert.ToBase64String(Encoding.UTF8.GetBytes("bogus")); + + var verifier = new RsaSignatureVerifier(publicPem); + var ok = await verifier.VerifyAsync(Array.Empty(), digest, sig, ct); + + Assert.False(ok); + } + + private static string ExportPublicPem(RSA rsa) + { + var builder = new StringBuilder(); + builder.AppendLine("-----BEGIN PUBLIC KEY-----"); + builder.AppendLine(Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo(), Base64FormattingOptions.InsertLineBreaks)); + builder.AppendLine("-----END PUBLIC KEY-----"); + return builder.ToString(); + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/StellaOps.PacksRegistry.Tests.csproj b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/StellaOps.PacksRegistry.Tests.csproj index 976978385..7442ba460 100644 --- a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/StellaOps.PacksRegistry.Tests.csproj +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/StellaOps.PacksRegistry.Tests.csproj @@ -1,135 +1,34 @@ - - - - - - - - - - - - - Exe - - - - - false - - - - - - - - - - - - - - net10.0 - - - enable - - - enable - - - false - - - preview - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + net10.0 + enable + enable + false + preview + true + false + Exe + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/UnitTest1.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/UnitTest1.cs deleted file mode 100644 index 6e7566ea6..000000000 --- a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace StellaOps.PacksRegistry.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/AttestationResponse.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/AttestationResponse.cs new file mode 100644 index 000000000..8ed3680e9 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/AttestationResponse.cs @@ -0,0 +1,9 @@ +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed record AttestationResponse(string PackId, string Type, string Digest, DateTimeOffset CreatedAtUtc, string? Notes) +{ + public static AttestationResponse From(AttestationRecord record) => new(record.PackId, record.Type, record.Digest, record.CreatedAtUtc, record.Notes); +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/AttestationUploadRequest.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/AttestationUploadRequest.cs new file mode 100644 index 000000000..3b7ea8096 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/AttestationUploadRequest.cs @@ -0,0 +1,9 @@ +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed class AttestationUploadRequest +{ + public string? Type { get; set; } + public string? Content { get; set; } + public string? Notes { get; set; } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/ComplianceSummaryResponse.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/ComplianceSummaryResponse.cs new file mode 100644 index 000000000..a6adde4b5 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/ComplianceSummaryResponse.cs @@ -0,0 +1,9 @@ +using StellaOps.PacksRegistry.Core.Services; + +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed record ComplianceSummaryResponse(int TotalPacks, int UnsignedPacks, int PromotedPacks, int DeprecatedPacks, int ParityReadyPacks) +{ + public static ComplianceSummaryResponse From(ComplianceSummary summary) => new(summary.TotalPacks, summary.UnsignedPacks, summary.PromotedPacks, summary.DeprecatedPacks, summary.ParityReadyPacks); +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/LifecycleRequest.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/LifecycleRequest.cs new file mode 100644 index 000000000..485bd0964 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/LifecycleRequest.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed record LifecycleRequest +{ + [Required] + public string? State { get; init; } + + public string? Notes { get; init; } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/LifecycleResponse.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/LifecycleResponse.cs new file mode 100644 index 000000000..b2f031af6 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/LifecycleResponse.cs @@ -0,0 +1,8 @@ +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed record LifecycleResponse(string PackId, string TenantId, string State, string? Notes, DateTimeOffset UpdatedAtUtc) +{ + public static LifecycleResponse From(LifecycleRecord record) => new(record.PackId, record.TenantId, record.State, record.Notes, record.UpdatedAtUtc); +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/MirrorRequest.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/MirrorRequest.cs new file mode 100644 index 000000000..6384e1a69 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/MirrorRequest.cs @@ -0,0 +1,10 @@ +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed class MirrorRequest +{ + public string? Id { get; set; } + public string? Upstream { get; set; } + public bool Enabled { get; set; } = true; + public string? Notes { get; set; } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/MirrorResponse.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/MirrorResponse.cs new file mode 100644 index 000000000..34cdd2e94 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/MirrorResponse.cs @@ -0,0 +1,9 @@ +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed record MirrorResponse(string Id, string TenantId, string Upstream, bool Enabled, string Status, DateTimeOffset UpdatedAtUtc, DateTimeOffset? LastSuccessfulSyncUtc, string? Notes) +{ + public static MirrorResponse From(MirrorSourceRecord record) => new(record.Id, record.TenantId, record.UpstreamUri.ToString(), record.Enabled, record.Status, record.UpdatedAtUtc, record.LastSuccessfulSyncUtc, record.Notes); +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/MirrorSyncRequest.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/MirrorSyncRequest.cs new file mode 100644 index 000000000..466641e4c --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/MirrorSyncRequest.cs @@ -0,0 +1,8 @@ +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed class MirrorSyncRequest +{ + public string? Status { get; set; } + public string? Notes { get; set; } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/OfflineSeedRequest.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/OfflineSeedRequest.cs new file mode 100644 index 000000000..f041d32f8 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/OfflineSeedRequest.cs @@ -0,0 +1,9 @@ +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed class OfflineSeedRequest +{ + public string? TenantId { get; set; } + public bool IncludeContent { get; set; } + public bool IncludeProvenance { get; set; } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/PackManifestResponse.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/PackManifestResponse.cs new file mode 100644 index 000000000..27457bda5 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/PackManifestResponse.cs @@ -0,0 +1,11 @@ +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed record PackManifestResponse( + string PackId, + string TenantId, + string Digest, + long ContentLength, + string? ProvenanceDigest, + long? ProvenanceLength, + DateTimeOffset CreatedAtUtc, + IReadOnlyDictionary? Metadata); diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/PackResponse.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/PackResponse.cs new file mode 100644 index 000000000..569b5adc3 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/PackResponse.cs @@ -0,0 +1,18 @@ +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed record PackResponse( + string PackId, + string Name, + string Version, + string TenantId, + string Digest, + string? Signature, + string? ProvenanceUri, + DateTimeOffset CreatedAtUtc, + IReadOnlyDictionary? Metadata) +{ + public static PackResponse From(PackRecord record) => + new(record.PackId, record.Name, record.Version, record.TenantId, record.Digest, record.Signature, record.ProvenanceUri, record.CreatedAtUtc, record.Metadata); +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/PackUploadRequest.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/PackUploadRequest.cs new file mode 100644 index 000000000..d6e14d9c5 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/PackUploadRequest.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed record PackUploadRequest +{ + [Required] + public string? Name { get; init; } + + [Required] + public string? Version { get; init; } + + public string? TenantId { get; init; } + + [Required] + public string? Content { get; init; } // base64 encoded + + public string? Signature { get; init; } + + public string? ProvenanceUri { get; init; } + + /// + /// Optional provenance manifest content (base64). Stored and downloadable when present. + /// + public string? ProvenanceContent { get; init; } + + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; init; } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/ParityRequest.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/ParityRequest.cs new file mode 100644 index 000000000..ffefb4df3 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/ParityRequest.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed record ParityRequest +{ + [Required] + public string? Status { get; init; } + + public string? Notes { get; init; } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/ParityResponse.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/ParityResponse.cs new file mode 100644 index 000000000..bcff1c49c --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/ParityResponse.cs @@ -0,0 +1,8 @@ +using StellaOps.PacksRegistry.Core.Models; + +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed record ParityResponse(string PackId, string TenantId, string Status, string? Notes, DateTimeOffset UpdatedAtUtc) +{ + public static ParityResponse From(ParityRecord record) => new(record.PackId, record.TenantId, record.Status, record.Notes, record.UpdatedAtUtc); +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/RotateSignatureRequest.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/RotateSignatureRequest.cs new file mode 100644 index 000000000..cb1745ac2 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Contracts/RotateSignatureRequest.cs @@ -0,0 +1,12 @@ +namespace StellaOps.PacksRegistry.WebService.Contracts; + +public sealed class RotateSignatureRequest +{ + public string? Signature { get; set; } + + /// + /// Optional PEM-encoded public key to validate the new signature; falls back to configured verifier if not provided. + /// + public string? PublicKeyPem { get; set; } +} + diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/OpenApi/pack-manifest.openapi.json b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/OpenApi/pack-manifest.openapi.json new file mode 100644 index 000000000..17b651d83 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/OpenApi/pack-manifest.openapi.json @@ -0,0 +1,50 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "StellaOps Packs Registry", + "version": "0.1.0" + }, + "paths": { + "/api/v1/packs/{packId}/manifest": { + "get": { + "summary": "Fetch pack manifest", + "parameters": [ + { "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { + "200": { + "description": "Manifest", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PackManifest" } + } + } + }, + "401": { "description": "Unauthorized" }, + "403": { "description": "Forbidden" }, + "404": { "description": "Not Found" } + } + } + } + }, + "components": { + "schemas": { + "PackManifest": { + "type": "object", + "properties": { + "packId": { "type": "string" }, + "tenantId": { "type": "string" }, + "digest": { "type": "string" }, + "contentLength": { "type": "integer", "format": "int64" }, + "provenanceDigest": { "type": "string", "nullable": true }, + "provenanceLength": { "type": "integer", "format": "int64", "nullable": true }, + "createdAtUtc": { "type": "string", "format": "date-time" }, + "metadata": { "type": "object", "additionalProperties": { "type": "string" }, "nullable": true } + }, + "required": ["packId", "tenantId", "digest", "contentLength", "createdAtUtc"] + } + } + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/OpenApi/packs.openapi.json b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/OpenApi/packs.openapi.json new file mode 100644 index 000000000..0d765b772 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/OpenApi/packs.openapi.json @@ -0,0 +1,290 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "StellaOps Packs Registry", + "version": "0.3.0" + }, + "paths": { + "/api/v1/packs": { + "get": { + "summary": "List packs", + "parameters": [ + { "name": "tenant", "in": "query", "schema": { "type": "string" }, "description": "Filter to tenant; required when allowlists are configured." }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "OK" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" } } + }, + "post": { + "summary": "Upload pack", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PackUpload" } } } }, + "parameters": [ + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" } } + } + }, + "/api/v1/packs/{packId}": { + "get": { + "summary": "Get pack", + "parameters": [ + { "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "OK" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } } + } + }, + "/api/v1/packs/{packId}/content": { + "get": { + "summary": "Download pack content", + "parameters": [ + { "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Content" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } } + } + }, + "/api/v1/packs/{packId}/provenance": { + "get": { + "summary": "Download provenance", + "parameters": [ + { "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Provenance" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } } + } + }, + "/api/v1/packs/{packId}/manifest": { + "get": { + "summary": "Get manifest", + "parameters": [ + { "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Manifest" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } } + } + }, + "/api/v1/packs/{packId}/parity": { + "get": { + "summary": "Get parity status", + "parameters": [ + { "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Parity" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } } + }, + "post": { + "summary": "Set parity status", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ParityRequest" } } } }, + "parameters": [ + { "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Updated" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } } + } + }, + "/api/v1/packs/{packId}/lifecycle": { + "get": { + "summary": "Get lifecycle state", + "parameters": [ + { "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Lifecycle" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } } + }, + "post": { + "summary": "Set lifecycle state", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LifecycleRequest" } } } }, + "parameters": [ + { "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Updated" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } } + } + }, + "/api/v1/packs/{packId}/signature": { + "post": { + "summary": "Rotate pack signature", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RotateSignatureRequest" } } } }, + "parameters": [ + { "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Rotated" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } } + } + }, + "/api/v1/packs/{packId}/attestations": { + "get": { + "summary": "List pack attestations", + "parameters": [ + { "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Attestations" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } } + }, + "post": { + "summary": "Upload attestation", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AttestationUpload" } } } }, + "parameters": [ + { "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } } + ], + "responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } } + } + }, + "/api/v1/packs/{packId}/attestations/{type}": { + "get": { + "summary": "Download attestation", + "parameters": [ + { "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "type", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Attestation" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } } + } + }, + "/api/v1/export/offline-seed": { + "post": { + "summary": "Export offline seed archive", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OfflineSeedRequest" } } } }, + "parameters": [ + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Archive" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" } } + } + }, + "/api/v1/mirrors": { + "get": { + "summary": "List mirror sources", + "parameters": [ + { "name": "tenant", "in": "query", "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "OK" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" } } + }, + "post": { + "summary": "Register or update mirror source", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MirrorRequest" } } } }, + "parameters": [ + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } } + ], + "responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" } } + } + }, + "/api/v1/mirrors/{id}/sync": { + "post": { + "summary": "Mark mirror sync status", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MirrorSyncRequest" } } } }, + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Updated" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } } + } + }, + "/api/v1/compliance/summary": { + "get": { + "summary": "Compliance summary", + "parameters": [ + { "name": "tenant", "in": "query", "schema": { "type": "string" } }, + { "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } }, + { "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Summary" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" } } + } + } + }, + "components": { + "schemas": { + "PackUpload": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" }, + "tenantId": { "type": "string" }, + "content": { "type": "string", "format": "byte" }, + "signature": { "type": "string" }, + "provenanceUri": { "type": "string" }, + "provenanceContent": { "type": "string", "format": "byte" }, + "metadata": { "type": "object", "additionalProperties": { "type": "string" } } + }, + "required": ["name", "version", "content"] + }, + "ParityRequest": { + "type": "object", + "properties": { + "status": { "type": "string" }, + "notes": { "type": "string" } + }, + "required": ["status"] + }, + "LifecycleRequest": { + "type": "object", + "properties": { + "state": { "type": "string", "enum": ["promoted", "deprecated", "draft"] }, + "notes": { "type": "string" } + }, + "required": ["state"] + }, + "RotateSignatureRequest": { + "type": "object", + "properties": { + "signature": { "type": "string" }, + "publicKeyPem": { "type": "string" } + }, + "required": ["signature"] + }, + "OfflineSeedRequest": { + "type": "object", + "properties": { + "tenantId": { "type": "string" }, + "includeContent": { "type": "boolean" }, + "includeProvenance": { "type": "boolean" } + } + }, + "AttestationUpload": { + "type": "object", + "properties": { + "type": { "type": "string" }, + "content": { "type": "string", "format": "byte" }, + "notes": { "type": "string" } + }, + "required": ["type", "content"] + }, + "MirrorRequest": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "upstream": { "type": "string", "format": "uri" }, + "enabled": { "type": "boolean" }, + "notes": { "type": "string" } + }, + "required": ["id", "upstream"] + }, + "MirrorSyncRequest": { + "type": "object", + "properties": { + "status": { "type": "string" }, + "notes": { "type": "string" } + } + } + } + } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Options/AuthOptions.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Options/AuthOptions.cs new file mode 100644 index 000000000..bed696332 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Options/AuthOptions.cs @@ -0,0 +1,8 @@ +namespace StellaOps.PacksRegistry.WebService.Options; + +public sealed class AuthOptions +{ + public string? ApiKey { get; set; } + + public string[] AllowedTenants { get; set; } = Array.Empty(); +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Options/VerificationOptions.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Options/VerificationOptions.cs new file mode 100644 index 000000000..4060dead8 --- /dev/null +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Options/VerificationOptions.cs @@ -0,0 +1,6 @@ +namespace StellaOps.PacksRegistry.WebService.Options; + +public sealed class VerificationOptions +{ + public string? PublicKeyPem { get; set; } +} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs index 3917ef1bd..511fcd1a0 100644 --- a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs +++ b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs @@ -1,41 +1,770 @@ -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} - -app.UseHttpsRedirection(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast"); - -app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} +using System.Text.Json.Serialization; +using StellaOps.PacksRegistry.Core.Contracts; +using StellaOps.PacksRegistry.Core.Models; +using StellaOps.PacksRegistry.Core.Services; +using StellaOps.PacksRegistry.Infrastructure.FileSystem; +using StellaOps.PacksRegistry.Infrastructure.InMemory; +using StellaOps.PacksRegistry.Infrastructure.Verification; +using StellaOps.PacksRegistry.Infrastructure.Mongo; +using StellaOps.PacksRegistry.Infrastructure.Options; +using StellaOps.PacksRegistry.WebService; +using StellaOps.PacksRegistry.WebService.Contracts; +using StellaOps.PacksRegistry.WebService.Options; +using Microsoft.Extensions.FileProviders; +using MongoDB.Driver; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; +}); + +builder.Services.AddOpenApi(); +var dataDir = builder.Configuration.GetValue("PacksRegistry:DataDir"); +var mongoOptions = builder.Configuration.GetSection("PacksRegistry:Mongo").Get() ?? new MongoOptions(); +mongoOptions.ConnectionString ??= builder.Configuration.GetConnectionString("packs-registry"); + +if (!string.IsNullOrWhiteSpace(mongoOptions.ConnectionString)) +{ + builder.Services.AddSingleton(mongoOptions); + builder.Services.AddSingleton(_ => new MongoClient(mongoOptions.ConnectionString)); + builder.Services.AddSingleton(sp => sp.GetRequiredService().GetDatabase(mongoOptions.Database)); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); +} +else +{ + builder.Services.AddSingleton(_ => new FilePackRepository(dataDir ?? "data/packs")); + builder.Services.AddSingleton(_ => new FileParityRepository(dataDir ?? "data/packs")); + builder.Services.AddSingleton(_ => new FileLifecycleRepository(dataDir ?? "data/packs")); + builder.Services.AddSingleton(_ => new FileAuditRepository(dataDir ?? "data/packs")); + builder.Services.AddSingleton(_ => new FileAttestationRepository(dataDir ?? "data/packs")); + builder.Services.AddSingleton(_ => new FileMirrorRepository(dataDir ?? "data/packs")); +} + +var verificationSection = builder.Configuration.GetSection("PacksRegistry:Verification"); +builder.Services.Configure(verificationSection); +var publicKeyPem = verificationSection.GetValue("PublicKeyPem"); +if (!string.IsNullOrWhiteSpace(publicKeyPem)) +{ + builder.Services.AddSingleton(_ => new RsaSignatureVerifier(publicKeyPem)); +} +else +{ + builder.Services.AddSingleton(); +} + +var authOptions = builder.Configuration.GetSection("PacksRegistry:Auth").Get() ?? new AuthOptions(); +builder.Services.AddSingleton(authOptions); +var policyOptions = builder.Configuration.GetSection("PacksRegistry:Policy").Get() ?? new PackPolicyOptions(); +builder.Services.AddSingleton(policyOptions); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(TimeProvider.System); + +builder.Services.AddHealthChecks(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.MapHealthChecks("/healthz"); + +// Serve static OpenAPI stubs for packs APIs (until unified spec is generated) +var openApiDir = Path.Combine(app.Environment.ContentRootPath, "OpenApi"); +if (Directory.Exists(openApiDir)) +{ + var provider = new PhysicalFileProvider(openApiDir); + app.MapGet("/openapi/packs.json", () => + { + var file = provider.GetFileInfo("packs.openapi.json"); + return file.Exists + ? Results.File(file.CreateReadStream(), "application/json") + : Results.NotFound(); + }) + .ExcludeFromDescription(); + app.MapGet("/openapi/pack-manifest.json", () => + { + var file = provider.GetFileInfo("pack-manifest.openapi.json"); + return file.Exists + ? Results.File(file.CreateReadStream(), "application/json") + : Results.NotFound(); + }) + .ExcludeFromDescription(); +} + +app.MapPost("/api/v1/packs", async (PackUploadRequest request, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var tenant = !string.IsNullOrWhiteSpace(request.TenantId) ? request.TenantId : tenantHeader; + if (string.IsNullOrWhiteSpace(tenant)) + { + return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header or tenantId is required." }); + } + + if (!IsTenantAllowed(tenant, auth, out var tenantResult)) + { + return tenantResult; + } + + if (request.Content == null || request.Content.Length == 0) + { + return Results.BadRequest(new { error = "content_missing", message = "Content (base64) is required." }); + } + + try + { + var contentBytes = Convert.FromBase64String(request.Content); + byte[]? provenanceBytes = null; + if (!string.IsNullOrWhiteSpace(request.ProvenanceContent)) + { + provenanceBytes = Convert.FromBase64String(request.ProvenanceContent); + } + var record = await service.UploadAsync( + name: request.Name ?? string.Empty, + version: request.Version ?? string.Empty, + tenantId: tenant, + content: contentBytes, + signature: request.Signature, + provenanceUri: request.ProvenanceUri, + provenanceContent: provenanceBytes, + metadata: request.Metadata, + cancellationToken: cancellationToken); + + return Results.Created($"/api/v1/packs/{record.PackId}", PackResponse.From(record)); + } + catch (FormatException) + { + return Results.BadRequest(new { error = "content_base64_invalid", message = "Content must be valid base64." }); + } + catch (Exception ex) + { + return Results.BadRequest(new { error = "upload_failed", message = ex.Message }); + } +}) +.Produces(StatusCodes.Status201Created) +.Produces(StatusCodes.Status400BadRequest) +.Produces(StatusCodes.Status401Unauthorized); + +app.MapGet("/api/v1/packs", async (string? tenant, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var effectiveTenant = !string.IsNullOrWhiteSpace(tenant) ? tenant : tenantHeader; + if (auth.AllowedTenants is { Length: > 0 } && string.IsNullOrWhiteSpace(effectiveTenant)) + { + return Results.BadRequest(new { error = "tenant_missing", message = "tenant query parameter or X-StellaOps-Tenant header is required when tenant allowlists are configured." }); + } + + if (!string.IsNullOrWhiteSpace(effectiveTenant) && !IsTenantAllowed(effectiveTenant, auth, out var tenantResult)) + { + return tenantResult; + } + + var packs = await service.ListAsync(effectiveTenant, cancellationToken).ConfigureAwait(false); + return Results.Ok(packs.Select(PackResponse.From)); +}) +.Produces>(StatusCodes.Status200OK) +.Produces(StatusCodes.Status401Unauthorized); + +app.MapGet("/api/v1/packs/{packId}", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return Results.NotFound(); + } + + if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) || + (!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase))) + { + return tenantResult ?? Results.Forbid(); + } + + return Results.Ok(PackResponse.From(record)); +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.MapGet("/api/v1/packs/{packId}/content", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return Results.NotFound(); + } + + if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) || + (!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase))) + { + return tenantResult ?? Results.Forbid(); + } + + var content = await service.GetContentAsync(packId, cancellationToken).ConfigureAwait(false); + if (content is null) + { + return Results.NotFound(); + } + + context.Response.Headers["X-Content-Digest"] = record.Digest; + return Results.File(content, "application/octet-stream", fileDownloadName: packId + ".bin"); +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.MapGet("/api/v1/packs/{packId}/provenance", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return Results.NotFound(); + } + + if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) || + (!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase))) + { + return tenantResult ?? Results.Forbid(); + } + + var content = await service.GetProvenanceAsync(packId, cancellationToken).ConfigureAwait(false); + if (content is null) + { + return Results.NotFound(); + } + + if (!string.IsNullOrWhiteSpace(record.ProvenanceDigest)) + { + context.Response.Headers["X-Provenance-Digest"] = record.ProvenanceDigest; + } + + return Results.File(content, "application/json", fileDownloadName: packId + "-provenance.json"); +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.MapGet("/api/v1/packs/{packId}/manifest", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return Results.NotFound(); + } + + if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) || + (!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase))) + { + return tenantResult ?? Results.Forbid(); + } + + var content = await service.GetContentAsync(packId, cancellationToken).ConfigureAwait(false); + var provenance = await service.GetProvenanceAsync(packId, cancellationToken).ConfigureAwait(false); + + var manifest = new PackManifestResponse( + record.PackId, + record.TenantId, + record.Digest, + content?.LongLength ?? 0, + record.ProvenanceDigest, + provenance?.LongLength, + record.CreatedAtUtc, + record.Metadata); + + return Results.Ok(manifest); +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.MapPost("/api/v1/packs/{packId}/signature", async (string packId, RotateSignatureRequest request, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantHeader)) + { + return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." }); + } + + if (!IsTenantAllowed(tenantHeader, auth, out var tenantResult)) + { + return tenantResult; + } + + if (string.IsNullOrWhiteSpace(request.Signature)) + { + return Results.BadRequest(new { error = "signature_missing", message = "signature is required." }); + } + + IPackSignatureVerifier? overrideVerifier = null; + if (!string.IsNullOrWhiteSpace(request.PublicKeyPem)) + { + overrideVerifier = new RsaSignatureVerifier(request.PublicKeyPem!); + } + + try + { + var updated = await service.RotateSignatureAsync(packId, tenantHeader, request.Signature!, overrideVerifier, cancellationToken).ConfigureAwait(false); + return Results.Ok(PackResponse.From(updated)); + } + catch (Exception ex) + { + return Results.BadRequest(new { error = "signature_rotation_failed", message = ex.Message }); + } +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status400BadRequest) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.MapPost("/api/v1/packs/{packId}/attestations", async (string packId, AttestationUploadRequest request, AttestationService attestationService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantHeader)) + { + return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." }); + } + + if (!IsTenantAllowed(tenantHeader, auth, out var tenantResult)) + { + return tenantResult; + } + + if (string.IsNullOrWhiteSpace(request.Type) || string.IsNullOrWhiteSpace(request.Content)) + { + return Results.BadRequest(new { error = "attestation_missing", message = "type and content are required." }); + } + + try + { + var bytes = Convert.FromBase64String(request.Content); + var record = await attestationService.UploadAsync(packId, tenantHeader, request.Type!, bytes, request.Notes, cancellationToken).ConfigureAwait(false); + return Results.Created($"/api/v1/packs/{packId}/attestations/{record.Type}", AttestationResponse.From(record)); + } + catch (Exception ex) + { + return Results.BadRequest(new { error = "attestation_failed", message = ex.Message }); + } +}) +.Produces(StatusCodes.Status201Created) +.Produces(StatusCodes.Status400BadRequest) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.MapGet("/api/v1/packs/{packId}/attestations", async (string packId, AttestationService attestationService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var records = await attestationService.ListAsync(packId, cancellationToken).ConfigureAwait(false); + if (records.Count == 0) + { + return Results.NotFound(); + } + + if (!string.IsNullOrWhiteSpace(tenantHeader) && !records.All(r => string.Equals(r.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase))) + { + return Results.Forbid(); + } + + return Results.Ok(records.Select(AttestationResponse.From)); +}) +.Produces>(StatusCodes.Status200OK) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.MapGet("/api/v1/packs/{packId}/attestations/{type}", async (string packId, string type, AttestationService attestationService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var record = await attestationService.GetAsync(packId, type, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return Results.NotFound(); + } + + if (!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)) + { + return Results.Forbid(); + } + + var content = await attestationService.GetContentAsync(packId, type, cancellationToken).ConfigureAwait(false); + if (content is null) + { + return Results.NotFound(); + } + + context.Response.Headers["X-Attestation-Digest"] = record.Digest; + return Results.File(content, "application/octet-stream", fileDownloadName: $"{packId}-{type}-attestation.bin"); +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.MapGet("/api/v1/packs/{packId}/parity", async (string packId, ParityService parityService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var parity = await parityService.GetAsync(packId, cancellationToken).ConfigureAwait(false); + if (parity is null) + { + return Results.NotFound(); + } + + if (!IsTenantAllowed(parity.TenantId, auth, out var tenantResult) || + (!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(parity.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase))) + { + return tenantResult ?? Results.Forbid(); + } + + return Results.Ok(ParityResponse.From(parity)); +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.MapGet("/api/v1/packs/{packId}/lifecycle", async (string packId, LifecycleService lifecycleService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var record = await lifecycleService.GetAsync(packId, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return Results.NotFound(); + } + + if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) || + (!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase))) + { + return tenantResult ?? Results.Forbid(); + } + + return Results.Ok(LifecycleResponse.From(record)); +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.MapPost("/api/v1/packs/{packId}/lifecycle", async (string packId, LifecycleRequest request, LifecycleService lifecycleService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var tenant = !string.IsNullOrWhiteSpace(tenantHeader) ? tenantHeader : null; + if (string.IsNullOrWhiteSpace(tenant)) + { + return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." }); + } + + if (!IsTenantAllowed(tenant, auth, out var tenantResult)) + { + return tenantResult; + } + + if (string.IsNullOrWhiteSpace(request.State)) + { + return Results.BadRequest(new { error = "state_missing", message = "state is required." }); + } + + try + { + var record = await lifecycleService.SetStateAsync(packId, tenant, request.State!, request.Notes, cancellationToken).ConfigureAwait(false); + return Results.Ok(LifecycleResponse.From(record)); + } + catch (Exception ex) + { + return Results.BadRequest(new { error = "lifecycle_failed", message = ex.Message }); + } +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status400BadRequest) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.MapPost("/api/v1/packs/{packId}/parity", async (string packId, ParityRequest request, ParityService parityService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var tenant = !string.IsNullOrWhiteSpace(tenantHeader) ? tenantHeader : null; + if (string.IsNullOrWhiteSpace(tenant)) + { + return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." }); + } + + if (!IsTenantAllowed(tenant, auth, out var tenantResult)) + { + return tenantResult; + } + + if (string.IsNullOrWhiteSpace(request.Status)) + { + return Results.BadRequest(new { error = "status_missing", message = "status is required." }); + } + + try + { + var record = await parityService.SetStatusAsync(packId, tenant, request.Status!, request.Notes, cancellationToken).ConfigureAwait(false); + return Results.Ok(ParityResponse.From(record)); + } + catch (Exception ex) + { + return Results.BadRequest(new { error = "parity_failed", message = ex.Message }); + } +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status400BadRequest) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.MapPost("/api/v1/export/offline-seed", async (OfflineSeedRequest request, ExportService exportService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var tenant = !string.IsNullOrWhiteSpace(request.TenantId) ? request.TenantId : tenantHeader; + if (auth.AllowedTenants is { Length: > 0 } && string.IsNullOrWhiteSpace(tenant)) + { + return Results.BadRequest(new { error = "tenant_missing", message = "tenantId or X-StellaOps-Tenant header is required when tenant allowlists are configured." }); + } + + if (!string.IsNullOrWhiteSpace(tenant) && !IsTenantAllowed(tenant, auth, out var tenantResult)) + { + return tenantResult; + } + + var archive = await exportService.ExportOfflineSeedAsync(tenant, request.IncludeContent, request.IncludeProvenance, cancellationToken).ConfigureAwait(false); + return Results.File(archive, "application/zip", fileDownloadName: "packs-offline-seed.zip"); +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status400BadRequest) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden); + +app.MapPost("/api/v1/mirrors", async (MirrorRequest request, MirrorService mirrorService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantHeader)) + { + return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." }); + } + + if (!IsTenantAllowed(tenantHeader, auth, out var tenantResult)) + { + return tenantResult; + } + + if (string.IsNullOrWhiteSpace(request.Id) || string.IsNullOrWhiteSpace(request.Upstream)) + { + return Results.BadRequest(new { error = "mirror_missing", message = "id and upstream are required." }); + } + + var record = await mirrorService.UpsertAsync(request.Id!, tenantHeader, new Uri(request.Upstream!), request.Enabled, request.Notes, cancellationToken).ConfigureAwait(false); + return Results.Created($"/api/v1/mirrors/{record.Id}", MirrorResponse.From(record)); +}) +.Produces(StatusCodes.Status201Created) +.Produces(StatusCodes.Status400BadRequest) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden); + +app.MapGet("/api/v1/mirrors", async (string? tenant, MirrorService mirrorService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var effectiveTenant = !string.IsNullOrWhiteSpace(tenant) ? tenant : tenantHeader; + if (!string.IsNullOrWhiteSpace(effectiveTenant) && !IsTenantAllowed(effectiveTenant, auth, out var tenantResult)) + { + return tenantResult; + } + + var mirrors = await mirrorService.ListAsync(effectiveTenant, cancellationToken).ConfigureAwait(false); + return Results.Ok(mirrors.Select(MirrorResponse.From)); +}) +.Produces>(StatusCodes.Status200OK) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden); + +app.MapPost("/api/v1/mirrors/{id}/sync", async (string id, MirrorSyncRequest request, MirrorService mirrorService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantHeader)) + { + return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." }); + } + + var updated = await mirrorService.MarkSyncAsync(id, tenantHeader, request.Status ?? "unknown", request.Notes, cancellationToken).ConfigureAwait(false); + if (updated is null) + { + return Results.NotFound(); + } + + return Results.Ok(MirrorResponse.From(updated)); +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status400BadRequest) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.MapGet("/api/v1/compliance/summary", async (string? tenant, ComplianceService complianceService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) => +{ + if (!IsAuthorized(context, auth, out var unauthorizedResult)) + { + return unauthorizedResult; + } + + var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + var effectiveTenant = !string.IsNullOrWhiteSpace(tenant) ? tenant : tenantHeader; + if (!string.IsNullOrWhiteSpace(effectiveTenant) && !IsTenantAllowed(effectiveTenant, auth, out var tenantResult)) + { + return tenantResult; + } + + var summary = await complianceService.SummarizeAsync(effectiveTenant, cancellationToken).ConfigureAwait(false); + return Results.Ok(summary); +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status401Unauthorized) +.Produces(StatusCodes.Status403Forbidden); + +app.Run(); + +static bool IsAuthorized(HttpContext context, AuthOptions auth, out IResult result) +{ + result = Results.Empty; + if (string.IsNullOrWhiteSpace(auth.ApiKey)) + { + return true; // auth disabled + } + + var provided = context.Request.Headers["X-API-Key"].ToString(); + if (string.Equals(provided, auth.ApiKey, StringComparison.Ordinal)) + { + return true; + } + + result = Results.Unauthorized(); + return false; +} + +static bool IsTenantAllowed(string tenant, AuthOptions auth, out IResult? result) +{ + result = null; + if (auth.AllowedTenants is { Length: > 0 } && !auth.AllowedTenants.Any(t => string.Equals(t, tenant, StringComparison.OrdinalIgnoreCase))) + { + result = Results.Forbid(); + return false; + } + + return true; +} + +public partial class Program; diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Properties/launchSettings.json b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Properties/launchSettings.json deleted file mode 100644 index 2ca6fd866..000000000 --- a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5151", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7136;http://localhost:5151", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/appsettings.Development.json b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/appsettings.Development.json deleted file mode 100644 index ff66ba6b2..000000000 --- a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/appsettings.json b/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/appsettings.json deleted file mode 100644 index 4d566948d..000000000 --- a/src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Class1.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Class1.cs deleted file mode 100644 index e92fe0f12..000000000 --- a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StellaOps.RiskEngine.Core; - -public class Class1 -{ - -} diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Contracts/RiskScoreResult.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Contracts/RiskScoreResult.cs new file mode 100644 index 000000000..59f409944 --- /dev/null +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Contracts/RiskScoreResult.cs @@ -0,0 +1,14 @@ +namespace StellaOps.RiskEngine.Core.Contracts; + +/// +/// Result of a risk score computation. +/// +public sealed record RiskScoreResult( + Guid JobId, + string Provider, + string Subject, + double Score, + bool Success, + string? Error, + IReadOnlyDictionary Signals, + DateTimeOffset CompletedAtUtc); diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Contracts/ScoreRequest.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Contracts/ScoreRequest.cs new file mode 100644 index 000000000..1ca2d4094 --- /dev/null +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Contracts/ScoreRequest.cs @@ -0,0 +1,14 @@ +namespace StellaOps.RiskEngine.Core.Contracts; + +/// +/// Input for a risk score computation. Subject is an opaque asset/id; Signals are deterministic numeric factors. +/// +public sealed record ScoreRequest( + string Provider, + string Subject, + IReadOnlyDictionary Signals); + +/// +/// Job envelope carried through the queue. +/// +public sealed record RiskScoreJob(Guid JobId, ScoreRequest Request); diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/CvssKevProvider.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/CvssKevProvider.cs new file mode 100644 index 000000000..36b9bde10 --- /dev/null +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/CvssKevProvider.cs @@ -0,0 +1,37 @@ +using StellaOps.RiskEngine.Core.Contracts; + +namespace StellaOps.RiskEngine.Core.Providers; + +/// +/// Risk provider that derives score from CVSS base score and KEV flag. +/// Score formula: clamp01((cvss/10) + kevBonus), where kevBonus = 0.2 if KEV, else 0. +/// +public sealed class CvssKevProvider : IRiskScoreProvider +{ + public const string ProviderName = "cvss-kev"; + + private readonly ICvssSource cvss; + private readonly IKevSource kev; + + public CvssKevProvider(ICvssSource cvss, IKevSource kev) + { + this.cvss = cvss ?? throw new ArgumentNullException(nameof(cvss)); + this.kev = kev ?? throw new ArgumentNullException(nameof(kev)); + } + + public string Name => ProviderName; + + public async Task ScoreAsync(ScoreRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var cvssScore = await cvss.GetCvssAsync(request.Subject, cancellationToken).ConfigureAwait(false) ?? 0d; + cvssScore = Math.Clamp(cvssScore, 0d, 10d); + + var kevFlag = await kev.IsKevAsync(request.Subject, cancellationToken).ConfigureAwait(false) ?? false; + + var kevBonus = kevFlag ? 0.2d : 0d; + var raw = (cvssScore / 10d) + kevBonus; + return Math.Round(Math.Min(1d, raw), 6, MidpointRounding.ToEven); + } +} diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/DefaultTransformsProvider.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/DefaultTransformsProvider.cs new file mode 100644 index 000000000..8f9624ce4 --- /dev/null +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/DefaultTransformsProvider.cs @@ -0,0 +1,40 @@ +using StellaOps.RiskEngine.Core.Contracts; + +namespace StellaOps.RiskEngine.Core.Providers; + +/// +/// Default provider that clamps each signal to [0,1] and averages the result. +/// Deterministic and side-effect free. +/// +public sealed class DefaultTransformsProvider : IRiskScoreProvider +{ + public const string ProviderName = "default-transforms"; + + public string Name => ProviderName; + + public Task ScoreAsync(ScoreRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (request.Signals.Count == 0) + { + return Task.FromResult(0d); + } + + var sum = 0d; + foreach (var kvp in request.Signals.OrderBy(k => k.Key, StringComparer.Ordinal)) + { + sum += Clamp01(kvp.Value); + } + + var average = sum / request.Signals.Count; + return Task.FromResult(Math.Round(average, 6, MidpointRounding.ToEven)); + } + + private static double Clamp01(double value) => + value switch + { + < 0d => 0d, + > 1d => 1d, + _ => value + }; +} diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/FixExposureProvider.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/FixExposureProvider.cs new file mode 100644 index 000000000..75caf1810 --- /dev/null +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/FixExposureProvider.cs @@ -0,0 +1,33 @@ +using StellaOps.RiskEngine.Core.Contracts; + +namespace StellaOps.RiskEngine.Core.Providers; + +/// +/// Combines fix availability, asset criticality, and internet exposure into a bounded score. +/// Formula: clamp01(0.5 * FixAvailability + 0.3 * Criticality + 0.2 * Exposure). +/// Inputs are expected in [0,1]; missing keys default to 0. +/// +public sealed class FixExposureProvider : IRiskScoreProvider +{ + public const string ProviderName = "fix-exposure"; + + public string Name => ProviderName; + + public Task ScoreAsync(ScoreRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var fix = Get(request, "FixAvailability"); + var crit = Get(request, "Criticality"); + var exposure = Get(request, "Exposure"); + + var weighted = (0.5 * fix) + (0.3 * crit) + (0.2 * exposure); + var score = Math.Round(Math.Clamp(weighted, 0d, 1d), 6, MidpointRounding.ToEven); + return Task.FromResult(score); + } + + private static double Get(ScoreRequest request, string key) => + request.Signals.TryGetValue(key, out var value) + ? Math.Clamp(value, 0d, 1d) + : 0d; +} diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/ICvssKevSources.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/ICvssKevSources.cs new file mode 100644 index 000000000..ae0bdc82f --- /dev/null +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/ICvssKevSources.cs @@ -0,0 +1,35 @@ +namespace StellaOps.RiskEngine.Core.Providers; + +public interface ICvssSource +{ + /// + /// Returns CVSS base score (0-10) for the subject, or null if unknown. + /// + Task GetCvssAsync(string subject, CancellationToken cancellationToken); +} + +public interface IKevSource +{ + /// + /// Returns true if the subject is marked as known exploited (KEV), false otherwise, null if unknown. + /// + Task IsKevAsync(string subject, CancellationToken cancellationToken); +} + +/// +/// Null-object CVSS source returning no score (treat as unknown). +/// +public sealed class NullCvssSource : ICvssSource +{ + public Task GetCvssAsync(string subject, CancellationToken cancellationToken) => + Task.FromResult(null); +} + +/// +/// Null-object KEV source returning false. +/// +public sealed class NullKevSource : IKevSource +{ + public Task IsKevAsync(string subject, CancellationToken cancellationToken) => + Task.FromResult(false); +} diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/IRiskScoreProvider.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/IRiskScoreProvider.cs new file mode 100644 index 000000000..90c12035a --- /dev/null +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/IRiskScoreProvider.cs @@ -0,0 +1,44 @@ +using StellaOps.RiskEngine.Core.Contracts; + +namespace StellaOps.RiskEngine.Core.Providers; + +/// +/// Computes a risk score for a request. Implementations must be deterministic for identical inputs. +/// +public interface IRiskScoreProvider +{ + string Name { get; } + + Task ScoreAsync(ScoreRequest request, CancellationToken cancellationToken); +} + +public interface IRiskScoreProviderRegistry +{ + bool TryGet(string name, out IRiskScoreProvider provider); + + IReadOnlyCollection ProviderNames { get; } +} + +/// +/// Simple in-memory provider registry. +/// +public sealed class RiskScoreProviderRegistry : IRiskScoreProviderRegistry +{ + private readonly IReadOnlyDictionary providers; + + public RiskScoreProviderRegistry(IEnumerable providers) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var provider in providers) + { + map[provider.Name] = provider; + } + + this.providers = map; + } + + public bool TryGet(string name, out IRiskScoreProvider provider) => + providers.TryGetValue(name, out provider!); + + public IReadOnlyCollection ProviderNames => providers.Keys.ToArray(); +} diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/VexGateProvider.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/VexGateProvider.cs new file mode 100644 index 000000000..72ac6aa1f --- /dev/null +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Providers/VexGateProvider.cs @@ -0,0 +1,35 @@ +using StellaOps.RiskEngine.Core.Contracts; + +namespace StellaOps.RiskEngine.Core.Providers; + +/// +/// VEX gate provider that short-circuits scoring when a denial is present. +/// Signals are ignored when HasDenial is true. +/// +public sealed class VexGateProvider : IRiskScoreProvider +{ + public const string ProviderName = "vex-gate"; + + public string Name => ProviderName; + + public Task ScoreAsync(ScoreRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var hasDenial = request.Signals.TryGetValue("HasDenial", out var denialFlag) && denialFlag >= 1; + if (hasDenial) + { + return Task.FromResult(0d); + } + + // Fall back to simple max of remaining signals (if any) + var max = request.Signals + .Where(kvp => !string.Equals(kvp.Key, "HasDenial", StringComparison.Ordinal)) + .Select(kvp => kvp.Value) + .DefaultIfEmpty(0d) + .Max(); + + var score = Math.Clamp(max, 0d, 1d); + return Task.FromResult(Math.Round(score, 6, MidpointRounding.ToEven)); + } +} diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Services/IRiskScoreResultStore.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Services/IRiskScoreResultStore.cs new file mode 100644 index 000000000..08ed39052 --- /dev/null +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Services/IRiskScoreResultStore.cs @@ -0,0 +1,12 @@ +using StellaOps.RiskEngine.Core.Contracts; + +namespace StellaOps.RiskEngine.Core.Services; + +/// +/// Persists risk score results for later retrieval/ledger projection. +/// Implementations must be deterministic and side‑effect free for identical inputs. +/// +public interface IRiskScoreResultStore +{ + Task SaveAsync(RiskScoreResult result, CancellationToken cancellationToken); +} diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Services/RiskScoreQueue.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Services/RiskScoreQueue.cs new file mode 100644 index 000000000..f389d8b6a --- /dev/null +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Services/RiskScoreQueue.cs @@ -0,0 +1,52 @@ +using System.Threading.Channels; +using StellaOps.RiskEngine.Core.Contracts; +using System.Diagnostics.CodeAnalysis; + +namespace StellaOps.RiskEngine.Core.Services; + +/// +/// Deterministic FIFO queue for risk score jobs. +/// +public sealed class RiskScoreQueue +{ + private readonly Channel channel; + + public RiskScoreQueue(int? capacity = null) + { + if (capacity.HasValue) + { + var options = new BoundedChannelOptions(capacity.Value) + { + AllowSynchronousContinuations = false, + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait + }; + + channel = Channel.CreateBounded(options); + } + else + { + var options = new UnboundedChannelOptions + { + AllowSynchronousContinuations = false, + SingleReader = true, + SingleWriter = false + }; + + channel = Channel.CreateUnbounded(options); + } + } + + public ValueTask EnqueueAsync(ScoreRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + var job = new RiskScoreJob(Guid.NewGuid(), request); + return channel.Writer.WriteAsync(job, cancellationToken); + } + + public ValueTask DequeueAsync(CancellationToken cancellationToken) => + channel.Reader.ReadAsync(cancellationToken); + + public bool TryDequeue([NotNullWhen(true)] out RiskScoreJob? job) => channel.Reader.TryRead(out job); +} diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Services/RiskScoreWorker.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Services/RiskScoreWorker.cs new file mode 100644 index 000000000..9a9f1a5b8 --- /dev/null +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/Services/RiskScoreWorker.cs @@ -0,0 +1,86 @@ +using StellaOps.RiskEngine.Core.Contracts; +using StellaOps.RiskEngine.Core.Providers; + +namespace StellaOps.RiskEngine.Core.Services; + +/// +/// Single-reader worker that pulls jobs from the queue and executes providers deterministically. +/// +public sealed class RiskScoreWorker +{ + private readonly RiskScoreQueue queue; + private readonly IRiskScoreProviderRegistry registry; + private readonly IRiskScoreResultStore? resultStore; + private readonly TimeProvider timeProvider; + + public RiskScoreWorker( + RiskScoreQueue queue, + IRiskScoreProviderRegistry registry, + IRiskScoreResultStore? resultStore = null, + TimeProvider? timeProvider = null) + { + this.queue = queue ?? throw new ArgumentNullException(nameof(queue)); + this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); + this.resultStore = resultStore; + this.timeProvider = timeProvider ?? TimeProvider.System; + } + + public async Task ProcessNextAsync(CancellationToken cancellationToken) + { + var job = await queue.DequeueAsync(cancellationToken).ConfigureAwait(false); + var request = job.Request; + + RiskScoreResult Build(double score, bool success, string? error) => + new( + job.JobId, + request.Provider, + request.Subject, + Score: score, + Success: success, + Error: error, + request.Signals, + CompletedAtUtc: timeProvider.GetUtcNow()); + + if (!registry.TryGet(request.Provider, out var provider)) + { + var missing = Build(0d, false, "Provider not registered"); + await PersistAsync(missing, cancellationToken).ConfigureAwait(false); + return missing; + } + + try + { + var score = await provider.ScoreAsync(request, cancellationToken).ConfigureAwait(false); + var success = Build(score, true, null); + await PersistAsync(success, cancellationToken).ConfigureAwait(false); + return success; + } + catch (Exception ex) + { + var failure = Build(0d, false, ex.Message); + await PersistAsync(failure, cancellationToken).ConfigureAwait(false); + return failure; + } + } + + public async Task> ProcessBatchAsync(int expectedCount, CancellationToken cancellationToken) + { + if (expectedCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(expectedCount)); + } + + var results = new List(expectedCount); + for (var i = 0; i < expectedCount; i++) + { + results.Add(await ProcessNextAsync(cancellationToken).ConfigureAwait(false)); + } + + return results; + } + + private Task PersistAsync(RiskScoreResult result, CancellationToken cancellationToken) => + resultStore is null + ? Task.CompletedTask + : resultStore.SaveAsync(result, cancellationToken); +} diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Infrastructure/Stores/InMemoryRiskScoreResultStore.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Infrastructure/Stores/InMemoryRiskScoreResultStore.cs new file mode 100644 index 000000000..ac9eb7412 --- /dev/null +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Infrastructure/Stores/InMemoryRiskScoreResultStore.cs @@ -0,0 +1,31 @@ +using System.Collections.Concurrent; +using StellaOps.RiskEngine.Core.Contracts; +using StellaOps.RiskEngine.Core.Services; + +namespace StellaOps.RiskEngine.Infrastructure.Stores; + +/// +/// Deterministic in-memory store for risk score results. +/// Used for offline/ephemeral runs and testing until ledger integration lands. +/// +public sealed class InMemoryRiskScoreResultStore : IRiskScoreResultStore +{ + private readonly ConcurrentDictionary results = new(); + private readonly ConcurrentQueue order = new(); + + public Task SaveAsync(RiskScoreResult result, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (results.TryAdd(result.JobId, result)) + { + order.Enqueue(result.JobId); + } + return Task.CompletedTask; + } + + public IReadOnlyList Snapshot() => + order.Select(id => results[id]).ToArray(); + + public bool TryGet(Guid jobId, out RiskScoreResult result) => + results.TryGetValue(jobId, out result!); +} diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Tests/UnitTest1.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Tests/UnitTest1.cs index 561bf2596..6fcd40f9b 100644 --- a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Tests/UnitTest1.cs +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Tests/UnitTest1.cs @@ -1,10 +1,298 @@ -namespace StellaOps.RiskEngine.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} +using StellaOps.RiskEngine.Core.Contracts; +using StellaOps.RiskEngine.Core.Providers; +using StellaOps.RiskEngine.Core.Services; +using StellaOps.RiskEngine.Infrastructure.Stores; + +namespace StellaOps.RiskEngine.Tests; + +public class RiskScoreWorkerTests +{ + [Fact] + public async Task ProcessesJobsInFifoOrder() + { + var provider = new DeterministicProvider("default", 1.0); + var registry = new RiskScoreProviderRegistry(new[] { provider }); + var queue = new RiskScoreQueue(); + var worker = new RiskScoreWorker(queue, registry); + + var first = new ScoreRequest("default", "asset-1", new Dictionary { ["a"] = 2 }); + var second = new ScoreRequest("default", "asset-2", new Dictionary { ["b"] = 3 }); + + await queue.EnqueueAsync(first, CancellationToken.None); + await queue.EnqueueAsync(second, CancellationToken.None); + + var results = await worker.ProcessBatchAsync(2, CancellationToken.None); + + Assert.Collection( + results, + r => + { + Assert.Equal(first.Subject, r.Subject); + Assert.True(r.Success); + Assert.Equal(2, r.Score); + }, + r => + { + Assert.Equal(second.Subject, r.Subject); + Assert.True(r.Success); + Assert.Equal(3, r.Score); + }); + } + + [Fact] + public async Task MissingProviderYieldsFailure() + { + var registry = new RiskScoreProviderRegistry(Array.Empty()); + var queue = new RiskScoreQueue(); + var store = new InMemoryRiskScoreResultStore(); + var worker = new RiskScoreWorker(queue, registry, store); + + await queue.EnqueueAsync( + new ScoreRequest("absent", "asset", new Dictionary()), + CancellationToken.None); + + var result = await worker.ProcessNextAsync(CancellationToken.None); + + Assert.False(result.Success); + Assert.Equal("absent", result.Provider); + Assert.NotNull(result.Error); + Assert.Equal(0d, result.Score); + Assert.True(store.TryGet(result.JobId, out var stored)); + Assert.False(stored.Success); + } + + [Fact] + public async Task DeterministicProviderReturnsStableScore() + { + var provider = new DeterministicProvider("default", weight: 2.0); + var registry = new RiskScoreProviderRegistry(new[] { provider }); + var queue = new RiskScoreQueue(); + var worker = new RiskScoreWorker(queue, registry); + + var request = new ScoreRequest("default", "asset", new Dictionary { ["x"] = 1.5, ["y"] = 0.5 }); + + await queue.EnqueueAsync(request, CancellationToken.None); + await queue.EnqueueAsync(request, CancellationToken.None); + + var results = await worker.ProcessBatchAsync(2, CancellationToken.None); + + var expected = await provider.ScoreAsync(request, CancellationToken.None); + Assert.All(results, r => + { + Assert.True(r.Success); + Assert.Equal(expected, r.Score); + }); + } + + [Fact] + public async Task DefaultProviderClampsAndAveragesSignals() + { + var provider = new DefaultTransformsProvider(); + var registry = new RiskScoreProviderRegistry(new[] { provider }); + var queue = new RiskScoreQueue(); + var worker = new RiskScoreWorker(queue, registry); + + var request = new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset", new Dictionary + { + ["low"] = -1, + ["mid"] = 0.25, + ["high"] = 2 + }); + + await queue.EnqueueAsync(request, CancellationToken.None); + var result = await worker.ProcessNextAsync(CancellationToken.None); + + Assert.True(result.Success); + var expected = Math.Round((0 + 0.25 + 1) / 3, 6, MidpointRounding.ToEven); + Assert.Equal(expected, result.Score); + } + + [Fact] + public async Task CvssKevProviderAddsKevBonus() + { + var cvssSource = new FakeCvssSource(new Dictionary + { + ["CVE-2025-0001"] = 9.8 + }); + var kevSource = new FakeKevSource(new Dictionary + { + ["CVE-2025-0001"] = true + }); + + var provider = new CvssKevProvider(cvssSource, kevSource); + var registry = new RiskScoreProviderRegistry(new[] { provider }); + var queue = new RiskScoreQueue(); + var worker = new RiskScoreWorker(queue, registry); + + var request = new ScoreRequest(CvssKevProvider.ProviderName, "CVE-2025-0001", new Dictionary()); + await queue.EnqueueAsync(request, CancellationToken.None); + + var result = await worker.ProcessNextAsync(CancellationToken.None); + + Assert.True(result.Success); + Assert.Equal(1.0d, result.Score); // 0.98 + 0.2 capped at 1.0 + } + + [Fact] + public async Task CvssKevProviderHandlesMissingCvss() + { + var cvssSource = new FakeCvssSource(new Dictionary()); + var kevSource = new FakeKevSource(new Dictionary()); + var provider = new CvssKevProvider(cvssSource, kevSource); + var registry = new RiskScoreProviderRegistry(new[] { provider }); + var queue = new RiskScoreQueue(); + var worker = new RiskScoreWorker(queue, registry); + + var request = new ScoreRequest(CvssKevProvider.ProviderName, "unknown", new Dictionary()); + await queue.EnqueueAsync(request, CancellationToken.None); + + var result = await worker.ProcessNextAsync(CancellationToken.None); + + Assert.True(result.Success); + Assert.Equal(0d, result.Score); + } + + [Fact] + public async Task VexGateProviderShortCircuitsOnDenial() + { + var provider = new VexGateProvider(); + var registry = new RiskScoreProviderRegistry(new[] { provider }); + var queue = new RiskScoreQueue(); + var worker = new RiskScoreWorker(queue, registry); + + var request = new ScoreRequest(VexGateProvider.ProviderName, "asset", new Dictionary + { + ["HasDenial"] = 1, + ["Other"] = 0.9 + }); + + await queue.EnqueueAsync(request, CancellationToken.None); + var result = await worker.ProcessNextAsync(CancellationToken.None); + + Assert.True(result.Success); + Assert.Equal(0d, result.Score); + } + + [Fact] + public async Task VexGateProviderUsesMaxSignalWhenNoDenial() + { + var provider = new VexGateProvider(); + var registry = new RiskScoreProviderRegistry(new[] { provider }); + var queue = new RiskScoreQueue(); + var worker = new RiskScoreWorker(queue, registry); + + var request = new ScoreRequest(VexGateProvider.ProviderName, "asset", new Dictionary + { + ["HasDenial"] = 0, + ["s1"] = 0.4, + ["s2"] = 0.8 + }); + + await queue.EnqueueAsync(request, CancellationToken.None); + var result = await worker.ProcessNextAsync(CancellationToken.None); + + Assert.True(result.Success); + Assert.Equal(0.8d, result.Score); + } + + [Fact] + public async Task FixExposureProviderAppliesWeights() + { + var provider = new FixExposureProvider(); + var registry = new RiskScoreProviderRegistry(new[] { provider }); + var queue = new RiskScoreQueue(); + var worker = new RiskScoreWorker(queue, registry); + + var request = new ScoreRequest(FixExposureProvider.ProviderName, "asset", new Dictionary + { + ["FixAvailability"] = 0.6, + ["Criticality"] = 0.8, + ["Exposure"] = 0.25 + }); + + await queue.EnqueueAsync(request, CancellationToken.None); + var result = await worker.ProcessNextAsync(CancellationToken.None); + + Assert.True(result.Success); + var expected = Math.Round((0.5 * 0.6) + (0.3 * 0.8) + (0.2 * 0.25), 6, MidpointRounding.ToEven); + Assert.Equal(expected, result.Score); + } + + [Fact] + public async Task FixExposureProviderDefaultsMissingSignalsToZero() + { + var provider = new FixExposureProvider(); + var registry = new RiskScoreProviderRegistry(new[] { provider }); + var queue = new RiskScoreQueue(); + var worker = new RiskScoreWorker(queue, registry); + + var request = new ScoreRequest(FixExposureProvider.ProviderName, "asset", new Dictionary + { + ["FixAvailability"] = 1.0 + }); + + await queue.EnqueueAsync(request, CancellationToken.None); + var result = await worker.ProcessNextAsync(CancellationToken.None); + + Assert.True(result.Success); + // Only fix=1; criticality/exposure default to 0 → 0.5 * 1.0 + Assert.Equal(0.5d, result.Score); + } + + [Fact] + public async Task ResultsPersistedToStore() + { + var provider = new DeterministicProvider("default", 1.0); + var registry = new RiskScoreProviderRegistry(new[] { provider }); + var queue = new RiskScoreQueue(); + var store = new InMemoryRiskScoreResultStore(); + var worker = new RiskScoreWorker(queue, registry, store); + + var first = new ScoreRequest("default", "a1", new Dictionary { ["s1"] = 1 }); + var second = new ScoreRequest("default", "a2", new Dictionary { ["s2"] = 2 }); + + await queue.EnqueueAsync(first, CancellationToken.None); + await queue.EnqueueAsync(second, CancellationToken.None); + + var results = await worker.ProcessBatchAsync(2, CancellationToken.None); + + var snapshot = store.Snapshot(); + Assert.Equal(results.Select(r => r.JobId), snapshot.Select(r => r.JobId)); + Assert.Equal(results.Select(r => r.Score), snapshot.Select(r => r.Score)); + } + + private sealed class FakeCvssSource : ICvssSource + { + private readonly IReadOnlyDictionary data; + public FakeCvssSource(IReadOnlyDictionary data) => this.data = data; + public Task GetCvssAsync(string subject, CancellationToken cancellationToken) => + Task.FromResult(data.TryGetValue(subject, out var value) ? value : null); + } + + private sealed class FakeKevSource : IKevSource + { + private readonly IReadOnlyDictionary data; + public FakeKevSource(IReadOnlyDictionary data) => this.data = data; + public Task IsKevAsync(string subject, CancellationToken cancellationToken) => + Task.FromResult(data.TryGetValue(subject, out var value) ? value : null); + } + + private sealed class DeterministicProvider : IRiskScoreProvider + { + public DeterministicProvider(string name, double weight) + { + Name = name; + this.weight = weight; + } + + private readonly double weight; + + public string Name { get; } + + public Task ScoreAsync(ScoreRequest request, CancellationToken cancellationToken) + { + var sum = request.Signals.Values.Sum(); + return Task.FromResult(Math.Round(sum * weight, 6, MidpointRounding.ToEven)); + } + } +} diff --git a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.WebService/Program.cs b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.WebService/Program.cs index 3917ef1bd..e400673d7 100644 --- a/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.WebService/Program.cs +++ b/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.WebService/Program.cs @@ -1,41 +1,82 @@ -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} - -app.UseHttpsRedirection(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast"); - -app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} +using StellaOps.RiskEngine.Core.Contracts; +using StellaOps.RiskEngine.Core.Providers; +using StellaOps.RiskEngine.Core.Services; +using StellaOps.RiskEngine.Infrastructure.Stores; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOpenApi(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(_ => + new RiskScoreProviderRegistry(new IRiskScoreProvider[] + { + new DefaultTransformsProvider(), + new CvssKevProvider(new NullCvssSource(), new NullKevSource()), + new VexGateProvider(), + new FixExposureProvider() + })); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +app.MapGet("/risk-scores/providers", (IRiskScoreProviderRegistry registry) => + Results.Ok(new { providers = registry.ProviderNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase) })); + +app.MapPost("/risk-scores/jobs", async ( + ScoreRequest request, + RiskScoreQueue queue, + IRiskScoreProviderRegistry registry, + IRiskScoreResultStore store, + TimeProvider timeProvider, + CancellationToken ct) => +{ + var job = new RiskScoreJob(Guid.NewGuid(), request); + await queue.EnqueueAsync(job.Request, ct).ConfigureAwait(false); + var worker = new RiskScoreWorker(queue, registry, store, timeProvider); + var result = await worker.ProcessNextAsync(ct).ConfigureAwait(false); + return Results.Accepted($"/risk-scores/jobs/{job.JobId}", new { jobId = job.JobId, result }); +}); + +app.MapGet("/risk-scores/jobs/{jobId:guid}", (Guid jobId, InMemoryRiskScoreResultStore store) => + store.TryGet(jobId, out var result) + ? Results.Ok(result) + : Results.NotFound()); + +app.MapPost("/risk-scores/simulations", async ( + IReadOnlyCollection requests, + IRiskScoreProviderRegistry registry, + TimeProvider timeProvider, + CancellationToken ct) => +{ + var results = new List(requests.Count); + foreach (var req in requests) + { + if (!registry.TryGet(req.Provider, out var provider)) + { + results.Add(new RiskScoreResult(Guid.NewGuid(), req.Provider, req.Subject, 0d, false, "Provider not registered", req.Signals, timeProvider.GetUtcNow())); + continue; + } + + try + { + var score = await provider.ScoreAsync(req, ct).ConfigureAwait(false); + results.Add(new RiskScoreResult(Guid.NewGuid(), req.Provider, req.Subject, score, true, null, req.Signals, timeProvider.GetUtcNow())); + } + catch (Exception ex) + { + results.Add(new RiskScoreResult(Guid.NewGuid(), req.Provider, req.Subject, 0d, false, ex.Message, req.Signals, timeProvider.GetUtcNow())); + } + } + + return Results.Ok(new { results }); +}); + +app.Run(); diff --git a/src/RiskEngine/StellaOps.RiskEngine/TASKS.md b/src/RiskEngine/StellaOps.RiskEngine/TASKS.md new file mode 100644 index 000000000..ccff62d20 --- /dev/null +++ b/src/RiskEngine/StellaOps.RiskEngine/TASKS.md @@ -0,0 +1,10 @@ +# Risk Engine Tasks (Sprint 0129-0001-0001) + +| Task ID | Status | Notes | +| --- | --- | --- | +| RISK-ENGINE-66-001 | DONE (2025-11-25) | Scoring queue + worker + provider registry scaffolded; deterministic tests added. | +| RISK-ENGINE-66-002 | DONE (2025-11-25) | Default transforms provider added; queue/worker tests updated. | +| RISK-ENGINE-67-001 | DONE (2025-11-25) | CVSS+KEV provider implemented with tests; clamped scoring formula shipped. | +| RISK-ENGINE-67-002 | DONE (2025-11-25) | VEX gate provider added; short-circuits on denial flag. | +| RISK-ENGINE-67-003 | DONE (2025-11-25) | Fix availability / criticality / exposure provider added with weighted scoring + missing-signal defaults tested. | +| RISK-ENGINE-68-001 | DONE (2025-11-25) | Worker now persists results via result-store abstraction; in-memory store added with FIFO snapshot + failure capture. | diff --git a/src/TaskRunner/StellaOps.TaskRunner/TASKS.md b/src/TaskRunner/StellaOps.TaskRunner/TASKS.md new file mode 100644 index 000000000..08abef693 --- /dev/null +++ b/src/TaskRunner/StellaOps.TaskRunner/TASKS.md @@ -0,0 +1,20 @@ +# TASKS · TaskRunner (Sprint 0157-0001-0001) + +| Task ID | Status | Sprint | Dependency | Notes | +| --- | --- | --- | --- | --- | +| TASKRUN-41-001 | BLOCKED | SPRINT_0157_0001_0001_taskrunner_i | — | Blocked: TaskRunner architecture/API contracts and Sprint 120/130/140 inputs not published. | +| TASKRUN-AIRGAP-56-001 | TODO | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-41-001 | Sealed-mode plan validation; depends on 41-001. | +| TASKRUN-AIRGAP-56-002 | TODO | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-AIRGAP-56-001 | Bundle ingestion helpers; depends on 56-001. | +| TASKRUN-AIRGAP-57-001 | TODO | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-AIRGAP-56-002 | Sealed install enforcement; depends on 56-002. | +| TASKRUN-AIRGAP-58-001 | TODO | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-AIRGAP-57-001 | Evidence bundles for imports; depends on 57-001. | +| TASKRUN-42-001 | TODO | SPRINT_0157_0001_0001_taskrunner_i | — | Execution engine enhancements (loops/conditionals/maxParallel), simulation mode, policy gate integration. | +| TASKRUN-OAS-61-001 | TODO | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-41-001 | Document APIs; depends on 41-001. | +| TASKRUN-OAS-61-002 | TODO | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-OAS-61-001 | Well-known OpenAPI endpoint; depends on 61-001. | +| TASKRUN-OAS-62-001 | TODO | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-OAS-61-002 | SDK examples; depends on 61-002. | +| TASKRUN-OAS-63-001 | TODO | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-OAS-62-001 | Deprecation headers/notifications; depends on 62-001. | +| TASKRUN-OBS-50-001 | TODO | SPRINT_0157_0001_0001_taskrunner_i | — | Telemetry core adoption. | +| TASKRUN-OBS-51-001 | TODO | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-OBS-50-001 | Metrics/SLOs; depends on 50-001. | +| TASKRUN-OBS-52-001 | TODO | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-OBS-51-001 | Timeline events; depends on 51-001. | +| TASKRUN-OBS-53-001 | TODO | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-OBS-52-001 | Evidence locker snapshots; depends on 52-001. | + +Status source of truth: `docs/implplan/SPRINT_0157_0001_0001_taskrunner_i.md`. Update both files together. Keep UTC dates when advancing status. diff --git a/src/VexLens/StellaOps.VexLens/TASKS.md b/src/VexLens/StellaOps.VexLens/TASKS.md new file mode 100644 index 000000000..d46770e64 --- /dev/null +++ b/src/VexLens/StellaOps.VexLens/TASKS.md @@ -0,0 +1,22 @@ +# TASKS · VexLens (Sprint 0129-0001-0001) + +| Task ID | Status | Sprint | Dependency | Notes | +| --- | --- | --- | --- | --- | +| VEXLENS-30-001 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | — | Blocked: normalization schema + issuer directory + API governance specs not published. | +| VEXLENS-30-002 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-001 | Product mapping library; depends on normalization shapes. | +| VEXLENS-30-003 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-002 | Signature verification (Ed25519/DSSE/PKIX); issuer directory inputs pending. | +| VEXLENS-30-004 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-003 | Trust weighting engine; needs policy config contract. | +| VEXLENS-30-005 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-004 | Consensus algorithm; blocked by trust weighting inputs. | +| VEXLENS-30-006 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-005 | Projection storage/events; awaiting consensus output schema. | +| VEXLENS-30-007 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-006 | Consensus APIs + OpenAPI; pending upstream API governance guidance. | +| VEXLENS-30-008 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-007 | Policy Engine/Vuln Explorer integration; needs upstream contracts. | +| VEXLENS-30-009 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-008 | Telemetry (metrics/logs/traces); observability schema not published. | +| VEXLENS-30-010 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-009 | Tests + determinism harness; fixtures pending normalization outputs. | +| VEXLENS-30-011 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-010 | Deployment/runbooks/offline kit; depends on API/telemetry shapes. | +| VEXLENS-AIAI-31-001 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-011 | Consensus rationale API enhancements; needs consensus API finalization. | +| VEXLENS-AIAI-31-002 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-AIAI-31-001 | Caching hooks for Advisory AI; requires rationale API shape. | +| VEXLENS-EXPORT-35-001 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-011 | Snapshot API for mirror bundles; export profile pending. | +| VEXLENS-ORCH-33-001 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-011 | Register consensus compute job; orchestrator contract TBD. | +| VEXLENS-ORCH-34-001 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-ORCH-33-001 | Emit completion events to orchestrator ledger; needs job spec. | + +Status source of truth: `docs/implplan/SPRINT_0129_0001_0001_policy_reasoning.md`. Update both files together. Keep UTC dates when advancing status. diff --git a/src/VulnExplorer/StellaOps.VulnExplorer.Api/Data/SampleData.cs b/src/VulnExplorer/StellaOps.VulnExplorer.Api/Data/SampleData.cs new file mode 100644 index 000000000..e8d12e840 --- /dev/null +++ b/src/VulnExplorer/StellaOps.VulnExplorer.Api/Data/SampleData.cs @@ -0,0 +1,87 @@ +using StellaOps.VulnExplorer.Api.Models; + +namespace StellaOps.VulnExplorer.Api.Data; + +internal static class SampleData +{ + private static readonly VulnSummary[] summaries = + { + new( + Id: "vuln-0001", + Severity: "HIGH", + Score: 8.2, + Kev: true, + Exploitability: "known", + FixAvailable: true, + CveIds: new[] { "CVE-2025-0001" }, + Purls: new[] { "pkg:maven/org.example/app@1.2.3" }, + PolicyVersion: "policy-main", + RationaleId: "rat-0001"), + new( + Id: "vuln-0002", + Severity: "MEDIUM", + Score: 5.4, + Kev: false, + Exploitability: "unknown", + FixAvailable: false, + CveIds: new[] { "CVE-2024-2222" }, + Purls: new[] { "pkg:npm/foo@4.5.6" }, + PolicyVersion: "policy-main", + RationaleId: "rat-0002") + }; + + private static readonly VulnDetail[] details = + { + new( + Id: "vuln-0001", + Severity: "HIGH", + Score: 8.2, + Kev: true, + Exploitability: "known", + FixAvailable: true, + CveIds: summaries[0].CveIds, + Purls: summaries[0].Purls, + Summary: "Example vulnerable library with RCE.", + AffectedPackages: new[] + { + new PackageAffect("pkg:maven/org.example/app", new[] { "1.2.3" }) + }, + AdvisoryRefs: new[] + { + new AdvisoryRef("https://example.com/advisory/0001", "Upstream advisory") + }, + FirstSeen: DateTimeOffset.Parse("2025-01-01T00:00:00Z"), + LastSeen: DateTimeOffset.Parse("2025-11-01T00:00:00Z"), + PolicyVersion: summaries[0].PolicyVersion, + RationaleId: summaries[0].RationaleId, + Provenance: new EvidenceProvenance("ledger-1", "evidence-1")), + new( + Id: "vuln-0002", + Severity: "MEDIUM", + Score: 5.4, + Kev: false, + Exploitability: "unknown", + FixAvailable: false, + CveIds: summaries[1].CveIds, + Purls: summaries[1].Purls, + Summary: "Prototype pollution risk.", + AffectedPackages: new[] + { + new PackageAffect("pkg:npm/foo", new[] { "4.5.6" }) + }, + AdvisoryRefs: Array.Empty(), + FirstSeen: DateTimeOffset.Parse("2024-06-10T00:00:00Z"), + LastSeen: DateTimeOffset.Parse("2025-08-15T00:00:00Z"), + PolicyVersion: summaries[1].PolicyVersion, + RationaleId: summaries[1].RationaleId, + Provenance: new EvidenceProvenance("ledger-2", "evidence-2")) + }; + + public static IReadOnlyList Summaries => summaries; + + public static bool TryGetDetail(string id, out VulnDetail? detail) + { + detail = details.FirstOrDefault(d => string.Equals(d.Id, id, StringComparison.Ordinal)); + return detail is not null; + } +} diff --git a/src/VulnExplorer/StellaOps.VulnExplorer.Api/Models/VulnModels.cs b/src/VulnExplorer/StellaOps.VulnExplorer.Api/Models/VulnModels.cs new file mode 100644 index 000000000..0cc7f55c8 --- /dev/null +++ b/src/VulnExplorer/StellaOps.VulnExplorer.Api/Models/VulnModels.cs @@ -0,0 +1,39 @@ +namespace StellaOps.VulnExplorer.Api.Models; + +public sealed record VulnSummary( + string Id, + string Severity, + double Score, + bool Kev, + string Exploitability, + bool FixAvailable, + IReadOnlyList CveIds, + IReadOnlyList Purls, + string PolicyVersion, + string RationaleId); + +public sealed record VulnDetail( + string Id, + string Severity, + double Score, + bool Kev, + string Exploitability, + bool FixAvailable, + IReadOnlyList CveIds, + IReadOnlyList Purls, + string Summary, + IReadOnlyList AffectedPackages, + IReadOnlyList AdvisoryRefs, + DateTimeOffset FirstSeen, + DateTimeOffset LastSeen, + string PolicyVersion, + string RationaleId, + EvidenceProvenance Provenance); + +public sealed record PackageAffect(string Purl, IReadOnlyList Versions); + +public sealed record AdvisoryRef(string Url, string Title); + +public sealed record EvidenceProvenance(string LedgerEntryId, string EvidenceBundleId); + +public sealed record VulnListResponse(IReadOnlyList Items, string? NextPageToken); diff --git a/src/VulnExplorer/StellaOps.VulnExplorer.Api/Program.cs b/src/VulnExplorer/StellaOps.VulnExplorer.Api/Program.cs new file mode 100644 index 000000000..35b4f296b --- /dev/null +++ b/src/VulnExplorer/StellaOps.VulnExplorer.Api/Program.cs @@ -0,0 +1,115 @@ +using System.Globalization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OpenApi; +using StellaOps.VulnExplorer.Api.Data; +using StellaOps.VulnExplorer.Api.Models; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +app.MapGet("/v1/vulns", ( + HttpRequest request, + [AsParameters] VulnFilter filter) => +{ + var tenant = request.Headers["x-stella-tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenant)) + { + return Results.BadRequest(new { error = "x-stella-tenant required" }); + } + + var data = ApplyFilter(SampleData.Summaries, filter); + var pageSize = Math.Clamp(filter.PageSize ?? 50, 1, 200); + var offset = ParsePageToken(filter.PageToken); + + var page = data.Skip(offset).Take(pageSize).ToArray(); + var nextOffset = offset + page.Length; + var next = nextOffset < data.Count ? nextOffset.ToString(CultureInfo.InvariantCulture) : null; + + var response = new VulnListResponse(page, next); + return Results.Ok(response); +}) +.WithOpenApi(); + +app.MapGet("/v1/vulns/{id}", (HttpRequest request, string id) => +{ + var tenant = request.Headers["x-stella-tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenant)) + { + return Results.BadRequest(new { error = "x-stella-tenant required" }); + } + + return SampleData.TryGetDetail(id, out var detail) && detail is not null + ? Results.Ok(detail) + : Results.NotFound(); +}) +.WithOpenApi(); + +app.Run(); + +static int ParsePageToken(string? token) => + int.TryParse(token, out var offset) && offset >= 0 ? offset : 0; + +static IReadOnlyList ApplyFilter(IReadOnlyList source, VulnFilter filter) +{ + IEnumerable query = source; + + if (filter.Cve?.Count > 0) + { + var set = filter.Cve.ToHashSet(StringComparer.OrdinalIgnoreCase); + query = query.Where(v => v.CveIds.Any(set.Contains)); + } + + if (filter.Purl?.Count > 0) + { + var set = filter.Purl.ToHashSet(StringComparer.OrdinalIgnoreCase); + query = query.Where(v => v.Purls.Any(set.Contains)); + } + + if (filter.Severity?.Count > 0) + { + var set = filter.Severity.ToHashSet(StringComparer.OrdinalIgnoreCase); + query = query.Where(v => set.Contains(v.Severity)); + } + + if (filter.Exploitability is not null) + { + query = query.Where(v => string.Equals(v.Exploitability, filter.Exploitability, StringComparison.OrdinalIgnoreCase)); + } + + if (filter.FixAvailable is not null) + { + query = query.Where(v => v.FixAvailable == filter.FixAvailable); + } + + // deterministic ordering: score desc, id asc + query = query + .OrderByDescending(v => v.Score) + .ThenBy(v => v.Id, StringComparer.Ordinal); + + return query.ToArray(); +} + +public record VulnFilter( + [FromHeader(Name = "x-stella-tenant")] string Tenant, + [FromQuery(Name = "policyVersion")] string? PolicyVersion, + [FromQuery(Name = "pageSize")] int? PageSize, + [FromQuery(Name = "pageToken")] string? PageToken, + [FromQuery(Name = "cve")] IReadOnlyList? Cve, + [FromQuery(Name = "purl")] IReadOnlyList? Purl, + [FromQuery(Name = "severity")] IReadOnlyList? Severity, + [FromQuery(Name = "exploitability")] string? Exploitability, + [FromQuery(Name = "fixAvailable")] bool? FixAvailable); + +public partial class Program { } + +public partial class Program { } diff --git a/src/VulnExplorer/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj b/src/VulnExplorer/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj new file mode 100644 index 000000000..19c979402 --- /dev/null +++ b/src/VulnExplorer/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + preview + true + StellaOps.VulnExplorer.Api + + + + + diff --git a/src/VulnExplorer/StellaOps.VulnExplorer.Api/appsettings.json b/src/VulnExplorer/StellaOps.VulnExplorer.Api/appsettings.json new file mode 100644 index 000000000..8983e0fc1 --- /dev/null +++ b/src/VulnExplorer/StellaOps.VulnExplorer.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/tests/StellaOps.VulnExplorer.Api.Tests/StellaOps.VulnExplorer.Api.Tests.csproj b/tests/StellaOps.VulnExplorer.Api.Tests/StellaOps.VulnExplorer.Api.Tests.csproj new file mode 100644 index 000000000..527fb35c7 --- /dev/null +++ b/tests/StellaOps.VulnExplorer.Api.Tests/StellaOps.VulnExplorer.Api.Tests.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + preview + true + false + + + + + + + + + + diff --git a/tests/StellaOps.VulnExplorer.Api.Tests/VulnApiTests.cs b/tests/StellaOps.VulnExplorer.Api.Tests/VulnApiTests.cs new file mode 100644 index 000000000..eb20c0c66 --- /dev/null +++ b/tests/StellaOps.VulnExplorer.Api.Tests/VulnApiTests.cs @@ -0,0 +1,54 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using StellaOps.VulnExplorer.Api.Models; + +namespace StellaOps.VulnExplorer.Api.Tests; + +public class VulnApiTests : IClassFixture> +{ + private readonly WebApplicationFactory factory; + + public VulnApiTests(WebApplicationFactory factory) + { + this.factory = factory.WithWebHostBuilder(_ => { }); + } + + [Fact] + public async Task List_ReturnsDeterministicOrder() + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-stella-tenant", "tenant-a"); + + var response = await client.GetAsync("/v1/vulns"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Equal(new[] { "vuln-0001", "vuln-0002" }, payload!.Items.Select(v => v.Id)); + } + + [Fact] + public async Task List_FiltersByCve() + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-stella-tenant", "tenant-a"); + + var response = await client.GetAsync("/v1/vulns?cve=CVE-2024-2222"); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(); + Assert.Single(payload!.Items); + Assert.Equal("vuln-0002", payload.Items[0].Id); + } + + [Fact] + public async Task Detail_ReturnsNotFoundWhenMissing() + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-stella-tenant", "tenant-a"); + + var response = await client.GetAsync("/v1/vulns/missing"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/tools/ci/run-concelier-attestation-tests.sh b/tools/ci/run-concelier-attestation-tests.sh new file mode 100644 index 000000000..62780e027 --- /dev/null +++ b/tools/ci/run-concelier-attestation-tests.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT_DIR" + +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 +export DOTNET_CLI_TELEMETRY_OPTOUT=1 +export DOTNET_NOLOGO=1 + +# Restore once for the Concelier solution. +dotnet restore src/Concelier/StellaOps.Concelier.sln + +# Build the two test projects with analyzers disabled to keep CI fast. +dotnet build src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/StellaOps.Concelier.Core.Tests.csproj \ + -c Release -p:DisableAnalyzers=true + +dotnet build src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj \ + -c Release -p:DisableAnalyzers=true + +# Run filtered attestation tests; keep logs in TestResults. +RESULTS=TestResults/concelier-attestation +mkdir -p "$RESULTS" + +core_log="$RESULTS/core.trx" +web_log="$RESULTS/web.trx" + +set +e + +dotnet test src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/StellaOps.Concelier.Core.Tests.csproj \ + -c Release --no-build --filter EvidenceBundleAttestationBuilderTests \ + --logger "trx;LogFileName=$(basename "$core_log")" --results-directory "$RESULTS" +CORE_EXIT=$? + +dotnet test src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj \ + -c Release --no-build --filter InternalAttestationVerify \ + --logger "trx;LogFileName=$(basename "$web_log")" --results-directory "$RESULTS" +WEB_EXIT=$? + +set -e + +if [[ $CORE_EXIT -ne 0 || $WEB_EXIT -ne 0 ]]; then + echo "Attestation test run failed: core=$CORE_EXIT web=$WEB_EXIT" >&2 + exit 1 +fi + +echo "Attestation tests succeeded; results in $RESULTS"