refactor(notify): merge Notifier WebService into Notify WebService
- Delete dead Notify Worker (NoOp handler) - Move 51 source files (endpoints, contracts, services, compat stores) - Transform namespaces from Notifier.WebService to Notify.WebService - Update DI registrations, WebSocket support, v2 endpoint mapping - Comment out notifier-web in compose, update gateway routes - Update architecture docs, port registry, rollout matrix - Notifier Worker stays as separate delivery engine container Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,6 @@
|
||||
"policy",
|
||||
"policy-engine",
|
||||
"notify",
|
||||
"notifier",
|
||||
"scanner",
|
||||
"findings-ledger",
|
||||
"integrations",
|
||||
@@ -75,8 +74,8 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/lineage(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/resolve(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/ops/binaryindex(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/policy$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/governance$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/workflow(.*)", "IsRegex": true, "TranslatesTo": "http://workflow.stella-ops.local/api/workflow$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/v1/workflows$1" },
|
||||
@@ -91,8 +90,8 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/audit(.*)", "IsRegex": true, "TranslatesTo": "http://timeline.stella-ops.local/api/v1/audit$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/export(.*)", "IsRegex": true, "TranslatesTo": "https://exportcenter.stella-ops.local/api/v1/export$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/notifier/(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notify.stella-ops.local/api/v2/notify/deliveries$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/notifier/(.*)", "IsRegex": true, "TranslatesTo": "http://notify.stella-ops.local/api/v2/notify/$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/notify/(digest-schedules|quiet-hours|throttle-configs|simulate|escalation-policies|localizations|incidents)(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/notify/$1$2" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
|
||||
@@ -112,7 +111,7 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v1/$1$2" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v2/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v2/$1$2" },
|
||||
|
||||
{ "Type": "Microservice", "Path": "^/api/(cvss|gate|exceptions|policy)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/$1$2" },
|
||||
{ "Type": "Microservice", "Path": "^/api/(cvss|gate|exceptions|policy)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/$1$2" },
|
||||
{ "Type": "Microservice", "Path": "^/api/(risk|risk-budget)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/$1$2" },
|
||||
{ "Type": "Microservice", "Path": "^/api/(release-orchestrator|releases|approvals)(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/$1$2" },
|
||||
{ "Type": "Microservice", "Path": "^/api/(compare|change-traces|sbomservice)(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/$1$2" },
|
||||
@@ -130,9 +129,9 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" },
|
||||
|
||||
{ "Type": "ReverseProxy", "Path": "^/policy/shadow(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/shadow$1", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "^/policy/simulations(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/simulations$1", "PreserveAuthHeaders": true },
|
||||
{ "Type": "Microservice", "Path": "^/policy(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" },
|
||||
{ "Type": "ReverseProxy", "Path": "^/policy/shadow(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/policy/shadow$1", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "^/policy/simulations(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/policy/simulations$1", "PreserveAuthHeaders": true },
|
||||
{ "Type": "Microservice", "Path": "^/policy(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/policy$1" },
|
||||
|
||||
{ "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" },
|
||||
{ "Type": "Microservice", "Path": "^/v1/runs(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/v1/runs$1" },
|
||||
|
||||
@@ -33,8 +33,8 @@ vexlens-web|devops/docker/Dockerfile.hardened.template|src/VexLens/StellaOps.Vex
|
||||
api|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj|StellaOps.VulnExplorer.Api|8080
|
||||
# ── Slot 14: Policy Engine ──────────────────────────────────────────────────────
|
||||
policy-engine|devops/docker/Dockerfile.hardened.template|src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj|StellaOps.Policy.Engine|8080
|
||||
# ── Slot 15: Policy Gateway ─────────────────────────────────────────────────────
|
||||
policy|devops/docker/Dockerfile.hardened.template|src/Policy/StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj|StellaOps.Policy.Gateway|8084
|
||||
# ── Slot 15: Policy Gateway (MERGED into policy-engine, Slot 14) ───────────────
|
||||
# policy|devops/docker/Dockerfile.hardened.template|src/Policy/StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj|StellaOps.Policy.Gateway|8084
|
||||
# ── Slot 16: RiskEngine ─────────────────────────────────────────────────────────
|
||||
riskengine-web|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps.RiskEngine.WebService/StellaOps.RiskEngine.WebService.csproj|StellaOps.RiskEngine.WebService|8080
|
||||
riskengine-worker|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps.RiskEngine.Worker/StellaOps.RiskEngine.Worker.csproj|StellaOps.RiskEngine.Worker|8080
|
||||
@@ -64,8 +64,8 @@ doctor-web|devops/docker/Dockerfile.hardened.template|src/Doctor/StellaOps.Docto
|
||||
doctor-scheduler|devops/docker/Dockerfile.hardened.template|src/Doctor/StellaOps.Doctor.Scheduler/StellaOps.Doctor.Scheduler.csproj|StellaOps.Doctor.Scheduler|8080
|
||||
# ── Slot 27: OpsMemory ──────────────────────────────────────────────────────────
|
||||
opsmemory-web|devops/docker/Dockerfile.hardened.template|src/AdvisoryAI/StellaOps.OpsMemory.WebService/StellaOps.OpsMemory.WebService.csproj|StellaOps.OpsMemory.WebService|8080
|
||||
# ── Slot 28: Notifier ───────────────────────────────────────────────────────────
|
||||
notifier-web|devops/docker/Dockerfile.hardened.template|src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/StellaOps.Notifier.WebService.csproj|StellaOps.Notifier.WebService|8080
|
||||
# ── Slot 28: Notifier (web merged into notify-web; worker stays) ────────────────
|
||||
# notifier-web: MERGED into notify-web
|
||||
notifier-worker|devops/docker/Dockerfile.hardened.template|src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj|StellaOps.Notifier.Worker|8080
|
||||
# ── Slot 29: Notify ─────────────────────────────────────────────────────────────
|
||||
notify-web|devops/docker/Dockerfile.hardened.template|src/Notify/StellaOps.Notify.WebService/StellaOps.Notify.WebService.csproj|StellaOps.Notify.WebService|8080
|
||||
|
||||
@@ -33,9 +33,9 @@ Tenant API│ REST + gRPC WIP │ │ rules/channels│
|
||||
└─────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
- **2025-11-02 decision — module boundaries.** Keep `src/Notify/` as the shared notification toolkit (engine, storage, queue, connectors) that multiple hosts can consume. `src/Notifier/` remains the Notifications Studio runtime (WebService + Worker) composed from those libraries. Do not collapse the directories until a packaging RFC covers build impacts, offline kit parity, and imposed-rule propagation.
|
||||
- **WebService** hosts REST endpoints (`/channels`, `/rules`, `/templates`, `/deliveries`, `/digests`, `/stats`) and handles schema normalisation, validation, and Authority enforcement.
|
||||
- **Worker** subscribes to the platform event bus, evaluates rules per tenant, applies throttles/digests, renders payloads, writes ledger entries, and invokes connectors.
|
||||
- **2025-11-02 decision — module boundaries.** Keep `src/Notify/` as the shared notification toolkit (engine, storage, queue, connectors) that multiple hosts can consume. `src/Notifier/` retains the Worker (delivery engine) while the Notifier WebService has been **merged into `src/Notify/StellaOps.Notify.WebService`** (2026-04-08). The `notifier.stella-ops.local` hostname is now a DNS alias on the `notify-web` container.
|
||||
- **Notify WebService** (`src/Notify/StellaOps.Notify.WebService`) hosts all REST endpoints — both original Notify v1 (`/channels`, `/rules`, `/templates`, `/deliveries`, `/digests`, `/stats`) and merged Notifier v2 (`/api/v2/notify/*` escalation, incident, simulation, storm-breaker, etc.) — with schema normalisation, validation, and Authority enforcement.
|
||||
- **Notifier Worker** (`src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker`) subscribes to the platform event bus, evaluates rules per tenant, applies throttles/digests, renders payloads, writes ledger entries, and invokes connectors. It remains a separate container.
|
||||
- **Plug-ins** live under `plugins/notify/` and are loaded deterministically at service start (`orderedPlugins` list). Each implements connector contracts and optional health/test-preview providers.
|
||||
|
||||
Both services share options via `notify.yaml` (see `etc/notify.yaml.sample`). For dev/test scenarios, an in-memory repository exists but production requires PostgreSQL + Valkey/NATS for durability and coordination.
|
||||
|
||||
@@ -543,8 +543,10 @@ Offline Kit builder and include:
|
||||
|
||||
These files are copied automatically by `ops/offline-kit/build_offline_kit.py`
|
||||
via `copy_bootstrap_configs`. Operators mount the configuration and secret into
|
||||
the `StellaOps.Notifier.WebService` container (Compose or Kubernetes) to keep
|
||||
sealed-mode roll-outs reproducible.
|
||||
the `StellaOps.Notify.WebService` container (Compose or Kubernetes) to keep
|
||||
sealed-mode roll-outs reproducible. (Notifier WebService was merged into
|
||||
Notify WebService; the `notifier.stella-ops.local` hostname is now an alias
|
||||
on the `notify-web` container.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,14 +33,13 @@ Legend:
|
||||
| _(gateway.stella-ops.local — removed, consolidated into router-gateway)_ | — | — | — | — | Legacy gateway container eliminated; all traffic served by router-gateway (slot 0). | N/A |
|
||||
| integrations.stella-ops.local | integrations-web | /api/v1/integrations, /integrations | A | Developer + Test Automation (Wave A) | Migrate API prefix first, then root compatibility path. | Route type revert + `INTEGRATIONS_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| issuerdirectory.stella-ops.local | issuer-directory | /issuerdirectory | B | Developer + Test Automation (Wave B) | Migrate route in trust-plane wave with issuer/auth verification checks. | Route type revert + `ISSUERDIRECTORY_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| notifier.stella-ops.local | notifier-web | /api/v1/notifier, /notifier | D | Developer + Test Automation (Wave D) | Migrate API prefix first, then root compatibility path. | Route type revert + `NOTIFIER_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| notify.stella-ops.local | notify-web | /api/v1/notify, /notify | D | Developer + Test Automation (Wave D) | Migrate API prefix first, then root compatibility path. | Route type revert + `NOTIFY_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| notify.stella-ops.local (+ notifier.stella-ops.local alias) | notify-web | /api/v1/notify, /notify, /api/v1/notifier, /notifier | D | Developer + Test Automation (Wave D) | Merged: notifier-web folded into notify-web. | Route type revert + `NOTIFY_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| opsmemory.stella-ops.local | opsmemory-web | /api/v1/opsmemory, /opsmemory | A | Developer + Test Automation (Wave A) | Migrate API prefix first, then root compatibility path. | Route type revert + `OPSMEMORY_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| jobengine.stella-ops.local | orchestrator | /api/approvals, /api/jobengine, /api/release-orchestrator, /api/releases, /api/v1/jobengine, /api/v1/release-orchestrator, /api/v1/workflows, /orchestrator, /v1/runs | C | Developer + Test Automation (Wave C) | Migrate all API/v1 and v1 routes first; keep root compatibility path until control-plane acceptance. | Route type revert + `ORCHESTRATOR_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| packsregistry.stella-ops.local | packsregistry-web | /packsregistry | A | Developer + Test Automation (Wave A) | Add API-form endpoint mapping if required, then migrate root compatibility route. | Route type revert + `PACKSREGISTRY_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| platform.stella-ops.local | platform | /api, /api/admin, /api/analytics, /api/v1/authority/quotas, /api/v1/gateway/rate-limits, /api/v1/platform, /envsettings.json, /platform | C | Developer + Test Automation (Wave C) | Migrate API prefixes to Microservice; keep `/platform` and `/envsettings.json` reverse proxy for static/bootstrap behavior. | Route type revert + `PLATFORM_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| policy-engine.stella-ops.local | policy-engine | /api/risk, /api/risk-budget, /api/v1/determinization, /policyEngine | C | Developer + Test Automation (Wave C) | Migrate API prefixes first; keep root compatibility path until control-plane verification completes. | Route type revert + `POLICY_ENGINE_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| policy-gateway.stella-ops.local | policy | /api/cvss, /api/exceptions, /api/gate, /api/policy, /api/v1/governance, /api/v1/policy, /policy, /policyGateway | C | Developer + Test Automation (Wave C) | Migrate API prefixes first; keep `/policy` and `/policyGateway` compatibility paths until final cutover. | Route type revert + `POLICY_GATEWAY_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| ~~policy-gateway.stella-ops.local~~ | ~~policy~~ | _Merged into policy-engine above_ | - | - | Gateway merged into policy-engine. All routes now served by policy-engine. | - |
|
||||
| reachgraph.stella-ops.local | reachgraph-web | /api/v1/reachability, /reachgraph | D | Developer + Test Automation (Wave D) | Migrate API prefix first, then root compatibility path. | Route type revert + `REACHGRAPH_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| remediation.stella-ops.local | — (not in compose snapshot) | — (no ReverseProxy route in 2026-02-21 snapshot) | C | Developer + Test Automation (Wave C) | `StellaOps.Remediation.WebService` exists, but router/compose mapping is missing. Add explicit remediation API route inventory and then migrate to Microservice route type in control-plane wave. | Missing rollback key; add `REMEDIATION_ROUTER_ENABLED` once route is added. |
|
||||
| registry-token.stella-ops.local | registry-token | /registryTokenservice | A | Developer + Test Automation (Wave A) | Migrate compatibility route with token flow validation in Wave A. | Route type revert + `REGISTRY_TOKEN_ROUTER_ENABLED=false` (RMW-03). |
|
||||
|
||||
@@ -40,8 +40,9 @@ Concise descriptions of every top-level component under `src/`, summarising the
|
||||
- **TimelineIndexer** — Builds timelines of evidence/events for forensics and audit tooling (`docs/modules/timeline-indexer/guides/timeline.md`).
|
||||
|
||||
## Notification & UI
|
||||
- **Notifier** — Current notifications studio (WebService + Worker under `src/Notifier/StellaOps.Notifier`) delivering rule evaluation, digests, incidents, and channel plug-ins. Built on the shared `StellaOps.Notify.*` libraries; see `docs/modules/notify/overview.md` and `src/Notifier/StellaOps.Notifier/docs/NOTIFY-SVC-38-001-FOUNDATIONS.md`.
|
||||
- **Notify (shared libraries / archival hosts)** — The former `StellaOps.Notify.WebService|Worker` hosts were archived on 2025-10-26. The directory now provides the reusable engine, storage, queue, and connector plug-ins that Notifier composes. Legacy guidance in `docs/modules/notify/architecture.md` remains as migration context until the Notifications Studio docs fully supersede it.
|
||||
- **Notify** — Unified notification service (`src/Notify/StellaOps.Notify.WebService`) hosting both v1 channel/rule/template APIs and merged v2 Notifier endpoints (escalation, incident, simulation, storm-breaker, etc.). The `notifier.stella-ops.local` hostname is a DNS alias on the `notify-web` container. Built on the shared `StellaOps.Notify.*` libraries; see `docs/modules/notify/architecture.md`.
|
||||
- **Notifier Worker** — Delivery engine (`src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker`) subscribing to the platform event bus, evaluating rules, rendering payloads, and invoking channel connectors. Remains a separate container.
|
||||
|
||||
- **UI** — Angular console surfacing scans, policy authoring, VEX evidence, runtime posture, and admin flows. Talks to Web gateway, Authority, Policy, Concelier, Scheduler, Notify, etc. (`docs/modules/ui/architecture.md`).
|
||||
- **DevPortal** — Developer onboarding portal consuming Api definitions, CLI samples, and Authority auth flows (`docs/modules/devops/architecture.md`, dev portal sections).
|
||||
|
||||
|
||||
@@ -82,8 +82,8 @@ The solution contains **46 top-level modules** in `src/`. The architecture docum
|
||||
| Module | Path | Purpose | WebService | Worker | Storage |
|
||||
|--------|------|---------|------------|--------|---------|
|
||||
| **JobEngine** | `src/JobEngine/` | Workflow orchestration, scheduling, task execution, pack registry. Includes Scheduler, TaskRunner, PacksRegistry (Sprint 208); renamed from Orchestrator (Sprint 221). | Yes | Yes | PostgreSQL (`orchestrator`, `scheduler`) |
|
||||
| **Notify** | `src/Notify/` | Notification toolkit (Email, Slack, Teams, Webhooks) - shared libraries. Boundary preserved with Notifier (Sprint 209). | Library | N/A | N/A |
|
||||
| **Notifier** | `src/Notifier/` | Notifications Studio host (WebService + Worker). Boundary preserved with Notify (Sprint 209). | Yes | Yes | PostgreSQL (`notify`) |
|
||||
| **Notify** | `src/Notify/` | Unified notification service (shared libraries + merged WebService). Notifier WebService merged into Notify WebService (2026-04-08). | Yes | N/A | PostgreSQL (`notify`) |
|
||||
| **Notifier** | `src/Notifier/` | Notifier Worker (delivery engine). WebService merged into Notify (2026-04-08). | N/A | Yes | PostgreSQL (`notify`) |
|
||||
| **Timeline** | `src/Timeline/` | Timeline query, event indexing, and replay. Includes TimelineIndexer (Sprint 210). | Yes | No | PostgreSQL |
|
||||
| **Replay** | `src/Replay/` | Deterministic replay engine | Yes | No | PostgreSQL |
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ This page focuses on deterministic slot/port allocation and may include legacy o
|
||||
| 12 | 10120 | 10121 | VexLens | `vexlens.stella-ops.local` | `src/VexLens/StellaOps.VexLens.WebService` | `STELLAOPS_VEXLENS_URL` |
|
||||
| 13 | 10130 | 10131 | VulnExplorer | `vulnexplorer.stella-ops.local` | `src/Findings/StellaOps.VulnExplorer.Api` | `STELLAOPS_VULNEXPLORER_URL` |
|
||||
| 14 | 10140 | 10141 | Policy Engine | `policy-engine.stella-ops.local` | `src/Policy/StellaOps.Policy.Engine` | `STELLAOPS_POLICY_ENGINE_URL` |
|
||||
| 15 | 10150 | 10151 | Policy Gateway | `policy-gateway.stella-ops.local` | `src/Policy/StellaOps.Policy.Gateway` | `STELLAOPS_POLICY_GATEWAY_URL` |
|
||||
| 15 | 10150 | 10151 | ~~Policy Gateway~~ (merged into Policy Engine, Slot 14) | `policy-gateway.stella-ops.local` -> `policy-engine.stella-ops.local` | _removed_ | _removed_ |
|
||||
| 16 | 10160 | 10161 | RiskEngine | `riskengine.stella-ops.local` | `src/Findings/StellaOps.RiskEngine.WebService` | `STELLAOPS_RISKENGINE_URL` |
|
||||
| 17 | 10170 | 10171 | Orchestrator | `jobengine.stella-ops.local` | `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService` | `STELLAOPS_JOBENGINE_URL` |
|
||||
| 18 | 10180 | 10181 | TaskRunner | `taskrunner.stella-ops.local` | `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService` | `STELLAOPS_TASKRUNNER_URL` |
|
||||
@@ -43,7 +43,7 @@ This page focuses on deterministic slot/port allocation and may include legacy o
|
||||
| 25 | 10250 | 10251 | Findings Ledger | `findings.stella-ops.local` | `src/Findings/StellaOps.Findings.Ledger.WebService` | `STELLAOPS_FINDINGS_LEDGER_URL` |
|
||||
| 26 | 10260 | 10261 | Doctor | `doctor.stella-ops.local` | `src/Doctor/StellaOps.Doctor.WebService` | `STELLAOPS_DOCTOR_URL` |
|
||||
| 27 | 10270 | 10271 | OpsMemory | `opsmemory.stella-ops.local` | `src/AdvisoryAI/StellaOps.OpsMemory.WebService` | `STELLAOPS_OPSMEMORY_URL` |
|
||||
| 28 | 10280 | 10281 | Notifier | `notifier.stella-ops.local` | `src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService` | `STELLAOPS_NOTIFIER_URL` |
|
||||
| 28 | 10280 | 10281 | _(Notifier WebService merged into Notify)_ | `notifier.stella-ops.local` (alias) | _(see Notify)_ | `STELLAOPS_NOTIFIER_URL` |
|
||||
| 29 | 10290 | 10291 | Notify | `notify.stella-ops.local` | `src/Notify/StellaOps.Notify.WebService` | `STELLAOPS_NOTIFY_URL` |
|
||||
| 30 | 10300 | 10301 | Signer | `signer.stella-ops.local` | `src/Attestor/StellaOps.Signer/StellaOps.Signer.WebService` | `STELLAOPS_SIGNER_URL` |
|
||||
| 31 | 10310 | 10311 | SmRemote | `smremote.stella-ops.local` | `src/SmRemote/StellaOps.SmRemote.Service` | `STELLAOPS_SMREMOTE_URL` |
|
||||
@@ -125,7 +125,7 @@ Add the following to your hosts file (`C:\Windows\System32\drivers\etc\hosts` on
|
||||
127.1.0.12 vexlens.stella-ops.local
|
||||
127.1.0.13 vulnexplorer.stella-ops.local
|
||||
127.1.0.14 policy-engine.stella-ops.local
|
||||
127.1.0.15 policy-gateway.stella-ops.local
|
||||
127.1.0.14 policy-gateway.stella-ops.local # alias -> policy-engine (merged)
|
||||
127.1.0.16 riskengine.stella-ops.local
|
||||
127.1.0.17 jobengine.stella-ops.local
|
||||
127.1.0.18 taskrunner.stella-ops.local
|
||||
|
||||
@@ -36,8 +36,7 @@ This page is the source-of-truth inventory for Stella Ops `*.WebService` runtime
|
||||
| JobEngine | PacksRegistry | `packsregistry.stella-ops.local` | Pack/provenance/attestation registry APIs. | postgres + seed-fs object payloads | `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService` | `src/JobEngine` |
|
||||
| JobEngine | Scheduler | `scheduler.stella-ops.local` | Schedule/run planning and event APIs. | postgres | `src/JobEngine/StellaOps.Scheduler.WebService` | `src/JobEngine` |
|
||||
| JobEngine | TaskRunner | `taskrunner.stella-ops.local` | Task execution, run state/log, approval, and artifact APIs. | postgres + seed-fs object payloads | `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService` | `src/JobEngine` |
|
||||
| Notifier | Notifier | `notifier.stella-ops.local` | Escalation and incident notification APIs. | postgres | `src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService` | `src/Notifier` |
|
||||
| Notify | Notify | `notify.stella-ops.local` | Notification rule/channel/template and delivery APIs. | postgres | `src/Notify/StellaOps.Notify.WebService` | `src/Notify` |
|
||||
| Notify | Notify | `notify.stella-ops.local` | Notification rule/channel/template, delivery, escalation, incident, and simulation APIs (merged from Notifier). | postgres | `src/Notify/StellaOps.Notify.WebService` | `src/Notify` |
|
||||
| Platform | Platform | `platform.stella-ops.local` | Console aggregation, setup, admin, and read-model APIs. | postgres | `src/Platform/StellaOps.Platform.WebService` | `src/Platform` |
|
||||
| ReachGraph | ReachGraph | `reachgraph.stella-ops.local` | Reachability graph and CVE mapping APIs. | postgres | `src/ReachGraph/StellaOps.ReachGraph.WebService` | `src/ReachGraph` |
|
||||
| Remediation | Remediation | `remediation.stella-ops.local` | Remediation source, registry, and match APIs. | postgres | `src/Remediation/StellaOps.Remediation.WebService` | `src/Remediation` |
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace StellaOps.Notify.WebService.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// Named authorization policy constants for Notifier API endpoints.
|
||||
/// These correspond to scopes defined in <see cref="StellaOps.Auth.Abstractions.StellaOpsScopes"/>.
|
||||
/// </summary>
|
||||
public static class NotifierPolicies
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only access to channels, rules, templates, delivery history, and observability.
|
||||
/// Maps to scope: notify.viewer
|
||||
/// </summary>
|
||||
public const string NotifyViewer = "notify.viewer";
|
||||
|
||||
/// <summary>
|
||||
/// Rule management, channel operations, template authoring, delivery actions, and simulation.
|
||||
/// Maps to scope: notify.operator
|
||||
/// </summary>
|
||||
public const string NotifyOperator = "notify.operator";
|
||||
|
||||
/// <summary>
|
||||
/// Administrative control over security configuration, signing key rotation,
|
||||
/// tenant isolation grants, retention policies, and platform-wide settings.
|
||||
/// Maps to scope: notify.admin
|
||||
/// </summary>
|
||||
public const string NotifyAdmin = "notify.admin";
|
||||
|
||||
/// <summary>
|
||||
/// Escalation-specific actions: starting, escalating, stopping incidents and
|
||||
/// managing escalation policies and on-call schedules.
|
||||
/// Maps to scope: notify.escalate
|
||||
/// </summary>
|
||||
public const string NotifyEscalate = "notify.escalate";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
public sealed record AttestationEventRequest
|
||||
{
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kind, e.g. authority.keys.rotated, authority.keys.revoked, attestor.transparency.anomaly.
|
||||
/// </summary>
|
||||
public string? Kind { get; init; }
|
||||
|
||||
public string? Actor { get; init; }
|
||||
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
public JsonObject? Payload { get; init; }
|
||||
|
||||
public IDictionary<string, string>? Attributes { get; init; }
|
||||
|
||||
public string? ResumeToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request for creating or updating a channel.
|
||||
/// </summary>
|
||||
public sealed record ChannelUpsertRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public NotifyChannelType? Type { get; init; }
|
||||
public string? Endpoint { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public string? SecretRef { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to enqueue a dead-letter entry.
|
||||
/// </summary>
|
||||
public sealed record EnqueueDeadLetterRequest
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string EventId { get; init; }
|
||||
public required string ChannelId { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string FailureReason { get; init; }
|
||||
public string? FailureDetails { get; init; }
|
||||
public int AttemptCount { get; init; }
|
||||
public DateTimeOffset? LastAttemptAt { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
public string? OriginalPayload { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for dead-letter entry operations.
|
||||
/// </summary>
|
||||
public sealed record DeadLetterEntryResponse
|
||||
{
|
||||
public required string EntryId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string EventId { get; init; }
|
||||
public required string ChannelId { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string FailureReason { get; init; }
|
||||
public string? FailureDetails { get; init; }
|
||||
public required int AttemptCount { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastAttemptAt { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public int RetryCount { get; init; }
|
||||
public DateTimeOffset? LastRetryAt { get; init; }
|
||||
public string? Resolution { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list dead-letter entries.
|
||||
/// </summary>
|
||||
public sealed record ListDeadLetterRequest
|
||||
{
|
||||
public string? Status { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public string? ChannelType { get; init; }
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
public int Limit { get; init; } = 50;
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing dead-letter entries.
|
||||
/// </summary>
|
||||
public sealed record ListDeadLetterResponse
|
||||
{
|
||||
public required IReadOnlyList<DeadLetterEntryResponse> Entries { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to retry dead-letter entries.
|
||||
/// </summary>
|
||||
public sealed record RetryDeadLetterRequest
|
||||
{
|
||||
public required IReadOnlyList<string> EntryIds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for retry operations.
|
||||
/// </summary>
|
||||
public sealed record RetryDeadLetterResponse
|
||||
{
|
||||
public required IReadOnlyList<DeadLetterRetryResultItem> Results { get; init; }
|
||||
public required int SuccessCount { get; init; }
|
||||
public required int FailureCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual retry result.
|
||||
/// </summary>
|
||||
public sealed record DeadLetterRetryResultItem
|
||||
{
|
||||
public required string EntryId { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public DateTimeOffset? RetriedAt { get; init; }
|
||||
public string? NewDeliveryId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve a dead-letter entry.
|
||||
/// </summary>
|
||||
public sealed record ResolveDeadLetterRequest
|
||||
{
|
||||
public required string Resolution { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for dead-letter statistics.
|
||||
/// </summary>
|
||||
public sealed record DeadLetterStatsResponse
|
||||
{
|
||||
public required int TotalCount { get; init; }
|
||||
public required int PendingCount { get; init; }
|
||||
public required int RetryingCount { get; init; }
|
||||
public required int RetriedCount { get; init; }
|
||||
public required int ResolvedCount { get; init; }
|
||||
public required int ExhaustedCount { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> ByChannel { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> ByReason { get; init; }
|
||||
public DateTimeOffset? OldestEntryAt { get; init; }
|
||||
public DateTimeOffset? NewestEntryAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to purge expired entries.
|
||||
/// </summary>
|
||||
public sealed record PurgeDeadLetterRequest
|
||||
{
|
||||
public int MaxAgeDays { get; init; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for purge operation.
|
||||
/// </summary>
|
||||
public sealed record PurgeDeadLetterResponse
|
||||
{
|
||||
public required int PurgedCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// API contracts for delivery history and retry endpoints.
|
||||
/// Sprint: SPRINT_20251229_018b_FE_notification_delivery_audit
|
||||
/// Task: NOTIFY-016
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Response for delivery listing.
|
||||
/// </summary>
|
||||
public sealed record DeliveryListResponse
|
||||
{
|
||||
public required IReadOnlyList<DeliveryResponse> Items { get; init; }
|
||||
public required int Total { get; init; }
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual delivery response.
|
||||
/// </summary>
|
||||
public sealed record DeliveryResponse
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string RuleId { get; init; }
|
||||
public required string ChannelId { get; init; }
|
||||
public string? EventId { get; init; }
|
||||
public string? EventKind { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required IReadOnlyList<DeliveryAttemptResponse> Attempts { get; init; }
|
||||
public required int RetryCount { get; init; }
|
||||
public string? NextRetryAt { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public required string CreatedAt { get; init; }
|
||||
public string? SentAt { get; init; }
|
||||
public string? CompletedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual delivery attempt response.
|
||||
/// </summary>
|
||||
public sealed record DeliveryAttemptResponse
|
||||
{
|
||||
public required int AttemptNumber { get; init; }
|
||||
public required string Timestamp { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public int? StatusCode { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public int? ResponseTimeMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to retry a failed delivery.
|
||||
/// </summary>
|
||||
public sealed record DeliveryRetryRequest
|
||||
{
|
||||
public string? ForceChannel { get; init; }
|
||||
public bool BypassThrottle { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from retry operation.
|
||||
/// </summary>
|
||||
public sealed record DeliveryRetryResponse
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required bool Retried { get; init; }
|
||||
public required int NewAttemptNumber { get; init; }
|
||||
public required string ScheduledAt { get; init; }
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery statistics response.
|
||||
/// </summary>
|
||||
public sealed record DeliveryStatsResponse
|
||||
{
|
||||
public required int TotalSent { get; init; }
|
||||
public required int TotalFailed { get; init; }
|
||||
public required int TotalThrottled { get; init; }
|
||||
public required int TotalPending { get; init; }
|
||||
public required double AvgDeliveryTimeMs { get; init; }
|
||||
public required double SuccessRate { get; init; }
|
||||
public required string Period { get; init; }
|
||||
public required IReadOnlyDictionary<string, ChannelStatsResponse> ByChannel { get; init; }
|
||||
public required IReadOnlyDictionary<string, EventKindStatsResponse> ByEventKind { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics by channel.
|
||||
/// </summary>
|
||||
public sealed record ChannelStatsResponse
|
||||
{
|
||||
public required int Sent { get; init; }
|
||||
public required int Failed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics by event kind.
|
||||
/// </summary>
|
||||
public sealed record EventKindStatsResponse
|
||||
{
|
||||
public required int Sent { get; init; }
|
||||
public required int Failed { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create/update an escalation policy.
|
||||
/// </summary>
|
||||
public sealed record EscalationPolicyUpsertRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public ImmutableArray<EscalationLevelRequest> Levels { get; init; }
|
||||
public int? RepeatCount { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escalation level configuration.
|
||||
/// </summary>
|
||||
public sealed record EscalationLevelRequest
|
||||
{
|
||||
public int Order { get; init; }
|
||||
public TimeSpan EscalateAfter { get; init; }
|
||||
public ImmutableArray<EscalationTargetRequest> Targets { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escalation target configuration.
|
||||
/// </summary>
|
||||
public sealed record EscalationTargetRequest
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public string? TargetId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to start an escalation for an incident.
|
||||
/// </summary>
|
||||
public sealed record StartEscalationRequest
|
||||
{
|
||||
public string? IncidentId { get; init; }
|
||||
public string? PolicyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to acknowledge an escalation.
|
||||
/// </summary>
|
||||
public sealed record AcknowledgeEscalationRequest
|
||||
{
|
||||
public string? StateIdOrIncidentId { get; init; }
|
||||
public string? AcknowledgedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve an escalation.
|
||||
/// </summary>
|
||||
public sealed record ResolveEscalationRequest
|
||||
{
|
||||
public string? StateIdOrIncidentId { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create/update an on-call schedule.
|
||||
/// </summary>
|
||||
public sealed record OnCallScheduleUpsertRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? TimeZone { get; init; }
|
||||
public ImmutableArray<OnCallLayerRequest> Layers { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On-call layer configuration.
|
||||
/// </summary>
|
||||
public sealed record OnCallLayerRequest
|
||||
{
|
||||
public string? LayerId { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public int Priority { get; init; }
|
||||
public DateTimeOffset RotationStartsAt { get; init; }
|
||||
public TimeSpan RotationInterval { get; init; }
|
||||
public ImmutableArray<OnCallParticipantRequest> Participants { get; init; }
|
||||
public OnCallRestrictionRequest? Restrictions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On-call participant configuration.
|
||||
/// </summary>
|
||||
public sealed record OnCallParticipantRequest
|
||||
{
|
||||
public string? UserId { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public ImmutableArray<ContactMethodRequest> ContactMethods { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact method configuration.
|
||||
/// </summary>
|
||||
public sealed record ContactMethodRequest
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public string? Address { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On-call restriction configuration.
|
||||
/// </summary>
|
||||
public sealed record OnCallRestrictionRequest
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public ImmutableArray<TimeRangeRequest> TimeRanges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time range for on-call restrictions.
|
||||
/// </summary>
|
||||
public sealed record TimeRangeRequest
|
||||
{
|
||||
public TimeOnly StartTime { get; init; }
|
||||
public TimeOnly EndTime { get; init; }
|
||||
public DayOfWeek? DayOfWeek { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to add an on-call override.
|
||||
/// </summary>
|
||||
public sealed record OnCallOverrideRequest
|
||||
{
|
||||
public string? UserId { get; init; }
|
||||
public DateTimeOffset StartsAt { get; init; }
|
||||
public DateTimeOffset EndsAt { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve who is on-call.
|
||||
/// </summary>
|
||||
public sealed record OnCallResolveRequest
|
||||
{
|
||||
public DateTimeOffset? EvaluationTime { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Incident list query parameters.
|
||||
/// </summary>
|
||||
public sealed record IncidentListQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by status (open, acknowledged, resolved).
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by event kind prefix.
|
||||
/// </summary>
|
||||
public string? EventKindPrefix { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter incidents after this timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter incidents before this timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of results.
|
||||
/// </summary>
|
||||
public int? Limit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cursor for pagination.
|
||||
/// </summary>
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incident response DTO.
|
||||
/// </summary>
|
||||
public sealed record IncidentResponse
|
||||
{
|
||||
public required string IncidentId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string EventKind { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required int EventCount { get; init; }
|
||||
public required DateTimeOffset FirstOccurrence { get; init; }
|
||||
public required DateTimeOffset LastOccurrence { get; init; }
|
||||
public string? AcknowledgedBy { get; init; }
|
||||
public DateTimeOffset? AcknowledgedAt { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
public List<string>? Labels { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incident list response with pagination.
|
||||
/// </summary>
|
||||
public sealed record IncidentListResponse
|
||||
{
|
||||
public required List<IncidentResponse> Incidents { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to acknowledge an incident.
|
||||
/// </summary>
|
||||
public sealed record IncidentAckRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Actor performing the acknowledgement.
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment.
|
||||
/// </summary>
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve an incident.
|
||||
/// </summary>
|
||||
public sealed record IncidentResolveRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Actor resolving the incident.
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolution reason.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment.
|
||||
/// </summary>
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery history item for an incident.
|
||||
/// </summary>
|
||||
public sealed record DeliveryHistoryItem
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string ChannelName { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public int Attempts { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create/update a localization bundle.
|
||||
/// </summary>
|
||||
public sealed record LocalizationBundleUpsertRequest
|
||||
{
|
||||
public string? Locale { get; init; }
|
||||
public string? BundleKey { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Strings { get; init; }
|
||||
public bool? IsDefault { get; init; }
|
||||
public string? ParentLocale { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve localized strings.
|
||||
/// </summary>
|
||||
public sealed record LocalizationResolveRequest
|
||||
{
|
||||
public string? BundleKey { get; init; }
|
||||
public IReadOnlyList<string>? StringKeys { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing resolved localized strings.
|
||||
/// </summary>
|
||||
public sealed record LocalizationResolveResponse
|
||||
{
|
||||
public required IReadOnlyDictionary<string, LocalizedStringResult> Strings { get; init; }
|
||||
public required string RequestedLocale { get; init; }
|
||||
public required IReadOnlyList<string> FallbackChain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for a single localized string.
|
||||
/// </summary>
|
||||
public sealed record LocalizedStringResult
|
||||
{
|
||||
public required string Value { get; init; }
|
||||
public required string ResolvedLocale { get; init; }
|
||||
public required bool UsedFallback { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request payload for acknowledging a pack approval decision.
|
||||
/// </summary>
|
||||
public sealed class PackApprovalAckRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Acknowledgement token from the notification.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[JsonPropertyName("ackToken")]
|
||||
public string AckToken { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Approval decision: "approved" or "rejected".
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public string? Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment for audit trail.
|
||||
/// </summary>
|
||||
[JsonPropertyName("comment")]
|
||||
public string? Comment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity acknowledging the approval.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request payload for pack approval events from Task Runner.
|
||||
/// See: docs/notifications/pack-approvals-contract.md
|
||||
/// </summary>
|
||||
public sealed class PackApprovalRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique event identifier for deduplication.
|
||||
/// </summary>
|
||||
[JsonPropertyName("eventId")]
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event timestamp in UTC (ISO 8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuedAt")]
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event type: pack.approval.requested, pack.approval.updated, pack.policy.hold, pack.policy.released.
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package identifier in PURL format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy metadata (id and version).
|
||||
/// </summary>
|
||||
[JsonPropertyName("policy")]
|
||||
public PackApprovalPolicy? Policy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current approval state: pending, approved, rejected, hold, expired.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public string Decision { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Identity that triggered the event.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public string Actor { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Opaque token for Task Runner resume flow. Echoed in X-Resume-After header.
|
||||
/// </summary>
|
||||
[JsonPropertyName("resumeToken")]
|
||||
public string? ResumeToken { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary for notifications.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom key-value metadata labels.
|
||||
/// </summary>
|
||||
[JsonPropertyName("labels")]
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy metadata associated with a pack approval.
|
||||
/// </summary>
|
||||
public sealed class PackApprovalPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a quiet hours schedule.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursUpsertRequest
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string CronExpression { get; init; }
|
||||
public required TimeSpan Duration { get; init; }
|
||||
public required string TimeZone { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a maintenance window.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceWindowUpsertRequest
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required DateTimeOffset StartsAt { get; init; }
|
||||
public required DateTimeOffset EndsAt { get; init; }
|
||||
public bool? SuppressNotifications { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public ImmutableArray<string> ChannelIds { get; init; } = [];
|
||||
public ImmutableArray<string> RuleIds { get; init; } = [];
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a throttle configuration.
|
||||
/// </summary>
|
||||
public sealed class ThrottleConfigUpsertRequest
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required TimeSpan DefaultWindow { get; init; }
|
||||
public int? MaxNotificationsPerWindow { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public bool? IsDefault { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an operator override.
|
||||
/// </summary>
|
||||
public sealed class OperatorOverrideCreateRequest
|
||||
{
|
||||
public required string OverrideType { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public string? RuleId { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Retention policy configuration request/response.
|
||||
/// </summary>
|
||||
public sealed record RetentionPolicyDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Retention period for delivery records in days.
|
||||
/// </summary>
|
||||
public int DeliveryRetentionDays { get; init; } = 90;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for audit log entries in days.
|
||||
/// </summary>
|
||||
public int AuditRetentionDays { get; init; } = 365;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for dead-letter entries in days.
|
||||
/// </summary>
|
||||
public int DeadLetterRetentionDays { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for storm tracking data in days.
|
||||
/// </summary>
|
||||
public int StormDataRetentionDays { get; init; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for inbox messages in days.
|
||||
/// </summary>
|
||||
public int InboxRetentionDays { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for event history in days.
|
||||
/// </summary>
|
||||
public int EventHistoryRetentionDays { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether automatic cleanup is enabled.
|
||||
/// </summary>
|
||||
public bool AutoCleanupEnabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Cron expression for automatic cleanup schedule.
|
||||
/// </summary>
|
||||
public string CleanupSchedule { get; init; } = "0 2 * * *";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum records to delete per cleanup run.
|
||||
/// </summary>
|
||||
public int MaxDeletesPerRun { get; init; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to keep resolved/acknowledged deliveries longer.
|
||||
/// </summary>
|
||||
public bool ExtendResolvedRetention { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Extension multiplier for resolved items.
|
||||
/// </summary>
|
||||
public double ResolvedRetentionMultiplier { get; init; } = 2.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update retention policy.
|
||||
/// </summary>
|
||||
public sealed record UpdateRetentionPolicyRequest
|
||||
{
|
||||
public required RetentionPolicyDto Policy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for retention policy operations.
|
||||
/// </summary>
|
||||
public sealed record RetentionPolicyResponse
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required RetentionPolicyDto Policy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for retention cleanup execution.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupResponse
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public required DateTimeOffset ExecutedAt { get; init; }
|
||||
public required double DurationMs { get; init; }
|
||||
public required RetentionCleanupCountsDto Counts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleanup counts DTO.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupCountsDto
|
||||
{
|
||||
public int Deliveries { get; init; }
|
||||
public int AuditEntries { get; init; }
|
||||
public int DeadLetterEntries { get; init; }
|
||||
public int StormData { get; init; }
|
||||
public int InboxMessages { get; init; }
|
||||
public int Events { get; init; }
|
||||
public int Total { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for cleanup preview.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupPreviewResponse
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required DateTimeOffset PreviewedAt { get; init; }
|
||||
public required RetentionCleanupCountsDto EstimatedCounts { get; init; }
|
||||
public required RetentionPolicyDto PolicyApplied { get; init; }
|
||||
public required IReadOnlyDictionary<string, DateTimeOffset> CutoffDates { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for last cleanup execution.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupExecutionResponse
|
||||
{
|
||||
public required string ExecutionId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public RetentionCleanupCountsDto? Counts { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for cleanup all tenants.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupAllResponse
|
||||
{
|
||||
public required IReadOnlyList<RetentionCleanupResponse> Results { get; init; }
|
||||
public required int SuccessCount { get; init; }
|
||||
public required int FailureCount { get; init; }
|
||||
public required int TotalDeleted { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
public sealed record RiskEventRequest
|
||||
{
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// risk.profile.severity.changed | risk.profile.published | risk.profile.deprecated | risk.profile.thresholds.changed
|
||||
/// </summary>
|
||||
public string? Kind { get; init; }
|
||||
|
||||
public string? Actor { get; init; }
|
||||
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
public JsonObject? Payload { get; init; }
|
||||
|
||||
public IDictionary<string, string>? Attributes { get; init; }
|
||||
|
||||
public string? ResumeToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a notification rule.
|
||||
/// </summary>
|
||||
public sealed record RuleCreateRequest
|
||||
{
|
||||
public required string RuleId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public bool Enabled { get; init; } = true;
|
||||
public required RuleMatchRequest Match { get; init; }
|
||||
public required List<RuleActionRequest> Actions { get; init; }
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update an existing rule.
|
||||
/// </summary>
|
||||
public sealed record RuleUpdateRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public RuleMatchRequest? Match { get; init; }
|
||||
public List<RuleActionRequest>? Actions { get; init; }
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to upsert a rule (v2 API).
|
||||
/// </summary>
|
||||
public sealed record RuleUpsertRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public RuleMatchRequest? Match { get; init; }
|
||||
public List<RuleActionRequest>? Actions { get; init; }
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule match criteria.
|
||||
/// </summary>
|
||||
public sealed record RuleMatchRequest
|
||||
{
|
||||
public List<string>? EventKinds { get; init; }
|
||||
public List<string>? Namespaces { get; init; }
|
||||
public List<string>? Repositories { get; init; }
|
||||
public List<string>? Digests { get; init; }
|
||||
public List<string>? Labels { get; init; }
|
||||
public List<string>? ComponentPurls { get; init; }
|
||||
public string? MinSeverity { get; init; }
|
||||
public List<string>? Verdicts { get; init; }
|
||||
public bool? KevOnly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule action configuration.
|
||||
/// </summary>
|
||||
public sealed record RuleActionRequest
|
||||
{
|
||||
public required string ActionId { get; init; }
|
||||
public required string Channel { get; init; }
|
||||
public string? Template { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public string? Throttle { get; init; } // ISO 8601 duration
|
||||
public string? Locale { get; init; }
|
||||
public bool Enabled { get; init; } = true;
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule response DTO.
|
||||
/// </summary>
|
||||
public sealed record RuleResponse
|
||||
{
|
||||
public required string RuleId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required bool Enabled { get; init; }
|
||||
public required RuleMatchResponse Match { get; init; }
|
||||
public required List<RuleActionResponse> Actions { get; init; }
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule match response.
|
||||
/// </summary>
|
||||
public sealed record RuleMatchResponse
|
||||
{
|
||||
public List<string> EventKinds { get; init; } = [];
|
||||
public List<string> Namespaces { get; init; } = [];
|
||||
public List<string> Repositories { get; init; } = [];
|
||||
public List<string> Digests { get; init; } = [];
|
||||
public List<string> Labels { get; init; } = [];
|
||||
public List<string> ComponentPurls { get; init; } = [];
|
||||
public string? MinSeverity { get; init; }
|
||||
public List<string> Verdicts { get; init; } = [];
|
||||
public bool KevOnly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule action response.
|
||||
/// </summary>
|
||||
public sealed record RuleActionResponse
|
||||
{
|
||||
public required string ActionId { get; init; }
|
||||
public required string Channel { get; init; }
|
||||
public string? Template { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public string? Throttle { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
public required bool Enabled { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to acknowledge a notification via signed token.
|
||||
/// </summary>
|
||||
public sealed record AckRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional comment for the acknowledgement.
|
||||
/// </summary>
|
||||
public string? Comment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata to include with the acknowledgement.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from acknowledging a notification.
|
||||
/// </summary>
|
||||
public sealed record AckResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the acknowledgement was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The delivery ID that was acknowledged.
|
||||
/// </summary>
|
||||
public string? DeliveryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The action that was performed.
|
||||
/// </summary>
|
||||
public string? Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the acknowledgement was processed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ProcessedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if unsuccessful.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an acknowledgement token.
|
||||
/// </summary>
|
||||
public sealed record CreateAckTokenRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The delivery ID to create an ack token for.
|
||||
/// </summary>
|
||||
public string? DeliveryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The action to acknowledge (e.g., "ack", "resolve", "escalate").
|
||||
/// </summary>
|
||||
public string? Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional expiration in hours. Default: 168 (7 days).
|
||||
/// </summary>
|
||||
public int? ExpirationHours { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata to embed in the token.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing the created ack token.
|
||||
/// </summary>
|
||||
public sealed record CreateAckTokenResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The signed token string.
|
||||
/// </summary>
|
||||
public required string Token { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The full acknowledgement URL.
|
||||
/// </summary>
|
||||
public required string AckUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the token expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify an ack token.
|
||||
/// </summary>
|
||||
public sealed record VerifyAckTokenRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The token to verify.
|
||||
/// </summary>
|
||||
public string? Token { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from token verification.
|
||||
/// </summary>
|
||||
public sealed record VerifyAckTokenResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the token is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The delivery ID embedded in the token.
|
||||
/// </summary>
|
||||
public string? DeliveryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The action embedded in the token.
|
||||
/// </summary>
|
||||
public string? Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the token expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Failure reason if invalid.
|
||||
/// </summary>
|
||||
public string? FailureReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to validate HTML content.
|
||||
/// </summary>
|
||||
public sealed record ValidateHtmlRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The HTML content to validate.
|
||||
/// </summary>
|
||||
public string? Html { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from HTML validation.
|
||||
/// </summary>
|
||||
public sealed record ValidateHtmlResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the HTML is safe.
|
||||
/// </summary>
|
||||
public required bool IsSafe { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of security issues found.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<HtmlIssue> Issues { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about the HTML content.
|
||||
/// </summary>
|
||||
public HtmlStats? Stats { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An HTML security issue.
|
||||
/// </summary>
|
||||
public sealed record HtmlIssue
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of issue.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the issue.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The element name if applicable.
|
||||
/// </summary>
|
||||
public string? Element { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The attribute name if applicable.
|
||||
/// </summary>
|
||||
public string? Attribute { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTML content statistics.
|
||||
/// </summary>
|
||||
public sealed record HtmlStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Total character count.
|
||||
/// </summary>
|
||||
public int CharacterCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of HTML elements.
|
||||
/// </summary>
|
||||
public int ElementCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum nesting depth.
|
||||
/// </summary>
|
||||
public int MaxDepth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of links.
|
||||
/// </summary>
|
||||
public int LinkCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of images.
|
||||
/// </summary>
|
||||
public int ImageCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to sanitize HTML content.
|
||||
/// </summary>
|
||||
public sealed record SanitizeHtmlRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The HTML content to sanitize.
|
||||
/// </summary>
|
||||
public string? Html { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow data: URLs. Default: false.
|
||||
/// </summary>
|
||||
public bool AllowDataUrls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional tags to allow.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AdditionalAllowedTags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing sanitized HTML.
|
||||
/// </summary>
|
||||
public sealed record SanitizeHtmlResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The sanitized HTML content.
|
||||
/// </summary>
|
||||
public required string SanitizedHtml { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether any changes were made.
|
||||
/// </summary>
|
||||
public required bool WasModified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to rotate a webhook secret.
|
||||
/// </summary>
|
||||
public sealed record RotateWebhookSecretRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The channel ID to rotate the secret for.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from webhook secret rotation.
|
||||
/// </summary>
|
||||
public sealed record RotateWebhookSecretResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether rotation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The new secret (only shown once).
|
||||
/// </summary>
|
||||
public string? NewSecret { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the new secret becomes active.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ActiveAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the old secret expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? OldSecretExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if unsuccessful.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to run a historical simulation against past events.
|
||||
/// </summary>
|
||||
public sealed class SimulationRunRequest
|
||||
{
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
public ImmutableArray<string> RuleIds { get; init; } = [];
|
||||
public ImmutableArray<string> EventKinds { get; init; } = [];
|
||||
public int MaxEvents { get; init; } = 1000;
|
||||
public bool IncludeNonMatches { get; init; } = true;
|
||||
public bool EvaluateThrottling { get; init; } = true;
|
||||
public bool EvaluateQuietHours { get; init; } = true;
|
||||
public DateTimeOffset? EvaluationTimestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to simulate a single event against current rules.
|
||||
/// </summary>
|
||||
public sealed class SimulateSingleEventRequest
|
||||
{
|
||||
public required JsonObject EventPayload { get; init; }
|
||||
public ImmutableArray<string> RuleIds { get; init; } = [];
|
||||
public DateTimeOffset? EvaluationTimestamp { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to preview a template rendering.
|
||||
/// </summary>
|
||||
public sealed record TemplatePreviewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Template ID to preview (mutually exclusive with TemplateBody).
|
||||
/// </summary>
|
||||
public string? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw template body to preview (mutually exclusive with TemplateId).
|
||||
/// </summary>
|
||||
public string? TemplateBody { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sample event payload for rendering.
|
||||
/// </summary>
|
||||
public JsonObject? SamplePayload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kind for context.
|
||||
/// </summary>
|
||||
public string? EventKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sample attributes.
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? SampleAttributes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output format override.
|
||||
/// </summary>
|
||||
public string? OutputFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include provenance links in preview output.
|
||||
/// </summary>
|
||||
public bool? IncludeProvenance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base URL for provenance links.
|
||||
/// </summary>
|
||||
public string? ProvenanceBaseUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional format override for rendering.
|
||||
/// </summary>
|
||||
public NotifyDeliveryFormat? FormatOverride { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from template preview.
|
||||
/// </summary>
|
||||
public sealed record TemplatePreviewResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Rendered body content.
|
||||
/// </summary>
|
||||
public required string RenderedBody { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rendered subject (if applicable).
|
||||
/// </summary>
|
||||
public string? RenderedSubject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash for deduplication.
|
||||
/// </summary>
|
||||
public required string BodyHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output format used.
|
||||
/// </summary>
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation warnings (if any).
|
||||
/// </summary>
|
||||
public List<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a template.
|
||||
/// </summary>
|
||||
public sealed record TemplateCreateRequest
|
||||
{
|
||||
public required string TemplateId { get; init; }
|
||||
public required string Key { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string Locale { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public string? RenderMode { get; init; }
|
||||
public string? Format { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to upsert a template (v2 API).
|
||||
/// </summary>
|
||||
public sealed record TemplateUpsertRequest
|
||||
{
|
||||
public required string Key { get; init; }
|
||||
public NotifyChannelType? ChannelType { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public NotifyTemplateRenderMode? RenderMode { get; init; }
|
||||
public NotifyDeliveryFormat? Format { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Template response DTO.
|
||||
/// </summary>
|
||||
public sealed record TemplateResponse
|
||||
{
|
||||
public required string TemplateId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Key { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string Locale { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public required string RenderMode { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Template list query parameters.
|
||||
/// </summary>
|
||||
public sealed record TemplateListQuery
|
||||
{
|
||||
public string? KeyPrefix { get; init; }
|
||||
public string? ChannelType { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
public int? Limit { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notify.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for escalation management.
|
||||
/// </summary>
|
||||
public static class EscalationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps escalation endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapEscalationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// Escalation Policies
|
||||
var policies = app.MapGroup("/api/v2/escalation-policies")
|
||||
.WithTags("Escalation Policies")
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
policies.MapGet("/", ListPoliciesAsync)
|
||||
.WithName("ListEscalationPolicies")
|
||||
.WithSummary("List escalation policies")
|
||||
.WithDescription(_t("notifier.escalation_policy.list_description"));
|
||||
|
||||
policies.MapGet("/{policyId}", GetPolicyAsync)
|
||||
.WithName("GetEscalationPolicy")
|
||||
.WithSummary("Get an escalation policy")
|
||||
.WithDescription(_t("notifier.escalation_policy.get_description"));
|
||||
|
||||
policies.MapPost("/", CreatePolicyAsync)
|
||||
.WithName("CreateEscalationPolicy")
|
||||
.WithSummary("Create an escalation policy")
|
||||
.WithDescription(_t("notifier.escalation_policy.create_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
policies.MapPut("/{policyId}", UpdatePolicyAsync)
|
||||
.WithName("UpdateEscalationPolicy")
|
||||
.WithSummary("Update an escalation policy")
|
||||
.WithDescription(_t("notifier.escalation_policy.update_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
policies.MapDelete("/{policyId}", DeletePolicyAsync)
|
||||
.WithName("DeleteEscalationPolicy")
|
||||
.WithSummary("Delete an escalation policy")
|
||||
.WithDescription(_t("notifier.escalation_policy.delete_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
// On-Call Schedules
|
||||
var schedules = app.MapGroup("/api/v2/oncall-schedules")
|
||||
.WithTags("On-Call Schedules")
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
schedules.MapGet("/", ListSchedulesAsync)
|
||||
.WithName("ListOnCallSchedules")
|
||||
.WithSummary("List on-call schedules")
|
||||
.WithDescription(_t("notifier.oncall_schedule.list_description"));
|
||||
|
||||
schedules.MapGet("/{scheduleId}", GetScheduleAsync)
|
||||
.WithName("GetOnCallSchedule")
|
||||
.WithSummary("Get an on-call schedule")
|
||||
.WithDescription(_t("notifier.oncall_schedule.get_description"));
|
||||
|
||||
schedules.MapPost("/", CreateScheduleAsync)
|
||||
.WithName("CreateOnCallSchedule")
|
||||
.WithSummary("Create an on-call schedule")
|
||||
.WithDescription(_t("notifier.oncall_schedule.create_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
schedules.MapPut("/{scheduleId}", UpdateScheduleAsync)
|
||||
.WithName("UpdateOnCallSchedule")
|
||||
.WithSummary("Update an on-call schedule")
|
||||
.WithDescription(_t("notifier.oncall_schedule.update_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
schedules.MapDelete("/{scheduleId}", DeleteScheduleAsync)
|
||||
.WithName("DeleteOnCallSchedule")
|
||||
.WithSummary("Delete an on-call schedule")
|
||||
.WithDescription(_t("notifier.oncall_schedule.delete_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
schedules.MapGet("/{scheduleId}/oncall", GetCurrentOnCallAsync)
|
||||
.WithName("GetCurrentOnCall")
|
||||
.WithSummary("Get current on-call users")
|
||||
.WithDescription(_t("notifier.oncall_schedule.current_description"));
|
||||
|
||||
schedules.MapPost("/{scheduleId}/overrides", CreateOverrideAsync)
|
||||
.WithName("CreateOnCallOverride")
|
||||
.WithSummary("Create an on-call override")
|
||||
.WithDescription(_t("notifier.oncall_schedule.create_override_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
schedules.MapDelete("/{scheduleId}/overrides/{overrideId}", DeleteOverrideAsync)
|
||||
.WithName("DeleteOnCallOverride")
|
||||
.WithSummary("Delete an on-call override")
|
||||
.WithDescription(_t("notifier.oncall_schedule.delete_override_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
// Active Escalations
|
||||
var escalations = app.MapGroup("/api/v2/escalations")
|
||||
.WithTags("Escalations")
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
escalations.MapGet("/", ListActiveEscalationsAsync)
|
||||
.WithName("ListActiveEscalations")
|
||||
.WithSummary("List active escalations")
|
||||
.WithDescription(_t("notifier.escalation.list_description"));
|
||||
|
||||
escalations.MapGet("/{incidentId}", GetEscalationStateAsync)
|
||||
.WithName("GetEscalationState")
|
||||
.WithSummary("Get escalation state for an incident")
|
||||
.WithDescription(_t("notifier.escalation.get_description"));
|
||||
|
||||
escalations.MapPost("/{incidentId}/start", StartEscalationAsync)
|
||||
.WithName("StartEscalation")
|
||||
.WithSummary("Start escalation for an incident")
|
||||
.WithDescription(_t("notifier.escalation.start_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
escalations.MapPost("/{incidentId}/escalate", ManualEscalateAsync)
|
||||
.WithName("ManualEscalate")
|
||||
.WithSummary("Manually escalate to next level")
|
||||
.WithDescription(_t("notifier.escalation.manual_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
escalations.MapPost("/{incidentId}/stop", StopEscalationAsync)
|
||||
.WithName("StopEscalation")
|
||||
.WithSummary("Stop escalation")
|
||||
.WithDescription(_t("notifier.escalation.stop_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
|
||||
|
||||
// Ack Bridge
|
||||
var ack = app.MapGroup("/api/v2/ack")
|
||||
.WithTags("Acknowledgment")
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator)
|
||||
.RequireTenant();
|
||||
|
||||
ack.MapPost("/", ProcessAckAsync)
|
||||
.WithName("ProcessAck")
|
||||
.WithSummary("Process an acknowledgment")
|
||||
.WithDescription(_t("notifier.ack.process_description"));
|
||||
|
||||
ack.MapGet("/", ProcessAckLinkAsync)
|
||||
.WithName("ProcessAckLink")
|
||||
.WithSummary("Process an acknowledgment link")
|
||||
.WithDescription(_t("notifier.ack.link_description"));
|
||||
|
||||
ack.MapPost("/webhook/pagerduty", ProcessPagerDutyWebhookAsync)
|
||||
.WithName("PagerDutyWebhook")
|
||||
.WithSummary("Process PagerDuty webhook")
|
||||
.WithDescription(_t("notifier.ack.pagerduty_description"))
|
||||
.AllowAnonymous();
|
||||
|
||||
ack.MapPost("/webhook/opsgenie", ProcessOpsGenieWebhookAsync)
|
||||
.WithName("OpsGenieWebhook")
|
||||
.WithSummary("Process OpsGenie webhook")
|
||||
.WithDescription(_t("notifier.ack.opsgenie_description"))
|
||||
.AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
#region Policy Endpoints
|
||||
|
||||
private static async Task<IResult> ListPoliciesAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var policies = await policyService.ListPoliciesAsync(tenantId, cancellationToken);
|
||||
return Results.Ok(policies);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPolicyAsync(
|
||||
string policyId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var policy = await policyService.GetPolicyAsync(tenantId, policyId, cancellationToken);
|
||||
return policy is null
|
||||
? Results.NotFound(new { error = _t("notifier.error.policy_not_found", policyId) })
|
||||
: Results.Ok(policy);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePolicyAsync(
|
||||
[FromBody] EscalationPolicyApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_required_stellaops") });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.policy_name_required") });
|
||||
}
|
||||
|
||||
if (request.Levels is null || request.Levels.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.policy_levels_required") });
|
||||
}
|
||||
|
||||
var policy = MapToPolicy(request, tenantId);
|
||||
var created = await policyService.UpsertPolicyAsync(policy, actor, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v2/escalation-policies/{created.PolicyId}", created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdatePolicyAsync(
|
||||
string policyId,
|
||||
[FromBody] EscalationPolicyApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_required_stellaops") });
|
||||
}
|
||||
|
||||
var existing = await policyService.GetPolicyAsync(tenantId, policyId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("notifier.error.policy_not_found", policyId) });
|
||||
}
|
||||
|
||||
var policy = MapToPolicy(request, tenantId) with
|
||||
{
|
||||
PolicyId = policyId,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
CreatedBy = existing.CreatedBy
|
||||
};
|
||||
|
||||
var updated = await policyService.UpsertPolicyAsync(policy, actor, cancellationToken);
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeletePolicyAsync(
|
||||
string policyId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var deleted = await policyService.DeletePolicyAsync(tenantId, policyId, actor, cancellationToken);
|
||||
return deleted ? Results.NoContent() : Results.NotFound(new { error = _t("notifier.error.policy_not_found", policyId) });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Schedule Endpoints
|
||||
|
||||
private static async Task<IResult> ListSchedulesAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var schedules = await scheduleService.ListSchedulesAsync(tenantId, cancellationToken);
|
||||
return Results.Ok(schedules);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetScheduleAsync(
|
||||
string scheduleId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var schedule = await scheduleService.GetScheduleAsync(tenantId, scheduleId, cancellationToken);
|
||||
return schedule is null
|
||||
? Results.NotFound(new { error = _t("notifier.error.schedule_not_found", scheduleId) })
|
||||
: Results.Ok(schedule);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateScheduleAsync(
|
||||
[FromBody] OnCallScheduleApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_required_stellaops") });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.schedule_name_required") });
|
||||
}
|
||||
|
||||
var schedule = MapToSchedule(request, tenantId);
|
||||
var created = await scheduleService.UpsertScheduleAsync(schedule, actor, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v2/oncall-schedules/{created.ScheduleId}", created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateScheduleAsync(
|
||||
string scheduleId,
|
||||
[FromBody] OnCallScheduleApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_required_stellaops") });
|
||||
}
|
||||
|
||||
var existing = await scheduleService.GetScheduleAsync(tenantId, scheduleId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("notifier.error.schedule_not_found", scheduleId) });
|
||||
}
|
||||
|
||||
var schedule = MapToSchedule(request, tenantId) with
|
||||
{
|
||||
ScheduleId = scheduleId,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
CreatedBy = existing.CreatedBy
|
||||
};
|
||||
|
||||
var updated = await scheduleService.UpsertScheduleAsync(schedule, actor, cancellationToken);
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteScheduleAsync(
|
||||
string scheduleId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var deleted = await scheduleService.DeleteScheduleAsync(tenantId, scheduleId, actor, cancellationToken);
|
||||
return deleted ? Results.NoContent() : Results.NotFound(new { error = _t("notifier.error.schedule_not_found", scheduleId) });
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetCurrentOnCallAsync(
|
||||
string scheduleId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromQuery] DateTimeOffset? atTime,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var users = await scheduleService.GetCurrentOnCallAsync(tenantId, scheduleId, atTime, cancellationToken);
|
||||
return Results.Ok(users);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateOverrideAsync(
|
||||
string scheduleId,
|
||||
[FromBody] OnCallOverrideApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var @override = new OnCallOverride
|
||||
{
|
||||
OverrideId = request.OverrideId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
User = new OnCallUser
|
||||
{
|
||||
UserId = request.UserId ?? "",
|
||||
Name = request.UserName ?? request.UserId ?? ""
|
||||
},
|
||||
StartsAt = request.StartsAt ?? DateTimeOffset.UtcNow,
|
||||
EndsAt = request.EndsAt ?? DateTimeOffset.UtcNow.AddHours(8),
|
||||
Reason = request.Reason
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var created = await scheduleService.CreateOverrideAsync(tenantId, scheduleId, @override, actor, cancellationToken);
|
||||
return Results.Created($"/api/v2/oncall-schedules/{scheduleId}/overrides/{created.OverrideId}", created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteOverrideAsync(
|
||||
string scheduleId,
|
||||
string overrideId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var deleted = await scheduleService.DeleteOverrideAsync(tenantId, scheduleId, overrideId, actor, cancellationToken);
|
||||
return deleted ? Results.NoContent() : Results.NotFound(new { error = _t("notifier.error.override_not_found", overrideId) });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Escalation Endpoints
|
||||
|
||||
private static async Task<IResult> ListActiveEscalationsAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var escalations = await escalationEngine.ListActiveEscalationsAsync(tenantId, cancellationToken);
|
||||
return Results.Ok(escalations);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEscalationStateAsync(
|
||||
string incidentId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var state = await escalationEngine.GetEscalationStateAsync(tenantId, incidentId, cancellationToken);
|
||||
return state is null
|
||||
? Results.NotFound(new { error = _t("notifier.error.escalation_not_found", incidentId) })
|
||||
: Results.Ok(state);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StartEscalationAsync(
|
||||
string incidentId,
|
||||
[FromBody] StartEscalationApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.policy_id_required") });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var state = await escalationEngine.StartEscalationAsync(tenantId, incidentId, request.PolicyId, cancellationToken);
|
||||
return Results.Created($"/api/v2/escalations/{incidentId}", state);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ManualEscalateAsync(
|
||||
string incidentId,
|
||||
[FromBody] ManualEscalateApiRequest? request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var state = await escalationEngine.EscalateAsync(tenantId, incidentId, request?.Reason, actor, cancellationToken);
|
||||
return state is null
|
||||
? Results.NotFound(new { error = _t("notifier.error.active_escalation_not_found", incidentId) })
|
||||
: Results.Ok(state);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StopEscalationAsync(
|
||||
string incidentId,
|
||||
[FromBody] StopEscalationApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var stopped = await escalationEngine.StopEscalationAsync(
|
||||
tenantId, incidentId, request.Reason ?? "Manually stopped", actor, cancellationToken);
|
||||
|
||||
return stopped
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { error = _t("notifier.error.active_escalation_not_found", incidentId) });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ack Endpoints
|
||||
|
||||
private static async Task<IResult> ProcessAckAsync(
|
||||
[FromBody] AckApiRequest request,
|
||||
[FromServices] IAckBridge ackBridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bridgeRequest = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.Api,
|
||||
TenantId = request.TenantId,
|
||||
IncidentId = request.IncidentId,
|
||||
AcknowledgedBy = request.AcknowledgedBy ?? "api",
|
||||
Comment = request.Comment
|
||||
};
|
||||
|
||||
var result = await ackBridge.ProcessAckAsync(bridgeRequest, cancellationToken);
|
||||
return result.Success
|
||||
? Results.Ok(result)
|
||||
: Results.BadRequest(new { error = result.Error });
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProcessAckLinkAsync(
|
||||
[FromQuery] string token,
|
||||
[FromServices] IAckBridge ackBridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var validation = await ackBridge.ValidateTokenAsync(token, cancellationToken);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(new { error = validation.Error });
|
||||
}
|
||||
|
||||
var bridgeRequest = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.SignedLink,
|
||||
Token = token,
|
||||
AcknowledgedBy = validation.TargetId ?? "link"
|
||||
};
|
||||
|
||||
var result = await ackBridge.ProcessAckAsync(bridgeRequest, cancellationToken);
|
||||
return result.Success
|
||||
? Results.Ok(new { message = "Acknowledged successfully", incidentId = result.IncidentId })
|
||||
: Results.BadRequest(new { error = result.Error });
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProcessPagerDutyWebhookAsync(
|
||||
HttpContext context,
|
||||
[FromServices] IEnumerable<IExternalIntegrationAdapter> adapters,
|
||||
[FromServices] IAckBridge ackBridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pagerDutyAdapter = adapters.OfType<PagerDutyAdapter>().FirstOrDefault();
|
||||
if (pagerDutyAdapter is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.pagerduty_not_configured") });
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var payload = await reader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
var request = pagerDutyAdapter.ParseWebhook(payload);
|
||||
if (request is null)
|
||||
{
|
||||
return Results.Ok(new { message = "Webhook received but no action taken." });
|
||||
}
|
||||
|
||||
var result = await ackBridge.ProcessAckAsync(request, cancellationToken);
|
||||
return Results.Ok(new { processed = result.Success });
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProcessOpsGenieWebhookAsync(
|
||||
HttpContext context,
|
||||
[FromServices] IEnumerable<IExternalIntegrationAdapter> adapters,
|
||||
[FromServices] IAckBridge ackBridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var opsGenieAdapter = adapters.OfType<OpsGenieAdapter>().FirstOrDefault();
|
||||
if (opsGenieAdapter is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.opsgenie_not_configured") });
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var payload = await reader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
var request = opsGenieAdapter.ParseWebhook(payload);
|
||||
if (request is null)
|
||||
{
|
||||
return Results.Ok(new { message = "Webhook received but no action taken." });
|
||||
}
|
||||
|
||||
var result = await ackBridge.ProcessAckAsync(request, cancellationToken);
|
||||
return Results.Ok(new { processed = result.Success });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mapping
|
||||
|
||||
private static EscalationPolicy MapToPolicy(EscalationPolicyApiRequest request, string tenantId) => new()
|
||||
{
|
||||
PolicyId = request.PolicyId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Name = request.Name!,
|
||||
Description = request.Description,
|
||||
IsDefault = request.IsDefault ?? false,
|
||||
Enabled = request.Enabled ?? true,
|
||||
EventKinds = request.EventKinds,
|
||||
MinSeverity = request.MinSeverity,
|
||||
Levels = request.Levels!.Select((l, i) => new EscalationLevel
|
||||
{
|
||||
Level = l.Level ?? i + 1,
|
||||
Name = l.Name,
|
||||
EscalateAfter = TimeSpan.FromMinutes(l.EscalateAfterMinutes ?? 15),
|
||||
Targets = l.Targets?.Select(t => new EscalationTarget
|
||||
{
|
||||
Type = Enum.TryParse<EscalationTargetType>(t.Type, true, out var type) ? type : EscalationTargetType.User,
|
||||
TargetId = t.TargetId ?? "",
|
||||
Name = t.Name,
|
||||
ChannelId = t.ChannelId
|
||||
}).ToList() ?? [],
|
||||
NotifyMode = Enum.TryParse<EscalationNotifyMode>(l.NotifyMode, true, out var mode) ? mode : EscalationNotifyMode.All,
|
||||
StopOnAck = l.StopOnAck ?? true
|
||||
}).ToList(),
|
||||
ExhaustedAction = Enum.TryParse<EscalationExhaustedAction>(request.ExhaustedAction, true, out var action)
|
||||
? action : EscalationExhaustedAction.RepeatLastLevel,
|
||||
MaxCycles = request.MaxCycles ?? 3
|
||||
};
|
||||
|
||||
private static OnCallSchedule MapToSchedule(OnCallScheduleApiRequest request, string tenantId) => new()
|
||||
{
|
||||
ScheduleId = request.ScheduleId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Name = request.Name!,
|
||||
Description = request.Description,
|
||||
Timezone = request.Timezone ?? "UTC",
|
||||
Enabled = request.Enabled ?? true,
|
||||
Layers = request.Layers?.Select(l => new RotationLayer
|
||||
{
|
||||
Name = l.Name ?? "Default",
|
||||
Priority = l.Priority ?? 100,
|
||||
Users = l.Users?.Select((u, i) => new OnCallUser
|
||||
{
|
||||
UserId = u.UserId ?? "",
|
||||
Name = u.Name ?? u.UserId ?? "",
|
||||
Email = u.Email,
|
||||
Phone = u.Phone,
|
||||
PreferredChannelId = u.PreferredChannelId,
|
||||
Order = u.Order ?? i
|
||||
}).ToList() ?? [],
|
||||
Type = Enum.TryParse<RotationType>(l.RotationType, true, out var type) ? type : RotationType.Weekly,
|
||||
HandoffTime = TimeOnly.TryParse(l.HandoffTime, out var time) ? time : new TimeOnly(9, 0),
|
||||
RotationInterval = TimeSpan.FromDays(l.RotationIntervalDays ?? 7),
|
||||
RotationStart = l.RotationStart ?? DateTimeOffset.UtcNow,
|
||||
Restrictions = l.Restrictions?.Select(r => new ScheduleRestriction
|
||||
{
|
||||
Type = Enum.TryParse<RestrictionType>(r.Type, true, out var rType) ? rType : RestrictionType.DaysOfWeek,
|
||||
DaysOfWeek = r.DaysOfWeek,
|
||||
StartTime = TimeOnly.TryParse(r.StartTime, out var start) ? start : null,
|
||||
EndTime = TimeOnly.TryParse(r.EndTime, out var end) ? end : null
|
||||
}).ToList(),
|
||||
Enabled = l.Enabled ?? true
|
||||
}).ToList() ?? []
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region API Request Models
|
||||
|
||||
public sealed class EscalationPolicyApiRequest
|
||||
{
|
||||
public string? PolicyId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? IsDefault { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public List<string>? EventKinds { get; set; }
|
||||
public string? MinSeverity { get; set; }
|
||||
public List<EscalationLevelApiRequest>? Levels { get; set; }
|
||||
public string? ExhaustedAction { get; set; }
|
||||
public int? MaxCycles { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EscalationLevelApiRequest
|
||||
{
|
||||
public int? Level { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public int? EscalateAfterMinutes { get; set; }
|
||||
public List<EscalationTargetApiRequest>? Targets { get; set; }
|
||||
public string? NotifyMode { get; set; }
|
||||
public bool? StopOnAck { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EscalationTargetApiRequest
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
public string? TargetId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? ChannelId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OnCallScheduleApiRequest
|
||||
{
|
||||
public string? ScheduleId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Timezone { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public List<RotationLayerApiRequest>? Layers { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RotationLayerApiRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public int? Priority { get; set; }
|
||||
public List<OnCallUserApiRequest>? Users { get; set; }
|
||||
public string? RotationType { get; set; }
|
||||
public string? HandoffTime { get; set; }
|
||||
public int? RotationIntervalDays { get; set; }
|
||||
public DateTimeOffset? RotationStart { get; set; }
|
||||
public List<ScheduleRestrictionApiRequest>? Restrictions { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OnCallUserApiRequest
|
||||
{
|
||||
public string? UserId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? PreferredChannelId { get; set; }
|
||||
public int? Order { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ScheduleRestrictionApiRequest
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
public List<int>? DaysOfWeek { get; set; }
|
||||
public string? StartTime { get; set; }
|
||||
public string? EndTime { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OnCallOverrideApiRequest
|
||||
{
|
||||
public string? OverrideId { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public DateTimeOffset? StartsAt { get; set; }
|
||||
public DateTimeOffset? EndsAt { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StartEscalationApiRequest
|
||||
{
|
||||
public string? PolicyId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ManualEscalateApiRequest
|
||||
{
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StopEscalationApiRequest
|
||||
{
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AckApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? IncidentId { get; set; }
|
||||
public string? AcknowledgedBy { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,208 @@
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notify.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Fallback;
|
||||
using StellaOps.Notify.Models;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for fallback handler operations.
|
||||
/// </summary>
|
||||
public static class FallbackEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps fallback API endpoints.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapFallbackEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/fallback")
|
||||
.WithTags("Fallback")
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
// Get fallback statistics
|
||||
group.MapGet("/statistics", async (
|
||||
int? windowHours,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var window = windowHours.HasValue ? TimeSpan.FromHours(windowHours.Value) : (TimeSpan?)null;
|
||||
|
||||
var stats = await fallbackHandler.GetStatisticsAsync(tenantId, window, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
stats.TenantId,
|
||||
window = stats.Window.ToString(),
|
||||
stats.TotalDeliveries,
|
||||
stats.PrimarySuccesses,
|
||||
stats.FallbackAttempts,
|
||||
stats.FallbackSuccesses,
|
||||
stats.ExhaustedDeliveries,
|
||||
successRate = $"{stats.SuccessRate:P1}",
|
||||
fallbackUtilizationRate = $"{stats.FallbackUtilizationRate:P1}",
|
||||
failuresByChannel = stats.FailuresByChannel.ToDictionary(
|
||||
kvp => kvp.Key.ToString(),
|
||||
kvp => kvp.Value)
|
||||
});
|
||||
})
|
||||
.WithName("GetFallbackStatistics")
|
||||
.WithSummary("Gets fallback handling statistics for a tenant")
|
||||
.WithDescription(_t("notifier.fallback.stats_description"));
|
||||
|
||||
// Get fallback chain for a channel
|
||||
group.MapGet("/chains/{channelType}", async (
|
||||
NotifyChannelType channelType,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var chain = await fallbackHandler.GetFallbackChainAsync(tenantId, channelType, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
primaryChannel = channelType.ToString(),
|
||||
fallbackChain = chain.Select(c => c.ToString()).ToList(),
|
||||
chainLength = chain.Count
|
||||
});
|
||||
})
|
||||
.WithName("GetFallbackChain")
|
||||
.WithSummary("Gets the fallback chain for a channel type")
|
||||
.WithDescription(_t("notifier.fallback.get_chain_description"));
|
||||
|
||||
// Set fallback chain for a channel
|
||||
group.MapPut("/chains/{channelType}", async (
|
||||
NotifyChannelType channelType,
|
||||
SetFallbackChainRequest request,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
|
||||
|
||||
var chain = request.FallbackChain
|
||||
.Select(s => Enum.TryParse<NotifyChannelType>(s, out var t) ? t : (NotifyChannelType?)null)
|
||||
.Where(t => t.HasValue)
|
||||
.Select(t => t!.Value)
|
||||
.ToList();
|
||||
|
||||
await fallbackHandler.SetFallbackChainAsync(tenantId, channelType, chain, actor, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
message = _t("notifier.message.fallback_chain_updated"),
|
||||
primaryChannel = channelType.ToString(),
|
||||
fallbackChain = chain.Select(c => c.ToString()).ToList()
|
||||
});
|
||||
})
|
||||
.WithName("SetFallbackChain")
|
||||
.WithSummary("Sets a custom fallback chain for a channel type")
|
||||
.WithDescription(_t("notifier.fallback.set_chain_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
// Test fallback resolution
|
||||
group.MapPost("/test", async (
|
||||
TestFallbackRequest request,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!Enum.TryParse<NotifyChannelType>(request.FailedChannelType, out var channelType))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.invalid_fallback_channel", request.FailedChannelType) });
|
||||
}
|
||||
|
||||
var deliveryId = $"test-{Guid.NewGuid():N}"[..20];
|
||||
|
||||
// Simulate failure recording
|
||||
await fallbackHandler.RecordFailureAsync(
|
||||
tenantId, deliveryId, channelType, "Test failure", cancellationToken);
|
||||
|
||||
// Get fallback result
|
||||
var result = await fallbackHandler.GetFallbackAsync(
|
||||
tenantId, channelType, deliveryId, cancellationToken);
|
||||
|
||||
// Clean up test state
|
||||
await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
testDeliveryId = deliveryId,
|
||||
result.HasFallback,
|
||||
nextChannelType = result.NextChannelType?.ToString(),
|
||||
result.AttemptNumber,
|
||||
result.TotalChannels,
|
||||
result.IsExhausted,
|
||||
result.ExhaustionReason,
|
||||
failedChannels = result.FailedChannels.Select(f => new
|
||||
{
|
||||
channelType = f.ChannelType.ToString(),
|
||||
f.Reason,
|
||||
f.FailedAt,
|
||||
f.AttemptNumber
|
||||
}).ToList()
|
||||
});
|
||||
})
|
||||
.WithName("TestFallback")
|
||||
.WithSummary("Tests fallback resolution without affecting real deliveries")
|
||||
.WithDescription(_t("notifier.fallback.test_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
// Clear delivery state
|
||||
group.MapDelete("/deliveries/{deliveryId}", async (
|
||||
string deliveryId,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
|
||||
|
||||
return Results.Ok(new { message = _t("notifier.message.delivery_state_cleared", deliveryId) });
|
||||
})
|
||||
.WithName("ClearDeliveryFallbackState")
|
||||
.WithSummary("Clears fallback state for a specific delivery")
|
||||
.WithDescription(_t("notifier.fallback.clear_delivery_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to set a custom fallback chain.
|
||||
/// </summary>
|
||||
public sealed record SetFallbackChainRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Ordered list of fallback channel types.
|
||||
/// </summary>
|
||||
public required List<string> FallbackChain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to test fallback resolution.
|
||||
/// </summary>
|
||||
public sealed record TestFallbackRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The channel type that "failed".
|
||||
/// </summary>
|
||||
public required string FailedChannelType { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Maps incident (delivery) management endpoints.
|
||||
/// </summary>
|
||||
public static class IncidentEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapIncidentEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/incidents")
|
||||
.WithTags("Incidents")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListIncidentsAsync)
|
||||
.WithName("ListIncidents")
|
||||
.WithSummary("Lists notification incidents (deliveries)")
|
||||
.WithDescription(_t("notifier.incident.list2_description"));
|
||||
|
||||
group.MapGet("/{deliveryId}", GetIncidentAsync)
|
||||
.WithName("GetIncident")
|
||||
.WithSummary("Gets an incident by delivery ID")
|
||||
.WithDescription(_t("notifier.incident.get2_description"));
|
||||
|
||||
group.MapPost("/{deliveryId}/ack", AcknowledgeIncidentAsync)
|
||||
.WithName("AcknowledgeIncident")
|
||||
.WithSummary("Acknowledges an incident")
|
||||
.WithDescription(_t("notifier.incident.ack2_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapGet("/stats", GetIncidentStatsAsync)
|
||||
.WithName("GetIncidentStats")
|
||||
.WithSummary("Gets incident statistics")
|
||||
.WithDescription(_t("notifier.incident.stats_description"));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListIncidentsAsync(
|
||||
HttpContext context,
|
||||
INotifyDeliveryRepository deliveries,
|
||||
string? status = null,
|
||||
string? kind = null,
|
||||
string? ruleId = null,
|
||||
int? limit = null,
|
||||
string? continuationToken = null,
|
||||
DateTimeOffset? since = null)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
// Query deliveries with filtering
|
||||
var queryResult = await deliveries.QueryAsync(
|
||||
tenantId,
|
||||
since,
|
||||
status,
|
||||
limit ?? 50,
|
||||
continuationToken,
|
||||
context.RequestAborted);
|
||||
|
||||
IEnumerable<NotifyDelivery> filtered = queryResult.Items;
|
||||
|
||||
// Apply additional filters not supported by the repository
|
||||
if (!string.IsNullOrWhiteSpace(kind))
|
||||
{
|
||||
filtered = filtered.Where(d => d.Kind.Equals(kind, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ruleId))
|
||||
{
|
||||
filtered = filtered.Where(d => d.RuleId.Equals(ruleId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var response = filtered.Select(MapToDeliveryResponse).ToList();
|
||||
|
||||
// Add continuation token header for pagination
|
||||
if (!string.IsNullOrWhiteSpace(queryResult.ContinuationToken))
|
||||
{
|
||||
context.Response.Headers["X-Continuation-Token"] = queryResult.ContinuationToken;
|
||||
}
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetIncidentAsync(
|
||||
HttpContext context,
|
||||
string deliveryId,
|
||||
INotifyDeliveryRepository deliveries)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
|
||||
if (delivery is null)
|
||||
{
|
||||
return Results.NotFound(Error("incident_not_found", _t("notifier.error.incident_not_found", deliveryId), context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapToDeliveryResponse(delivery));
|
||||
}
|
||||
|
||||
private static async Task<IResult> AcknowledgeIncidentAsync(
|
||||
HttpContext context,
|
||||
string deliveryId,
|
||||
DeliveryAckRequest request,
|
||||
INotifyDeliveryRepository deliveries,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
var delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
|
||||
if (delivery is null)
|
||||
{
|
||||
return Results.NotFound(Error("incident_not_found", _t("notifier.error.incident_not_found", deliveryId), context));
|
||||
}
|
||||
|
||||
// Update delivery status based on acknowledgment
|
||||
var newStatus = request.Resolution?.ToLowerInvariant() switch
|
||||
{
|
||||
"resolved" => NotifyDeliveryStatus.Delivered,
|
||||
"dismissed" => NotifyDeliveryStatus.Failed,
|
||||
_ => delivery.Status
|
||||
};
|
||||
|
||||
var attempt = new NotifyDeliveryAttempt(
|
||||
timestamp: timeProvider.GetUtcNow(),
|
||||
status: NotifyDeliveryAttemptStatus.Success,
|
||||
reason: $"Acknowledged by {actor}: {request.Comment ?? request.Resolution ?? "ack"}");
|
||||
|
||||
var updated = NotifyDelivery.Create(
|
||||
deliveryId: delivery.DeliveryId,
|
||||
tenantId: delivery.TenantId,
|
||||
ruleId: delivery.RuleId,
|
||||
actionId: delivery.ActionId,
|
||||
eventId: delivery.EventId,
|
||||
kind: delivery.Kind,
|
||||
status: newStatus,
|
||||
statusReason: request.Comment ?? $"Acknowledged: {request.Resolution}",
|
||||
rendered: delivery.Rendered,
|
||||
attempts: delivery.Attempts.Add(attempt),
|
||||
metadata: delivery.Metadata,
|
||||
createdAt: delivery.CreatedAt,
|
||||
sentAt: delivery.SentAt,
|
||||
completedAt: timeProvider.GetUtcNow());
|
||||
|
||||
await deliveries.UpdateAsync(updated, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "incident.acknowledged", deliveryId, "incident", new
|
||||
{
|
||||
deliveryId,
|
||||
request.Resolution,
|
||||
request.Comment
|
||||
}, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Ok(MapToDeliveryResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetIncidentStatsAsync(
|
||||
HttpContext context,
|
||||
INotifyDeliveryRepository deliveries)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var allDeliveries = await deliveries.ListAsync(tenantId, context.RequestAborted);
|
||||
|
||||
var stats = new DeliveryStatsResponse
|
||||
{
|
||||
Total = allDeliveries.Count,
|
||||
Pending = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Pending),
|
||||
Delivered = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Delivered),
|
||||
Failed = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Failed),
|
||||
ByKind = allDeliveries
|
||||
.GroupBy(d => d.Kind)
|
||||
.ToDictionary(g => g.Key, g => g.Count()),
|
||||
ByRule = allDeliveries
|
||||
.GroupBy(d => d.RuleId)
|
||||
.ToDictionary(g => g.Key, g => g.Count())
|
||||
};
|
||||
|
||||
return Results.Ok(stats);
|
||||
}
|
||||
|
||||
private static DeliveryResponse MapToDeliveryResponse(NotifyDelivery delivery)
|
||||
{
|
||||
return new DeliveryResponse
|
||||
{
|
||||
DeliveryId = delivery.DeliveryId,
|
||||
TenantId = delivery.TenantId,
|
||||
RuleId = delivery.RuleId,
|
||||
ActionId = delivery.ActionId,
|
||||
EventId = delivery.EventId.ToString(),
|
||||
Kind = delivery.Kind,
|
||||
Status = delivery.Status.ToString(),
|
||||
StatusReason = delivery.StatusReason,
|
||||
AttemptCount = delivery.Attempts.Length,
|
||||
LastAttempt = delivery.Attempts.Length > 0 ? delivery.Attempts[^1].Timestamp : null,
|
||||
CreatedAt = delivery.CreatedAt,
|
||||
SentAt = delivery.SentAt,
|
||||
CompletedAt = delivery.CompletedAt,
|
||||
Metadata = delivery.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
return string.IsNullOrWhiteSpace(tenantId) ? null : tenantId;
|
||||
}
|
||||
|
||||
private static string GetActor(HttpContext context)
|
||||
{
|
||||
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
|
||||
return string.IsNullOrWhiteSpace(actor) ? "api" : actor;
|
||||
}
|
||||
|
||||
private static async Task AppendAuditAsync(
|
||||
INotifyAuditRepository audit,
|
||||
string tenantId,
|
||||
string actor,
|
||||
string action,
|
||||
string entityId,
|
||||
string entityType,
|
||||
object payload,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payloadNode = JsonSerializer.SerializeToNode(payload) as JsonObject;
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["entityId"] = entityId,
|
||||
["entityType"] = entityType,
|
||||
["payload"] = payloadNode?.ToJsonString() ?? "{}"
|
||||
};
|
||||
|
||||
await audit.AppendAsync(tenantId, action, actor, data, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore audit failures
|
||||
}
|
||||
}
|
||||
|
||||
private static object Error(string code, string message, HttpContext context) => new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery acknowledgment request for v2 API.
|
||||
/// </summary>
|
||||
public sealed record DeliveryAckRequest
|
||||
{
|
||||
public string? Resolution { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery response DTO for v2 API.
|
||||
/// </summary>
|
||||
public sealed record DeliveryResponse
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string RuleId { get; init; }
|
||||
public required string ActionId { get; init; }
|
||||
public required string EventId { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? StatusReason { get; init; }
|
||||
public required int AttemptCount { get; init; }
|
||||
public DateTimeOffset? LastAttempt { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? SentAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery statistics response for v2 API.
|
||||
/// </summary>
|
||||
public sealed record DeliveryStatsResponse
|
||||
{
|
||||
public required int Total { get; init; }
|
||||
public required int Pending { get; init; }
|
||||
public required int Delivered { get; init; }
|
||||
public required int Failed { get; init; }
|
||||
public required Dictionary<string, int> ByKind { get; init; }
|
||||
public required Dictionary<string, int> ByRule { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket live feed for real-time incident updates.
|
||||
/// </summary>
|
||||
public static class IncidentLiveFeed
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, ConcurrentBag<WebSocket>> _tenantSubscriptions = new();
|
||||
|
||||
public static IEndpointRouteBuilder MapIncidentLiveFeed(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.Map("/api/v2/incidents/live", HandleWebSocketAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task HandleWebSocketAsync(HttpContext context)
|
||||
{
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = "websocket_required",
|
||||
message = "This endpoint requires a WebSocket connection.",
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
// Try query string fallback for WebSocket clients that can't set headers
|
||||
tenantId = context.Request.Query["tenant"].ToString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = "tenant_missing",
|
||||
message = "X-StellaOps-Tenant header or 'tenant' query parameter is required.",
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
|
||||
var subscriptions = _tenantSubscriptions.GetOrAdd(tenantId, _ => new ConcurrentBag<WebSocket>());
|
||||
subscriptions.Add(webSocket);
|
||||
|
||||
try
|
||||
{
|
||||
// Send connection acknowledgment
|
||||
var ackMessage = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "connected",
|
||||
tenantId,
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
await SendMessageAsync(webSocket, ackMessage, context.RequestAborted);
|
||||
|
||||
// Keep connection alive and handle incoming messages
|
||||
await ReceiveMessagesAsync(webSocket, tenantId, context.RequestAborted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Remove from subscriptions
|
||||
var newBag = new ConcurrentBag<WebSocket>(
|
||||
subscriptions.Where(s => s != webSocket && s.State == WebSocketState.Open));
|
||||
_tenantSubscriptions.TryUpdate(tenantId, newBag, subscriptions);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReceiveMessagesAsync(WebSocket webSocket, string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
|
||||
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await webSocket.CloseAsync(
|
||||
WebSocketCloseStatus.NormalClosure,
|
||||
"Client initiated close",
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
await HandleClientMessageAsync(webSocket, tenantId, message, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleClientMessageAsync(WebSocket webSocket, string tenantId, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("type", out var typeElement))
|
||||
{
|
||||
var type = typeElement.GetString();
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "ping":
|
||||
var pongResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "pong",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
await SendMessageAsync(webSocket, pongResponse, cancellationToken);
|
||||
break;
|
||||
|
||||
case "subscribe":
|
||||
// Handle filter subscriptions (e.g., specific rule IDs, kinds)
|
||||
var subResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "subscribed",
|
||||
tenantId,
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
await SendMessageAsync(webSocket, subResponse, cancellationToken);
|
||||
break;
|
||||
|
||||
default:
|
||||
var errorResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "error",
|
||||
message = $"Unknown message type: {type}"
|
||||
});
|
||||
await SendMessageAsync(webSocket, errorResponse, cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var errorResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "error",
|
||||
message = "Invalid JSON message"
|
||||
});
|
||||
await SendMessageAsync(webSocket, errorResponse, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendMessageAsync(WebSocket webSocket, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
if (webSocket.State != WebSocketState.Open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(message);
|
||||
await webSocket.SendAsync(
|
||||
new ArraySegment<byte>(bytes),
|
||||
WebSocketMessageType.Text,
|
||||
endOfMessage: true,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts an incident update to all connected clients for the specified tenant.
|
||||
/// </summary>
|
||||
public static async Task BroadcastIncidentUpdateAsync(
|
||||
string tenantId,
|
||||
NotifyDelivery delivery,
|
||||
string updateType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "incident_update",
|
||||
updateType, // created, updated, acknowledged, delivered, failed
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
incident = new
|
||||
{
|
||||
deliveryId = delivery.DeliveryId,
|
||||
tenantId = delivery.TenantId,
|
||||
ruleId = delivery.RuleId,
|
||||
actionId = delivery.ActionId,
|
||||
eventId = delivery.EventId.ToString(),
|
||||
kind = delivery.Kind,
|
||||
status = delivery.Status.ToString(),
|
||||
statusReason = delivery.StatusReason,
|
||||
attemptCount = delivery.Attempts.Length,
|
||||
createdAt = delivery.CreatedAt,
|
||||
sentAt = delivery.SentAt,
|
||||
completedAt = delivery.CompletedAt
|
||||
}
|
||||
});
|
||||
|
||||
var deadSockets = new List<WebSocket>();
|
||||
|
||||
foreach (var socket in subscriptions)
|
||||
{
|
||||
if (socket.State != WebSocketState.Open)
|
||||
{
|
||||
deadSockets.Add(socket);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await SendMessageAsync(socket, message, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
deadSockets.Add(socket);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up dead sockets
|
||||
if (deadSockets.Count > 0)
|
||||
{
|
||||
var newBag = new ConcurrentBag<WebSocket>(
|
||||
subscriptions.Where(s => !deadSockets.Contains(s)));
|
||||
_tenantSubscriptions.TryUpdate(tenantId, newBag, subscriptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts incident statistics update to all connected clients for the specified tenant.
|
||||
/// </summary>
|
||||
public static async Task BroadcastStatsUpdateAsync(
|
||||
string tenantId,
|
||||
int total,
|
||||
int pending,
|
||||
int delivered,
|
||||
int failed,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "stats_update",
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
stats = new
|
||||
{
|
||||
total,
|
||||
pending,
|
||||
delivered,
|
||||
failed
|
||||
}
|
||||
});
|
||||
|
||||
foreach (var socket in subscriptions.Where(s => s.State == WebSocketState.Open))
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendMessageAsync(socket, message, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore send failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of active WebSocket connections for a tenant.
|
||||
/// </summary>
|
||||
public static int GetConnectionCount(string tenantId)
|
||||
{
|
||||
if (_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
|
||||
{
|
||||
return subscriptions.Count(s => s.State == WebSocketState.Open);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notify.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Localization;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for localization operations.
|
||||
/// </summary>
|
||||
public static class LocalizationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps localization API endpoints.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapLocalizationEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/localization")
|
||||
.WithTags("Localization")
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
// List bundles
|
||||
group.MapGet("/bundles", async (
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var bundles = await localizationService.ListBundlesAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
bundles = bundles.Select(b => new
|
||||
{
|
||||
b.BundleId,
|
||||
b.TenantId,
|
||||
b.Locale,
|
||||
b.Namespace,
|
||||
stringCount = b.Strings.Count,
|
||||
b.Priority,
|
||||
b.Enabled,
|
||||
b.Source,
|
||||
b.Description,
|
||||
b.CreatedAt,
|
||||
b.UpdatedAt
|
||||
}).ToList(),
|
||||
count = bundles.Count
|
||||
});
|
||||
})
|
||||
.WithName("ListLocalizationBundles")
|
||||
.WithSummary("Lists all localization bundles for a tenant")
|
||||
.WithDescription(_t("notifier.localization.list_bundles_description"));
|
||||
|
||||
// Get supported locales
|
||||
group.MapGet("/locales", async (
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var locales = await localizationService.GetSupportedLocalesAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
locales,
|
||||
count = locales.Count
|
||||
});
|
||||
})
|
||||
.WithName("GetSupportedLocales")
|
||||
.WithSummary("Gets all supported locales for a tenant")
|
||||
.WithDescription(_t("notifier.localization.get_locales_description"));
|
||||
|
||||
// Get bundle contents
|
||||
group.MapGet("/bundles/{locale}", async (
|
||||
string locale,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var strings = await localizationService.GetBundleAsync(tenantId, locale, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
locale,
|
||||
strings,
|
||||
count = strings.Count
|
||||
});
|
||||
})
|
||||
.WithName("GetLocalizationBundle")
|
||||
.WithSummary("Gets all localized strings for a locale")
|
||||
.WithDescription(_t("notifier.localization.get_bundle_description"));
|
||||
|
||||
// Get single string
|
||||
group.MapGet("/strings/{key}", async (
|
||||
string key,
|
||||
string? locale,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var effectiveLocale = locale ?? "en-US";
|
||||
|
||||
var value = await localizationService.GetStringAsync(tenantId, key, effectiveLocale, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
key,
|
||||
locale = effectiveLocale,
|
||||
value
|
||||
});
|
||||
})
|
||||
.WithName("GetLocalizedString")
|
||||
.WithSummary("Gets a single localized string")
|
||||
.WithDescription(_t("notifier.localization.get_string_description"));
|
||||
|
||||
// Format string with parameters
|
||||
group.MapPost("/strings/{key}/format", async (
|
||||
string key,
|
||||
FormatStringRequest request,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var locale = request.Locale ?? "en-US";
|
||||
|
||||
var parameters = request.Parameters ?? new Dictionary<string, object>();
|
||||
var value = await localizationService.GetFormattedStringAsync(
|
||||
tenantId, key, locale, parameters, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
key,
|
||||
locale,
|
||||
formatted = value
|
||||
});
|
||||
})
|
||||
.WithName("FormatLocalizedString")
|
||||
.WithSummary("Gets a localized string with parameter substitution")
|
||||
.WithDescription(_t("notifier.localization.format_string_description"));
|
||||
|
||||
// Create/update bundle
|
||||
group.MapPut("/bundles", async (
|
||||
CreateBundleRequest request,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
|
||||
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = request.BundleId ?? $"bundle-{Guid.NewGuid():N}"[..20],
|
||||
TenantId = tenantId,
|
||||
Locale = request.Locale,
|
||||
Namespace = request.Namespace ?? "default",
|
||||
Strings = request.Strings,
|
||||
Priority = request.Priority,
|
||||
Enabled = request.Enabled,
|
||||
Description = request.Description,
|
||||
Source = "api"
|
||||
};
|
||||
|
||||
var result = await localizationService.UpsertBundleAsync(bundle, actor, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(new { error = result.Error });
|
||||
}
|
||||
|
||||
return result.IsNew
|
||||
? Results.Created($"/api/v2/localization/bundles/{bundle.Locale}", new
|
||||
{
|
||||
bundleId = result.BundleId,
|
||||
message = _t("notifier.message.bundle_created")
|
||||
})
|
||||
: Results.Ok(new
|
||||
{
|
||||
bundleId = result.BundleId,
|
||||
message = _t("notifier.message.bundle_updated")
|
||||
});
|
||||
})
|
||||
.WithName("UpsertLocalizationBundle")
|
||||
.WithSummary("Creates or updates a localization bundle")
|
||||
.WithDescription(_t("notifier.localization.upsert_bundle_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
// Delete bundle
|
||||
group.MapDelete("/bundles/{bundleId}", async (
|
||||
string bundleId,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
|
||||
|
||||
var deleted = await localizationService.DeleteBundleAsync(tenantId, bundleId, actor, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("notifier.error.bundle_not_found", bundleId) });
|
||||
}
|
||||
|
||||
return Results.Ok(new { message = _t("notifier.message.bundle_deleted", bundleId) });
|
||||
})
|
||||
.WithName("DeleteLocalizationBundle")
|
||||
.WithSummary("Deletes a localization bundle")
|
||||
.WithDescription(_t("notifier.localization.delete_bundle_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
// Validate bundle
|
||||
group.MapPost("/bundles/validate", (
|
||||
CreateBundleRequest request,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = request.BundleId ?? "validation",
|
||||
TenantId = tenantId,
|
||||
Locale = request.Locale,
|
||||
Namespace = request.Namespace ?? "default",
|
||||
Strings = request.Strings,
|
||||
Priority = request.Priority,
|
||||
Enabled = request.Enabled,
|
||||
Description = request.Description
|
||||
};
|
||||
|
||||
var result = localizationService.Validate(bundle);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
result.IsValid,
|
||||
result.Errors,
|
||||
result.Warnings
|
||||
});
|
||||
})
|
||||
.WithName("ValidateLocalizationBundle")
|
||||
.WithSummary("Validates a localization bundle without saving")
|
||||
.WithDescription(_t("notifier.localization.validate_bundle_description"));
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to format a localized string.
|
||||
/// </summary>
|
||||
public sealed record FormatStringRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Target locale.
|
||||
/// </summary>
|
||||
public string? Locale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for substitution.
|
||||
/// </summary>
|
||||
public Dictionary<string, object>? Parameters { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create/update a localization bundle.
|
||||
/// </summary>
|
||||
public sealed record CreateBundleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle ID (auto-generated if not provided).
|
||||
/// </summary>
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Locale code.
|
||||
/// </summary>
|
||||
public required string Locale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Namespace/category.
|
||||
/// </summary>
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Localized strings.
|
||||
/// </summary>
|
||||
public required Dictionary<string, string> Strings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle priority.
|
||||
/// </summary>
|
||||
public int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether bundle is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notify.WebService.Contracts;
|
||||
using StellaOps.Notify.WebService.Extensions;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for rules, templates, and incidents management.
|
||||
/// </summary>
|
||||
public static class NotifyApiEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps all Notify API v2 endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapNotifyApiV2(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/notify")
|
||||
.WithTags("Notify")
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
// Rules CRUD
|
||||
MapRulesEndpoints(group);
|
||||
|
||||
// Templates CRUD + Preview
|
||||
MapTemplatesEndpoints(group);
|
||||
|
||||
// Incidents
|
||||
MapIncidentsEndpoints(group);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapRulesEndpoints(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/rules", async (
|
||||
HttpContext context,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var rules = await ruleRepository.ListAsync(tenantId, cancellationToken);
|
||||
var items = rules.Select(MapRuleToResponse).ToList();
|
||||
|
||||
return Results.Ok(new { items, total = items.Count });
|
||||
})
|
||||
.WithDescription(_t("notifier.rule.list_description"));
|
||||
|
||||
group.MapGet("/rules/{ruleId}", async (
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var rule = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
|
||||
if (rule is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapRuleToResponse(rule));
|
||||
})
|
||||
.WithDescription(_t("notifier.rule.get_description"));
|
||||
|
||||
group.MapPost("/rules", async (
|
||||
HttpContext context,
|
||||
RuleCreateRequest request,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
INotifyAuditRepository auditRepository,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var rule = MapRequestToRule(request, tenantId, actor, now);
|
||||
|
||||
await ruleRepository.UpsertAsync(rule, cancellationToken);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "rule.created", actor, new Dictionary<string, string>
|
||||
{
|
||||
["ruleId"] = rule.RuleId,
|
||||
["name"] = rule.Name
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v2/notify/rules/{rule.RuleId}", MapRuleToResponse(rule));
|
||||
})
|
||||
.WithDescription(_t("notifier.rule.create_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPut("/rules/{ruleId}", async (
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
RuleUpdateRequest request,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
INotifyAuditRepository auditRepository,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var updated = ApplyRuleUpdate(existing, request, actor, now);
|
||||
|
||||
await ruleRepository.UpsertAsync(updated, cancellationToken);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "rule.updated", actor, new Dictionary<string, string>
|
||||
{
|
||||
["ruleId"] = updated.RuleId,
|
||||
["name"] = updated.Name
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.Ok(MapRuleToResponse(updated));
|
||||
})
|
||||
.WithDescription(_t("notifier.rule.update_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapDelete("/rules/{ruleId}", async (
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
INotifyAuditRepository auditRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
await ruleRepository.DeleteAsync(tenantId, ruleId, cancellationToken);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "rule.deleted", actor, new Dictionary<string, string>
|
||||
{
|
||||
["ruleId"] = ruleId
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithDescription(_t("notifier.rule.delete_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
}
|
||||
|
||||
private static void MapTemplatesEndpoints(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/templates", async (
|
||||
HttpContext context,
|
||||
string? keyPrefix,
|
||||
string? channelType,
|
||||
string? locale,
|
||||
int? limit,
|
||||
INotifyTemplateService templateService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
NotifyChannelType? channelTypeEnum = null;
|
||||
if (!string.IsNullOrWhiteSpace(channelType) &&
|
||||
Enum.TryParse<NotifyChannelType>(channelType, true, out var parsed))
|
||||
{
|
||||
channelTypeEnum = parsed;
|
||||
}
|
||||
|
||||
var templates = await templateService.ListAsync(tenantId, new TemplateListOptions
|
||||
{
|
||||
KeyPrefix = keyPrefix,
|
||||
ChannelType = channelTypeEnum,
|
||||
Locale = locale,
|
||||
Limit = limit
|
||||
}, cancellationToken);
|
||||
|
||||
var response = templates.Select(MapTemplateToResponse).ToList();
|
||||
|
||||
return Results.Ok(response);
|
||||
})
|
||||
.WithDescription(_t("notifier.template.list_description"));
|
||||
|
||||
group.MapGet("/templates/{templateId}", async (
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
INotifyTemplateService templateService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var template = await templateService.GetByIdAsync(tenantId, templateId, cancellationToken);
|
||||
if (template is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", _t("notifier.error.template_not_found", templateId), context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapTemplateToResponse(template));
|
||||
})
|
||||
.WithDescription(_t("notifier.template.get_description"));
|
||||
|
||||
group.MapPost("/templates", async (
|
||||
HttpContext context,
|
||||
TemplateCreateRequest request,
|
||||
INotifyTemplateService templateService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
if (!Enum.TryParse<NotifyChannelType>(request.ChannelType, true, out var channelType))
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_channel_type", _t("notifier.error.invalid_channel_type", request.ChannelType), context));
|
||||
}
|
||||
|
||||
var renderMode = NotifyTemplateRenderMode.Markdown;
|
||||
if (!string.IsNullOrWhiteSpace(request.RenderMode) &&
|
||||
Enum.TryParse<NotifyTemplateRenderMode>(request.RenderMode, true, out var parsedMode))
|
||||
{
|
||||
renderMode = parsedMode;
|
||||
}
|
||||
|
||||
var format = NotifyDeliveryFormat.Json;
|
||||
if (!string.IsNullOrWhiteSpace(request.Format) &&
|
||||
Enum.TryParse<NotifyDeliveryFormat>(request.Format, true, out var parsedFormat))
|
||||
{
|
||||
format = parsedFormat;
|
||||
}
|
||||
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: request.TemplateId,
|
||||
tenantId: tenantId,
|
||||
channelType: channelType,
|
||||
key: request.Key,
|
||||
locale: request.Locale,
|
||||
body: request.Body,
|
||||
renderMode: renderMode,
|
||||
format: format,
|
||||
description: request.Description,
|
||||
metadata: request.Metadata);
|
||||
|
||||
var result = await templateService.UpsertAsync(template, actor, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(Error("template_validation_failed", result.Error ?? _t("notifier.error.template_validation_failed"), context));
|
||||
}
|
||||
|
||||
var created = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken);
|
||||
|
||||
return result.IsNew
|
||||
? Results.Created($"/api/v2/notify/templates/{request.TemplateId}", MapTemplateToResponse(created!))
|
||||
: Results.Ok(MapTemplateToResponse(created!));
|
||||
})
|
||||
.WithDescription(_t("notifier.template.upsert_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapDelete("/templates/{templateId}", async (
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
INotifyTemplateService templateService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
var deleted = await templateService.DeleteAsync(tenantId, templateId, actor, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", _t("notifier.error.template_not_found", templateId), context));
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithDescription(_t("notifier.template.delete_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/templates/preview", async (
|
||||
HttpContext context,
|
||||
TemplatePreviewRequest request,
|
||||
INotifyTemplateService templateService,
|
||||
INotifyTemplateRenderer templateRenderer,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
NotifyTemplate? template = null;
|
||||
List<string>? warnings = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TemplateId))
|
||||
{
|
||||
template = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken);
|
||||
if (template is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", _t("notifier.error.template_not_found", request.TemplateId), context));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(request.TemplateBody))
|
||||
{
|
||||
var validation = templateService.Validate(request.TemplateBody);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(Error("template_invalid", string.Join("; ", validation.Errors), context));
|
||||
}
|
||||
warnings = validation.Warnings.ToList();
|
||||
|
||||
var format = NotifyDeliveryFormat.PlainText;
|
||||
if (!string.IsNullOrWhiteSpace(request.OutputFormat) &&
|
||||
Enum.TryParse<NotifyDeliveryFormat>(request.OutputFormat, true, out var parsedFormat))
|
||||
{
|
||||
format = parsedFormat;
|
||||
}
|
||||
|
||||
template = NotifyTemplate.Create(
|
||||
templateId: "preview",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Custom,
|
||||
key: "preview",
|
||||
locale: "en-us",
|
||||
body: request.TemplateBody,
|
||||
format: format);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Results.BadRequest(Error("template_required", _t("notifier.error.template_required"), context));
|
||||
}
|
||||
|
||||
var sampleEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: request.EventKind ?? "preview.event",
|
||||
tenant: tenantId,
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: request.SamplePayload ?? new JsonObject(),
|
||||
attributes: request.SampleAttributes ?? new Dictionary<string, string>(),
|
||||
actor: "preview",
|
||||
version: "1");
|
||||
|
||||
var rendered = await templateRenderer.RenderAsync(template, sampleEvent, cancellationToken);
|
||||
|
||||
return Results.Ok(new TemplatePreviewResponse
|
||||
{
|
||||
RenderedBody = rendered.Body,
|
||||
RenderedSubject = rendered.Subject,
|
||||
BodyHash = rendered.BodyHash,
|
||||
Format = rendered.Format.ToString(),
|
||||
Warnings = warnings
|
||||
});
|
||||
})
|
||||
.WithDescription(_t("notifier.template.preview_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/templates/validate", (
|
||||
HttpContext context,
|
||||
TemplatePreviewRequest request,
|
||||
INotifyTemplateService templateService) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.TemplateBody))
|
||||
{
|
||||
return Results.BadRequest(Error("template_body_required", _t("notifier.error.template_body_required"), context));
|
||||
}
|
||||
|
||||
var result = templateService.Validate(request.TemplateBody);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
isValid = result.IsValid,
|
||||
errors = result.Errors,
|
||||
warnings = result.Warnings
|
||||
});
|
||||
})
|
||||
.WithDescription(_t("notifier.template.validate_description"));
|
||||
}
|
||||
|
||||
private static void MapIncidentsEndpoints(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/incidents", async (
|
||||
HttpContext context,
|
||||
string? status,
|
||||
string? eventKindPrefix,
|
||||
DateTimeOffset? since,
|
||||
DateTimeOffset? until,
|
||||
int? limit,
|
||||
string? cursor,
|
||||
INotifyDeliveryRepository deliveryRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
// For now, return recent deliveries grouped by event kind as "incidents"
|
||||
// Full incident correlation will be implemented in NOTIFY-SVC-39-001
|
||||
var queryResult = await deliveryRepository.QueryAsync(tenantId, since, status, limit ?? 100, cursor, cancellationToken);
|
||||
var deliveries = queryResult.Items;
|
||||
|
||||
var incidents = deliveries
|
||||
.GroupBy(d => d.EventId)
|
||||
.Select(g => new IncidentResponse
|
||||
{
|
||||
IncidentId = g.Key.ToString(),
|
||||
TenantId = tenantId,
|
||||
EventKind = g.First().Kind,
|
||||
Status = g.All(d => d.Status == NotifyDeliveryStatus.Delivered) ? "resolved" : "open",
|
||||
Severity = "medium",
|
||||
Title = $"Notification: {g.First().Kind}",
|
||||
Description = null,
|
||||
EventCount = g.Count(),
|
||||
FirstOccurrence = g.Min(d => d.CreatedAt),
|
||||
LastOccurrence = g.Max(d => d.CreatedAt),
|
||||
Labels = null,
|
||||
Metadata = null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new IncidentListResponse
|
||||
{
|
||||
Incidents = incidents,
|
||||
TotalCount = incidents.Count,
|
||||
NextCursor = queryResult.ContinuationToken
|
||||
});
|
||||
})
|
||||
.WithDescription(_t("notifier.incident.list_description"));
|
||||
|
||||
group.MapPost("/incidents/{incidentId}/ack", async (
|
||||
HttpContext context,
|
||||
string incidentId,
|
||||
IncidentAckRequest request,
|
||||
INotifyAuditRepository auditRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var actor = request.Actor ?? GetActor(context);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "incident.acknowledged", actor, new Dictionary<string, string>
|
||||
{
|
||||
["incidentId"] = incidentId,
|
||||
["comment"] = request.Comment ?? ""
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithDescription(_t("notifier.incident.ack_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/incidents/{incidentId}/resolve", async (
|
||||
HttpContext context,
|
||||
string incidentId,
|
||||
IncidentResolveRequest request,
|
||||
INotifyAuditRepository auditRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var actor = request.Actor ?? GetActor(context);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "incident.resolved", actor, new Dictionary<string, string>
|
||||
{
|
||||
["incidentId"] = incidentId,
|
||||
["reason"] = request.Reason ?? "",
|
||||
["comment"] = request.Comment ?? ""
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithDescription(_t("notifier.incident.resolve_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
var value = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static string GetActor(HttpContext context)
|
||||
{
|
||||
return context.Request.Headers["X-StellaOps-Actor"].ToString() is { Length: > 0 } actor
|
||||
? actor
|
||||
: "api";
|
||||
}
|
||||
|
||||
private static object Error(string code, string message, HttpContext context) => new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
};
|
||||
|
||||
private static async Task AuditAsync(
|
||||
INotifyAuditRepository repository,
|
||||
string tenantId,
|
||||
string action,
|
||||
string actor,
|
||||
Dictionary<string, string> metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await repository.AppendAsync(tenantId, action, actor, metadata, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore audit failures
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mappers
|
||||
|
||||
private static RuleResponse MapRuleToResponse(NotifyRule rule)
|
||||
{
|
||||
return new RuleResponse
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
TenantId = rule.TenantId,
|
||||
Name = rule.Name,
|
||||
Description = rule.Description,
|
||||
Enabled = rule.Enabled,
|
||||
Match = new RuleMatchResponse
|
||||
{
|
||||
EventKinds = rule.Match.EventKinds.ToList(),
|
||||
Namespaces = rule.Match.Namespaces.ToList(),
|
||||
Repositories = rule.Match.Repositories.ToList(),
|
||||
Digests = rule.Match.Digests.ToList(),
|
||||
Labels = rule.Match.Labels.ToList(),
|
||||
ComponentPurls = rule.Match.ComponentPurls.ToList(),
|
||||
MinSeverity = rule.Match.MinSeverity,
|
||||
Verdicts = rule.Match.Verdicts.ToList(),
|
||||
KevOnly = rule.Match.KevOnly ?? false
|
||||
},
|
||||
Actions = rule.Actions.Select(a => new RuleActionResponse
|
||||
{
|
||||
ActionId = a.ActionId,
|
||||
Channel = a.Channel,
|
||||
Template = a.Template,
|
||||
Digest = a.Digest,
|
||||
Throttle = a.Throttle?.ToString(),
|
||||
Locale = a.Locale,
|
||||
Enabled = a.Enabled,
|
||||
Metadata = a.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value)
|
||||
}).ToList(),
|
||||
Labels = rule.Labels.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
Metadata = rule.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
CreatedBy = rule.CreatedBy,
|
||||
CreatedAt = rule.CreatedAt,
|
||||
UpdatedBy = rule.UpdatedBy,
|
||||
UpdatedAt = rule.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static NotifyRule MapRequestToRule(
|
||||
RuleCreateRequest request,
|
||||
string tenantId,
|
||||
string actor,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var match = NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match.EventKinds,
|
||||
namespaces: request.Match.Namespaces,
|
||||
repositories: request.Match.Repositories,
|
||||
digests: request.Match.Digests,
|
||||
labels: request.Match.Labels,
|
||||
componentPurls: request.Match.ComponentPurls,
|
||||
minSeverity: request.Match.MinSeverity,
|
||||
verdicts: request.Match.Verdicts,
|
||||
kevOnly: request.Match.KevOnly);
|
||||
|
||||
var actions = request.Actions.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId,
|
||||
channel: a.Channel,
|
||||
template: a.Template,
|
||||
digest: a.Digest,
|
||||
throttle: string.IsNullOrWhiteSpace(a.Throttle) ? null : System.Xml.XmlConvert.ToTimeSpan(a.Throttle),
|
||||
locale: a.Locale,
|
||||
enabled: a.Enabled,
|
||||
metadata: a.Metadata));
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: request.RuleId,
|
||||
tenantId: tenantId,
|
||||
name: request.Name,
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled,
|
||||
description: request.Description,
|
||||
labels: request.Labels,
|
||||
metadata: request.Metadata,
|
||||
createdBy: actor,
|
||||
createdAt: now,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static NotifyRule ApplyRuleUpdate(
|
||||
NotifyRule existing,
|
||||
RuleUpdateRequest request,
|
||||
string actor,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var match = request.Match is not null
|
||||
? NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds.ToList(),
|
||||
namespaces: request.Match.Namespaces ?? existing.Match.Namespaces.ToList(),
|
||||
repositories: request.Match.Repositories ?? existing.Match.Repositories.ToList(),
|
||||
digests: request.Match.Digests ?? existing.Match.Digests.ToList(),
|
||||
labels: request.Match.Labels ?? existing.Match.Labels.ToList(),
|
||||
componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls.ToList(),
|
||||
minSeverity: request.Match.MinSeverity ?? existing.Match.MinSeverity,
|
||||
verdicts: request.Match.Verdicts ?? existing.Match.Verdicts.ToList(),
|
||||
kevOnly: request.Match.KevOnly ?? existing.Match.KevOnly)
|
||||
: existing.Match;
|
||||
|
||||
var actions = request.Actions is not null
|
||||
? request.Actions.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId,
|
||||
channel: a.Channel,
|
||||
template: a.Template,
|
||||
digest: a.Digest,
|
||||
throttle: string.IsNullOrWhiteSpace(a.Throttle) ? null : System.Xml.XmlConvert.ToTimeSpan(a.Throttle),
|
||||
locale: a.Locale,
|
||||
enabled: a.Enabled,
|
||||
metadata: a.Metadata))
|
||||
: existing.Actions;
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: existing.RuleId,
|
||||
tenantId: existing.TenantId,
|
||||
name: request.Name ?? existing.Name,
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled ?? existing.Enabled,
|
||||
description: request.Description ?? existing.Description,
|
||||
labels: request.Labels ?? existing.Labels.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
metadata: request.Metadata ?? existing.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
createdBy: existing.CreatedBy,
|
||||
createdAt: existing.CreatedAt,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static TemplateResponse MapTemplateToResponse(NotifyTemplate template)
|
||||
{
|
||||
return new TemplateResponse
|
||||
{
|
||||
TemplateId = template.TemplateId,
|
||||
TenantId = template.TenantId,
|
||||
Key = template.Key,
|
||||
ChannelType = template.ChannelType.ToString(),
|
||||
Locale = template.Locale,
|
||||
Body = template.Body,
|
||||
RenderMode = template.RenderMode.ToString(),
|
||||
Format = template.Format.ToString(),
|
||||
Description = template.Description,
|
||||
Metadata = template.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
CreatedBy = template.CreatedBy,
|
||||
CreatedAt = template.CreatedAt,
|
||||
UpdatedBy = template.UpdatedBy,
|
||||
UpdatedAt = template.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
using StellaOps.Notifier.Worker.Retention;
|
||||
using System.Linq;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for observability services.
|
||||
/// </summary>
|
||||
public static class ObservabilityEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps observability endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapObservabilityEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/observability")
|
||||
.WithTags("Observability")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer);
|
||||
|
||||
// Metrics endpoints
|
||||
group.MapGet("/metrics", GetMetricsSnapshot)
|
||||
.WithName("GetMetricsSnapshot")
|
||||
.WithSummary("Gets current metrics snapshot")
|
||||
.WithDescription(_t("notifier.observability.metrics_description"));
|
||||
|
||||
group.MapGet("/metrics/{tenantId}", GetTenantMetrics)
|
||||
.WithName("GetTenantMetrics")
|
||||
.WithSummary("Gets metrics for a specific tenant")
|
||||
.WithDescription(_t("notifier.observability.tenant_metrics_description"));
|
||||
|
||||
// Dead letter endpoints
|
||||
group.MapGet("/dead-letters/{tenantId}", GetDeadLetters)
|
||||
.WithName("GetDeadLetters")
|
||||
.WithSummary("Lists dead letter entries for a tenant")
|
||||
.WithDescription(_t("notifier.observability.dead_letters_description"));
|
||||
|
||||
group.MapGet("/dead-letters/{tenantId}/{entryId}", GetDeadLetterEntry)
|
||||
.WithName("GetDeadLetterEntry")
|
||||
.WithSummary("Gets a specific dead letter entry")
|
||||
.WithDescription(_t("notifier.observability.dead_letter_get_description"));
|
||||
|
||||
group.MapPost("/dead-letters/{tenantId}/{entryId}/retry", RetryDeadLetter)
|
||||
.WithName("RetryDeadLetter")
|
||||
.WithSummary("Retries a dead letter entry")
|
||||
.WithDescription(_t("notifier.observability.dead_letter_retry_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/dead-letters/{tenantId}/{entryId}/discard", DiscardDeadLetter)
|
||||
.WithName("DiscardDeadLetter")
|
||||
.WithSummary("Discards a dead letter entry")
|
||||
.WithDescription(_t("notifier.observability.dead_letter_discard_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapGet("/dead-letters/{tenantId}/stats", GetDeadLetterStats)
|
||||
.WithName("GetDeadLetterStats")
|
||||
.WithSummary("Gets dead letter statistics")
|
||||
.WithDescription(_t("notifier.observability.dead_letter_stats_description"));
|
||||
|
||||
group.MapDelete("/dead-letters/{tenantId}/purge", PurgeDeadLetters)
|
||||
.WithName("PurgeDeadLetters")
|
||||
.WithSummary("Purges old dead letter entries")
|
||||
.WithDescription(_t("notifier.observability.dead_letter_purge_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
// Chaos testing endpoints
|
||||
group.MapGet("/chaos/experiments", ListChaosExperiments)
|
||||
.WithName("ListChaosExperiments")
|
||||
.WithSummary("Lists chaos experiments")
|
||||
.WithDescription(_t("notifier.observability.chaos_list_description"));
|
||||
|
||||
group.MapGet("/chaos/experiments/{experimentId}", GetChaosExperiment)
|
||||
.WithName("GetChaosExperiment")
|
||||
.WithSummary("Gets a chaos experiment")
|
||||
.WithDescription(_t("notifier.observability.chaos_get_description"));
|
||||
|
||||
group.MapPost("/chaos/experiments", StartChaosExperiment)
|
||||
.WithName("StartChaosExperiment")
|
||||
.WithSummary("Starts a new chaos experiment")
|
||||
.WithDescription(_t("notifier.observability.chaos_start_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
group.MapPost("/chaos/experiments/{experimentId}/stop", StopChaosExperiment)
|
||||
.WithName("StopChaosExperiment")
|
||||
.WithSummary("Stops a running chaos experiment")
|
||||
.WithDescription(_t("notifier.observability.chaos_stop_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
group.MapGet("/chaos/experiments/{experimentId}/results", GetChaosResults)
|
||||
.WithName("GetChaosResults")
|
||||
.WithSummary("Gets chaos experiment results")
|
||||
.WithDescription(_t("notifier.observability.chaos_results_description"));
|
||||
|
||||
// Retention policy endpoints
|
||||
group.MapGet("/retention/policies", ListRetentionPolicies)
|
||||
.WithName("ListRetentionPolicies")
|
||||
.WithSummary("Lists retention policies")
|
||||
.WithDescription(_t("notifier.observability.retention_list_description"));
|
||||
|
||||
group.MapGet("/retention/policies/{policyId}", GetRetentionPolicy)
|
||||
.WithName("GetRetentionPolicy")
|
||||
.WithSummary("Gets a retention policy")
|
||||
.WithDescription(_t("notifier.observability.retention_get_description"));
|
||||
|
||||
group.MapPost("/retention/policies", CreateRetentionPolicy)
|
||||
.WithName("CreateRetentionPolicy")
|
||||
.WithSummary("Creates a retention policy")
|
||||
.WithDescription(_t("notifier.observability.retention_create_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
group.MapPut("/retention/policies/{policyId}", UpdateRetentionPolicy)
|
||||
.WithName("UpdateRetentionPolicy")
|
||||
.WithSummary("Updates a retention policy")
|
||||
.WithDescription(_t("notifier.observability.retention_update_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
group.MapDelete("/retention/policies/{policyId}", DeleteRetentionPolicy)
|
||||
.WithName("DeleteRetentionPolicy")
|
||||
.WithSummary("Deletes a retention policy")
|
||||
.WithDescription(_t("notifier.observability.retention_delete_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
group.MapPost("/retention/execute", ExecuteRetention)
|
||||
.WithName("ExecuteRetention")
|
||||
.WithSummary("Executes retention policies")
|
||||
.WithDescription(_t("notifier.observability.retention_execute_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
|
||||
|
||||
group.MapGet("/retention/policies/{policyId}/preview", PreviewRetention)
|
||||
.WithName("PreviewRetention")
|
||||
.WithSummary("Previews retention policy effects")
|
||||
.WithDescription(_t("notifier.observability.retention_preview_description"));
|
||||
|
||||
group.MapGet("/retention/policies/{policyId}/history", GetRetentionHistory)
|
||||
.WithName("GetRetentionHistory")
|
||||
.WithSummary("Gets retention execution history")
|
||||
.WithDescription(_t("notifier.observability.retention_history_description"));
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
// Metrics handlers
|
||||
private static IResult GetMetricsSnapshot(
|
||||
[FromServices] INotifierMetrics metrics)
|
||||
{
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
return Results.Ok(snapshot);
|
||||
}
|
||||
|
||||
private static IResult GetTenantMetrics(
|
||||
string tenantId,
|
||||
[FromServices] INotifierMetrics metrics)
|
||||
{
|
||||
var snapshot = metrics.GetSnapshot(tenantId);
|
||||
return Results.Ok(snapshot);
|
||||
}
|
||||
|
||||
// Dead letter handlers
|
||||
private static async Task<IResult> GetDeadLetters(
|
||||
string tenantId,
|
||||
[FromQuery] int limit,
|
||||
[FromQuery] int offset,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entries = await handler.GetEntriesAsync(
|
||||
tenantId,
|
||||
limit: limit > 0 ? limit : 100,
|
||||
offset: offset,
|
||||
ct: ct);
|
||||
return Results.Ok(entries);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetDeadLetterEntry(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entry = await handler.GetEntryAsync(tenantId, entryId, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("notifier.error.dead_letter_not_found") });
|
||||
}
|
||||
return Results.Ok(entry);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RetryDeadLetter(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
[FromBody] RetryDeadLetterRequest request,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await handler.RetryAsync(tenantId, entryId, request.Actor, ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DiscardDeadLetter(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
[FromBody] DiscardDeadLetterRequest request,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await handler.DiscardAsync(tenantId, entryId, request.Reason, request.Actor, ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetDeadLetterStats(
|
||||
string tenantId,
|
||||
[FromQuery] int? windowHours,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var window = windowHours.HasValue
|
||||
? TimeSpan.FromHours(windowHours.Value)
|
||||
: (TimeSpan?)null;
|
||||
var stats = await handler.GetStatisticsAsync(tenantId, window, ct);
|
||||
return Results.Ok(stats);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PurgeDeadLetters(
|
||||
string tenantId,
|
||||
[FromQuery] int olderThanDays,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var olderThan = TimeSpan.FromDays(olderThanDays > 0 ? olderThanDays : 7);
|
||||
var count = await handler.PurgeAsync(tenantId, olderThan, ct);
|
||||
return Results.Ok(new { purged = count });
|
||||
}
|
||||
|
||||
// Chaos testing handlers
|
||||
private static async Task<IResult> ListChaosExperiments(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int limit,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ChaosExperimentStatus? parsedStatus = null;
|
||||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<ChaosExperimentStatus>(status, true, out var s))
|
||||
{
|
||||
parsedStatus = s;
|
||||
}
|
||||
|
||||
var experiments = await runner.ListExperimentsAsync(parsedStatus, limit > 0 ? limit : 100, ct);
|
||||
return Results.Ok(experiments);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetChaosExperiment(
|
||||
string experimentId,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var experiment = await runner.GetExperimentAsync(experimentId, ct);
|
||||
if (experiment is null)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("notifier.error.experiment_not_found") });
|
||||
}
|
||||
return Results.Ok(experiment);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StartChaosExperiment(
|
||||
[FromBody] ChaosExperimentConfig config,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var experiment = await runner.StartExperimentAsync(config, ct);
|
||||
return Results.Created($"/api/v1/observability/chaos/experiments/{experiment.Id}", experiment);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> StopChaosExperiment(
|
||||
string experimentId,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await runner.StopExperimentAsync(experimentId, ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetChaosResults(
|
||||
string experimentId,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var results = await runner.GetResultsAsync(experimentId, ct);
|
||||
return Results.Ok(results);
|
||||
}
|
||||
|
||||
// Retention policy handlers
|
||||
private static async Task<IResult> ListRetentionPolicies(
|
||||
[FromQuery] string? tenantId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var policies = await service.ListPoliciesAsync(tenantId, ct);
|
||||
return Results.Ok(policies);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRetentionPolicy(
|
||||
string policyId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var policy = await service.GetPolicyAsync(policyId, ct);
|
||||
if (policy is null)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("notifier.error.retention_policy_not_found") });
|
||||
}
|
||||
return Results.Ok(policy);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRetentionPolicy(
|
||||
[FromBody] RetentionPolicy policy,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await service.RegisterPolicyAsync(policy, ct);
|
||||
return Results.Created($"/api/v1/observability/retention/policies/{policy.Id}", policy);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Conflict(new { error = ex.Message });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateRetentionPolicy(
|
||||
string policyId,
|
||||
[FromBody] RetentionPolicy policy,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await service.UpdatePolicyAsync(policyId, policy, ct);
|
||||
return Results.Ok(policy);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("notifier.error.retention_policy_not_found") });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteRetentionPolicy(
|
||||
string policyId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await service.DeletePolicyAsync(policyId, ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExecuteRetention(
|
||||
[FromQuery] string? policyId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await service.ExecuteRetentionAsync(policyId, ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewRetention(
|
||||
string policyId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var preview = await service.PreviewRetentionAsync(policyId, ct);
|
||||
return Results.Ok(preview);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("notifier.error.retention_policy_not_found") });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRetentionHistory(
|
||||
string policyId,
|
||||
[FromQuery] int limit,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var history = await service.GetExecutionHistoryAsync(policyId, limit > 0 ? limit : 100, ct);
|
||||
return Results.Ok(history);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to retry a dead letter entry.
|
||||
/// </summary>
|
||||
public sealed record RetryDeadLetterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Actor performing the retry.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to discard a dead letter entry.
|
||||
/// </summary>
|
||||
public sealed record DiscardDeadLetterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Reason for discarding.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor performing the discard.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
}
|
||||
|
||||
internal static class DeadLetterHandlerCompatExtensions
|
||||
{
|
||||
public static Task<IReadOnlyList<DeadLetteredDelivery>> GetEntriesAsync(
|
||||
this IDeadLetterHandler handler,
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken ct) =>
|
||||
handler.GetAsync(tenantId, new DeadLetterQuery { Limit = limit, Offset = offset }, ct);
|
||||
|
||||
public static async Task<DeadLetteredDelivery?> GetEntryAsync(
|
||||
this IDeadLetterHandler handler,
|
||||
string tenantId,
|
||||
string entryId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var results = await handler.GetAsync(tenantId, new DeadLetterQuery { Limit = 1, Offset = 0, Id = entryId }, ct).ConfigureAwait(false);
|
||||
return results.FirstOrDefault();
|
||||
}
|
||||
|
||||
public static Task<DeadLetterRetryResult> RetryAsync(
|
||||
this IDeadLetterHandler handler,
|
||||
string tenantId,
|
||||
string deadLetterId,
|
||||
string? actor,
|
||||
CancellationToken ct) => handler.RetryAsync(tenantId, deadLetterId, ct);
|
||||
|
||||
public static Task<bool> DiscardAsync(
|
||||
this IDeadLetterHandler handler,
|
||||
string tenantId,
|
||||
string deadLetterId,
|
||||
string? reason,
|
||||
string? actor,
|
||||
CancellationToken ct) => handler.DiscardAsync(tenantId, deadLetterId, reason, ct);
|
||||
|
||||
public static Task<DeadLetterStats> GetStatisticsAsync(
|
||||
this IDeadLetterHandler handler,
|
||||
string tenantId,
|
||||
TimeSpan? window,
|
||||
CancellationToken ct) => handler.GetStatsAsync(tenantId, ct);
|
||||
|
||||
public static Task<int> PurgeAsync(
|
||||
this IDeadLetterHandler handler,
|
||||
string tenantId,
|
||||
TimeSpan olderThan,
|
||||
CancellationToken ct) => Task.FromResult(0);
|
||||
}
|
||||
|
||||
internal static class RetentionPolicyServiceCompatExtensions
|
||||
{
|
||||
private const string DefaultPolicyId = "default";
|
||||
|
||||
public static async Task<IReadOnlyList<RetentionPolicy>> ListPoliciesAsync(
|
||||
this IRetentionPolicyService service,
|
||||
string? tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(tenantId) ? DefaultPolicyId : tenantId;
|
||||
var policy = await service.GetPolicyAsync(id, ct).ConfigureAwait(false);
|
||||
return new[] { policy with { Id = id } };
|
||||
}
|
||||
|
||||
public static async Task<RetentionPolicy?> GetPolicyAsync(
|
||||
this IRetentionPolicyService service,
|
||||
string policyId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
|
||||
var policy = await service.GetPolicyAsync(id, ct).ConfigureAwait(false);
|
||||
return policy with { Id = id };
|
||||
}
|
||||
|
||||
public static Task RegisterPolicyAsync(
|
||||
this IRetentionPolicyService service,
|
||||
RetentionPolicy policy,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(policy.Id) ? DefaultPolicyId : policy.Id;
|
||||
return service.SetPolicyAsync(id, policy with { Id = id }, ct);
|
||||
}
|
||||
|
||||
public static Task UpdatePolicyAsync(
|
||||
this IRetentionPolicyService service,
|
||||
string policyId,
|
||||
RetentionPolicy policy,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
|
||||
return service.SetPolicyAsync(id, policy with { Id = id }, ct);
|
||||
}
|
||||
|
||||
public static Task DeletePolicyAsync(
|
||||
this IRetentionPolicyService service,
|
||||
string policyId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
|
||||
return service.SetPolicyAsync(id, RetentionPolicy.Default with { Id = id }, ct);
|
||||
}
|
||||
|
||||
public static Task<RetentionCleanupResult> ExecuteRetentionAsync(
|
||||
this IRetentionPolicyService service,
|
||||
string? policyId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
|
||||
return service.ExecuteCleanupAsync(id, ct);
|
||||
}
|
||||
|
||||
public static Task<RetentionCleanupPreview> PreviewRetentionAsync(
|
||||
this IRetentionPolicyService service,
|
||||
string policyId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
|
||||
return service.PreviewCleanupAsync(id, ct);
|
||||
}
|
||||
|
||||
public static async Task<IReadOnlyList<RetentionCleanupExecution>> GetExecutionHistoryAsync(
|
||||
this IRetentionPolicyService service,
|
||||
string policyId,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
|
||||
var last = await service.GetLastExecutionAsync(id, ct).ConfigureAwait(false);
|
||||
if (last is null)
|
||||
{
|
||||
return Array.Empty<RetentionCleanupExecution>();
|
||||
}
|
||||
|
||||
return new[] { last };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notify.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for operator override management.
|
||||
/// </summary>
|
||||
public static class OperatorOverrideEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps operator override endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapOperatorOverrideEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/overrides")
|
||||
.WithTags("Overrides")
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListOverridesAsync)
|
||||
.WithName("ListOperatorOverrides")
|
||||
.WithSummary("List active operator overrides")
|
||||
.WithDescription(_t("notifier.override.list_description"));
|
||||
|
||||
group.MapGet("/{overrideId}", GetOverrideAsync)
|
||||
.WithName("GetOperatorOverride")
|
||||
.WithSummary("Get an operator override")
|
||||
.WithDescription(_t("notifier.override.get_description"));
|
||||
|
||||
group.MapPost("/", CreateOverrideAsync)
|
||||
.WithName("CreateOperatorOverride")
|
||||
.WithSummary("Create an operator override")
|
||||
.WithDescription(_t("notifier.override.create_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/{overrideId}/revoke", RevokeOverrideAsync)
|
||||
.WithName("RevokeOperatorOverride")
|
||||
.WithSummary("Revoke an operator override")
|
||||
.WithDescription(_t("notifier.override.revoke_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/check", CheckOverrideAsync)
|
||||
.WithName("CheckOperatorOverride")
|
||||
.WithSummary("Check for applicable override")
|
||||
.WithDescription(_t("notifier.override.check_description"));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListOverridesAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var overrides = await overrideService.ListActiveOverridesAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(overrides.Select(MapToApiResponse));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOverrideAsync(
|
||||
string overrideId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var @override = await overrideService.GetOverrideAsync(tenantId, overrideId, cancellationToken);
|
||||
|
||||
if (@override is null)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("notifier.error.override_not_found", overrideId) });
|
||||
}
|
||||
|
||||
return Results.Ok(MapToApiResponse(@override));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateOverrideAsync(
|
||||
[FromBody] OperatorOverrideApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actorHeader,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
|
||||
}
|
||||
|
||||
var actor = request.Actor ?? actorHeader;
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.actor_required") });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.reason_required") });
|
||||
}
|
||||
|
||||
if (request.DurationMinutes is null or <= 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.duration_required") });
|
||||
}
|
||||
|
||||
var createRequest = new OperatorOverrideCreate
|
||||
{
|
||||
Type = MapOverrideType(request.Type),
|
||||
Reason = request.Reason,
|
||||
Duration = TimeSpan.FromMinutes(request.DurationMinutes.Value),
|
||||
EffectiveFrom = request.EffectiveFrom,
|
||||
EventKinds = request.EventKinds,
|
||||
CorrelationKeys = request.CorrelationKeys,
|
||||
MaxUsageCount = request.MaxUsageCount
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var created = await overrideService.CreateOverrideAsync(tenantId, createRequest, actor, cancellationToken);
|
||||
return Results.Created($"/api/v2/overrides/{created.OverrideId}", MapToApiResponse(created));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Conflict(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeOverrideAsync(
|
||||
string overrideId,
|
||||
[FromBody] RevokeOverrideApiRequest? request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actorHeader,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var actor = request?.Actor ?? actorHeader;
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.actor_required") });
|
||||
}
|
||||
|
||||
var revoked = await overrideService.RevokeOverrideAsync(
|
||||
tenantId,
|
||||
overrideId,
|
||||
actor,
|
||||
request?.Reason,
|
||||
cancellationToken);
|
||||
|
||||
if (!revoked)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("notifier.error.override_not_found_or_inactive", overrideId) });
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CheckOverrideAsync(
|
||||
[FromBody] CheckOverrideApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.EventKind))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.event_kind_required") });
|
||||
}
|
||||
|
||||
var result = await overrideService.CheckOverrideAsync(
|
||||
tenantId,
|
||||
request.EventKind,
|
||||
request.CorrelationKey,
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new CheckOverrideApiResponse
|
||||
{
|
||||
HasOverride = result.HasOverride,
|
||||
BypassedTypes = MapOverrideTypeToStrings(result.BypassedTypes),
|
||||
Override = result.Override is not null ? MapToApiResponse(result.Override) : null
|
||||
});
|
||||
}
|
||||
|
||||
private static OverrideType MapOverrideType(string? type) => type?.ToLowerInvariant() switch
|
||||
{
|
||||
"quiethours" or "quiet_hours" => OverrideType.QuietHours,
|
||||
"throttle" => OverrideType.Throttle,
|
||||
"maintenance" => OverrideType.Maintenance,
|
||||
"all" or _ => OverrideType.All
|
||||
};
|
||||
|
||||
private static List<string> MapOverrideTypeToStrings(OverrideType type)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (type.HasFlag(OverrideType.QuietHours)) result.Add("quiet_hours");
|
||||
if (type.HasFlag(OverrideType.Throttle)) result.Add("throttle");
|
||||
if (type.HasFlag(OverrideType.Maintenance)) result.Add("maintenance");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static OperatorOverrideApiResponse MapToApiResponse(OperatorOverride @override) => new()
|
||||
{
|
||||
OverrideId = @override.OverrideId,
|
||||
TenantId = @override.TenantId,
|
||||
Type = MapOverrideTypeToStrings(@override.Type),
|
||||
Reason = @override.Reason,
|
||||
EffectiveFrom = @override.EffectiveFrom,
|
||||
ExpiresAt = @override.ExpiresAt,
|
||||
EventKinds = @override.EventKinds.ToList(),
|
||||
CorrelationKeys = @override.CorrelationKeys.ToList(),
|
||||
MaxUsageCount = @override.MaxUsageCount,
|
||||
UsageCount = @override.UsageCount,
|
||||
Status = @override.Status.ToString().ToLowerInvariant(),
|
||||
CreatedBy = @override.CreatedBy,
|
||||
CreatedAt = @override.CreatedAt,
|
||||
RevokedBy = @override.RevokedBy,
|
||||
RevokedAt = @override.RevokedAt,
|
||||
RevocationReason = @override.RevocationReason
|
||||
};
|
||||
}
|
||||
|
||||
#region API Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an operator override.
|
||||
/// </summary>
|
||||
public sealed class OperatorOverrideApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? Actor { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public int? DurationMinutes { get; set; }
|
||||
public DateTimeOffset? EffectiveFrom { get; set; }
|
||||
public List<string>? EventKinds { get; set; }
|
||||
public List<string>? CorrelationKeys { get; set; }
|
||||
public int? MaxUsageCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to revoke an operator override.
|
||||
/// </summary>
|
||||
public sealed class RevokeOverrideApiRequest
|
||||
{
|
||||
public string? Actor { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to check for applicable override.
|
||||
/// </summary>
|
||||
public sealed class CheckOverrideApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? EventKind { get; set; }
|
||||
public string? CorrelationKey { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for an operator override.
|
||||
/// </summary>
|
||||
public sealed class OperatorOverrideApiResponse
|
||||
{
|
||||
public required string OverrideId { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required List<string> Type { get; set; }
|
||||
public required string Reason { get; set; }
|
||||
public required DateTimeOffset EffectiveFrom { get; set; }
|
||||
public required DateTimeOffset ExpiresAt { get; set; }
|
||||
public required List<string> EventKinds { get; set; }
|
||||
public required List<string> CorrelationKeys { get; set; }
|
||||
public int? MaxUsageCount { get; set; }
|
||||
public required int UsageCount { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public required string CreatedBy { get; set; }
|
||||
public required DateTimeOffset CreatedAt { get; set; }
|
||||
public string? RevokedBy { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevocationReason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for override check.
|
||||
/// </summary>
|
||||
public sealed class CheckOverrideApiResponse
|
||||
{
|
||||
public required bool HasOverride { get; set; }
|
||||
public required List<string> BypassedTypes { get; set; }
|
||||
public OperatorOverrideApiResponse? Override { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,361 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notify.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for quiet hours calendar management.
|
||||
/// </summary>
|
||||
public static class QuietHoursEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps quiet hours endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapQuietHoursEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/quiet-hours")
|
||||
.WithTags("QuietHours")
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/calendars", ListCalendarsAsync)
|
||||
.WithName("ListQuietHoursCalendars")
|
||||
.WithSummary("List all quiet hours calendars")
|
||||
.WithDescription(_t("notifier.quiet_hours.list_description"));
|
||||
|
||||
group.MapGet("/calendars/{calendarId}", GetCalendarAsync)
|
||||
.WithName("GetQuietHoursCalendar")
|
||||
.WithSummary("Get a quiet hours calendar")
|
||||
.WithDescription(_t("notifier.quiet_hours.get_description"));
|
||||
|
||||
group.MapPost("/calendars", CreateCalendarAsync)
|
||||
.WithName("CreateQuietHoursCalendar")
|
||||
.WithSummary("Create a quiet hours calendar")
|
||||
.WithDescription(_t("notifier.quiet_hours.create_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPut("/calendars/{calendarId}", UpdateCalendarAsync)
|
||||
.WithName("UpdateQuietHoursCalendar")
|
||||
.WithSummary("Update a quiet hours calendar")
|
||||
.WithDescription(_t("notifier.quiet_hours.update_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapDelete("/calendars/{calendarId}", DeleteCalendarAsync)
|
||||
.WithName("DeleteQuietHoursCalendar")
|
||||
.WithSummary("Delete a quiet hours calendar")
|
||||
.WithDescription(_t("notifier.quiet_hours.delete_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/evaluate", EvaluateAsync)
|
||||
.WithName("EvaluateQuietHours")
|
||||
.WithSummary("Evaluate quiet hours")
|
||||
.WithDescription(_t("notifier.quiet_hours.evaluate_description"));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListCalendarsAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var calendars = await calendarService.ListCalendarsAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(calendars.Select(MapToApiResponse));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetCalendarAsync(
|
||||
string calendarId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var calendar = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
|
||||
|
||||
if (calendar is null)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("notifier.error.calendar_not_found", calendarId) });
|
||||
}
|
||||
|
||||
return Results.Ok(MapToApiResponse(calendar));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateCalendarAsync(
|
||||
[FromBody] QuietHoursCalendarApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.calendar_name_required") });
|
||||
}
|
||||
|
||||
if (request.Schedules is null || request.Schedules.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.calendar_schedules_required") });
|
||||
}
|
||||
|
||||
var calendarId = request.CalendarId ?? Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
var calendar = new QuietHoursCalendar
|
||||
{
|
||||
CalendarId = calendarId,
|
||||
TenantId = tenantId,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Enabled = request.Enabled ?? true,
|
||||
Priority = request.Priority ?? 100,
|
||||
Schedules = request.Schedules.Select(MapToScheduleEntry).ToList(),
|
||||
ExcludedEventKinds = request.ExcludedEventKinds,
|
||||
IncludedEventKinds = request.IncludedEventKinds
|
||||
};
|
||||
|
||||
var created = await calendarService.UpsertCalendarAsync(calendar, actor, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v2/quiet-hours/calendars/{created.CalendarId}", MapToApiResponse(created));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateCalendarAsync(
|
||||
string calendarId,
|
||||
[FromBody] QuietHoursCalendarApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
|
||||
}
|
||||
|
||||
var existing = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("notifier.error.calendar_not_found", calendarId) });
|
||||
}
|
||||
|
||||
var calendar = new QuietHoursCalendar
|
||||
{
|
||||
CalendarId = calendarId,
|
||||
TenantId = tenantId,
|
||||
Name = request.Name ?? existing.Name,
|
||||
Description = request.Description ?? existing.Description,
|
||||
Enabled = request.Enabled ?? existing.Enabled,
|
||||
Priority = request.Priority ?? existing.Priority,
|
||||
Schedules = request.Schedules?.Select(MapToScheduleEntry).ToList() ?? existing.Schedules,
|
||||
ExcludedEventKinds = request.ExcludedEventKinds ?? existing.ExcludedEventKinds,
|
||||
IncludedEventKinds = request.IncludedEventKinds ?? existing.IncludedEventKinds,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
CreatedBy = existing.CreatedBy
|
||||
};
|
||||
|
||||
var updated = await calendarService.UpsertCalendarAsync(calendar, actor, cancellationToken);
|
||||
|
||||
return Results.Ok(MapToApiResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteCalendarAsync(
|
||||
string calendarId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
||||
}
|
||||
|
||||
var deleted = await calendarService.DeleteCalendarAsync(tenantId, calendarId, actor, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("notifier.error.calendar_not_found", calendarId) });
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> EvaluateAsync(
|
||||
[FromBody] QuietHoursEvaluateApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.EventKind))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("notifier.error.event_kind_required") });
|
||||
}
|
||||
|
||||
var result = await calendarService.EvaluateAsync(
|
||||
tenantId,
|
||||
request.EventKind,
|
||||
request.EvaluationTime,
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new QuietHoursEvaluateApiResponse
|
||||
{
|
||||
IsActive = result.IsActive,
|
||||
MatchedCalendarId = result.MatchedCalendarId,
|
||||
MatchedCalendarName = result.MatchedCalendarName,
|
||||
MatchedScheduleName = result.MatchedScheduleName,
|
||||
EndsAt = result.EndsAt,
|
||||
Reason = result.Reason
|
||||
});
|
||||
}
|
||||
|
||||
private static QuietHoursScheduleEntry MapToScheduleEntry(QuietHoursScheduleApiRequest request) => new()
|
||||
{
|
||||
Name = request.Name ?? "Unnamed Schedule",
|
||||
StartTime = request.StartTime ?? "00:00",
|
||||
EndTime = request.EndTime ?? "00:00",
|
||||
DaysOfWeek = request.DaysOfWeek,
|
||||
Timezone = request.Timezone,
|
||||
Enabled = request.Enabled ?? true
|
||||
};
|
||||
|
||||
private static QuietHoursCalendarApiResponse MapToApiResponse(QuietHoursCalendar calendar) => new()
|
||||
{
|
||||
CalendarId = calendar.CalendarId,
|
||||
TenantId = calendar.TenantId,
|
||||
Name = calendar.Name,
|
||||
Description = calendar.Description,
|
||||
Enabled = calendar.Enabled,
|
||||
Priority = calendar.Priority,
|
||||
Schedules = calendar.Schedules.Select(s => new QuietHoursScheduleApiResponse
|
||||
{
|
||||
Name = s.Name,
|
||||
StartTime = s.StartTime,
|
||||
EndTime = s.EndTime,
|
||||
DaysOfWeek = s.DaysOfWeek?.ToList(),
|
||||
Timezone = s.Timezone,
|
||||
Enabled = s.Enabled
|
||||
}).ToList(),
|
||||
ExcludedEventKinds = calendar.ExcludedEventKinds?.ToList(),
|
||||
IncludedEventKinds = calendar.IncludedEventKinds?.ToList(),
|
||||
CreatedAt = calendar.CreatedAt,
|
||||
CreatedBy = calendar.CreatedBy,
|
||||
UpdatedAt = calendar.UpdatedAt,
|
||||
UpdatedBy = calendar.UpdatedBy
|
||||
};
|
||||
}
|
||||
|
||||
#region API Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a quiet hours calendar.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursCalendarApiRequest
|
||||
{
|
||||
public string? CalendarId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public int? Priority { get; set; }
|
||||
public List<QuietHoursScheduleApiRequest>? Schedules { get; set; }
|
||||
public List<string>? ExcludedEventKinds { get; set; }
|
||||
public List<string>? IncludedEventKinds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedule entry in a quiet hours calendar request.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursScheduleApiRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? StartTime { get; set; }
|
||||
public string? EndTime { get; set; }
|
||||
public List<int>? DaysOfWeek { get; set; }
|
||||
public string? Timezone { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to evaluate quiet hours.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursEvaluateApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? EventKind { get; set; }
|
||||
public DateTimeOffset? EvaluationTime { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a quiet hours calendar.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursCalendarApiResponse
|
||||
{
|
||||
public required string CalendarId { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
public required int Priority { get; set; }
|
||||
public required List<QuietHoursScheduleApiResponse> Schedules { get; set; }
|
||||
public List<string>? ExcludedEventKinds { get; set; }
|
||||
public List<string>? IncludedEventKinds { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedule entry in a quiet hours calendar response.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursScheduleApiResponse
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string StartTime { get; set; }
|
||||
public required string EndTime { get; set; }
|
||||
public List<int>? DaysOfWeek { get; set; }
|
||||
public string? Timezone { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for quiet hours evaluation.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursEvaluateApiResponse
|
||||
{
|
||||
public required bool IsActive { get; set; }
|
||||
public string? MatchedCalendarId { get; set; }
|
||||
public string? MatchedCalendarName { get; set; }
|
||||
public string? MatchedScheduleName { get; set; }
|
||||
public DateTimeOffset? EndsAt { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,420 @@
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notify.WebService.Contracts;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Maps rule management endpoints.
|
||||
/// </summary>
|
||||
public static class RuleEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRuleEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/rules")
|
||||
.WithTags("Rules")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListRulesAsync)
|
||||
.WithName("ListRules")
|
||||
.WithSummary("Lists all rules for a tenant")
|
||||
.WithDescription(_t("notifier.rule.list2_description"));
|
||||
|
||||
group.MapGet("/{ruleId}", GetRuleAsync)
|
||||
.WithName("GetRule")
|
||||
.WithSummary("Gets a rule by ID")
|
||||
.WithDescription(_t("notifier.rule.get2_description"));
|
||||
|
||||
group.MapPost("/", CreateRuleAsync)
|
||||
.WithName("CreateRule")
|
||||
.WithSummary("Creates a new rule")
|
||||
.WithDescription(_t("notifier.rule.create2_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPut("/{ruleId}", UpdateRuleAsync)
|
||||
.WithName("UpdateRule")
|
||||
.WithSummary("Updates an existing rule")
|
||||
.WithDescription(_t("notifier.rule.update2_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapDelete("/{ruleId}", DeleteRuleAsync)
|
||||
.WithName("DeleteRule")
|
||||
.WithSummary("Deletes a rule")
|
||||
.WithDescription(_t("notifier.rule.delete2_description"))
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListRulesAsync(
|
||||
HttpContext context,
|
||||
INotifyRuleRepository rules,
|
||||
bool? enabled = null,
|
||||
string? keyPrefix = null,
|
||||
int? limit = null)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var allRules = await rules.ListAsync(tenantId, context.RequestAborted);
|
||||
|
||||
IEnumerable<NotifyRule> filtered = allRules;
|
||||
|
||||
if (enabled.HasValue)
|
||||
{
|
||||
filtered = filtered.Where(r => r.Enabled == enabled.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyPrefix))
|
||||
{
|
||||
filtered = filtered.Where(r => r.Name.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (limit.HasValue && limit.Value > 0)
|
||||
{
|
||||
filtered = filtered.Take(limit.Value);
|
||||
}
|
||||
|
||||
var items = filtered.Select(MapToResponse).ToList();
|
||||
return Results.Ok(new { items, total = items.Count });
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRuleAsync(
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
INotifyRuleRepository rules)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var rule = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
|
||||
if (rule is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapToResponse(rule));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRuleAsync(
|
||||
HttpContext context,
|
||||
RuleCreateRequest request,
|
||||
INotifyRuleRepository rules,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
// Check if rule already exists
|
||||
var existing = await rules.GetAsync(tenantId, request.RuleId, context.RequestAborted);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Results.Conflict(Error("rule_exists", _t("notifier.error.rule_exists", request.RuleId), context));
|
||||
}
|
||||
|
||||
var rule = MapFromRequest(request, tenantId, actor, timeProvider);
|
||||
|
||||
await rules.UpsertAsync(rule, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "rule.created", request.RuleId, "rule", request, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Created($"/api/v2/rules/{rule.RuleId}", MapToResponse(rule));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateRuleAsync(
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
RuleUpdateRequest request,
|
||||
INotifyRuleRepository rules,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
var existing = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
|
||||
}
|
||||
|
||||
var updated = MergeUpdate(existing, request, actor, timeProvider);
|
||||
|
||||
await rules.UpsertAsync(updated, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "rule.updated", ruleId, "rule", request, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Ok(MapToResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteRuleAsync(
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
INotifyRuleRepository rules,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
var existing = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
|
||||
}
|
||||
|
||||
await rules.DeleteAsync(tenantId, ruleId, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "rule.deleted", ruleId, "rule", new { ruleId }, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static NotifyRule MapFromRequest(RuleCreateRequest request, string tenantId, string actor, TimeProvider timeProvider)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var match = NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match.EventKinds,
|
||||
namespaces: request.Match.Namespaces,
|
||||
repositories: request.Match.Repositories,
|
||||
digests: request.Match.Digests,
|
||||
labels: request.Match.Labels,
|
||||
componentPurls: request.Match.ComponentPurls,
|
||||
minSeverity: request.Match.MinSeverity,
|
||||
verdicts: request.Match.Verdicts,
|
||||
kevOnly: request.Match.KevOnly);
|
||||
|
||||
var actions = request.Actions.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId,
|
||||
channel: a.Channel,
|
||||
template: a.Template,
|
||||
digest: a.Digest,
|
||||
throttle: ParseThrottle(a.Throttle),
|
||||
locale: a.Locale,
|
||||
enabled: a.Enabled,
|
||||
metadata: a.Metadata));
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: request.RuleId,
|
||||
tenantId: tenantId,
|
||||
name: request.Name,
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled,
|
||||
description: request.Description,
|
||||
labels: request.Labels,
|
||||
metadata: request.Metadata,
|
||||
createdBy: actor,
|
||||
createdAt: now,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static NotifyRule MergeUpdate(NotifyRule existing, RuleUpdateRequest request, string actor, TimeProvider timeProvider)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var match = request.Match is not null
|
||||
? NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds.AsEnumerable(),
|
||||
namespaces: request.Match.Namespaces ?? existing.Match.Namespaces.AsEnumerable(),
|
||||
repositories: request.Match.Repositories ?? existing.Match.Repositories.AsEnumerable(),
|
||||
digests: request.Match.Digests ?? existing.Match.Digests.AsEnumerable(),
|
||||
labels: request.Match.Labels ?? existing.Match.Labels.AsEnumerable(),
|
||||
componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls.AsEnumerable(),
|
||||
minSeverity: request.Match.MinSeverity ?? existing.Match.MinSeverity,
|
||||
verdicts: request.Match.Verdicts ?? existing.Match.Verdicts.AsEnumerable(),
|
||||
kevOnly: request.Match.KevOnly ?? existing.Match.KevOnly)
|
||||
: existing.Match;
|
||||
|
||||
var actions = request.Actions is not null
|
||||
? request.Actions.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId,
|
||||
channel: a.Channel,
|
||||
template: a.Template,
|
||||
digest: a.Digest,
|
||||
throttle: ParseThrottle(a.Throttle),
|
||||
locale: a.Locale,
|
||||
enabled: a.Enabled,
|
||||
metadata: a.Metadata))
|
||||
: existing.Actions;
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: existing.RuleId,
|
||||
tenantId: existing.TenantId,
|
||||
name: request.Name ?? existing.Name,
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled ?? existing.Enabled,
|
||||
description: request.Description ?? existing.Description,
|
||||
labels: request.Labels ?? existing.Labels.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||
metadata: request.Metadata ?? existing.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||
createdBy: existing.CreatedBy,
|
||||
createdAt: existing.CreatedAt,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static RuleResponse MapToResponse(NotifyRule rule)
|
||||
{
|
||||
return new RuleResponse
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
TenantId = rule.TenantId,
|
||||
Name = rule.Name,
|
||||
Description = rule.Description,
|
||||
Enabled = rule.Enabled,
|
||||
Match = new RuleMatchResponse
|
||||
{
|
||||
EventKinds = rule.Match.EventKinds.ToList(),
|
||||
Namespaces = rule.Match.Namespaces.ToList(),
|
||||
Repositories = rule.Match.Repositories.ToList(),
|
||||
Digests = rule.Match.Digests.ToList(),
|
||||
Labels = rule.Match.Labels.ToList(),
|
||||
ComponentPurls = rule.Match.ComponentPurls.ToList(),
|
||||
MinSeverity = rule.Match.MinSeverity,
|
||||
Verdicts = rule.Match.Verdicts.ToList(),
|
||||
KevOnly = rule.Match.KevOnly ?? false
|
||||
},
|
||||
Actions = rule.Actions.Select(a => new RuleActionResponse
|
||||
{
|
||||
ActionId = a.ActionId,
|
||||
Channel = a.Channel,
|
||||
Template = a.Template,
|
||||
Digest = a.Digest,
|
||||
Throttle = a.Throttle?.ToString(),
|
||||
Locale = a.Locale,
|
||||
Enabled = a.Enabled,
|
||||
Metadata = a.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
|
||||
}).ToList(),
|
||||
Labels = rule.Labels.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||
Metadata = rule.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||
CreatedBy = rule.CreatedBy,
|
||||
CreatedAt = rule.CreatedAt,
|
||||
UpdatedBy = rule.UpdatedBy,
|
||||
UpdatedAt = rule.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseThrottle(string? throttle)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(throttle))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try parsing as TimeSpan directly
|
||||
if (TimeSpan.TryParse(throttle, out var ts))
|
||||
{
|
||||
return ts;
|
||||
}
|
||||
|
||||
// Try parsing ISO 8601 duration (simplified: PT1H, PT30M, etc.)
|
||||
if (throttle.StartsWith("PT", StringComparison.OrdinalIgnoreCase) && throttle.Length > 2)
|
||||
{
|
||||
var value = throttle[2..^1];
|
||||
var unit = throttle[^1];
|
||||
|
||||
if (int.TryParse(value, out var num))
|
||||
{
|
||||
return char.ToUpperInvariant(unit) switch
|
||||
{
|
||||
'H' => TimeSpan.FromHours(num),
|
||||
'M' => TimeSpan.FromMinutes(num),
|
||||
'S' => TimeSpan.FromSeconds(num),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
return string.IsNullOrWhiteSpace(tenantId) ? null : tenantId;
|
||||
}
|
||||
|
||||
private static string GetActor(HttpContext context)
|
||||
{
|
||||
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
|
||||
return string.IsNullOrWhiteSpace(actor) ? "api" : actor;
|
||||
}
|
||||
|
||||
private static async Task AppendAuditAsync(
|
||||
INotifyAuditRepository audit,
|
||||
string tenantId,
|
||||
string actor,
|
||||
string action,
|
||||
string entityId,
|
||||
string entityType,
|
||||
object payload,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = actor,
|
||||
Action = action,
|
||||
EntityId = entityId,
|
||||
EntityType = entityType,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Payload = JsonSerializer.SerializeToNode(payload) as JsonObject
|
||||
};
|
||||
|
||||
await audit.AppendAsync(entry, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore audit failures
|
||||
}
|
||||
}
|
||||
|
||||
private static object Error(string code, string message, HttpContext context) => new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST endpoints for security services.
|
||||
/// </summary>
|
||||
public static class SecurityEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSecurityEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/security")
|
||||
.WithTags("Security")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyAdmin)
|
||||
.RequireTenant();
|
||||
|
||||
// Signing endpoints
|
||||
group.MapPost("/tokens/sign", SignTokenAsync)
|
||||
.WithName("SignToken")
|
||||
.WithDescription(_t("notifier.security.sign_description"));
|
||||
|
||||
group.MapPost("/tokens/verify", VerifyTokenAsync)
|
||||
.WithName("VerifyToken")
|
||||
.WithDescription("Verifies a signed token and returns the decoded payload if valid. Returns an error if the token is expired, tampered, or issued by a rotated key.");
|
||||
|
||||
group.MapGet("/tokens/{token}/info", GetTokenInfo)
|
||||
.WithName("GetTokenInfo")
|
||||
.WithDescription("Decodes and returns structural information about a token without performing cryptographic verification. Useful for debugging expired or unknown tokens.");
|
||||
|
||||
group.MapPost("/keys/rotate", RotateKeyAsync)
|
||||
.WithName("RotateSigningKey")
|
||||
.WithDescription("Rotates the active signing key. Previously signed tokens remain verifiable during the overlap window. Old keys are retired after the configured grace period.");
|
||||
|
||||
// Webhook security endpoints
|
||||
group.MapPost("/webhooks", RegisterWebhookConfigAsync)
|
||||
.WithName("RegisterWebhookConfig")
|
||||
.WithDescription("Registers or replaces the webhook security configuration for a channel, including the shared secret and allowed IP ranges.");
|
||||
|
||||
group.MapGet("/webhooks/{tenantId}/{channelId}", GetWebhookConfigAsync)
|
||||
.WithName("GetWebhookConfig")
|
||||
.WithDescription("Returns the webhook security configuration for a tenant and channel. The secret is not included in the response.");
|
||||
|
||||
group.MapPost("/webhooks/validate", ValidateWebhookAsync)
|
||||
.WithName("ValidateWebhook")
|
||||
.WithDescription("Validates an inbound webhook request against its registered security configuration, verifying the signature and checking the source IP against the allowlist.");
|
||||
|
||||
group.MapPut("/webhooks/{tenantId}/{channelId}/allowlist", UpdateWebhookAllowlistAsync)
|
||||
.WithName("UpdateWebhookAllowlist")
|
||||
.WithDescription("Replaces the IP allowlist for a webhook channel. An empty list removes all IP restrictions.");
|
||||
|
||||
// HTML sanitization endpoints
|
||||
group.MapPost("/html/sanitize", SanitizeHtmlAsync)
|
||||
.WithName("SanitizeHtml")
|
||||
.WithDescription("Sanitizes HTML content using the specified profile (or the default profile if omitted), removing disallowed tags and attributes.");
|
||||
|
||||
group.MapPost("/html/validate", ValidateHtmlAsync)
|
||||
.WithName("ValidateHtml")
|
||||
.WithDescription("Validates HTML content against the specified profile and returns whether it is safe, along with details of any disallowed elements found.");
|
||||
|
||||
group.MapPost("/html/strip", StripHtmlTagsAsync)
|
||||
.WithName("StripHtmlTags")
|
||||
.WithDescription("Removes all HTML tags from the input, returning plain text. Useful for generating fallback plain-text notification bodies from HTML templates.");
|
||||
|
||||
// Tenant isolation endpoints
|
||||
group.MapPost("/tenants/validate", ValidateTenantAccessAsync)
|
||||
.WithName("ValidateTenantAccess")
|
||||
.WithDescription("Validates whether the calling tenant is permitted to access the specified resource type and ID for the requested operation. Returns a violation record if access is denied.");
|
||||
|
||||
group.MapGet("/tenants/{tenantId}/violations", GetTenantViolationsAsync)
|
||||
.WithName("GetTenantViolations")
|
||||
.WithDescription("Returns recorded tenant isolation violations for the specified tenant, optionally filtered by time range.");
|
||||
|
||||
group.MapPost("/tenants/fuzz-test", RunTenantFuzzTestAsync)
|
||||
.WithName("RunTenantFuzzTest")
|
||||
.WithDescription("Runs automated tenant isolation fuzz tests, exercising cross-tenant access paths to surface potential data-leakage vulnerabilities.");
|
||||
|
||||
group.MapPost("/tenants/grants", GrantCrossTenantAccessAsync)
|
||||
.WithName("GrantCrossTenantAccess")
|
||||
.WithDescription("Grants a target tenant time-bounded access to a resource owned by the owner tenant. Grant records are auditable and expire automatically.");
|
||||
|
||||
group.MapDelete("/tenants/grants", RevokeCrossTenantAccessAsync)
|
||||
.WithName("RevokeCrossTenantAccess")
|
||||
.WithDescription("Revokes a previously granted cross-tenant access grant before its expiry. Revocation is immediate and recorded in the audit log.");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
// Signing endpoints
|
||||
private static async Task<IResult> SignTokenAsync(
|
||||
[FromBody] SignTokenRequest request,
|
||||
[FromServices] ISigningService signingService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = request.TokenId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
Purpose = request.Purpose,
|
||||
TenantId = request.TenantId,
|
||||
Subject = request.Subject,
|
||||
Target = request.Target,
|
||||
ExpiresAt = request.ExpiresAt ?? DateTimeOffset.UtcNow.AddHours(24),
|
||||
Claims = request.Claims ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var token = await signingService.SignAsync(payload, cancellationToken);
|
||||
return Results.Ok(new { token });
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyTokenAsync(
|
||||
[FromBody] VerifyTokenRequest request,
|
||||
[FromServices] ISigningService signingService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await signingService.VerifyAsync(request.Token, cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static IResult GetTokenInfo(
|
||||
string token,
|
||||
[FromServices] ISigningService signingService)
|
||||
{
|
||||
var info = signingService.GetTokenInfo(token);
|
||||
return info is not null ? Results.Ok(info) : Results.NotFound();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RotateKeyAsync(
|
||||
[FromServices] ISigningService signingService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var success = await signingService.RotateKeyAsync(cancellationToken);
|
||||
return success ? Results.Ok(new { message = "Key rotated successfully" }) : Results.Problem("Failed to rotate key");
|
||||
}
|
||||
|
||||
// Webhook security endpoints
|
||||
private static async Task<IResult> RegisterWebhookConfigAsync(
|
||||
[FromBody] WebhookSecurityConfig config,
|
||||
[FromServices] IWebhookSecurityService webhookService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await webhookService.RegisterWebhookAsync(config, cancellationToken);
|
||||
return Results.Created($"/api/v2/security/webhooks/{config.TenantId}/{config.ChannelId}", config);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetWebhookConfigAsync(
|
||||
string tenantId,
|
||||
string channelId,
|
||||
[FromServices] IWebhookSecurityService webhookService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var config = await webhookService.GetConfigAsync(tenantId, channelId, cancellationToken);
|
||||
return config is not null ? Results.Ok(config) : Results.NotFound();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ValidateWebhookAsync(
|
||||
[FromBody] WebhookValidationRequest request,
|
||||
[FromServices] IWebhookSecurityService webhookService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await webhookService.ValidateAsync(request, cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateWebhookAllowlistAsync(
|
||||
string tenantId,
|
||||
string channelId,
|
||||
[FromBody] UpdateAllowlistRequest request,
|
||||
[FromServices] IWebhookSecurityService webhookService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await webhookService.UpdateAllowlistAsync(tenantId, channelId, request.AllowedIps, request.Actor, cancellationToken);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// HTML sanitization endpoints
|
||||
private static Task<IResult> SanitizeHtmlAsync(
|
||||
[FromBody] SanitizeHtmlRequest request,
|
||||
[FromServices] IHtmlSanitizer sanitizer)
|
||||
{
|
||||
var profile = request.Profile is not null ? sanitizer.GetProfile(request.Profile) : null;
|
||||
var sanitized = sanitizer.Sanitize(request.Html, profile);
|
||||
return Task.FromResult(Results.Ok(new { sanitized }));
|
||||
}
|
||||
|
||||
private static Task<IResult> ValidateHtmlAsync(
|
||||
[FromBody] ValidateHtmlRequest request,
|
||||
[FromServices] IHtmlSanitizer sanitizer)
|
||||
{
|
||||
var profile = request.Profile is not null ? sanitizer.GetProfile(request.Profile) : null;
|
||||
var result = sanitizer.Validate(request.Html, profile);
|
||||
return Task.FromResult(Results.Ok(result));
|
||||
}
|
||||
|
||||
private static Task<IResult> StripHtmlTagsAsync(
|
||||
[FromBody] StripHtmlRequest request,
|
||||
[FromServices] IHtmlSanitizer sanitizer)
|
||||
{
|
||||
var stripped = sanitizer.StripTags(request.Html);
|
||||
return Task.FromResult(Results.Ok(new { text = stripped }));
|
||||
}
|
||||
|
||||
// Tenant isolation endpoints
|
||||
private static async Task<IResult> ValidateTenantAccessAsync(
|
||||
[FromBody] ValidateTenantAccessRequest request,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await validator.ValidateResourceAccessAsync(
|
||||
request.TenantId,
|
||||
request.ResourceType,
|
||||
request.ResourceId,
|
||||
request.Operation,
|
||||
cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTenantViolationsAsync(
|
||||
string tenantId,
|
||||
[FromQuery] DateTimeOffset? since,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var violations = await validator.GetViolationsAsync(tenantId, since, cancellationToken);
|
||||
return Results.Ok(violations);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RunTenantFuzzTestAsync(
|
||||
[FromBody] TenantFuzzTestConfig config,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await validator.RunFuzzTestAsync(config, cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GrantCrossTenantAccessAsync(
|
||||
[FromBody] CrossTenantGrantRequest request,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await validator.GrantCrossTenantAccessAsync(
|
||||
request.OwnerTenantId,
|
||||
request.TargetTenantId,
|
||||
request.ResourceType,
|
||||
request.ResourceId,
|
||||
request.AllowedOperations,
|
||||
request.ExpiresAt,
|
||||
request.GrantedBy,
|
||||
cancellationToken);
|
||||
return Results.Created();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeCrossTenantAccessAsync(
|
||||
[FromBody] RevokeCrossTenantRequest request,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await validator.RevokeCrossTenantAccessAsync(
|
||||
request.OwnerTenantId,
|
||||
request.TargetTenantId,
|
||||
request.ResourceType,
|
||||
request.ResourceId,
|
||||
request.RevokedBy,
|
||||
cancellationToken);
|
||||
return Results.NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// Request DTOs
|
||||
public sealed record SignTokenRequest
|
||||
{
|
||||
public string? TokenId { get; init; }
|
||||
public required string Purpose { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Subject { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public Dictionary<string, string>? Claims { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VerifyTokenRequest
|
||||
{
|
||||
public required string Token { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UpdateAllowlistRequest
|
||||
{
|
||||
public required IReadOnlyList<string> AllowedIps { get; init; }
|
||||
public required string Actor { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SanitizeHtmlRequest
|
||||
{
|
||||
public required string Html { get; init; }
|
||||
public string? Profile { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ValidateHtmlRequest
|
||||
{
|
||||
public required string Html { get; init; }
|
||||
public string? Profile { get; init; }
|
||||
}
|
||||
|
||||
public sealed record StripHtmlRequest
|
||||
{
|
||||
public required string Html { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ValidateTenantAccessRequest
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string ResourceType { get; init; }
|
||||
public required string ResourceId { get; init; }
|
||||
public TenantAccessOperation Operation { get; init; } = TenantAccessOperation.Read;
|
||||
}
|
||||
|
||||
public sealed record CrossTenantGrantRequest
|
||||
{
|
||||
public required string OwnerTenantId { get; init; }
|
||||
public required string TargetTenantId { get; init; }
|
||||
public required string ResourceType { get; init; }
|
||||
public required string ResourceId { get; init; }
|
||||
public required TenantAccessOperation AllowedOperations { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public required string GrantedBy { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RevokeCrossTenantRequest
|
||||
{
|
||||
public required string OwnerTenantId { get; init; }
|
||||
public required string TargetTenantId { get; init; }
|
||||
public required string ResourceType { get; init; }
|
||||
public required string ResourceId { get; init; }
|
||||
public required string RevokedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notify.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Simulation;
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for rule simulation.
|
||||
/// </summary>
|
||||
public static class SimulationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps simulation endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapSimulationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/simulate")
|
||||
.WithTags("Simulation")
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapPost("/", SimulateAsync)
|
||||
.WithName("SimulateRules")
|
||||
.WithSummary("Simulate rule evaluation against events")
|
||||
.WithDescription("Dry-runs rules against provided or historical events without side effects. Returns matched actions with detailed explanations.");
|
||||
|
||||
group.MapPost("/validate", ValidateRuleAsync)
|
||||
.WithName("ValidateRule")
|
||||
.WithSummary("Validate a rule definition")
|
||||
.WithDescription("Validates a rule definition and returns any errors or warnings.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SimulateAsync(
|
||||
[FromBody] SimulationApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] ISimulationEngine simulationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
// Convert API events to NotifyEvent
|
||||
var events = request.Events?.Select(MapToNotifyEvent).ToList();
|
||||
|
||||
// Convert API rules to NotifyRule
|
||||
var rules = request.Rules?.Select(r => MapToNotifyRule(r, tenantId)).ToList();
|
||||
|
||||
var simulationRequest = new SimulationRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Events = events,
|
||||
Rules = rules,
|
||||
EnabledRulesOnly = request.EnabledRulesOnly ?? true,
|
||||
HistoricalLookback = request.HistoricalLookbackMinutes.HasValue
|
||||
? TimeSpan.FromMinutes(request.HistoricalLookbackMinutes.Value)
|
||||
: null,
|
||||
MaxEvents = request.MaxEvents ?? 100,
|
||||
EventKindFilter = request.EventKindFilter,
|
||||
IncludeNonMatches = request.IncludeNonMatches ?? false,
|
||||
EvaluationTimestamp = request.EvaluationTimestamp
|
||||
};
|
||||
|
||||
var result = await simulationEngine.SimulateAsync(simulationRequest, cancellationToken);
|
||||
|
||||
return Results.Ok(MapToApiResponse(result));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ValidateRuleAsync(
|
||||
[FromBody] RuleApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] ISimulationEngine simulationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
var rule = MapToNotifyRule(request, tenantId);
|
||||
var result = await simulationEngine.ValidateRuleAsync(rule, cancellationToken);
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static NotifyEvent MapToNotifyEvent(EventApiRequest request)
|
||||
{
|
||||
return NotifyEvent.Create(
|
||||
eventId: request.EventId ?? Guid.NewGuid(),
|
||||
kind: request.Kind ?? "unknown",
|
||||
tenant: request.TenantId ?? "default",
|
||||
ts: request.Timestamp ?? DateTimeOffset.UtcNow,
|
||||
payload: request.Payload is not null
|
||||
? JsonNode.Parse(JsonSerializer.Serialize(request.Payload))
|
||||
: null,
|
||||
scope: request.Scope is not null
|
||||
? NotifyEventScope.Create(
|
||||
@namespace: request.Scope.Namespace,
|
||||
repo: request.Scope.Repo,
|
||||
digest: request.Scope.Digest,
|
||||
component: request.Scope.Component,
|
||||
image: request.Scope.Image,
|
||||
labels: request.Scope.Labels)
|
||||
: null,
|
||||
attributes: request.Attributes);
|
||||
}
|
||||
|
||||
private static NotifyRule MapToNotifyRule(RuleApiRequest request, string tenantId)
|
||||
{
|
||||
var match = NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match?.EventKinds,
|
||||
namespaces: request.Match?.Namespaces,
|
||||
repositories: request.Match?.Repositories,
|
||||
digests: request.Match?.Digests,
|
||||
labels: request.Match?.Labels,
|
||||
componentPurls: request.Match?.ComponentPurls,
|
||||
minSeverity: request.Match?.MinSeverity,
|
||||
verdicts: request.Match?.Verdicts,
|
||||
kevOnly: request.Match?.KevOnly);
|
||||
|
||||
var actions = request.Actions?.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId ?? Guid.NewGuid().ToString("N")[..8],
|
||||
channel: a.Channel ?? "default",
|
||||
template: a.Template,
|
||||
throttle: a.ThrottleSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(a.ThrottleSeconds.Value)
|
||||
: null,
|
||||
enabled: a.Enabled ?? true)).ToList() ?? [];
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: request.RuleId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
tenantId: request.TenantId ?? tenantId,
|
||||
name: request.Name ?? "Unnamed Rule",
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled ?? true,
|
||||
description: request.Description);
|
||||
}
|
||||
|
||||
private static SimulationApiResponse MapToApiResponse(SimulationResult result)
|
||||
{
|
||||
return new SimulationApiResponse
|
||||
{
|
||||
SimulationId = result.SimulationId,
|
||||
ExecutedAt = result.ExecutedAt,
|
||||
TotalEvents = result.TotalEvents,
|
||||
TotalRules = result.TotalRules,
|
||||
MatchedEvents = result.MatchedEvents,
|
||||
TotalActionsTriggered = result.TotalActionsTriggered,
|
||||
DurationMs = result.Duration.TotalMilliseconds,
|
||||
EventResults = result.EventResults.Select(e => new EventResultApiResponse
|
||||
{
|
||||
EventId = e.EventId,
|
||||
EventKind = e.EventKind,
|
||||
EventTimestamp = e.EventTimestamp,
|
||||
Matched = e.Matched,
|
||||
MatchedRules = e.MatchedRules.Select(r => new RuleMatchApiResponse
|
||||
{
|
||||
RuleId = r.RuleId,
|
||||
RuleName = r.RuleName,
|
||||
MatchedAt = r.MatchedAt,
|
||||
Actions = r.Actions.Select(a => new ActionMatchApiResponse
|
||||
{
|
||||
ActionId = a.ActionId,
|
||||
Channel = a.Channel,
|
||||
Template = a.Template,
|
||||
Enabled = a.Enabled,
|
||||
ThrottleSeconds = a.Throttle?.TotalSeconds,
|
||||
Explanation = a.Explanation
|
||||
}).ToList()
|
||||
}).ToList(),
|
||||
NonMatchedRules = e.NonMatchedRules?.Select(r => new RuleNonMatchApiResponse
|
||||
{
|
||||
RuleId = r.RuleId,
|
||||
RuleName = r.RuleName,
|
||||
Reason = r.Reason,
|
||||
Explanation = r.Explanation
|
||||
}).ToList()
|
||||
}).ToList(),
|
||||
RuleSummaries = result.RuleSummaries.Select(s => new RuleSummaryApiResponse
|
||||
{
|
||||
RuleId = s.RuleId,
|
||||
RuleName = s.RuleName,
|
||||
Enabled = s.Enabled,
|
||||
MatchCount = s.MatchCount,
|
||||
ActionCount = s.ActionCount,
|
||||
MatchPercentage = s.MatchPercentage,
|
||||
TopNonMatchReasons = s.TopNonMatchReasons.Select(r => new NonMatchReasonApiResponse
|
||||
{
|
||||
Reason = r.Reason,
|
||||
Explanation = r.Explanation,
|
||||
Count = r.Count
|
||||
}).ToList()
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#region API Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Simulation API request.
|
||||
/// </summary>
|
||||
public sealed class SimulationApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public List<EventApiRequest>? Events { get; set; }
|
||||
public List<RuleApiRequest>? Rules { get; set; }
|
||||
public bool? EnabledRulesOnly { get; set; }
|
||||
public int? HistoricalLookbackMinutes { get; set; }
|
||||
public int? MaxEvents { get; set; }
|
||||
public List<string>? EventKindFilter { get; set; }
|
||||
public bool? IncludeNonMatches { get; set; }
|
||||
public DateTimeOffset? EvaluationTimestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event for simulation.
|
||||
/// </summary>
|
||||
public sealed class EventApiRequest
|
||||
{
|
||||
public Guid? EventId { get; set; }
|
||||
public string? Kind { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public DateTimeOffset? Timestamp { get; set; }
|
||||
public Dictionary<string, object>? Payload { get; set; }
|
||||
public EventScopeApiRequest? Scope { get; set; }
|
||||
public Dictionary<string, string>? Attributes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event scope for simulation.
|
||||
/// </summary>
|
||||
public sealed class EventScopeApiRequest
|
||||
{
|
||||
public string? Namespace { get; set; }
|
||||
public string? Repo { get; set; }
|
||||
public string? Digest { get; set; }
|
||||
public string? Component { get; set; }
|
||||
public string? Image { get; set; }
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule for simulation.
|
||||
/// </summary>
|
||||
public sealed class RuleApiRequest
|
||||
{
|
||||
public string? RuleId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public RuleMatchApiRequest? Match { get; set; }
|
||||
public List<RuleActionApiRequest>? Actions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule match criteria for simulation.
|
||||
/// </summary>
|
||||
public sealed class RuleMatchApiRequest
|
||||
{
|
||||
public List<string>? EventKinds { get; set; }
|
||||
public List<string>? Namespaces { get; set; }
|
||||
public List<string>? Repositories { get; set; }
|
||||
public List<string>? Digests { get; set; }
|
||||
public List<string>? Labels { get; set; }
|
||||
public List<string>? ComponentPurls { get; set; }
|
||||
public string? MinSeverity { get; set; }
|
||||
public List<string>? Verdicts { get; set; }
|
||||
public bool? KevOnly { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule action for simulation.
|
||||
/// </summary>
|
||||
public sealed class RuleActionApiRequest
|
||||
{
|
||||
public string? ActionId { get; set; }
|
||||
public string? Channel { get; set; }
|
||||
public string? Template { get; set; }
|
||||
public int? ThrottleSeconds { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulation API response.
|
||||
/// </summary>
|
||||
public sealed class SimulationApiResponse
|
||||
{
|
||||
public required string SimulationId { get; set; }
|
||||
public required DateTimeOffset ExecutedAt { get; set; }
|
||||
public required int TotalEvents { get; set; }
|
||||
public required int TotalRules { get; set; }
|
||||
public required int MatchedEvents { get; set; }
|
||||
public required int TotalActionsTriggered { get; set; }
|
||||
public required double DurationMs { get; set; }
|
||||
public required List<EventResultApiResponse> EventResults { get; set; }
|
||||
public required List<RuleSummaryApiResponse> RuleSummaries { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event result in simulation response.
|
||||
/// </summary>
|
||||
public sealed class EventResultApiResponse
|
||||
{
|
||||
public required Guid EventId { get; set; }
|
||||
public required string EventKind { get; set; }
|
||||
public required DateTimeOffset EventTimestamp { get; set; }
|
||||
public required bool Matched { get; set; }
|
||||
public required List<RuleMatchApiResponse> MatchedRules { get; set; }
|
||||
public List<RuleNonMatchApiResponse>? NonMatchedRules { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule match in simulation response.
|
||||
/// </summary>
|
||||
public sealed class RuleMatchApiResponse
|
||||
{
|
||||
public required string RuleId { get; set; }
|
||||
public required string RuleName { get; set; }
|
||||
public required DateTimeOffset MatchedAt { get; set; }
|
||||
public required List<ActionMatchApiResponse> Actions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action match in simulation response.
|
||||
/// </summary>
|
||||
public sealed class ActionMatchApiResponse
|
||||
{
|
||||
public required string ActionId { get; set; }
|
||||
public required string Channel { get; set; }
|
||||
public string? Template { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
public double? ThrottleSeconds { get; set; }
|
||||
public required string Explanation { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule non-match in simulation response.
|
||||
/// </summary>
|
||||
public sealed class RuleNonMatchApiResponse
|
||||
{
|
||||
public required string RuleId { get; set; }
|
||||
public required string RuleName { get; set; }
|
||||
public required string Reason { get; set; }
|
||||
public required string Explanation { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule summary in simulation response.
|
||||
/// </summary>
|
||||
public sealed class RuleSummaryApiResponse
|
||||
{
|
||||
public required string RuleId { get; set; }
|
||||
public required string RuleName { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
public required int MatchCount { get; set; }
|
||||
public required int ActionCount { get; set; }
|
||||
public required double MatchPercentage { get; set; }
|
||||
public required List<NonMatchReasonApiResponse> TopNonMatchReasons { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-match reason summary in simulation response.
|
||||
/// </summary>
|
||||
public sealed class NonMatchReasonApiResponse
|
||||
{
|
||||
public required string Reason { get; set; }
|
||||
public required string Explanation { get; set; }
|
||||
public required int Count { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,132 @@
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notify.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.StormBreaker;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for storm breaker operations.
|
||||
/// </summary>
|
||||
public static class StormBreakerEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps storm breaker API endpoints.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapStormBreakerEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/storm-breaker")
|
||||
.WithTags("Storm Breaker")
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
// List active storms for tenant
|
||||
group.MapGet("/storms", async (
|
||||
HttpContext context,
|
||||
IStormBreaker stormBreaker,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var storms = await stormBreaker.GetActiveStormsAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
activeStorms = storms.Select(s => new
|
||||
{
|
||||
s.TenantId,
|
||||
s.StormKey,
|
||||
s.StartedAt,
|
||||
eventCount = s.EventIds.Count,
|
||||
s.SuppressedCount,
|
||||
s.LastActivityAt,
|
||||
s.IsActive
|
||||
}).ToList(),
|
||||
count = storms.Count
|
||||
});
|
||||
})
|
||||
.WithName("ListActiveStorms")
|
||||
.WithSummary("Lists all active notification storms for a tenant")
|
||||
.WithDescription("Returns all currently active notification storms for the tenant. A storm is declared when the same event kind fires at a rate exceeding the configured threshold, triggering suppression.");
|
||||
|
||||
// Get specific storm state
|
||||
group.MapGet("/storms/{stormKey}", async (
|
||||
string stormKey,
|
||||
HttpContext context,
|
||||
IStormBreaker stormBreaker,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var state = await stormBreaker.GetStateAsync(tenantId, stormKey, cancellationToken);
|
||||
if (state is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"No storm found with key '{stormKey}'" });
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
state.TenantId,
|
||||
state.StormKey,
|
||||
state.StartedAt,
|
||||
eventCount = state.EventIds.Count,
|
||||
state.SuppressedCount,
|
||||
state.LastActivityAt,
|
||||
state.LastSummaryAt,
|
||||
state.IsActive,
|
||||
sampleEventIds = state.EventIds.Take(10).ToList()
|
||||
});
|
||||
})
|
||||
.WithName("GetStormState")
|
||||
.WithSummary("Gets the current state of a specific storm")
|
||||
.WithDescription("Returns the current state of a storm identified by its storm key, including event count, suppressed count, and the time of the last summarization.");
|
||||
|
||||
// Generate storm summary
|
||||
group.MapPost("/storms/{stormKey}/summary", async (
|
||||
string stormKey,
|
||||
HttpContext context,
|
||||
IStormBreaker stormBreaker,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var summary = await stormBreaker.GenerateSummaryAsync(tenantId, stormKey, cancellationToken);
|
||||
if (summary is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"No storm found with key '{stormKey}'" });
|
||||
}
|
||||
|
||||
return Results.Ok(summary);
|
||||
})
|
||||
.WithName("GenerateStormSummary")
|
||||
.WithSummary("Generates a summary for an active storm")
|
||||
.WithDescription("Generates and returns a suppression summary notification for the storm, delivering a single digest notification in place of all suppressed individual events.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
// Clear storm state
|
||||
group.MapDelete("/storms/{stormKey}", async (
|
||||
string stormKey,
|
||||
HttpContext context,
|
||||
IStormBreaker stormBreaker,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
await stormBreaker.ClearAsync(tenantId, stormKey, cancellationToken);
|
||||
|
||||
return Results.Ok(new { message = $"Storm '{stormKey}' cleared successfully" });
|
||||
})
|
||||
.WithName("ClearStorm")
|
||||
.WithSummary("Clears a storm state manually")
|
||||
.WithDescription("Manually clears the storm state for the specified key. Subsequent events of the same kind will be processed normally until a new storm threshold is exceeded.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notify.WebService.Contracts;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Maps template management endpoints.
|
||||
/// </summary>
|
||||
public static class TemplateEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/templates")
|
||||
.WithTags("Templates")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListTemplatesAsync)
|
||||
.WithName("ListTemplates")
|
||||
.WithSummary("Lists all templates for a tenant")
|
||||
.WithDescription("Returns all notification templates for the tenant with optional filtering by key prefix, channel type, and locale. Templates define rendered message bodies used by alert routing rules.");
|
||||
|
||||
group.MapGet("/{templateId}", GetTemplateAsync)
|
||||
.WithName("GetTemplate")
|
||||
.WithSummary("Gets a template by ID")
|
||||
.WithDescription("Returns a single notification template by its identifier, including body, channel type, locale, render mode, format, and audit metadata.");
|
||||
|
||||
group.MapPost("/", CreateTemplateAsync)
|
||||
.WithName("CreateTemplate")
|
||||
.WithSummary("Creates a new template")
|
||||
.WithDescription("Creates a new notification template. Template body syntax is validated before persisting. Returns conflict if a template with the same ID already exists.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPut("/{templateId}", UpdateTemplateAsync)
|
||||
.WithName("UpdateTemplate")
|
||||
.WithSummary("Updates an existing template")
|
||||
.WithDescription("Updates an existing notification template. Template body syntax is validated before persisting. An audit entry is written on update.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapDelete("/{templateId}", DeleteTemplateAsync)
|
||||
.WithName("DeleteTemplate")
|
||||
.WithSummary("Deletes a template")
|
||||
.WithDescription("Permanently removes a notification template. Rules referencing this template will fall back to channel defaults on the next delivery. An audit entry is written on deletion.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/preview", PreviewTemplateAsync)
|
||||
.WithName("PreviewTemplate")
|
||||
.WithSummary("Previews a template rendering")
|
||||
.WithDescription("Renders a template against a sample event payload without sending any notification. Accepts either an existing templateId or an inline templateBody. Returns the rendered body, subject, and any template warnings.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTemplatesAsync(
|
||||
HttpContext context,
|
||||
INotifyTemplateRepository templates,
|
||||
string? keyPrefix = null,
|
||||
string? channelType = null,
|
||||
string? locale = null,
|
||||
int? limit = null)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var allTemplates = await templates.ListAsync(tenantId, context.RequestAborted);
|
||||
|
||||
IEnumerable<NotifyTemplate> filtered = allTemplates;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyPrefix))
|
||||
{
|
||||
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelType) && Enum.TryParse<NotifyChannelType>(channelType, true, out var ct))
|
||||
{
|
||||
filtered = filtered.Where(t => t.ChannelType == ct);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(locale))
|
||||
{
|
||||
filtered = filtered.Where(t => t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (limit.HasValue && limit.Value > 0)
|
||||
{
|
||||
filtered = filtered.Take(limit.Value);
|
||||
}
|
||||
|
||||
var response = filtered.Select(MapToResponse).ToList();
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTemplateAsync(
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
INotifyTemplateRepository templates)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var template = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
|
||||
if (template is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapToResponse(template));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateTemplateAsync(
|
||||
HttpContext context,
|
||||
TemplateCreateRequest request,
|
||||
INotifyTemplateRepository templates,
|
||||
INotifyTemplateService? templateService,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
// Validate template body
|
||||
if (templateService is not null)
|
||||
{
|
||||
var validation = templateService.Validate(request.Body);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if template already exists
|
||||
var existing = await templates.GetAsync(tenantId, request.TemplateId, context.RequestAborted);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Results.Conflict(Error("template_exists", $"Template '{request.TemplateId}' already exists.", context));
|
||||
}
|
||||
|
||||
var template = MapFromRequest(request, tenantId, actor, timeProvider);
|
||||
|
||||
await templates.UpsertAsync(template, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "template.created", request.TemplateId, "template", request, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Created($"/api/v2/templates/{template.TemplateId}", MapToResponse(template));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTemplateAsync(
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
TemplateCreateRequest request,
|
||||
INotifyTemplateRepository templates,
|
||||
INotifyTemplateService? templateService,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
// Validate template body
|
||||
if (templateService is not null)
|
||||
{
|
||||
var validation = templateService.Validate(request.Body);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
|
||||
}
|
||||
}
|
||||
|
||||
var existing = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
|
||||
}
|
||||
|
||||
var updated = MapFromRequest(request with { TemplateId = templateId }, tenantId, actor, timeProvider, existing);
|
||||
|
||||
await templates.UpsertAsync(updated, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "template.updated", templateId, "template", request, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Ok(MapToResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteTemplateAsync(
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
INotifyTemplateRepository templates,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
var existing = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
|
||||
}
|
||||
|
||||
await templates.DeleteAsync(tenantId, templateId, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "template.deleted", templateId, "template", new { templateId }, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewTemplateAsync(
|
||||
HttpContext context,
|
||||
TemplatePreviewRequest request,
|
||||
INotifyTemplateRepository templates,
|
||||
INotifyTemplateRenderer renderer,
|
||||
INotifyTemplateService? templateService,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
NotifyTemplate? template = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TemplateId))
|
||||
{
|
||||
template = await templates.GetAsync(tenantId, request.TemplateId, context.RequestAborted);
|
||||
if (template is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template '{request.TemplateId}' not found.", context));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(request.TemplateBody))
|
||||
{
|
||||
// Create a temporary template for preview
|
||||
var format = Enum.TryParse<NotifyDeliveryFormat>(request.OutputFormat, true, out var f)
|
||||
? f
|
||||
: NotifyDeliveryFormat.PlainText;
|
||||
|
||||
template = NotifyTemplate.Create(
|
||||
templateId: "preview",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Custom,
|
||||
key: "preview",
|
||||
locale: "en-us",
|
||||
body: request.TemplateBody,
|
||||
format: format);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_request", "Either templateId or templateBody is required.", context));
|
||||
}
|
||||
|
||||
// Validate template body
|
||||
List<string>? warnings = null;
|
||||
if (templateService is not null)
|
||||
{
|
||||
var validation = templateService.Validate(template.Body);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
|
||||
}
|
||||
|
||||
warnings = validation.Warnings.ToList();
|
||||
}
|
||||
|
||||
// Create sample event
|
||||
var sampleEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: request.EventKind ?? "sample.event",
|
||||
tenant: tenantId,
|
||||
ts: timeProvider.GetUtcNow(),
|
||||
payload: request.SamplePayload ?? new JsonObject(),
|
||||
attributes: request.SampleAttributes ?? new Dictionary<string, string>(),
|
||||
actor: "preview",
|
||||
version: "1");
|
||||
|
||||
var rendered = await renderer.RenderAsync(template, sampleEvent, context.RequestAborted);
|
||||
|
||||
return Results.Ok(new TemplatePreviewResponse
|
||||
{
|
||||
RenderedBody = rendered.Body,
|
||||
RenderedSubject = rendered.Subject,
|
||||
BodyHash = rendered.BodyHash,
|
||||
Format = rendered.Format.ToString(),
|
||||
Warnings = warnings?.Count > 0 ? warnings : null
|
||||
});
|
||||
}
|
||||
|
||||
private static NotifyTemplate MapFromRequest(
|
||||
TemplateCreateRequest request,
|
||||
string tenantId,
|
||||
string actor,
|
||||
TimeProvider timeProvider,
|
||||
NotifyTemplate? existing = null)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var channelType = Enum.TryParse<NotifyChannelType>(request.ChannelType, true, out var ct)
|
||||
? ct
|
||||
: NotifyChannelType.Custom;
|
||||
|
||||
var renderMode = Enum.TryParse<NotifyTemplateRenderMode>(request.RenderMode, true, out var rm)
|
||||
? rm
|
||||
: NotifyTemplateRenderMode.Markdown;
|
||||
|
||||
var format = Enum.TryParse<NotifyDeliveryFormat>(request.Format, true, out var f)
|
||||
? f
|
||||
: NotifyDeliveryFormat.PlainText;
|
||||
|
||||
return NotifyTemplate.Create(
|
||||
templateId: request.TemplateId,
|
||||
tenantId: tenantId,
|
||||
channelType: channelType,
|
||||
key: request.Key,
|
||||
locale: request.Locale,
|
||||
body: request.Body,
|
||||
renderMode: renderMode,
|
||||
format: format,
|
||||
description: request.Description,
|
||||
metadata: request.Metadata,
|
||||
createdBy: existing?.CreatedBy ?? actor,
|
||||
createdAt: existing?.CreatedAt ?? now,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static TemplateResponse MapToResponse(NotifyTemplate template)
|
||||
{
|
||||
return new TemplateResponse
|
||||
{
|
||||
TemplateId = template.TemplateId,
|
||||
TenantId = template.TenantId,
|
||||
Key = template.Key,
|
||||
ChannelType = template.ChannelType.ToString(),
|
||||
Locale = template.Locale,
|
||||
Body = template.Body,
|
||||
RenderMode = template.RenderMode.ToString(),
|
||||
Format = template.Format.ToString(),
|
||||
Description = template.Description,
|
||||
Metadata = template.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||
CreatedBy = template.CreatedBy,
|
||||
CreatedAt = template.CreatedAt,
|
||||
UpdatedBy = template.UpdatedBy,
|
||||
UpdatedAt = template.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
return string.IsNullOrWhiteSpace(tenantId) ? null : tenantId;
|
||||
}
|
||||
|
||||
private static string GetActor(HttpContext context)
|
||||
{
|
||||
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
|
||||
return string.IsNullOrWhiteSpace(actor) ? "api" : actor;
|
||||
}
|
||||
|
||||
private static async Task AppendAuditAsync(
|
||||
INotifyAuditRepository audit,
|
||||
string tenantId,
|
||||
string actor,
|
||||
string action,
|
||||
string entityId,
|
||||
string entityType,
|
||||
object payload,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = actor,
|
||||
Action = action,
|
||||
EntityId = entityId,
|
||||
EntityType = entityType,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Payload = JsonSerializer.SerializeToNode(payload) as JsonObject
|
||||
};
|
||||
|
||||
await audit.AppendAsync(entry, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore audit failures
|
||||
}
|
||||
}
|
||||
|
||||
private static object Error(string code, string message, HttpContext context) => new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notify.WebService.Extensions;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for throttle configuration management.
|
||||
/// </summary>
|
||||
public static class ThrottleEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps throttle configuration endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapThrottleEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/throttles")
|
||||
.WithTags("Throttles")
|
||||
.WithOpenApi()
|
||||
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/config", GetConfigurationAsync)
|
||||
.WithName("GetThrottleConfiguration")
|
||||
.WithSummary("Get throttle configuration")
|
||||
.WithDescription("Returns the throttle configuration for the tenant, including the default suppression window, per-event-kind overrides, and burst window settings. Returns platform defaults if no custom configuration exists.");
|
||||
|
||||
group.MapPut("/config", UpdateConfigurationAsync)
|
||||
.WithName("UpdateThrottleConfiguration")
|
||||
.WithSummary("Update throttle configuration")
|
||||
.WithDescription("Creates or replaces the throttle configuration for the tenant. The default duration and optional per-event-kind overrides control how long duplicate notifications are suppressed.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapDelete("/config", DeleteConfigurationAsync)
|
||||
.WithName("DeleteThrottleConfiguration")
|
||||
.WithSummary("Delete throttle configuration")
|
||||
.WithDescription("Removes the tenant-specific throttle configuration, reverting all throttle windows to the platform defaults.")
|
||||
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
||||
|
||||
group.MapPost("/evaluate", EvaluateAsync)
|
||||
.WithName("EvaluateThrottle")
|
||||
.WithSummary("Evaluate throttle duration")
|
||||
.WithDescription("Returns the effective throttle duration in seconds for a given event kind, applying the tenant-specific override if present or the default if not.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetConfigurationAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IThrottleConfigurationService throttleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var config = await throttleService.GetConfigurationAsync(tenantId, cancellationToken);
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
return Results.Ok(new ThrottleConfigurationApiResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DefaultDurationSeconds = 900, // 15 minutes default
|
||||
Enabled = true,
|
||||
EventKindOverrides = new Dictionary<string, int>(),
|
||||
IsDefault = true
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(MapToApiResponse(config));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateConfigurationAsync(
|
||||
[FromBody] ThrottleConfigurationApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IThrottleConfigurationService throttleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
if (request.DefaultDurationSeconds is <= 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Default duration must be a positive value in seconds." });
|
||||
}
|
||||
|
||||
var config = new ThrottleConfiguration
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DefaultDuration = TimeSpan.FromSeconds(request.DefaultDurationSeconds ?? 900),
|
||||
EventKindOverrides = request.EventKindOverrides?
|
||||
.ToDictionary(kvp => kvp.Key, kvp => TimeSpan.FromSeconds(kvp.Value)),
|
||||
MaxEventsPerWindow = request.MaxEventsPerWindow,
|
||||
BurstWindowDuration = request.BurstWindowDurationSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(request.BurstWindowDurationSeconds.Value)
|
||||
: null,
|
||||
Enabled = request.Enabled ?? true
|
||||
};
|
||||
|
||||
var updated = await throttleService.UpsertConfigurationAsync(config, actor, cancellationToken);
|
||||
|
||||
return Results.Ok(MapToApiResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteConfigurationAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IThrottleConfigurationService throttleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var deleted = await throttleService.DeleteConfigurationAsync(tenantId, actor, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new { error = "No throttle configuration exists for this tenant." });
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> EvaluateAsync(
|
||||
[FromBody] ThrottleEvaluateApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IThrottleConfigurationService throttleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.EventKind))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Event kind is required." });
|
||||
}
|
||||
|
||||
var duration = await throttleService.GetEffectiveThrottleDurationAsync(
|
||||
tenantId,
|
||||
request.EventKind,
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new ThrottleEvaluateApiResponse
|
||||
{
|
||||
EventKind = request.EventKind,
|
||||
EffectiveDurationSeconds = (int)duration.TotalSeconds
|
||||
});
|
||||
}
|
||||
|
||||
private static ThrottleConfigurationApiResponse MapToApiResponse(ThrottleConfiguration config) => new()
|
||||
{
|
||||
TenantId = config.TenantId,
|
||||
DefaultDurationSeconds = (int)config.DefaultDuration.TotalSeconds,
|
||||
EventKindOverrides = config.EventKindOverrides?
|
||||
.ToDictionary(kvp => kvp.Key, kvp => (int)kvp.Value.TotalSeconds)
|
||||
?? new Dictionary<string, int>(),
|
||||
MaxEventsPerWindow = config.MaxEventsPerWindow,
|
||||
BurstWindowDurationSeconds = config.BurstWindowDuration.HasValue
|
||||
? (int)config.BurstWindowDuration.Value.TotalSeconds
|
||||
: null,
|
||||
Enabled = config.Enabled,
|
||||
CreatedAt = config.CreatedAt,
|
||||
CreatedBy = config.CreatedBy,
|
||||
UpdatedAt = config.UpdatedAt,
|
||||
UpdatedBy = config.UpdatedBy,
|
||||
IsDefault = false
|
||||
};
|
||||
}
|
||||
|
||||
#region API Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update throttle configuration.
|
||||
/// </summary>
|
||||
public sealed class ThrottleConfigurationApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public int? DefaultDurationSeconds { get; set; }
|
||||
public Dictionary<string, int>? EventKindOverrides { get; set; }
|
||||
public int? MaxEventsPerWindow { get; set; }
|
||||
public int? BurstWindowDurationSeconds { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to evaluate throttle duration.
|
||||
/// </summary>
|
||||
public sealed class ThrottleEvaluateApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? EventKind { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for throttle configuration.
|
||||
/// </summary>
|
||||
public sealed class ThrottleConfigurationApiResponse
|
||||
{
|
||||
public required string TenantId { get; set; }
|
||||
public required int DefaultDurationSeconds { get; set; }
|
||||
public required Dictionary<string, int> EventKindOverrides { get; set; }
|
||||
public int? MaxEventsPerWindow { get; set; }
|
||||
public int? BurstWindowDurationSeconds { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for throttle evaluation.
|
||||
/// </summary>
|
||||
public sealed class ThrottleEvaluateApiResponse
|
||||
{
|
||||
public required string EventKind { get; set; }
|
||||
public required int EffectiveDurationSeconds { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal no-op OpenAPI extension to preserve existing endpoint grouping without external dependencies.
|
||||
/// </summary>
|
||||
public static class OpenApiExtensions
|
||||
{
|
||||
public static TBuilder WithOpenApi<TBuilder>(this TBuilder builder)
|
||||
where TBuilder : IEndpointConventionBuilder => builder;
|
||||
}
|
||||
@@ -21,6 +21,8 @@ using StellaOps.Notify.Persistence.Extensions;
|
||||
using StellaOps.Notify.Persistence.Postgres;
|
||||
using StellaOps.Notify.Persistence.Postgres.Models;
|
||||
using StellaOps.Notify.Persistence.Postgres.Repositories;
|
||||
// Alias to disambiguate from StellaOps.Notifier.Worker.Storage.INotifyAuditRepository
|
||||
using INotifyAuditRepository = StellaOps.Notify.Persistence.Postgres.Repositories.INotifyAuditRepository;
|
||||
using StellaOps.Notify.WebService.Contracts;
|
||||
using StellaOps.Notify.WebService.Diagnostics;
|
||||
using StellaOps.Notify.WebService.Extensions;
|
||||
@@ -32,6 +34,27 @@ using StellaOps.Notify.WebService.Security;
|
||||
using StellaOps.Notify.WebService.Services;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Router.AspNet;
|
||||
// Notifier Worker shared types (correlation, simulation, security, escalation, etc.)
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notify.WebService.Constants;
|
||||
using StellaOps.Notify.WebService.Endpoints;
|
||||
using StellaOps.Notify.WebService.Setup;
|
||||
using StellaOps.Notify.WebService.Storage.Compat;
|
||||
using StellaOps.Notifier.Worker.Channels;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
using StellaOps.Notifier.Worker.StormBreaker;
|
||||
using StellaOps.Notifier.Worker.DeadLetter;
|
||||
using StellaOps.Notifier.Worker.Retention;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
using StellaOps.Notifier.Worker.Tenancy;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
using StellaOps.Notifier.Worker.Simulation;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
@@ -102,6 +125,104 @@ builder.Services.AddSingleton<INotifyPluginRegistry, NotifyPluginRegistry>();
|
||||
builder.Services.AddSingleton<INotifyChannelTestService, NotifyChannelTestService>();
|
||||
builder.Services.AddSingleton<INotifyChannelHealthService, NotifyChannelHealthService>();
|
||||
|
||||
// =========================================================================
|
||||
// Notifier v2 DI registrations (merged from Notifier WebService)
|
||||
// =========================================================================
|
||||
|
||||
builder.Services.AddSingleton<ICryptoHmac, DefaultCryptoHmac>();
|
||||
|
||||
// Core correlation engine registrations required by incident and escalation flows.
|
||||
builder.Services.AddCorrelationServices(builder.Configuration);
|
||||
|
||||
// Rule evaluation + simulation services power /api/v2/simulate* endpoints.
|
||||
builder.Services.AddSingleton<StellaOps.Notify.Engine.INotifyRuleEvaluator, StellaOps.Notifier.Worker.Processing.DefaultNotifyRuleEvaluator>();
|
||||
SimulationServiceExtensions.AddSimulationServices(builder.Services, builder.Configuration);
|
||||
|
||||
// Fallback no-op event queue for environments that do not configure a real backend.
|
||||
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
|
||||
|
||||
// In-memory storage for Notifier v2 endpoints (fully qualified to avoid ambiguity with Notify.Persistence types)
|
||||
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyChannelRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyRuleRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyTemplateRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyDeliveryRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyAuditRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyLockRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<IInAppInboxStore, InMemoryInboxStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyInboxRepository, InMemoryInboxStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyLocalizationRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyPackApprovalRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryPackApprovalRepository>();
|
||||
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyThrottleConfigRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryThrottleConfigRepository>();
|
||||
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyOperatorOverrideRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryOperatorOverrideRepository>();
|
||||
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyQuietHoursRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryQuietHoursRepository>();
|
||||
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyMaintenanceWindowRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryMaintenanceWindowRepository>();
|
||||
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyEscalationPolicyRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryEscalationPolicyRepository>();
|
||||
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyOnCallScheduleRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryOnCallScheduleRepository>();
|
||||
|
||||
// Correlation suppression services
|
||||
builder.Services.Configure<SuppressionAuditOptions>(builder.Configuration.GetSection(SuppressionAuditOptions.SectionName));
|
||||
builder.Services.Configure<OperatorOverrideOptions>(builder.Configuration.GetSection(OperatorOverrideOptions.SectionName));
|
||||
builder.Services.AddSingleton<ISuppressionAuditLogger, InMemorySuppressionAuditLogger>();
|
||||
builder.Services.AddSingleton<IThrottleConfigurationService, InMemoryThrottleConfigurationService>();
|
||||
builder.Services.AddSingleton<IQuietHoursCalendarService, InMemoryQuietHoursCalendarService>();
|
||||
builder.Services.AddSingleton<IOperatorOverrideService, InMemoryOperatorOverrideService>();
|
||||
|
||||
// Template service with enhanced renderer (worker contracts)
|
||||
builder.Services.AddTemplateServices(options =>
|
||||
{
|
||||
var provenanceUrl = builder.Configuration["notifier:provenance:baseUrl"];
|
||||
if (!string.IsNullOrWhiteSpace(provenanceUrl))
|
||||
{
|
||||
options.ProvenanceBaseUrl = provenanceUrl;
|
||||
}
|
||||
});
|
||||
|
||||
// Localization resolver with fallback chain
|
||||
builder.Services.AddSingleton<StellaOps.Notify.Models.ILocalizationResolver, StellaOps.Notify.WebService.Services.DefaultLocalizationResolver>();
|
||||
|
||||
// Security services (ack tokens, webhook, HTML sanitizer, tenant isolation)
|
||||
builder.Services.Configure<AckTokenOptions>(builder.Configuration.GetSection("notifier:security:ackToken"));
|
||||
builder.Services.AddSingleton<IAckTokenService, HmacAckTokenService>();
|
||||
builder.Services.Configure<WebhookSecurityOptions>(builder.Configuration.GetSection("notifier:security:webhook"));
|
||||
builder.Services.AddSingleton<IWebhookSecurityService, InMemoryWebhookSecurityService>();
|
||||
builder.Services.AddSingleton<IHtmlSanitizer, DefaultHtmlSanitizer>();
|
||||
builder.Services.Configure<TenantIsolationOptions>(builder.Configuration.GetSection("notifier:security:tenantIsolation"));
|
||||
builder.Services.AddSingleton<ITenantIsolationValidator, InMemoryTenantIsolationValidator>();
|
||||
|
||||
// Observability, dead-letter, and retention services
|
||||
builder.Services.AddSingleton<INotifyMetrics, DefaultNotifyMetrics>();
|
||||
builder.Services.AddSingleton<IDeadLetterService, InMemoryDeadLetterService>();
|
||||
builder.Services.AddSingleton<IRetentionPolicyService, DefaultRetentionPolicyService>();
|
||||
|
||||
// Escalation and on-call services
|
||||
builder.Services.AddEscalationServices(builder.Configuration);
|
||||
|
||||
// Storm breaker services
|
||||
builder.Services.AddStormBreakerServices(builder.Configuration);
|
||||
|
||||
// Additional security services (signing, webhook validation)
|
||||
builder.Services.AddNotifierSecurityServices(builder.Configuration);
|
||||
|
||||
// Tenancy services (context accessor, RLS enforcement, channel resolution, notification enrichment)
|
||||
builder.Services.AddNotifierTenancy(builder.Configuration);
|
||||
|
||||
// Notifier WebService template/renderer services
|
||||
builder.Services.AddSingleton<StellaOps.Notify.WebService.Services.INotifyTemplateService, StellaOps.Notify.WebService.Services.NotifyTemplateService>();
|
||||
builder.Services.AddSingleton<StellaOps.Notify.WebService.Services.INotifyTemplateRenderer, StellaOps.Notify.WebService.Services.AdvancedTemplateRenderer>();
|
||||
|
||||
// Notifier authorization policies
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyViewer, StellaOpsScopes.NotifyViewer);
|
||||
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyOperator, StellaOpsScopes.NotifyOperator);
|
||||
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyAdmin, StellaOpsScopes.NotifyAdmin);
|
||||
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyEscalate, StellaOpsScopes.NotifyEscalate);
|
||||
});
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// =========================================================================
|
||||
|
||||
ConfigureAuthentication(builder, bootstrapOptions, builder.Configuration);
|
||||
ConfigureRateLimiting(builder, bootstrapOptions);
|
||||
|
||||
@@ -129,8 +250,38 @@ var resolvedOptions = app.Services.GetRequiredService<IOptions<NotifyWebServiceO
|
||||
await InitialiseAsync(app.Services, readyStatus, app.Logger, resolvedOptions);
|
||||
|
||||
ConfigureRequestPipeline(app, bootstrapOptions, routerEnabled);
|
||||
|
||||
// Enable WebSocket support for live incident feed (merged from Notifier)
|
||||
app.UseWebSockets(new WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
|
||||
// Tenant context middleware (from Notifier merge)
|
||||
app.UseTenantContext();
|
||||
|
||||
ConfigureEndpoints(app);
|
||||
|
||||
// =========================================================================
|
||||
// Notifier v2 endpoint mappings (merged from Notifier WebService)
|
||||
// =========================================================================
|
||||
app.MapNotifyApiV2();
|
||||
app.MapRuleEndpoints();
|
||||
app.MapTemplateEndpoints();
|
||||
app.MapIncidentEndpoints();
|
||||
app.MapIncidentLiveFeed();
|
||||
app.MapSimulationEndpoints();
|
||||
app.MapQuietHoursEndpoints();
|
||||
app.MapThrottleEndpoints();
|
||||
app.MapOperatorOverrideEndpoints();
|
||||
app.MapEscalationEndpoints();
|
||||
app.MapStormBreakerEndpoints();
|
||||
app.MapLocalizationEndpoints();
|
||||
app.MapFallbackEndpoints();
|
||||
app.MapSecurityEndpoints();
|
||||
app.MapObservabilityEndpoints();
|
||||
// =========================================================================
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Web;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Advanced template renderer with Handlebars-style syntax, format conversion, and redaction support.
|
||||
/// Supports {{property}}, {{#each}}, {{#if}}, and format-specific output (Markdown/HTML/JSON/PlainText).
|
||||
/// </summary>
|
||||
public sealed partial class AdvancedTemplateRenderer : INotifyTemplateRenderer
|
||||
{
|
||||
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
|
||||
private static readonly Regex EachBlockPattern = EachBlockRegex();
|
||||
private static readonly Regex IfBlockPattern = IfBlockRegex();
|
||||
private static readonly Regex ElseBlockPattern = ElseBlockRegex();
|
||||
|
||||
private readonly ILogger<AdvancedTemplateRenderer> _logger;
|
||||
|
||||
public AdvancedTemplateRenderer(ILogger<AdvancedTemplateRenderer> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
|
||||
var body = template.Body;
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
options ??= new TemplateRenderOptions();
|
||||
|
||||
try
|
||||
{
|
||||
// Process conditional blocks first
|
||||
body = ProcessIfBlocks(body, payload);
|
||||
|
||||
// Process {{#each}} blocks
|
||||
body = ProcessEachBlocks(body, payload);
|
||||
|
||||
// Substitute simple placeholders
|
||||
body = SubstitutePlaceholders(body, payload);
|
||||
|
||||
// Convert to target format based on render mode
|
||||
body = ConvertToTargetFormat(body, template.RenderMode, options.FormatOverride ?? template.Format);
|
||||
|
||||
// Append provenance link if requested
|
||||
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
|
||||
{
|
||||
body = AppendProvenanceLink(body, template, options.ProvenanceBaseUrl);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Template rendering failed for {TemplateId}.", template.TemplateId);
|
||||
return $"[Render Error: {ex.Message}]";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ProcessIfBlocks(string body, JsonNode? payload)
|
||||
{
|
||||
// Process {{#if condition}}...{{else}}...{{/if}} blocks
|
||||
return IfBlockPattern.Replace(body, match =>
|
||||
{
|
||||
var conditionPath = match.Groups[1].Value.Trim();
|
||||
var ifContent = match.Groups[2].Value;
|
||||
|
||||
var elseMatch = ElseBlockPattern.Match(ifContent);
|
||||
string trueContent;
|
||||
string falseContent;
|
||||
|
||||
if (elseMatch.Success)
|
||||
{
|
||||
trueContent = ifContent[..elseMatch.Index];
|
||||
falseContent = elseMatch.Groups[1].Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
trueContent = ifContent;
|
||||
falseContent = string.Empty;
|
||||
}
|
||||
|
||||
var conditionValue = ResolvePath(payload, conditionPath);
|
||||
var isTruthy = EvaluateTruthy(conditionValue);
|
||||
|
||||
return isTruthy ? trueContent : falseContent;
|
||||
});
|
||||
}
|
||||
|
||||
private static bool EvaluateTruthy(JsonNode? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonValue jv when jv.TryGetValue(out bool b) => b,
|
||||
JsonValue jv when jv.TryGetValue(out string? s) => !string.IsNullOrEmpty(s),
|
||||
JsonValue jv when jv.TryGetValue(out int i) => i != 0,
|
||||
JsonValue jv when jv.TryGetValue(out double d) => d != 0.0,
|
||||
JsonArray arr => arr.Count > 0,
|
||||
JsonObject obj => obj.Count > 0,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
private static string ProcessEachBlocks(string body, JsonNode? payload)
|
||||
{
|
||||
return EachBlockPattern.Replace(body, match =>
|
||||
{
|
||||
var collectionPath = match.Groups[1].Value.Trim();
|
||||
var innerTemplate = match.Groups[2].Value;
|
||||
|
||||
var collection = ResolvePath(payload, collectionPath);
|
||||
|
||||
if (collection is JsonArray arr)
|
||||
{
|
||||
var results = new List<string>();
|
||||
var index = 0;
|
||||
foreach (var item in arr)
|
||||
{
|
||||
var itemResult = innerTemplate
|
||||
.Replace("{{@index}}", index.ToString())
|
||||
.Replace("{{this}}", item?.ToString() ?? string.Empty);
|
||||
|
||||
// Also substitute nested properties from item
|
||||
if (item is JsonObject itemObj)
|
||||
{
|
||||
itemResult = SubstitutePlaceholders(itemResult, itemObj);
|
||||
}
|
||||
|
||||
results.Add(itemResult);
|
||||
index++;
|
||||
}
|
||||
|
||||
return string.Join(string.Empty, results);
|
||||
}
|
||||
|
||||
if (collection is JsonObject obj)
|
||||
{
|
||||
var results = new List<string>();
|
||||
foreach (var (key, value) in obj)
|
||||
{
|
||||
var itemResult = innerTemplate
|
||||
.Replace("{{@key}}", key)
|
||||
.Replace("{{this}}", value?.ToString() ?? string.Empty);
|
||||
results.Add(itemResult);
|
||||
}
|
||||
|
||||
return string.Join(string.Empty, results);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
private static string SubstitutePlaceholders(string body, JsonNode? payload)
|
||||
{
|
||||
return PlaceholderPattern.Replace(body, match =>
|
||||
{
|
||||
var path = match.Groups[1].Value.Trim();
|
||||
var resolved = ResolvePath(payload, path);
|
||||
return resolved?.ToString() ?? string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
private static JsonNode? ResolvePath(JsonNode? root, string path)
|
||||
{
|
||||
if (root is null || string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var segments = path.Split('.');
|
||||
var current = root;
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
|
||||
{
|
||||
current = next;
|
||||
}
|
||||
else if (current is JsonArray arr && int.TryParse(segment, out var index) && index >= 0 && index < arr.Count)
|
||||
{
|
||||
current = arr[index];
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private string ConvertToTargetFormat(string body, NotifyTemplateRenderMode sourceMode, NotifyDeliveryFormat targetFormat)
|
||||
{
|
||||
// If source is already in the target format family, return as-is
|
||||
if (sourceMode == NotifyTemplateRenderMode.Json && targetFormat == NotifyDeliveryFormat.Json)
|
||||
{
|
||||
return body;
|
||||
}
|
||||
|
||||
return targetFormat switch
|
||||
{
|
||||
NotifyDeliveryFormat.Json => ConvertToJson(body, sourceMode),
|
||||
NotifyDeliveryFormat.Slack => ConvertToSlack(body, sourceMode),
|
||||
NotifyDeliveryFormat.Teams => ConvertToTeams(body, sourceMode),
|
||||
NotifyDeliveryFormat.Email => ConvertToEmail(body, sourceMode),
|
||||
NotifyDeliveryFormat.Webhook => body, // Pass through as-is
|
||||
_ => body
|
||||
};
|
||||
}
|
||||
|
||||
private static string ConvertToJson(string body, NotifyTemplateRenderMode sourceMode)
|
||||
{
|
||||
// Wrap content in a JSON structure
|
||||
var content = new JsonObject
|
||||
{
|
||||
["content"] = body,
|
||||
["format"] = sourceMode.ToString()
|
||||
};
|
||||
|
||||
return content.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
private static string ConvertToSlack(string body, NotifyTemplateRenderMode sourceMode)
|
||||
{
|
||||
// Convert Markdown to Slack mrkdwn format
|
||||
if (sourceMode == NotifyTemplateRenderMode.Markdown)
|
||||
{
|
||||
// Slack uses similar markdown but with some differences
|
||||
// Convert **bold** to *bold* for Slack
|
||||
body = Regex.Replace(body, @"\*\*(.+?)\*\*", "*$1*");
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private static string ConvertToTeams(string body, NotifyTemplateRenderMode sourceMode)
|
||||
{
|
||||
// Teams uses Adaptive Cards or MessageCard format
|
||||
// For simple conversion, wrap in basic card structure
|
||||
if (sourceMode == NotifyTemplateRenderMode.Markdown ||
|
||||
sourceMode == NotifyTemplateRenderMode.PlainText)
|
||||
{
|
||||
var card = new JsonObject
|
||||
{
|
||||
["@type"] = "MessageCard",
|
||||
["@context"] = "http://schema.org/extensions",
|
||||
["summary"] = "Notification",
|
||||
["sections"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["text"] = body
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return card.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private static string ConvertToEmail(string body, NotifyTemplateRenderMode sourceMode)
|
||||
{
|
||||
if (sourceMode == NotifyTemplateRenderMode.Markdown)
|
||||
{
|
||||
// Basic Markdown to HTML conversion for email
|
||||
return ConvertMarkdownToHtml(body);
|
||||
}
|
||||
|
||||
if (sourceMode == NotifyTemplateRenderMode.PlainText)
|
||||
{
|
||||
// Wrap plain text in basic HTML structure
|
||||
return $"<html><body><pre>{HttpUtility.HtmlEncode(body)}</pre></body></html>";
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private static string ConvertMarkdownToHtml(string markdown)
|
||||
{
|
||||
var html = new StringBuilder(markdown);
|
||||
|
||||
// Headers
|
||||
html.Replace("\n### ", "\n<h3>");
|
||||
html.Replace("\n## ", "\n<h2>");
|
||||
html.Replace("\n# ", "\n<h1>");
|
||||
|
||||
// Bold
|
||||
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*\*(.+?)\*\*", "<strong>$1</strong>"));
|
||||
|
||||
// Italic
|
||||
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*(.+?)\*", "<em>$1</em>"));
|
||||
|
||||
// Code
|
||||
html = new StringBuilder(Regex.Replace(html.ToString(), @"`(.+?)`", "<code>$1</code>"));
|
||||
|
||||
// Links
|
||||
html = new StringBuilder(Regex.Replace(html.ToString(), @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>"));
|
||||
|
||||
// Line breaks
|
||||
html.Replace("\n\n", "</p><p>");
|
||||
html.Replace("\n", "<br/>");
|
||||
|
||||
return $"<html><body><p>{html}</p></body></html>";
|
||||
}
|
||||
|
||||
private static string AppendProvenanceLink(string body, NotifyTemplate template, string baseUrl)
|
||||
{
|
||||
var provenanceUrl = $"{baseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
|
||||
|
||||
return template.RenderMode switch
|
||||
{
|
||||
NotifyTemplateRenderMode.Markdown => $"{body}\n\n---\n_Template: [{template.Key}]({provenanceUrl})_",
|
||||
NotifyTemplateRenderMode.Html => $"{body}<hr/><p><small>Template: <a href=\"{provenanceUrl}\">{template.Key}</a></small></p>",
|
||||
NotifyTemplateRenderMode.PlainText => $"{body}\n\n---\nTemplate: {template.Key} ({provenanceUrl})",
|
||||
_ => body
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{\{([^#/}]+)\}\}", RegexOptions.Compiled)]
|
||||
private static partial Regex PlaceholderRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{#each\s+([^}]+)\}\}(.*?)\{\{/each\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
|
||||
private static partial Regex EachBlockRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{#if\s+([^}]+)\}\}(.*?)\{\{/if\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
|
||||
private static partial Regex IfBlockRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{else\}\}(.*)", RegexOptions.Compiled | RegexOptions.Singleline)]
|
||||
private static partial Regex ElseBlockRegex();
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of ILocalizationResolver with hierarchical fallback chain.
|
||||
/// </summary>
|
||||
public sealed class DefaultLocalizationResolver : ILocalizationResolver
|
||||
{
|
||||
private const string DefaultLocale = "en-us";
|
||||
private const string DefaultLanguage = "en";
|
||||
|
||||
private readonly INotifyLocalizationRepository _repository;
|
||||
private readonly ILogger<DefaultLocalizationResolver> _logger;
|
||||
|
||||
public DefaultLocalizationResolver(
|
||||
INotifyLocalizationRepository repository,
|
||||
ILogger<DefaultLocalizationResolver> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<LocalizedString?> ResolveAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
string stringKey,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(stringKey);
|
||||
|
||||
locale = NormalizeLocale(locale);
|
||||
var fallbackChain = BuildFallbackChain(locale);
|
||||
|
||||
foreach (var tryLocale in fallbackChain)
|
||||
{
|
||||
var bundle = await _repository.GetByKeyAndLocaleAsync(
|
||||
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = bundle.GetString(stringKey);
|
||||
if (value is not null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Resolved string '{StringKey}' from bundle '{BundleKey}' locale '{ResolvedLocale}' (requested: {RequestedLocale})",
|
||||
stringKey, bundleKey, tryLocale, locale);
|
||||
|
||||
return new LocalizedString
|
||||
{
|
||||
Value = value,
|
||||
ResolvedLocale = tryLocale,
|
||||
RequestedLocale = locale,
|
||||
FallbackChain = fallbackChain
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try the default bundle
|
||||
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (defaultBundle is not null)
|
||||
{
|
||||
var value = defaultBundle.GetString(stringKey);
|
||||
if (value is not null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Resolved string '{StringKey}' from default bundle '{BundleKey}' locale '{ResolvedLocale}'",
|
||||
stringKey, bundleKey, defaultBundle.Locale);
|
||||
|
||||
return new LocalizedString
|
||||
{
|
||||
Value = value,
|
||||
ResolvedLocale = defaultBundle.Locale,
|
||||
RequestedLocale = locale,
|
||||
FallbackChain = fallbackChain.Append(defaultBundle.Locale).Distinct().ToArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"String '{StringKey}' not found in bundle '{BundleKey}' for any locale in chain: {FallbackChain}",
|
||||
stringKey, bundleKey, string.Join(" -> ", fallbackChain));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
IEnumerable<string> stringKeys,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
|
||||
ArgumentNullException.ThrowIfNull(stringKeys);
|
||||
|
||||
locale = NormalizeLocale(locale);
|
||||
var fallbackChain = BuildFallbackChain(locale);
|
||||
var keysToResolve = new HashSet<string>(stringKeys, StringComparer.Ordinal);
|
||||
var results = new Dictionary<string, LocalizedString>(StringComparer.Ordinal);
|
||||
|
||||
// Load all bundles in the fallback chain
|
||||
var bundles = new List<NotifyLocalizationBundle>();
|
||||
foreach (var tryLocale in fallbackChain)
|
||||
{
|
||||
var bundle = await _repository.GetByKeyAndLocaleAsync(
|
||||
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bundle is not null)
|
||||
{
|
||||
bundles.Add(bundle);
|
||||
}
|
||||
}
|
||||
|
||||
// Add default bundle
|
||||
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (defaultBundle is not null && !bundles.Any(b => b.BundleId == defaultBundle.BundleId))
|
||||
{
|
||||
bundles.Add(defaultBundle);
|
||||
}
|
||||
|
||||
// Resolve each key through the bundles
|
||||
foreach (var key in keysToResolve)
|
||||
{
|
||||
foreach (var bundle in bundles)
|
||||
{
|
||||
var value = bundle.GetString(key);
|
||||
if (value is not null)
|
||||
{
|
||||
results[key] = new LocalizedString
|
||||
{
|
||||
Value = value,
|
||||
ResolvedLocale = bundle.Locale,
|
||||
RequestedLocale = locale,
|
||||
FallbackChain = fallbackChain
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a fallback chain for the given locale.
|
||||
/// Example: "pt-br" -> ["pt-br", "pt", "en-us", "en"]
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string> BuildFallbackChain(string locale)
|
||||
{
|
||||
var chain = new List<string> { locale };
|
||||
|
||||
// Add language-only fallback (e.g., "pt" from "pt-br")
|
||||
var dashIndex = locale.IndexOf('-');
|
||||
if (dashIndex > 0)
|
||||
{
|
||||
var languageOnly = locale[..dashIndex];
|
||||
if (!chain.Contains(languageOnly, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.Add(languageOnly);
|
||||
}
|
||||
}
|
||||
|
||||
// Add default locale if not already in chain
|
||||
if (!chain.Contains(DefaultLocale, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.Add(DefaultLocale);
|
||||
}
|
||||
|
||||
// Add default language if not already in chain
|
||||
if (!chain.Contains(DefaultLanguage, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.Add(DefaultLanguage);
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
private static string NormalizeLocale(string? locale)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(locale))
|
||||
{
|
||||
return DefaultLocale;
|
||||
}
|
||||
|
||||
return locale.ToLowerInvariant().Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Template renderer with support for render options, format conversion, and redaction.
|
||||
/// </summary>
|
||||
public interface INotifyTemplateRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a template with the given payload and options.
|
||||
/// </summary>
|
||||
string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Application-level service for managing versioned templates with localization support.
|
||||
/// </summary>
|
||||
public interface INotifyTemplateService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a template by key and locale, falling back to the default locale if not found.
|
||||
/// </summary>
|
||||
Task<NotifyTemplate?> GetByKeyAsync(
|
||||
string tenantId,
|
||||
string key,
|
||||
string locale,
|
||||
NotifyChannelType? channelType = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific template by ID.
|
||||
/// </summary>
|
||||
Task<NotifyTemplate?> GetByIdAsync(
|
||||
string tenantId,
|
||||
string templateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all templates for a tenant, optionally filtered.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyTemplate>> ListAsync(
|
||||
string tenantId,
|
||||
string? keyPrefix = null,
|
||||
string? locale = null,
|
||||
NotifyChannelType? channelType = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a template with version tracking.
|
||||
/// </summary>
|
||||
Task<NotifyTemplate> UpsertAsync(
|
||||
NotifyTemplate template,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a template.
|
||||
/// </summary>
|
||||
Task DeleteAsync(
|
||||
string tenantId,
|
||||
string templateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a template preview with sample payload (no persistence).
|
||||
/// </summary>
|
||||
Task<TemplatePreviewResult> PreviewAsync(
|
||||
NotifyTemplate template,
|
||||
JsonNode? samplePayload,
|
||||
TemplateRenderOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a template preview render.
|
||||
/// </summary>
|
||||
public sealed record TemplatePreviewResult
|
||||
{
|
||||
public required string RenderedBody { get; init; }
|
||||
public required string? RenderedSubject { get; init; }
|
||||
public required NotifyTemplateRenderMode RenderMode { get; init; }
|
||||
public required NotifyDeliveryFormat Format { get; init; }
|
||||
public IReadOnlyList<string> RedactedFields { get; init; } = [];
|
||||
public string? ProvenanceLink { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for template rendering.
|
||||
/// </summary>
|
||||
public sealed record TemplateRenderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Fields to redact from the output (dot-notation paths).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string>? RedactionAllowlist { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include provenance links in output.
|
||||
/// </summary>
|
||||
public bool IncludeProvenance { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Base URL for provenance links.
|
||||
/// </summary>
|
||||
public string? ProvenanceBaseUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target format override.
|
||||
/// </summary>
|
||||
public NotifyDeliveryFormat? FormatOverride { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of INotifyTemplateService with locale fallback and version tracking.
|
||||
/// </summary>
|
||||
public sealed class NotifyTemplateService : INotifyTemplateService
|
||||
{
|
||||
private const string DefaultLocale = "en-us";
|
||||
|
||||
private readonly INotifyTemplateRepository _repository;
|
||||
private readonly INotifyTemplateRenderer _renderer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<NotifyTemplateService> _logger;
|
||||
|
||||
public NotifyTemplateService(
|
||||
INotifyTemplateRepository repository,
|
||||
INotifyTemplateRenderer renderer,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<NotifyTemplateService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<NotifyTemplate?> GetByKeyAsync(
|
||||
string tenantId,
|
||||
string key,
|
||||
string locale,
|
||||
NotifyChannelType? channelType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
|
||||
locale = NormalizeLocale(locale);
|
||||
|
||||
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Filter by key
|
||||
var matching = allTemplates.Where(t => t.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Filter by channel type if specified
|
||||
if (channelType.HasValue)
|
||||
{
|
||||
matching = matching.Where(t => t.ChannelType == channelType.Value);
|
||||
}
|
||||
|
||||
var candidates = matching.ToArray();
|
||||
|
||||
// Try exact locale match
|
||||
var exactMatch = candidates.FirstOrDefault(t =>
|
||||
t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (exactMatch is not null)
|
||||
{
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
// Try language-only match (e.g., "en" from "en-us")
|
||||
var languageCode = locale.Split('-')[0];
|
||||
var languageMatch = candidates.FirstOrDefault(t =>
|
||||
t.Locale.StartsWith(languageCode, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (languageMatch is not null)
|
||||
{
|
||||
_logger.LogDebug("Template {Key} not found for locale {Locale}, using {FallbackLocale}.",
|
||||
key, locale, languageMatch.Locale);
|
||||
return languageMatch;
|
||||
}
|
||||
|
||||
// Fall back to default locale
|
||||
var defaultMatch = candidates.FirstOrDefault(t =>
|
||||
t.Locale.Equals(DefaultLocale, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (defaultMatch is not null)
|
||||
{
|
||||
_logger.LogDebug("Template {Key} not found for locale {Locale}, using default locale.",
|
||||
key, locale);
|
||||
return defaultMatch;
|
||||
}
|
||||
|
||||
// Return any available template for the key
|
||||
return candidates.FirstOrDefault();
|
||||
}
|
||||
|
||||
public Task<NotifyTemplate?> GetByIdAsync(
|
||||
string tenantId,
|
||||
string templateId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
|
||||
|
||||
return _repository.GetAsync(tenantId, templateId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyTemplate>> ListAsync(
|
||||
string tenantId,
|
||||
string? keyPrefix = null,
|
||||
string? locale = null,
|
||||
NotifyChannelType? channelType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IEnumerable<NotifyTemplate> filtered = allTemplates;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyPrefix))
|
||||
{
|
||||
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(locale))
|
||||
{
|
||||
var normalizedLocale = NormalizeLocale(locale);
|
||||
filtered = filtered.Where(t => t.Locale.Equals(normalizedLocale, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (channelType.HasValue)
|
||||
{
|
||||
filtered = filtered.Where(t => t.ChannelType == channelType.Value);
|
||||
}
|
||||
|
||||
return filtered.OrderBy(t => t.Key).ThenBy(t => t.Locale).ToArray();
|
||||
}
|
||||
|
||||
public async Task<NotifyTemplate> UpsertAsync(
|
||||
NotifyTemplate template,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(updatedBy);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check for existing template to preserve creation metadata
|
||||
var existing = await _repository.GetAsync(template.TenantId, template.TemplateId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var updatedTemplate = NotifyTemplate.Create(
|
||||
templateId: template.TemplateId,
|
||||
tenantId: template.TenantId,
|
||||
channelType: template.ChannelType,
|
||||
key: template.Key,
|
||||
locale: template.Locale,
|
||||
body: template.Body,
|
||||
renderMode: template.RenderMode,
|
||||
format: template.Format,
|
||||
description: template.Description,
|
||||
metadata: template.Metadata,
|
||||
createdBy: existing?.CreatedBy ?? updatedBy,
|
||||
createdAt: existing?.CreatedAt ?? now,
|
||||
updatedBy: updatedBy,
|
||||
updatedAt: now);
|
||||
|
||||
await _repository.UpsertAsync(updatedTemplate, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Template {TemplateId} (key={Key}, locale={Locale}) upserted by {UpdatedBy}.",
|
||||
updatedTemplate.TemplateId, updatedTemplate.Key, updatedTemplate.Locale, updatedBy);
|
||||
|
||||
return updatedTemplate;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(
|
||||
string tenantId,
|
||||
string templateId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
|
||||
|
||||
await _repository.DeleteAsync(tenantId, templateId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Template {TemplateId} deleted from tenant {TenantId}.", templateId, tenantId);
|
||||
}
|
||||
|
||||
public Task<TemplatePreviewResult> PreviewAsync(
|
||||
NotifyTemplate template,
|
||||
JsonNode? samplePayload,
|
||||
TemplateRenderOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
|
||||
options ??= new TemplateRenderOptions();
|
||||
|
||||
// Apply redaction to payload if allowlist is specified
|
||||
var redactedFields = new List<string>();
|
||||
var processedPayload = samplePayload;
|
||||
|
||||
if (options.RedactionAllowlist is { Count: > 0 })
|
||||
{
|
||||
processedPayload = ApplyRedaction(samplePayload, options.RedactionAllowlist, redactedFields);
|
||||
}
|
||||
|
||||
// Render body
|
||||
var renderedBody = _renderer.Render(template, processedPayload, options);
|
||||
|
||||
// Render subject if present in metadata
|
||||
string? renderedSubject = null;
|
||||
if (template.Metadata.TryGetValue("subject", out var subjectTemplate))
|
||||
{
|
||||
var subjectTemplateObj = NotifyTemplate.Create(
|
||||
templateId: "subject-preview",
|
||||
tenantId: template.TenantId,
|
||||
channelType: template.ChannelType,
|
||||
key: "subject",
|
||||
locale: template.Locale,
|
||||
body: subjectTemplate);
|
||||
renderedSubject = _renderer.Render(subjectTemplateObj, processedPayload, options);
|
||||
}
|
||||
|
||||
// Build provenance link if requested
|
||||
string? provenanceLink = null;
|
||||
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
|
||||
{
|
||||
provenanceLink = $"{options.ProvenanceBaseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
|
||||
}
|
||||
|
||||
var result = new TemplatePreviewResult
|
||||
{
|
||||
RenderedBody = renderedBody,
|
||||
RenderedSubject = renderedSubject,
|
||||
RenderMode = template.RenderMode,
|
||||
Format = options.FormatOverride ?? template.Format,
|
||||
RedactedFields = redactedFields,
|
||||
ProvenanceLink = provenanceLink
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static JsonNode? ApplyRedaction(JsonNode? payload, IReadOnlySet<string> allowlist, List<string> redactedFields)
|
||||
{
|
||||
if (payload is not JsonObject obj)
|
||||
{
|
||||
return payload;
|
||||
}
|
||||
|
||||
var result = new JsonObject();
|
||||
|
||||
foreach (var (key, value) in obj)
|
||||
{
|
||||
if (allowlist.Contains(key))
|
||||
{
|
||||
result[key] = value?.DeepClone();
|
||||
}
|
||||
else
|
||||
{
|
||||
result[key] = "[REDACTED]";
|
||||
redactedFields.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string NormalizeLocale(string? locale)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(locale) ? DefaultLocale : locale.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds attestation templates and default routing for dev/test/bootstrap scenarios.
|
||||
/// </summary>
|
||||
public sealed class AttestationTemplateSeeder : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IHostEnvironment _environment;
|
||||
private readonly ILogger<AttestationTemplateSeeder> _logger;
|
||||
|
||||
public AttestationTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<AttestationTemplateSeeder> 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<INotifyTemplateRepository>();
|
||||
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
|
||||
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
|
||||
|
||||
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<int> 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<int> 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(el => ToChannel(el, tenant))
|
||||
.ToArray();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await channelRepository.UpsertAsync(channel, 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, string tenantOverride)
|
||||
{
|
||||
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
|
||||
var type = ParseEnum<NotifyChannelType>(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: tenantOverride,
|
||||
name: name,
|
||||
type: type,
|
||||
config: config,
|
||||
description: element.TryGetProperty("description", out var d) ? d.GetString() : null);
|
||||
}
|
||||
|
||||
private static async Task<NotifyTemplate> 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<NotifyChannelType>(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<NotifyTemplateRenderMode>(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
|
||||
var format = ParseEnum<NotifyDeliveryFormat>(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<KeyValuePair<string, string>>();
|
||||
if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
metadata = meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(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<TEnum>(string? value, TEnum fallback) where TEnum : struct
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
using StellaOps.Notify.Queue;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// No-op event queue used when a real queue backend is not configured (dev/test/offline).
|
||||
/// </summary>
|
||||
public sealed class NullNotifyEventQueue : INotifyEventQueue
|
||||
{
|
||||
public ValueTask<NotifyQueueEnqueueResult> PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(new NotifyQueueEnqueueResult("null", false));
|
||||
|
||||
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
|
||||
|
||||
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Setup;
|
||||
|
||||
public sealed class OpenApiDocumentCache
|
||||
{
|
||||
private readonly string _document;
|
||||
private readonly string _hash;
|
||||
|
||||
public OpenApiDocumentCache(IHostEnvironment environment)
|
||||
{
|
||||
var candidateRoots = new[]
|
||||
{
|
||||
Path.Combine(environment.ContentRootPath, "openapi", "notify-openapi.yaml"),
|
||||
Path.Combine(environment.ContentRootPath, "TestContent", "openapi", "notify-openapi.yaml"),
|
||||
Path.Combine(AppContext.BaseDirectory, "openapi", "notify-openapi.yaml")
|
||||
};
|
||||
|
||||
var path = candidateRoots.FirstOrDefault(File.Exists);
|
||||
if (path is null)
|
||||
{
|
||||
_document = "# notifier openapi (stub for tests)\nopenapi: 3.1.0\ninfo:\n title: stub\n version: 0.0.0\npaths: {}\n";
|
||||
_hash = "stub-openapi";
|
||||
return;
|
||||
}
|
||||
|
||||
_document = File.ReadAllText(path, Encoding.UTF8);
|
||||
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(_document);
|
||||
_hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public string Document => _document;
|
||||
|
||||
public string Sha256 => _hash;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notify.Models;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds pack-approval templates and default routing for dev/test/bootstrap scenarios.
|
||||
/// </summary>
|
||||
public sealed class PackApprovalTemplateSeeder : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IHostEnvironment _environment;
|
||||
private readonly ILogger<PackApprovalTemplateSeeder> _logger;
|
||||
|
||||
public PackApprovalTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<PackApprovalTemplateSeeder> 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<INotifyTemplateRepository>();
|
||||
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
|
||||
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
|
||||
|
||||
if (templateRepo is null)
|
||||
{
|
||||
_logger.LogWarning("Template repository not registered; skipping pack-approval template seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var contentRoot = _environment.ContentRootPath;
|
||||
var seeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
|
||||
if (seeded > 0)
|
||||
{
|
||||
_logger.LogInformation("Seeded {TemplateCount} pack-approval templates from docs.", seeded);
|
||||
}
|
||||
|
||||
if (channelRepo is null || ruleRepo is null)
|
||||
{
|
||||
_logger.LogWarning("Channel or rule repository not registered; skipping pack-approval routing seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var routed = await SeedRoutingAsync(channelRepo, ruleRepo, _logger, cancellationToken).ConfigureAwait(false);
|
||||
if (routed > 0)
|
||||
{
|
||||
_logger.LogInformation("Seeded default pack-approval routing (channels + rule).");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public static async Task<int> SeedTemplatesAsync(
|
||||
INotifyTemplateRepository repository,
|
||||
string contentRootPath,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var path = LocateTemplatesPath(contentRootPath);
|
||||
if (path is null)
|
||||
{
|
||||
logger.LogWarning("pack-approval-templates.json not found under content root {ContentRoot}; skipping seed.", contentRootPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
|
||||
if (!document.RootElement.TryGetProperty("templates", out var templatesElement))
|
||||
{
|
||||
logger.LogWarning("pack-approval-templates.json missing 'templates' array; skipping seed.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
foreach (var template in templatesElement.EnumerateArray())
|
||||
{
|
||||
try
|
||||
{
|
||||
var model = ToTemplate(template);
|
||||
await repository.UpsertAsync(model, cancellationToken).ConfigureAwait(false);
|
||||
count++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to seed template entry; skipping.");
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public static async Task<int> SeedRoutingAsync(
|
||||
INotifyChannelRepository channelRepository,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channelRepository);
|
||||
ArgumentNullException.ThrowIfNull(ruleRepository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
const string tenant = "tenant-sample";
|
||||
|
||||
var slackChannel = NotifyChannel.Create(
|
||||
channelId: "chn-pack-approvals-slack",
|
||||
tenantId: tenant,
|
||||
name: "Slack · Pack Approvals",
|
||||
type: NotifyChannelType.Slack,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "ref://notify/channels/slack/pack-approvals",
|
||||
endpoint: "https://hooks.slack.local/services/T000/B000/DEV",
|
||||
target: "#pack-approvals"),
|
||||
description: "Default Slack channel for pack approval notifications.");
|
||||
|
||||
var emailChannel = NotifyChannel.Create(
|
||||
channelId: "chn-pack-approvals-email",
|
||||
tenantId: tenant,
|
||||
name: "Email · Pack Approvals",
|
||||
type: NotifyChannelType.Email,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "ref://notify/channels/email/pack-approvals",
|
||||
target: "pack-approvals@example.com"),
|
||||
description: "Default email channel for pack approval notifications.");
|
||||
|
||||
await channelRepository.UpsertAsync(slackChannel, cancellationToken).ConfigureAwait(false);
|
||||
await channelRepository.UpsertAsync(emailChannel, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "rule-pack-approvals-default",
|
||||
tenantId: tenant,
|
||||
name: "Pack approvals → Slack + Email",
|
||||
match: NotifyRuleMatch.Create(
|
||||
eventKinds: new[] { "pack.approval.granted", "pack.approval.denied", "pack.policy.override" },
|
||||
labels: new[] { "environment=prod" }),
|
||||
actions: new[]
|
||||
{
|
||||
NotifyRuleAction.Create(
|
||||
actionId: "act-pack-approvals-slack",
|
||||
channel: slackChannel.ChannelId,
|
||||
template: "tmpl-pack-approval-slack-en",
|
||||
locale: "en-US"),
|
||||
NotifyRuleAction.Create(
|
||||
actionId: "act-pack-approvals-email",
|
||||
channel: emailChannel.ChannelId,
|
||||
template: "tmpl-pack-approval-email-en",
|
||||
locale: "en-US")
|
||||
},
|
||||
description: "Routes pack approval events to seeded Slack and Email channels.");
|
||||
|
||||
await ruleRepository.UpsertAsync(rule, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return 3; // two channels + one rule
|
||||
}
|
||||
|
||||
private static string? LocateTemplatesPath(string contentRootPath)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "pack-approval-templates.json"),
|
||||
Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "pack-approval-templates.json"),
|
||||
Path.Combine(contentRootPath, "StellaOps.Notifier", "StellaOps.Notifier.docs", "pack-approval-templates.json"),
|
||||
Path.Combine(contentRootPath, "Notifier", "StellaOps.Notifier", "StellaOps.Notifier.docs", "pack-approval-templates.json"),
|
||||
Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "StellaOps.Notifier.docs", "pack-approval-templates.json")
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return Path.GetFullPath(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static NotifyTemplate ToTemplate(JsonElement element)
|
||||
{
|
||||
var templateId = element.GetProperty("templateId").GetString() ?? throw new InvalidOperationException("templateId missing");
|
||||
var tenantId = element.GetProperty("tenantId").GetString() ?? throw new InvalidOperationException("tenantId missing");
|
||||
var key = element.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing");
|
||||
var locale = element.GetProperty("locale").GetString() ?? "en-US";
|
||||
var body = element.GetProperty("body").GetString() ?? string.Empty;
|
||||
|
||||
var channelType = ParseEnum<NotifyChannelType>(element.GetProperty("channelType").GetString(), NotifyChannelType.Custom);
|
||||
var renderMode = ParseEnum<NotifyTemplateRenderMode>(element.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
|
||||
var format = ParseEnum<NotifyDeliveryFormat>(element.GetProperty("format").GetString(), NotifyDeliveryFormat.Json);
|
||||
|
||||
var description = element.TryGetProperty("description", out var desc) ? desc.GetString() : null;
|
||||
|
||||
var metadata = element.TryGetProperty("metadata", out var meta)
|
||||
? meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(p.Name, p.Value.GetString() ?? string.Empty))
|
||||
: Enumerable.Empty<KeyValuePair<string, string>>();
|
||||
|
||||
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:pack-approvals");
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Xml;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds risk templates and default routing for dev/test/bootstrap scenarios.
|
||||
/// </summary>
|
||||
public sealed class RiskTemplateSeeder : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IHostEnvironment _environment;
|
||||
private readonly ILogger<RiskTemplateSeeder> _logger;
|
||||
|
||||
public RiskTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<RiskTemplateSeeder> 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<INotifyTemplateRepository>();
|
||||
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
|
||||
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
|
||||
|
||||
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<int> 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<int> 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(el => ToChannel(el, tenant))
|
||||
.ToArray();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await channelRepository.UpsertAsync(channel, 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, string tenantOverride)
|
||||
{
|
||||
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
|
||||
var type = ParseEnum<NotifyChannelType>(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: tenantOverride,
|
||||
name: name,
|
||||
type: type,
|
||||
config: config,
|
||||
description: element.TryGetProperty("description", out var d) ? d.GetString() : null);
|
||||
}
|
||||
|
||||
private static async Task<NotifyTemplate> 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<NotifyChannelType>(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<NotifyTemplateRenderMode>(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
|
||||
var format = ParseEnum<NotifyDeliveryFormat>(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<KeyValuePair<string, string>>();
|
||||
if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
metadata = meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(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<TEnum>(string? value, TEnum fallback) where TEnum : struct
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Notify.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Marker type used for testing/hosting the web application.
|
||||
/// </summary>
|
||||
public sealed class WebServiceAssemblyMarker;
|
||||
@@ -8,6 +8,10 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" />
|
||||
@@ -24,12 +28,16 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Persistence/StellaOps.Notify.Persistence.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
|
||||
<!-- Notifier Worker: shared types for correlation, simulation, security, escalation, etc. -->
|
||||
<ProjectReference Include="../../Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Translations\*.json" />
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Storage.Compat;
|
||||
|
||||
public interface INotifyEscalationPolicyRepository
|
||||
{
|
||||
Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(
|
||||
string tenantId,
|
||||
string? policyType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyEscalationPolicy?> GetAsync(
|
||||
string tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyEscalationPolicy> UpsertAsync(
|
||||
NotifyEscalationPolicy policy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class InMemoryEscalationPolicyRepository : INotifyEscalationPolicyRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyEscalationPolicy>> _store = new();
|
||||
|
||||
public Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(
|
||||
string tenantId,
|
||||
string? policyType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = ForTenant(tenantId).Values
|
||||
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyEscalationPolicy>>(result);
|
||||
}
|
||||
|
||||
public Task<NotifyEscalationPolicy?> GetAsync(
|
||||
string tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
items.TryGetValue(policyId, out var policy);
|
||||
return Task.FromResult(policy);
|
||||
}
|
||||
|
||||
public Task<NotifyEscalationPolicy> UpsertAsync(
|
||||
NotifyEscalationPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(policy.TenantId);
|
||||
items[policy.PolicyId] = policy;
|
||||
return Task.FromResult(policy);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
return Task.FromResult(items.TryRemove(policyId, out _));
|
||||
}
|
||||
|
||||
private ConcurrentDictionary<string, NotifyEscalationPolicy> ForTenant(string tenantId) =>
|
||||
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyEscalationPolicy>());
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Storage.Compat;
|
||||
|
||||
public interface INotifyMaintenanceWindowRepository
|
||||
{
|
||||
Task<IReadOnlyList<NotifyMaintenanceWindow>> ListAsync(
|
||||
string tenantId,
|
||||
bool? activeOnly,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyMaintenanceWindow?> GetAsync(
|
||||
string tenantId,
|
||||
string windowId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyMaintenanceWindow> UpsertAsync(
|
||||
NotifyMaintenanceWindow window,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
string windowId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class InMemoryMaintenanceWindowRepository : INotifyMaintenanceWindowRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyMaintenanceWindow>> _store = new();
|
||||
|
||||
public Task<IReadOnlyList<NotifyMaintenanceWindow>> ListAsync(
|
||||
string tenantId,
|
||||
bool? activeOnly,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId).Values.AsEnumerable();
|
||||
|
||||
if (activeOnly is true)
|
||||
{
|
||||
items = items.Where(w => w.IsActiveAt(now));
|
||||
}
|
||||
|
||||
var result = items
|
||||
.OrderBy(w => w.StartsAt)
|
||||
.ThenBy(w => w.WindowId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyMaintenanceWindow>>(result);
|
||||
}
|
||||
|
||||
public Task<NotifyMaintenanceWindow?> GetAsync(
|
||||
string tenantId,
|
||||
string windowId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
items.TryGetValue(windowId, out var window);
|
||||
return Task.FromResult(window);
|
||||
}
|
||||
|
||||
public Task<NotifyMaintenanceWindow> UpsertAsync(
|
||||
NotifyMaintenanceWindow window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(window.TenantId);
|
||||
items[window.WindowId] = window;
|
||||
return Task.FromResult(window);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
string windowId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
return Task.FromResult(items.TryRemove(windowId, out _));
|
||||
}
|
||||
|
||||
private ConcurrentDictionary<string, NotifyMaintenanceWindow> ForTenant(string tenantId) =>
|
||||
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyMaintenanceWindow>());
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Storage.Compat;
|
||||
|
||||
public interface INotifyOnCallScheduleRepository
|
||||
{
|
||||
Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(
|
||||
string tenantId,
|
||||
bool? includeInactive,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyOnCallSchedule?> GetAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyOnCallSchedule> UpsertAsync(
|
||||
NotifyOnCallSchedule schedule,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddOverrideAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
NotifyOnCallOverride @override,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> RemoveOverrideAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
string overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class InMemoryOnCallScheduleRepository : INotifyOnCallScheduleRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyOnCallSchedule>> _store = new();
|
||||
|
||||
public Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(
|
||||
string tenantId,
|
||||
bool? includeInactive,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId).Values.AsEnumerable();
|
||||
|
||||
if (includeInactive is not true)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
items = items.Where(s => s.Overrides.Any(o => o.IsActiveAt(now)) || !s.Overrides.Any());
|
||||
}
|
||||
|
||||
var result = items
|
||||
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyOnCallSchedule>>(result);
|
||||
}
|
||||
|
||||
public Task<NotifyOnCallSchedule?> GetAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
items.TryGetValue(scheduleId, out var schedule);
|
||||
return Task.FromResult(schedule);
|
||||
}
|
||||
|
||||
public Task<NotifyOnCallSchedule> UpsertAsync(
|
||||
NotifyOnCallSchedule schedule,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(schedule.TenantId);
|
||||
items[schedule.ScheduleId] = schedule;
|
||||
return Task.FromResult(schedule);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
return Task.FromResult(items.TryRemove(scheduleId, out _));
|
||||
}
|
||||
|
||||
public Task AddOverrideAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
NotifyOnCallOverride @override,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
if (!items.TryGetValue(scheduleId, out var schedule))
|
||||
{
|
||||
throw new KeyNotFoundException($"On-call schedule '{scheduleId}' not found.");
|
||||
}
|
||||
|
||||
var updatedOverrides = schedule.Overrides.IsDefaultOrEmpty
|
||||
? ImmutableArray.Create(@override)
|
||||
: schedule.Overrides.Add(@override);
|
||||
|
||||
var updatedSchedule = NotifyOnCallSchedule.Create(
|
||||
schedule.ScheduleId,
|
||||
schedule.TenantId,
|
||||
schedule.Name,
|
||||
schedule.TimeZone,
|
||||
schedule.Layers,
|
||||
updatedOverrides,
|
||||
schedule.Enabled,
|
||||
schedule.Description,
|
||||
schedule.Metadata,
|
||||
schedule.CreatedBy,
|
||||
schedule.CreatedAt,
|
||||
schedule.UpdatedBy,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
items[scheduleId] = updatedSchedule;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> RemoveOverrideAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
string overrideId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
if (!items.TryGetValue(scheduleId, out var schedule))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var updatedOverrides = schedule.Overrides
|
||||
.Where(o => !string.Equals(o.OverrideId, overrideId, StringComparison.Ordinal))
|
||||
.ToImmutableArray();
|
||||
|
||||
var updatedSchedule = NotifyOnCallSchedule.Create(
|
||||
schedule.ScheduleId,
|
||||
schedule.TenantId,
|
||||
schedule.Name,
|
||||
schedule.TimeZone,
|
||||
schedule.Layers,
|
||||
updatedOverrides,
|
||||
schedule.Enabled,
|
||||
schedule.Description,
|
||||
schedule.Metadata,
|
||||
schedule.CreatedBy,
|
||||
schedule.CreatedAt,
|
||||
schedule.UpdatedBy,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
items[scheduleId] = updatedSchedule;
|
||||
return Task.FromResult(!schedule.Overrides.SequenceEqual(updatedOverrides));
|
||||
}
|
||||
|
||||
private ConcurrentDictionary<string, NotifyOnCallSchedule> ForTenant(string tenantId) =>
|
||||
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyOnCallSchedule>());
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Storage.Compat;
|
||||
|
||||
public interface INotifyOperatorOverrideRepository
|
||||
{
|
||||
Task<IReadOnlyList<NotifyOperatorOverride>> ListAsync(string tenantId, bool? activeOnly, DateTimeOffset now, CancellationToken cancellationToken = default);
|
||||
Task<NotifyOperatorOverride?> GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default);
|
||||
Task<NotifyOperatorOverride> UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class InMemoryOperatorOverrideRepository : INotifyOperatorOverrideRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyOperatorOverride>> _store = new();
|
||||
|
||||
public Task<IReadOnlyList<NotifyOperatorOverride>> ListAsync(string tenantId, bool? activeOnly, DateTimeOffset now, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId).Values.AsEnumerable();
|
||||
if (activeOnly == true)
|
||||
{
|
||||
items = items.Where(o => o.ExpiresAt > now);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyOperatorOverride>>(items.OrderBy(o => o.ExpiresAt).ToList());
|
||||
}
|
||||
|
||||
public Task<NotifyOperatorOverride?> GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
items.TryGetValue(overrideId, out var result);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<NotifyOperatorOverride> UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(@override.TenantId);
|
||||
items[@override.OverrideId] = @override;
|
||||
return Task.FromResult(@override);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
return Task.FromResult(items.TryRemove(overrideId, out _));
|
||||
}
|
||||
|
||||
private ConcurrentDictionary<string, NotifyOperatorOverride> ForTenant(string tenantId) =>
|
||||
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyOperatorOverride>());
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Storage.Compat;
|
||||
|
||||
public interface INotifyPackApprovalRepository
|
||||
{
|
||||
Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class InMemoryPackApprovalRepository : INotifyPackApprovalRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string TenantId, Guid EventId, string PackId), PackApprovalDocument> _store = new();
|
||||
|
||||
public Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store[(document.TenantId, document.EventId, document.PackId)] = document;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PackApprovalDocument
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required Guid EventId { get; init; }
|
||||
public required string PackId { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Decision { get; init; }
|
||||
public required string Actor { get; init; }
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
public string? PolicyId { get; init; }
|
||||
public string? PolicyVersion { get; init; }
|
||||
public string? ResumeToken { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public IDictionary<string, string>? Labels { get; init; }
|
||||
public IDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Storage.Compat;
|
||||
|
||||
public interface INotifyQuietHoursRepository
|
||||
{
|
||||
Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListAsync(
|
||||
string tenantId,
|
||||
string? channelId,
|
||||
bool? enabledOnly,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyQuietHoursSchedule?> GetAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyQuietHoursSchedule> UpsertAsync(
|
||||
NotifyQuietHoursSchedule schedule,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class InMemoryQuietHoursRepository : INotifyQuietHoursRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyQuietHoursSchedule>> _store = new();
|
||||
|
||||
public Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListAsync(
|
||||
string tenantId,
|
||||
string? channelId,
|
||||
bool? enabledOnly,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId).Values.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelId))
|
||||
{
|
||||
items = items.Where(s =>
|
||||
string.Equals(s.ChannelId, channelId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (enabledOnly is true)
|
||||
{
|
||||
items = items.Where(s => s.Enabled);
|
||||
}
|
||||
|
||||
var result = items
|
||||
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyQuietHoursSchedule>>(result);
|
||||
}
|
||||
|
||||
public Task<NotifyQuietHoursSchedule?> GetAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
items.TryGetValue(scheduleId, out var schedule);
|
||||
return Task.FromResult(schedule);
|
||||
}
|
||||
|
||||
public Task<NotifyQuietHoursSchedule> UpsertAsync(
|
||||
NotifyQuietHoursSchedule schedule,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(schedule.TenantId);
|
||||
items[schedule.ScheduleId] = schedule;
|
||||
return Task.FromResult(schedule);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
return Task.FromResult(items.TryRemove(scheduleId, out _));
|
||||
}
|
||||
|
||||
private ConcurrentDictionary<string, NotifyQuietHoursSchedule> ForTenant(string tenantId) =>
|
||||
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyQuietHoursSchedule>());
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Storage.Compat;
|
||||
|
||||
public interface INotifyThrottleConfigRepository
|
||||
{
|
||||
Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
|
||||
Task<NotifyThrottleConfig> UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class InMemoryThrottleConfigRepository : INotifyThrottleConfigRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyThrottleConfig>> _store = new();
|
||||
|
||||
public Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId).Values
|
||||
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<NotifyThrottleConfig>>(items);
|
||||
}
|
||||
|
||||
public Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
items.TryGetValue(configId, out var config);
|
||||
return Task.FromResult(config);
|
||||
}
|
||||
|
||||
public Task<NotifyThrottleConfig> UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(config.TenantId);
|
||||
items[config.ConfigId] = config;
|
||||
return Task.FromResult(config);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = ForTenant(tenantId);
|
||||
return Task.FromResult(items.TryRemove(configId, out _));
|
||||
}
|
||||
|
||||
private ConcurrentDictionary<string, NotifyThrottleConfig> ForTenant(string tenantId) =>
|
||||
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyThrottleConfig>());
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
# StellaOps.Notify.Worker — Agent Charter
|
||||
|
||||
## Mission
|
||||
Consume events, evaluate rules, and dispatch deliveries per `docs/modules/notify/ARCHITECTURE.md`.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/notify/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 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.
|
||||
@@ -1,11 +0,0 @@
|
||||
|
||||
using StellaOps.Notify.Queue;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notify.Worker.Handlers;
|
||||
|
||||
public interface INotifyEventHandler
|
||||
{
|
||||
Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Queue;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notify.Worker.Handlers;
|
||||
|
||||
internal sealed class NoOpNotifyEventHandler : INotifyEventHandler
|
||||
{
|
||||
private readonly ILogger<NoOpNotifyEventHandler> _logger;
|
||||
|
||||
public NoOpNotifyEventHandler(ILogger<NoOpNotifyEventHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No-op handler acknowledged event {EventId} (tenant {TenantId}).",
|
||||
message.Event.EventId,
|
||||
message.TenantId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Notify.Worker;
|
||||
|
||||
public sealed class NotifyWorkerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Worker identifier prefix; defaults to machine name.
|
||||
/// </summary>
|
||||
public string? WorkerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of messages to lease per iteration.
|
||||
/// </summary>
|
||||
public int LeaseBatchSize { get; set; } = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Duration a lease remains active before it becomes eligible for claim.
|
||||
/// </summary>
|
||||
public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Delay applied when no work is available.
|
||||
/// </summary>
|
||||
public TimeSpan IdleDelay { get; set; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of event leases processed concurrently.
|
||||
/// </summary>
|
||||
public int MaxConcurrency { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of consecutive failures before the worker delays.
|
||||
/// </summary>
|
||||
public int FailureBackoffThreshold { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Delay applied when the failure threshold is reached.
|
||||
/// </summary>
|
||||
public TimeSpan FailureBackoffDelay { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
internal string ResolveWorkerId(Guid? fallbackGuid = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(WorkerId))
|
||||
{
|
||||
return WorkerId!;
|
||||
}
|
||||
|
||||
var host = Environment.MachineName;
|
||||
return $"{host}-{(fallbackGuid ?? Guid.NewGuid()):n}";
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notify.Worker.Handlers;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notify.Worker.Processing;
|
||||
|
||||
internal sealed class NotifyEventLeaseProcessor
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.Notify.Worker");
|
||||
|
||||
private readonly INotifyEventQueue _queue;
|
||||
private readonly INotifyEventHandler _handler;
|
||||
private readonly NotifyWorkerOptions _options;
|
||||
private readonly ILogger<NotifyEventLeaseProcessor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly string _workerId;
|
||||
|
||||
public NotifyEventLeaseProcessor(
|
||||
INotifyEventQueue queue,
|
||||
INotifyEventHandler handler,
|
||||
IOptions<NotifyWorkerOptions> options,
|
||||
ILogger<NotifyEventLeaseProcessor> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_workerId = _options.ResolveWorkerId();
|
||||
}
|
||||
|
||||
public async Task<int> ProcessOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var leaseRequest = new NotifyQueueLeaseRequest(
|
||||
consumer: _workerId,
|
||||
batchSize: Math.Max(1, _options.LeaseBatchSize),
|
||||
leaseDuration: _options.LeaseDuration <= TimeSpan.Zero ? TimeSpan.FromSeconds(30) : _options.LeaseDuration);
|
||||
|
||||
IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>> leases;
|
||||
try
|
||||
{
|
||||
leases = await _queue.LeaseAsync(leaseRequest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to lease Notify events.");
|
||||
throw;
|
||||
}
|
||||
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var processed = 0;
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
processed++;
|
||||
await ProcessLeaseAsync(lease, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
private async Task ProcessLeaseAsync(
|
||||
INotifyQueueLease<NotifyQueueEventMessage> lease,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var message = lease.Message;
|
||||
var correlationId = message.TraceId ?? message.Event.EventId.ToString("N");
|
||||
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["notifyTraceId"] = correlationId,
|
||||
["notifyTenantId"] = message.TenantId,
|
||||
["notifyEventId"] = message.Event.EventId,
|
||||
["notifyAttempt"] = lease.Attempt
|
||||
});
|
||||
|
||||
using var activity = ActivitySource.StartActivity("notify.event.process", ActivityKind.Consumer);
|
||||
activity?.SetTag("notify.tenant_id", message.TenantId);
|
||||
activity?.SetTag("notify.event_id", message.Event.EventId);
|
||||
activity?.SetTag("notify.attempt", lease.Attempt);
|
||||
activity?.SetTag("notify.worker_id", _workerId);
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Processing notify event {EventId} (tenant {TenantId}, attempt {Attempt}).",
|
||||
message.Event.EventId,
|
||||
message.TenantId,
|
||||
lease.Attempt);
|
||||
|
||||
await _handler.HandleAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await lease.AcknowledgeAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Acknowledged notify event {EventId} (tenant {TenantId}).",
|
||||
message.Event.EventId,
|
||||
message.TenantId);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Worker cancellation requested while processing event {EventId}; returning lease to queue.",
|
||||
message.Event.EventId);
|
||||
|
||||
await SafeReleaseAsync(lease, NotifyQueueReleaseDisposition.Retry, CancellationToken.None).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to process notify event {EventId}; scheduling retry.",
|
||||
message.Event.EventId);
|
||||
|
||||
await SafeReleaseAsync(lease, NotifyQueueReleaseDisposition.Retry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SafeReleaseAsync(
|
||||
INotifyQueueLease<NotifyQueueEventMessage> lease,
|
||||
NotifyQueueReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await lease.ReleaseAsync(disposition, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Suppress release errors during shutdown.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notify.Worker.Processing;
|
||||
|
||||
internal sealed class NotifyEventLeaseWorker : BackgroundService
|
||||
{
|
||||
private readonly NotifyEventLeaseProcessor _processor;
|
||||
private readonly NotifyWorkerOptions _options;
|
||||
private readonly ILogger<NotifyEventLeaseWorker> _logger;
|
||||
|
||||
public NotifyEventLeaseWorker(
|
||||
NotifyEventLeaseProcessor processor,
|
||||
IOptions<NotifyWorkerOptions> options,
|
||||
ILogger<NotifyEventLeaseWorker> logger)
|
||||
{
|
||||
_processor = processor ?? throw new ArgumentNullException(nameof(processor));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var idleDelay = _options.IdleDelay <= TimeSpan.Zero
|
||||
? TimeSpan.FromMilliseconds(500)
|
||||
: _options.IdleDelay;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
int processed;
|
||||
try
|
||||
{
|
||||
processed = await _processor.ProcessOnceAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Notify worker processing loop encountered an error.");
|
||||
await Task.Delay(_options.FailureBackoffDelay, stoppingToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (processed == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(idleDelay, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notify.Worker;
|
||||
using StellaOps.Notify.Worker.Handlers;
|
||||
using StellaOps.Notify.Worker.Processing;
|
||||
using StellaOps.Worker.Health;
|
||||
|
||||
var builder = WebApplication.CreateSlimBuilder(args);
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
.AddEnvironmentVariables(prefix: "NOTIFY_");
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddSimpleConsole(options =>
|
||||
{
|
||||
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ ";
|
||||
options.UseUtcTimestamp = true;
|
||||
});
|
||||
|
||||
builder.Services.Configure<NotifyWorkerOptions>(builder.Configuration.GetSection("notify:worker"));
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
builder.Services.AddNotifyEventQueue(builder.Configuration, "notify:queue");
|
||||
builder.Services.AddNotifyDeliveryQueue(builder.Configuration, "notify:deliveryQueue");
|
||||
|
||||
builder.Services.AddSingleton<INotifyEventHandler, NoOpNotifyEventHandler>();
|
||||
builder.Services.AddSingleton<NotifyEventLeaseProcessor>();
|
||||
builder.Services.AddHostedService<NotifyEventLeaseWorker>();
|
||||
|
||||
builder.Services.AddWorkerHealthChecks();
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapWorkerHealthEndpoints();
|
||||
await app.RunAsync().ConfigureAwait(false);
|
||||
@@ -1,3 +0,0 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Notify.Worker.Tests")]
|
||||
@@ -1,19 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<!-- FrameworkReference Microsoft.AspNetCore.App and Hosting packages are provided by Sdk.Web -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Worker.Health\StellaOps.Worker.Health.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,11 +0,0 @@
|
||||
# StellaOps.Notify.Worker Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0418-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Notify.Worker. |
|
||||
| AUDIT-0418-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Notify.Worker. |
|
||||
| AUDIT-0418-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"worker": {
|
||||
"leaseBatchSize": 16,
|
||||
"leaseDuration": "00:00:30",
|
||||
"idleDelay": "00:00:00.250",
|
||||
"maxConcurrency": 4,
|
||||
"failureBackoffThreshold": 3,
|
||||
"failureBackoffDelay": "00:00:05"
|
||||
},
|
||||
"queue": {
|
||||
"transport": "Redis",
|
||||
"redis": {
|
||||
"connectionString": "localhost:6379",
|
||||
"streams": [
|
||||
{
|
||||
"stream": "notify:events",
|
||||
"consumerGroup": "notify-workers",
|
||||
"idempotencyKeyPrefix": "notify:events:idemp:",
|
||||
"approximateMaxLength": 100000
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"deliveryQueue": {
|
||||
"transport": "Redis",
|
||||
"redis": {
|
||||
"connectionString": "localhost:6379",
|
||||
"streamName": "notify:deliveries",
|
||||
"consumerGroup": "notify-delivery",
|
||||
"idempotencyKeyPrefix": "notify:deliveries:idemp:",
|
||||
"deadLetterStreamName": "notify:deliveries:dead"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -101,8 +101,8 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/lineage(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/resolve(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/ops/binaryindex(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/policy$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/governance$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/workflows$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/aoc(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/aoc$1" },
|
||||
@@ -115,8 +115,8 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/audit(.*)", "IsRegex": true, "TranslatesTo": "http://timeline.stella-ops.local/api/v1/audit$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/export(.*)", "IsRegex": true, "TranslatesTo": "http://exportcenter.stella-ops.local/api/v1/export$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/notifier/(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notify.stella-ops.local/api/v2/notify/deliveries$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/notifier/(.*)", "IsRegex": true, "TranslatesTo": "http://notify.stella-ops.local/api/v2/notify/$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" },
|
||||
@@ -134,7 +134,7 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v1/$1$2" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v2/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v2/$1$2" },
|
||||
|
||||
{ "Type": "Microservice", "Path": "^/api/(cvss|gate|exceptions|policy)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/$1$2" },
|
||||
{ "Type": "Microservice", "Path": "^/api/(cvss|gate|exceptions|policy)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/$1$2" },
|
||||
{ "Type": "Microservice", "Path": "^/api/(risk|risk-budget)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/$1$2" },
|
||||
{ "Type": "Microservice", "Path": "^/api/(release-orchestrator|releases|approvals)(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/$1$2" },
|
||||
{ "Type": "Microservice", "Path": "^/api/(compare|change-traces|sbomservice)(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/$1$2" },
|
||||
@@ -152,9 +152,9 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" },
|
||||
|
||||
{ "Type": "ReverseProxy", "Path": "^/policy/shadow(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/shadow$1", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "^/policy/simulations(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/simulations$1", "PreserveAuthHeaders": true },
|
||||
{ "Type": "Microservice", "Path": "^/policy(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" },
|
||||
{ "Type": "ReverseProxy", "Path": "^/policy/shadow(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/policy/shadow$1", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "^/policy/simulations(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/policy/simulations$1", "PreserveAuthHeaders": true },
|
||||
{ "Type": "Microservice", "Path": "^/policy(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/policy$1" },
|
||||
|
||||
{ "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" },
|
||||
{ "Type": "Microservice", "Path": "^/v1/runs(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/v1/runs$1" },
|
||||
|
||||
Reference in New Issue
Block a user