up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
2025-10-24 09:15:37 +03:00
parent f4d7a15a00
commit 17d861e4ab
163 changed files with 14269 additions and 452 deletions

View File

@@ -0,0 +1,149 @@
# .gitea/workflows/release.yml
# Deterministic release pipeline producing signed images, SBOMs, provenance, and manifest
name: Release Bundle
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Release version (overrides tag, e.g. 2025.10.0-edge)'
required: false
type: string
channel:
description: 'Release channel (edge|stable|lts)'
required: false
default: 'edge'
type: choice
options:
- edge
- stable
- lts
calendar:
description: 'Calendar tag (YYYY.MM) - optional override'
required: false
type: string
push_images:
description: 'Push container images to registry'
required: false
default: true
type: boolean
jobs:
build-release:
runs-on: ubuntu-22.04
env:
DOTNET_VERSION: '10.0.100-rc.1.25451.107'
REGISTRY: registry.stella-ops.org
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20.14.0'
- name: Set up .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
include-prerelease: true
- name: Install Helm 3.16.0
run: |
curl -fsSL https://get.helm.sh/helm-v3.16.0-linux-amd64.tar.gz -o /tmp/helm.tgz
tar -xzf /tmp/helm.tgz -C /tmp
sudo install -m 0755 /tmp/linux-amd64/helm /usr/local/bin/helm
- name: Install Cosign
uses: sigstore/cosign-installer@v3.4.0
- name: Determine release metadata
id: meta
run: |
set -euo pipefail
RAW_VERSION="${{ github.ref_name }}"
if [[ "${{ github.event_name }}" != "push" ]]; then
RAW_VERSION="${{ github.event.inputs.version }}"
fi
if [[ -z "$RAW_VERSION" ]]; then
echo "::error::Release version not provided" >&2
exit 1
fi
VERSION="${RAW_VERSION#v}"
CHANNEL="${{ github.event.inputs.channel || '' }}"
if [[ -z "$CHANNEL" ]]; then
CHANNEL="edge"
fi
CALENDAR_INPUT="${{ github.event.inputs.calendar || '' }}"
if [[ -z "$CALENDAR_INPUT" ]]; then
YEAR=$(echo "$VERSION" | awk -F'.' '{print $1}')
MONTH=$(echo "$VERSION" | awk -F'.' '{print $2}')
if [[ -n "$YEAR" && -n "$MONTH" ]]; then
CALENDAR_INPUT="$YEAR.$MONTH"
else
CALENDAR_INPUT=$(date -u +'%Y.%m')
fi
fi
PUSH_INPUT="${{ github.event.inputs.push_images || '' }}"
if [[ "${{ github.event_name }}" == "push" ]]; then
PUSH_INPUT="true"
elif [[ -z "$PUSH_INPUT" ]]; then
PUSH_INPUT="true"
fi
if [[ "$PUSH_INPUT" == "false" || "$PUSH_INPUT" == "0" ]]; then
PUSH_FLAG="false"
else
PUSH_FLAG="true"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
echo "calendar=$CALENDAR_INPUT" >> "$GITHUB_OUTPUT"
echo "push=$PUSH_FLAG" >> "$GITHUB_OUTPUT"
- name: Log in to registry
if: steps.meta.outputs.push == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Prepare release output directory
run: |
rm -rf out/release
mkdir -p out/release
- name: Build release bundle
env:
COSIGN_KEY_REF: ${{ secrets.COSIGN_KEY_REF }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_IDENTITY_TOKEN: ${{ secrets.COSIGN_IDENTITY_TOKEN }}
run: |
set -euo pipefail
EXTRA_ARGS=()
if [[ "${{ steps.meta.outputs.push }}" != "true" ]]; then
EXTRA_ARGS+=("--no-push")
fi
./ops/devops/release/build_release.py \
--version "${{ steps.meta.outputs.version }}" \
--channel "${{ steps.meta.outputs.channel }}" \
--calendar "${{ steps.meta.outputs.calendar }}" \
--git-sha "${{ github.sha }}" \
"${EXTRA_ARGS[@]}"
- name: Upload release artefacts
uses: actions/upload-artifact@v4
with:
name: stellaops-release-${{ steps.meta.outputs.version }}
path: out/release
if-no-files-found: error

2
.gitignore vendored
View File

@@ -27,3 +27,5 @@ seed-data/cert-bund/**/*.json
seed-data/cert-bund/**/*.sha256
out/offline-kit/web/**/*
src/StellaOps.Web/node_modules/**/*
src/StellaOps.Web/.angular/**/*

View File

@@ -3,7 +3,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
## Wave Instructions
### Wave 0
- Team Attestor Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Attestor/TASKS.md`. Focus on ATTESTOR-API-11-201 (TODO), ATTESTOR-VERIFY-11-202 (TODO), ATTESTOR-OBS-11-203 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Attestor Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Attestor/TASKS.md`. ATTESTOR-API-11-201, ATTESTOR-VERIFY-11-202, and ATTESTOR-OBS-11-203 are DONE (2025-10-19); continue monitoring Rekor inclusion proofs/archives and keep module docs/tests aligned.
- Team Authority Core & Security Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Authority/TASKS.md`. Focus on AUTH-DPOP-11-001 (DONE 2025-10-20), AUTH-MTLS-11-002 (DONE 2025-10-23). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Authority Core & Storage Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Authority/TASKS.md`. Focus on AUTHSTORAGE-MONGO-08-001 (DONE 2025-10-19). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team DevEx/CLI: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on EXCITITOR-CLI-01-002 (TODO), CLI-RUNTIME-13-005 (TODO). Confirm prerequisites (external: EXCITITOR-CLI-01-001, EXCITITOR-EXPORT-01-001) before starting and report status in module TASKS.md.
@@ -49,19 +49,19 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- Team Team WebService & Authority: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md`, `src/StellaOps.Concelier.WebService/TASKS.md`. Focus on SEC2.PLG (DOING), SEC3.PLG (DOING), SEC5.PLG (DOING), PLG4-6.CAPABILITIES (BLOCKED), PLG6.DIAGRAM (TODO), PLG7.RFC (REVIEW), FEEDWEB-DOCS-01-001 (DOING), FEEDWEB-OPS-01-006 (TODO), FEEDWEB-OPS-01-007 (BLOCKED). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Tools Guild, BE-Conn-MSRC: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Concelier.Connector.Common/TASKS.md`. Focus on FEEDCONN-SHARED-STATE-003 (**TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team UX Specialist, Angular Eng: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Web/TASKS.md`. Focus on WEB1.TRIVY-SETTINGS (DONE 2025-10-21), WEB1.TRIVY-SETTINGS-TESTS (DONE 2025-10-21), and WEB1.DEPS-13-001 (DONE 2025-10-21). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Zastava Core Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Zastava.Core/TASKS.md`. Focus on ZASTAVA-CORE-12-201 (TODO), ZASTAVA-CORE-12-202 (TODO), ZASTAVA-CORE-12-203 (TODO), ZASTAVA-OPS-12-204 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Zastava Webhook Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Zastava.Webhook/TASKS.md`. Focus on ZASTAVA-WEBHOOK-12-101 (TODO), ZASTAVA-WEBHOOK-12-102 (TODO), ZASTAVA-WEBHOOK-12-103 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Zastava Core Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Zastava.Core/TASKS.md`. Focus on ZASTAVA-CORE-12-201 (DONE 2025-10-23), ZASTAVA-CORE-12-202 (DONE 2025-10-23), ZASTAVA-CORE-12-203 (DONE 2025-10-23), ZASTAVA-OPS-12-204 (DONE 2025-10-23). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Zastava Webhook Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Zastava.Webhook/TASKS.md`. Focus on ZASTAVA-WEBHOOK-12-101 (DONE 2025-10-24), ZASTAVA-WEBHOOK-12-102 (DOING 2025-10-24), ZASTAVA-WEBHOOK-12-103 (DOING 2025-10-24), ZASTAVA-WEBHOOK-12-104 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
### Wave 1
- Team Bench Guild, Language Analyzer Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `bench/TASKS.md`. Focus on BENCH-SCANNER-10-002 (TODO). Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-301 (Wave 0)) before starting and report status in module TASKS.md.
- Team DevEx/CLI, QA Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on CLI-RUNTIME-13-009 (TODO). Confirm prerequisites (internal: CLI-RUNTIME-13-005 (Wave 0)) before starting and report status in module TASKS.md.
- Team DevOps Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-REL-14-001 (TODO). Confirm prerequisites (internal: ATTESTOR-API-11-201 (Wave 0), SIGNER-API-11-101 (Wave 0)) before starting and report status in module TASKS.md.
- Team DevOps Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-REL-14-001 (DOING 2025-10-23). Confirm prerequisites (internal: ATTESTOR-API-11-201 (Wave 0), SIGNER-API-11-101 (Wave 0)) before starting and report status in module TASKS.md.
- Team DevOps Guild, Scanner WebService Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-SCANNER-09-204 (TODO). Confirm prerequisites (internal: SCANNER-EVENTS-15-201 (Wave 0)) before starting and report status in module TASKS.md.
- Team Emit Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Scanner.Emit/TASKS.md`. SCANNER-EMIT-10-607 shipped 2025-10-22; remaining focus is SCANNER-EMIT-17-701 (build-id enrichment). Confirm prerequisites (internal: POLICY-CORE-09-005 (Wave 0), SCANNER-EMIT-10-602 (Wave 0), SCANNER-EMIT-10-604 (Wave 0)) before starting and report status in module TASKS.md.
- Team Language Analyzer Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang/TASKS.md`. Sprint10 language analyzers (10-303..10-306) wrapped by 2025-10-22; shift to Wave1 benchmarking/packaging follow-ups (10-308+/309 variants) and ensure shared helpers stay stable. Node stream (tasks 10-302/309) closed on 2025-10-21; verify prereqs SCANNER-ANALYZERS-LANG-10-301/307 remain satisfied before new work.
- Team Licensing Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `ops/licensing/TASKS.md`. Focus on DEVOPS-LIC-14-004 (TODO). Confirm prerequisites (internal: AUTH-MTLS-11-002 (Wave 0)) before starting and report status in module TASKS.md.
- Team Notify Engine Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Notify.Engine/TASKS.md`. Focus on NOTIFY-ENGINE-15-301 (TODO). Confirm prerequisites (internal: NOTIFY-MODELS-15-101 (Wave 0)) before starting and report status in module TASKS.md.
- Team Notify Queue Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Notify.Queue/TASKS.md`. Focus on NOTIFY-QUEUE-15-401 (TODO). Confirm prerequisites (internal: NOTIFY-MODELS-15-101 (Wave 0)) before starting and report status in module TASKS.md.
- Team Notify Queue Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Notify.Queue/TASKS.md`. Focus on NOTIFY-QUEUE-15-401 (DONE 2025-10-23). Confirm prerequisites (internal: NOTIFY-MODELS-15-101 (Wave 0)) before starting and report status in module TASKS.md.
- Team Notify WebService Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Notify.WebService/TASKS.md`. Focus on NOTIFY-WEB-15-103 (DONE). Confirm prerequisites (internal: NOTIFY-WEB-15-102 (Wave 0)) before starting and report status in module TASKS.md.
- Team Scanner WebService Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Scanner.WebService/TASKS.md`. SCANNER-RUNTIME-12-301 closed (2025-10-20); coordinate with Zastava observer guild on batch fixtures and advance to SCANNER-RUNTIME-12-302.
- Team Scheduler ImpactIndex Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Scheduler.ImpactIndex/TASKS.md`. Focus on SCHED-IMPACT-16-301 (TODO). Confirm prerequisites (internal: SCANNER-EMIT-10-605 (Wave 0)) before starting and report status in module TASKS.md.
@@ -76,19 +76,19 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- Team Team Excititor Connectors Ubuntu: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.Ubuntu.CSAF/TASKS.md`. Focus on EXCITITOR-CONN-UBUNTU-01-003 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-UBUNTU-01-002 (Wave 0); external: EXCITITOR-POLICY-01-001) before starting and report status in module TASKS.md.
- Team Team Excititor Export: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Export/TASKS.md`. Focus on EXCITITOR-EXPORT-01-006 (DONE 2025-10-21). Confirm prerequisites (internal: EXCITITOR-EXPORT-01-005 (Wave 0), POLICY-CORE-09-005 (Wave 0)) before starting and report status in module TASKS.md.
- Team Team Excititor Worker: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Worker/TASKS.md`. Focus on EXCITITOR-WORKER-01-003 (TODO). Confirm prerequisites (internal: EXCITITOR-ATTEST-01-003 (Wave 0); external: EXCITITOR-EXPORT-01-002, EXCITITOR-WORKER-01-001) before starting and report status in module TASKS.md.
- Team UI Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.UI/TASKS.md`. Focus on UI-ATTEST-11-005 (TODO), UI-VEX-13-003 (TODO), UI-POLICY-13-007 (TODO), UI-ADMIN-13-004 (TODO), UI-AUTH-13-001 (TODO), UI-SCANS-13-002 (TODO), UI-NOTIFY-13-006 (DOING), UI-SCHED-13-005 (TODO). Confirm prerequisites (internal: ATTESTOR-API-11-201 (Wave 0), AUTH-DPOP-11-001 (Wave 0), AUTH-MTLS-11-002 (Wave 0), EXCITITOR-EXPORT-01-005 (Wave 0), NOTIFY-WEB-15-101 (Wave 0), POLICY-CORE-09-006 (Wave 0), SCHED-WEB-16-101 (Wave 0), SIGNER-API-11-101 (Wave 0); external: EXCITITOR-CORE-02-001, SCANNER-WEB-09-102, SCANNER-WEB-09-103) before starting and report status in module TASKS.md.
- Team Zastava Observer Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Zastava.Observer/TASKS.md`. Focus on ZASTAVA-OBS-12-001 (TODO). Confirm prerequisites (internal: ZASTAVA-CORE-12-201 (Wave 0)) before starting and report status in module TASKS.md.
- Team UI Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.UI/TASKS.md`. Focus on UI-ATTEST-11-005 (DONE 2025-10-23), UI-VEX-13-003 (TODO), UI-POLICY-13-007 (TODO), UI-ADMIN-13-004 (TODO), UI-AUTH-13-001 (DONE 2025-10-23), UI-SCANS-13-002 (TODO), UI-NOTIFY-13-006 (DOING 2025-10-19), UI-SCHED-13-005 (TODO). Confirm prerequisites (internal: ATTESTOR-API-11-201 (Wave 0), AUTH-DPOP-11-001 (Wave 0), AUTH-MTLS-11-002 (Wave 0), EXCITITOR-EXPORT-01-005 (Wave 0), NOTIFY-WEB-15-101 (Wave 0), POLICY-CORE-09-006 (Wave 0), SCHED-WEB-16-101 (Wave 0), SIGNER-API-11-101 (Wave 0); external: EXCITITOR-CORE-02-001, SCANNER-WEB-09-102, SCANNER-WEB-09-103) before starting and report status in module TASKS.md.
- Team Zastava Observer Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Zastava.Observer/TASKS.md`. Focus on ZASTAVA-OBS-12-001 (DOING 2025-10-24). Confirm prerequisites (internal: ZASTAVA-CORE-12-201 (Wave 0)) before starting and report status in module TASKS.md.
### Wave 2
- Team Bench Guild, Notify Team: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `bench/TASKS.md`. Focus on BENCH-NOTIFY-15-001 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-301 (Wave 1)) before starting and report status in module TASKS.md.
- Team Bench Guild, Scheduler Team: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `bench/TASKS.md`. Focus on BENCH-IMPACT-16-001 (TODO). Confirm prerequisites (internal: SCHED-IMPACT-16-301 (Wave 1)) before starting and report status in module TASKS.md.
- Team Deployment Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/deployment/TASKS.md`. Focus on DEVOPS-OPS-14-003 (TODO). Confirm prerequisites (internal: DEVOPS-REL-14-001 (Wave 1)) before starting and report status in module TASKS.md.
- Team DevOps Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-MIRROR-08-001 (DONE 2025-10-19), DEVOPS-PERF-10-002 (TODO), DEVOPS-REL-14-004 (TODO), DEVOPS-REL-17-002 (TODO), and DEVOPS-NUGET-13-001 (TODO). Confirm prerequisites (internal: BENCH-SCANNER-10-002 (Wave 1), DEVOPS-REL-14-001 (Wave 1), SCANNER-EMIT-17-701 (Wave 1)) before starting and report status in module TASKS.md.
- Team DevOps Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-MIRROR-08-001 (DONE 2025-10-19), DEVOPS-PERF-10-002 (TODO), DEVOPS-REL-14-004 (TODO), DEVOPS-REL-17-002 (TODO), DEVOPS-NUGET-13-001 (DOING 2025-10-24), and DEVOPS-UI-13-006 (TODO). Confirm prerequisites (internal: BENCH-SCANNER-10-002 (Wave 1), DEVOPS-REL-14-001 (Wave 1), SCANNER-EMIT-17-701 (Wave 1), UI-AUTH-13-001 (Wave 1)) before starting and report status in module TASKS.md.
- Team DevOps Guild, Notify Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-SCANNER-09-205 (TODO). Confirm prerequisites (internal: DEVOPS-SCANNER-09-204 (Wave 1)) before starting and report status in module TASKS.md.
- Team Notify Engine Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.Engine/TASKS.md`. Focus on NOTIFY-ENGINE-15-302 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-301 (Wave 1)) before starting and report status in module TASKS.md.
- Team Notify Queue Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.Queue/TASKS.md`. Focus on NOTIFY-QUEUE-15-403 (TODO), NOTIFY-QUEUE-15-402 (TODO). Confirm prerequisites (internal: NOTIFY-QUEUE-15-401 (Wave 1)) before starting and report status in module TASKS.md.
- Team Notify Queue Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.Queue/TASKS.md`. Focus on NOTIFY-QUEUE-15-403 (DONE 2025-10-23), NOTIFY-QUEUE-15-402 (DONE 2025-10-23). Confirm prerequisites (internal: NOTIFY-QUEUE-15-401 (Wave 1)) before starting and report status in module TASKS.md.
- Team Notify WebService Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.WebService/TASKS.md`. Focus on NOTIFY-WEB-15-104 (TODO). Confirm prerequisites (internal: NOTIFY-QUEUE-15-401 (Wave 1), NOTIFY-STORAGE-15-201 (Wave 0)) before starting and report status in module TASKS.md.
- Team Notify Worker Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.Worker/TASKS.md`. Focus on NOTIFY-WORKER-15-201 (TODO), NOTIFY-WORKER-15-202 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-301 (Wave 1), NOTIFY-QUEUE-15-401 (Wave 1)) before starting and report status in module TASKS.md.
- Team Notify Worker Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.Worker/TASKS.md`. Focus on NOTIFY-WORKER-15-201 (DONE 2025-10-23), NOTIFY-WORKER-15-202 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-301 (Wave 1), NOTIFY-QUEUE-15-401 (Wave 1)) before starting and report status in module TASKS.md.
- Team Offline Kit Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/offline-kit/TASKS.md`. Focus on DEVOPS-OFFLINE-14-002 (TODO), DEVOPS-OFFLINE-18-003 (TODO), and DEVOPS-OFFLINE-18-005 (TODO). Confirm prerequisites (internal: DEVOPS-REL-14-001 (Wave 1), DEVOPS-REL-14-004 (Wave 2)) before starting and report status in module TASKS.md.
- Team Samples Guild, Policy Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `samples/TASKS.md`. Focus on SAMPLES-13-004 (TODO). Confirm prerequisites (internal: POLICY-CORE-09-006 (Wave 0), UI-POLICY-13-007 (Wave 1)) before starting and report status in module TASKS.md.
- Team Scanner WebService Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Scanner.WebService/TASKS.md`. Focus on SCANNER-RUNTIME-12-302 (TODO). Confirm prerequisites (internal: SCANNER-RUNTIME-12-301 (Wave 1), ZASTAVA-CORE-12-201 (Wave 0)) before starting and report status in module TASKS.md.
@@ -388,15 +388,15 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- **Sprint 11** · Signing Chain Bring-up
- Team: Attestor Guild
- Path: `src/StellaOps.Attestor/TASKS.md`
1. [TODO] ATTESTOR-API-11-201 — `/rekor/entries` submission pipeline with dedupe, proof acquisition, and persistence.
1. [DONE 2025-10-19] ATTESTOR-API-11-201 — `/rekor/entries` submission pipeline with dedupe, proof acquisition, and persistence.
• Prereqs: —
• Current: DOING (2025-10-23) — RustFS migration underway.
2. [TODO] ATTESTOR-VERIFY-11-202 — `/rekor/verify` + retrieval endpoints validating signatures and Merkle proofs.
• Current: DONE — mTLS-gated `POST /api/v1/rekor/entries` dedupes `bundleSha256`, coordinates dual-log submissions, archives DSSE/proof bundles when requested.
2. [DONE 2025-10-19] ATTESTOR-VERIFY-11-202 — `/rekor/verify` + retrieval endpoints validating signatures and Merkle proofs.
• Prereqs: —
• Current: TODO
3. [TODO] ATTESTOR-OBS-11-203 — Telemetry, alerting, mTLS hardening, and archive workflow for Attestor.
• Current: DONE — verification pipeline validates DSSE signatures and Merkle proofs, returns cached entries with optional refresh paths.
3. [DONE 2025-10-19] ATTESTOR-OBS-11-203 — Telemetry, alerting, mTLS hardening, and archive workflow for Attestor.
• Prereqs: —
• Current: TODO
• Current: DONE — structured metrics/logs, mTLS thumbprint/SAN enforcement, and archive retention jobs integrated with alerting runbooks.
- Team: Scanner Storage Guild
- Path: `src/StellaOps.Scanner.Storage/TASKS.md`
1. [DONE 2025-10-23] SCANNER-STORAGE-11-401 — Migrate scanner artifact storage from MinIO to RustFS, including driver, configuration, and migration tooling.
@@ -411,29 +411,32 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- **Sprint 12** · Runtime Guardrails
- Team: Zastava Core Guild
- Path: `src/StellaOps.Zastava.Core/TASKS.md`
1. [TODO] ZASTAVA-CORE-12-201 — Define runtime event/admission DTOs, hashing helpers, and versioning strategy.
1. [DONE 2025-10-23] ZASTAVA-CORE-12-201 — Define runtime event/admission DTOs, hashing helpers, and versioning strategy.
• Prereqs: —
• Current: TODO
2. [TODO] ZASTAVA-CORE-12-202 — Provide configuration/logging/metrics utilities shared by Observer/Webhook.
• Current: DONE — runtime/admission envelopes canonically serialised, multihash helpers covered by new tests, architecture doc updated with negotiation rules.
2. [DONE 2025-10-23] ZASTAVA-CORE-12-202 — Provide configuration/logging/metrics utilities shared by Observer/Webhook.
• Prereqs: —
• Current: TODO
3. [TODO] ZASTAVA-CORE-12-203 — Authority client helpers, OpTok caching, and security guardrails for runtime services.
• Current: DONE — `AddZastavaRuntimeCore` binds options, emits scoped logging/metrics, integration tests exercise DI wiring.
3. [DONE 2025-10-23] ZASTAVA-CORE-12-203 — Authority client helpers, OpTok caching, and security guardrails for runtime services.
• Prereqs: —
• Current: TODO
4. [TODO] ZASTAVA-OPS-12-204 — Operational runbooks, alert rules, and dashboard exports for runtime plane.
• Current: DONE — Zastava authority token provider caches OpToks, enforces DPoP/mTLS guardrails, negative tests cover static fallback + incompat scopes.
4. [DONE 2025-10-23] ZASTAVA-OPS-12-204 — Operational runbooks, alert rules, and dashboard exports for runtime plane.
• Prereqs: —
• Current: TODO
• Current: DONE — new runtime runbook plus Prometheus/Grafana assets committed and referenced in docs/offline kit guidance.
- Team: Zastava Webhook Guild
- Path: `src/StellaOps.Zastava.Webhook/TASKS.md`
1. [TODO] ZASTAVA-WEBHOOK-12-101 — Admission controller host with TLS bootstrap and Authority auth.
1. [DONE 2025-10-24] ZASTAVA-WEBHOOK-12-101 — Admission controller host with TLS bootstrap and Authority auth.
• Prereqs: —
• Current: TODO
2. [TODO] ZASTAVA-WEBHOOK-12-102 — Query Scanner `/policy/runtime`, resolve digests, enforce verdicts.
• Current: DONE — host boots with deterministic TLS + shared runtime core, authority health checks in place, smoke coverage shipped.
2. [DOING 2025-10-24] ZASTAVA-WEBHOOK-12-102 — Query Scanner `/policy/runtime`, resolve digests, enforce verdicts.
• Prereqs: —
• Current: TODO
3. [TODO] ZASTAVA-WEBHOOK-12-103 — Caching, fail-open/closed toggles, metrics/logging for admission decisions.
• Current: DOING — runtime policy client and telemetry landed; admission wiring + verdict enforcement pending.
3. [DOING 2025-10-24] ZASTAVA-WEBHOOK-12-103 — Caching, fail-open/closed toggles, metrics/logging for admission decisions.
• Prereqs: —
• Current: TODO
• Current: DOING — instrumentation scaffolding ready, awaiting decision pipeline implementation.
4. [TODO] ZASTAVA-WEBHOOK-12-104 — Wire `/admission` endpoint to runtime policy client and emit allow/deny envelopes.
• Prereqs: ZASTAVA-WEBHOOK-12-102
• Current: TODO — implement decision handler using new backend client, produce canonical AdmissionDecision envelopes.
- **Sprint 13** · UX & CLI Experience
- Team: DevEx/CLI
- Path: `src/StellaOps.Cli/TASKS.md`
@@ -590,9 +593,9 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- **Sprint 11** · UI Integration
- Team: UI Guild
- Path: `src/StellaOps.UI/TASKS.md`
1. [TODO] UI-ATTEST-11-005 — Attestation visibility (Rekor id, status) on Scan Detail.
1. [DONE 2025-10-23] UI-ATTEST-11-005 — Attestation visibility (Rekor id, status) on Scan Detail.
• Prereqs: SIGNER-API-11-101 (Wave 0), ATTESTOR-API-11-201 (Wave 0)
• Current: TODO
• Current: DONE (2025-10-23) — Scan Detail route renders Rekor UUID/status via fixtures with verified/failure states covered by specs.
- **Sprint 12** · Runtime Guardrails
- Team: Scanner WebService Guild
- Path: `src/StellaOps.Scanner.WebService/TASKS.md`
@@ -604,9 +607,9 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
5. [TODO] SCANNER-RUNTIME-12-305 — Finalize shared fixtures and CI automation with Zastava + CLI teams for runtime APIs.
- Team: Zastava Observer Guild
- Path: `src/StellaOps.Zastava.Observer/TASKS.md`
1. [TODO] ZASTAVA-OBS-12-001 — Build container lifecycle watcher that tails CRI (containerd/cri-o/docker) events and emits deterministic runtime records with buffering + backoff.
1. [DOING 2025-10-24] ZASTAVA-OBS-12-001 — Build container lifecycle watcher that tails CRI (containerd/cri-o/docker) events and emits deterministic runtime records with buffering + backoff.
• Prereqs: ZASTAVA-CORE-12-201 (Wave 0)
• Current: TODO
• Current: DOING — lifecycle watcher scaffolding and buffering design underway (2025-10-24)
- **Sprint 13** · UX & CLI Experience
- Team: DevEx/CLI, QA Guild
- Path: `src/StellaOps.Cli/TASKS.md`
@@ -629,7 +632,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
3. [TODO] UI-ADMIN-13-004 — Deliver admin area (tenants/clients/quotas/licensing) with RBAC + audit hooks.
• Prereqs: AUTH-MTLS-11-002 (Wave 0)
• Current: TODO
4. [TODO] UI-AUTH-13-001 — Integrate Authority OIDC + DPoP flows with session management.
4. [DONE 2025-10-23] UI-AUTH-13-001 — Integrate Authority OIDC + DPoP flows with session management.
• Prereqs: AUTH-DPOP-11-001 (Wave 0), AUTH-MTLS-11-002 (Wave 0)
• Current: TODO
5. [TODO] UI-SCANS-13-002 — Build scans module (list/detail/SBOM/diff/attestation) with performance + accessibility targets.
@@ -644,13 +647,16 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- **Sprint 13** · Platform Reliability
- Team: DevOps Guild, Platform Leads
- Path: `ops/devops/TASKS.md`
1. [TODO] DEVOPS-NUGET-13-001 — Add .NET 10 preview feeds/local mirrors so `dotnet restore` succeeds offline; document updated NuGet bootstrap.
1. [DOING 2025-10-24] DEVOPS-NUGET-13-001 — Add .NET 10 preview feeds/local mirrors so `dotnet restore` succeeds offline; document updated NuGet bootstrap.
• Prereqs: DEVOPS-REL-14-001 (Wave 1)
• Current: TODO Mirror preview packages into Offline Kit/allowlisted feeds, update NuGet.config mapping, and refresh restore documentation.
• Current: DOING Mirror preview packages into Offline Kit/allowlisted feeds, update NuGet.config mapping, and refresh restore documentation.
2. [TODO] DEVOPS-UI-13-006 — Add Playwright-based UI auth smoke job to CI/offline pipelines, wiring sample `/config.json` provisioning and reporting.
• Prereqs: UI-AUTH-13-001 (Wave 1), DEVOPS-REL-14-001 (Wave 1)
• Current: TODO Extend release/offline pipelines to run `npm run test:e2e`, publish traces on failure, and ensure stub config assets ship alongside the UI bundle.
- **Sprint 14** · Release & Offline Ops
- Team: DevOps Guild
- Path: `ops/devops/TASKS.md`
1. [TODO] DEVOPS-REL-14-001 — Deterministic build/release pipeline with SBOM/provenance, signing, manifest generation.
1. [DOING 2025-10-23] DEVOPS-REL-14-001 — Deterministic build/release pipeline with SBOM/provenance, signing, manifest generation.
• Prereqs: SIGNER-API-11-101 (Wave 0), ATTESTOR-API-11-201 (Wave 0)
• Current: TODO
- Team: Licensing Guild
@@ -661,14 +667,14 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- **Sprint 15** · Notify Foundations
- Team: Notify Engine Guild
- Path: `src/StellaOps.Notify.Engine/TASKS.md`
1. [TODO] NOTIFY-ENGINE-15-301 — Rules evaluation core: tenant/kind filters, severity/delta gates, VEX gating, throttling, idempotency key generation.
1. [DOING (2025-10-24)] NOTIFY-ENGINE-15-301 — Rules evaluation core: tenant/kind filters, severity/delta gates, VEX gating, throttling, idempotency key generation.
• Prereqs: NOTIFY-MODELS-15-101 (Wave 0)
• Current: TODO
• Current: DOING (2025-10-24)
- Team: Notify Queue Guild
- Path: `src/StellaOps.Notify.Queue/TASKS.md`
1. [TODO] NOTIFY-QUEUE-15-401 — Build queue abstraction + Redis Streams adapter with ack/claim APIs, idempotency tokens, serialization contracts.
1. [DONE 2025-10-23] NOTIFY-QUEUE-15-401 — Build queue abstraction + Redis Streams adapter with ack/claim APIs, idempotency tokens, serialization contracts.
• Prereqs: NOTIFY-MODELS-15-101 (Wave 0)
• Current: TODO
• Current: DONE — Redis transport, queue contracts, and integration tests delivered (2025-10-23).
- **Sprint 16** · Scheduler Intelligence
- Team: Scheduler ImpactIndex Guild
@@ -799,12 +805,12 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
• Current: TODO
- Team: Notify Queue Guild
- Path: `src/StellaOps.Notify.Queue/TASKS.md`
1. [TODO] NOTIFY-QUEUE-15-403 — Delivery queue for channel actions with retry schedules, poison queues, and metrics instrumentation.
1. [DONE 2025-10-23] NOTIFY-QUEUE-15-403 — Delivery queue for channel actions with retry schedules, poison queues, and metrics instrumentation.
• Prereqs: NOTIFY-QUEUE-15-401 (Wave 1)
• Current: TODO
2. [TODO] NOTIFY-QUEUE-15-402 — Add NATS JetStream adapter with configuration binding, health probes, failover.
• Current: DONE — delivery queue + retry/dead-letter pipeline shipped with integration tests and metrics (2025-10-23).
2. [DONE 2025-10-23] NOTIFY-QUEUE-15-402 — Add NATS JetStream adapter with configuration binding, health probes, failover.
• Prereqs: NOTIFY-QUEUE-15-401 (Wave 1)
• Current: TODO
• Current: DONE — JetStream transport, DI binding, health check, and integration tests delivered (2025-10-23).
- Team: Notify WebService Guild
- Path: `src/StellaOps.Notify.WebService/TASKS.md`
1. [TODO] NOTIFY-WEB-15-104 — Configuration binding for Mongo/queue/secrets; startup diagnostics.
@@ -812,9 +818,9 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
• Current: TODO
- Team: Notify Worker Guild
- Path: `src/StellaOps.Notify.Worker/TASKS.md`
1. [TODO] NOTIFY-WORKER-15-201 — Implement bus subscription + leasing loop with correlation IDs, backoff, dead-letter handling (§1§5).
1. [DONE 2025-10-23] NOTIFY-WORKER-15-201 — Implement bus subscription + leasing loop with correlation IDs, backoff, dead-letter handling (§1§5).
• Prereqs: NOTIFY-QUEUE-15-401 (Wave 1)
• Current: TODO
• Current: DONE — worker leasing loop wired to queue adapters with retry/backoff telemetry (2025-10-23).
2. [TODO] NOTIFY-WORKER-15-202 — Wire rules evaluation pipeline (tenant scoping, filters, throttles, digests, idempotency) with deterministic decisions.
• Prereqs: NOTIFY-ENGINE-15-301 (Wave 1)
• Current: TODO

View File

@@ -1,17 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<config>
<add key="restoreIgnoreFailedSources" value="true" />
</config>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="local" value="local-nuget" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="local">
<package pattern="Mongo2Go" />
<package pattern="Microsoft.Extensions.Http.Polly" />
<package pattern="Microsoft.Extensions.Caching.Memory" />
<package pattern="Microsoft.Extensions.Configuration" />
<package pattern="Microsoft.Extensions.Configuration.Binder" />
<package pattern="Microsoft.Extensions.DependencyInjection.Abstractions" />
<package pattern="Microsoft.Extensions.Hosting" />
<package pattern="Microsoft.Extensions.Hosting.Abstractions" />
<package pattern="Microsoft.Extensions.Http" />
<package pattern="Microsoft.Extensions.Logging.Abstractions" />
<package pattern="Microsoft.Extensions.Options" />
<package pattern="Microsoft.Extensions.Options.ConfigurationExtensions" />
<package pattern="Microsoft.Data.Sqlite" />
<package pattern="Microsoft.AspNetCore.Authentication.JwtBearer" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>

View File

@@ -2,37 +2,39 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint | Theme | Tasks File Path | Status | Type of Specialist | Task ID | Task Description |
| --- | --- | --- | --- | --- | --- | --- |
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Attestor/TASKS.md | TODO | Attestor Guild | ATTESTOR-API-11-201 | `/rekor/entries` submission pipeline with dedupe, proof acquisition, and persistence. |
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Attestor/TASKS.md | TODO | Attestor Guild | ATTESTOR-VERIFY-11-202 | `/rekor/verify` + retrieval endpoints validating signatures and Merkle proofs. |
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Attestor/TASKS.md | TODO | Attestor Guild | ATTESTOR-OBS-11-203 | Telemetry, alerting, mTLS hardening, and archive workflow for Attestor. |
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Attestor/TASKS.md | DONE (2025-10-19) | Attestor Guild | ATTESTOR-API-11-201 | `/rekor/entries` submission pipeline with dedupe, proof acquisition, and persistence. |
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Attestor/TASKS.md | DONE (2025-10-19) | Attestor Guild | ATTESTOR-VERIFY-11-202 | `/rekor/verify` + retrieval endpoints validating signatures and Merkle proofs. |
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Attestor/TASKS.md | DONE (2025-10-19) | Attestor Guild | ATTESTOR-OBS-11-203 | Telemetry, alerting, mTLS hardening, and archive workflow for Attestor. |
| Sprint 11 | Storage Platform Hardening | src/StellaOps.Scanner.Storage/TASKS.md | DONE (2025-10-23) | Scanner Storage Guild | SCANNER-STORAGE-11-401 | Migrate scanner object storage integration from MinIO to RustFS with data migration plan. |
| Sprint 11 | UI Integration | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-ATTEST-11-005 | Attestation visibility (Rekor id, status) on Scan Detail. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Core/TASKS.md | TODO | Zastava Core Guild | ZASTAVA-CORE-12-201 | Define runtime event/admission DTOs, hashing helpers, and versioning strategy. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Core/TASKS.md | TODO | Zastava Core Guild | ZASTAVA-CORE-12-202 | Provide configuration/logging/metrics utilities shared by Observer/Webhook. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Core/TASKS.md | TODO | Zastava Core Guild | ZASTAVA-CORE-12-203 | Authority client helpers, OpTok caching, and security guardrails for runtime services. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Core/TASKS.md | TODO | Zastava Core Guild | ZASTAVA-OPS-12-204 | Operational runbooks, alert rules, and dashboard exports for runtime plane. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Observer/TASKS.md | TODO | Zastava Observer Guild | ZASTAVA-OBS-12-001 | Container lifecycle watcher emitting deterministic runtime events with buffering. |
| Sprint 11 | UI Integration | src/StellaOps.UI/TASKS.md | DONE (2025-10-23) | UI Guild | UI-ATTEST-11-005 | Attestation visibility (Rekor id, status) on Scan Detail. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Core/TASKS.md | DONE (2025-10-23) | Zastava Core Guild | ZASTAVA-CORE-12-201 | Define runtime event/admission DTOs, hashing helpers, and versioning strategy. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Core/TASKS.md | DONE (2025-10-23) | Zastava Core Guild | ZASTAVA-CORE-12-202 | Provide configuration/logging/metrics utilities shared by Observer/Webhook. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Core/TASKS.md | DONE (2025-10-23) | Zastava Core Guild | ZASTAVA-CORE-12-203 | Authority client helpers, OpTok caching, and security guardrails for runtime services. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Core/TASKS.md | DONE (2025-10-23) | Zastava Core Guild | ZASTAVA-OPS-12-204 | Operational runbooks, alert rules, and dashboard exports for runtime plane. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Observer/TASKS.md | DOING (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-12-001 | Container lifecycle watcher emitting deterministic runtime events with buffering. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Observer/TASKS.md | TODO | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Capture entrypoint traces + loaded libraries, hashing binaries and linking to baseline SBOM. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Observer/TASKS.md | TODO | Zastava Observer Guild | ZASTAVA-OBS-12-003 | Posture checks for signatures/SBOM/attestation with offline caching. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Observer/TASKS.md | TODO | Zastava Observer Guild | ZASTAVA-OBS-12-004 | Batch `/runtime/events` submissions with disk-backed buffer and rate limits. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Webhook/TASKS.md | TODO | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-101 | Admission controller host with TLS bootstrap and Authority auth. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Webhook/TASKS.md | TODO | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-102 | Query Scanner `/policy/runtime`, resolve digests, enforce verdicts. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Webhook/TASKS.md | TODO | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-103 | Caching, fail-open/closed toggles, metrics/logging for admission decisions. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Webhook/TASKS.md | DONE (2025-10-24) | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-101 | Admission controller host with TLS bootstrap and Authority auth. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Webhook/TASKS.md | DOING (2025-10-24) | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-102 | Query Scanner `/policy/runtime`, resolve digests, enforce verdicts. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Webhook/TASKS.md | DOING (2025-10-24) | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-103 | Caching, fail-open/closed toggles, metrics/logging for admission decisions. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Webhook/TASKS.md | TODO | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-104 | Wire `/admission` endpoint to runtime policy client and emit allow/deny envelopes. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Scanner.WebService/TASKS.md | DOING (2025-10-20) | Scanner WebService Guild | SCANNER-RUNTIME-12-302 | `/policy/runtime` endpoint joining SBOM baseline + policy verdict, returning admission guidance. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-303 | Align `/policy/runtime` verdicts with canonical policy evaluation (Feedser/Vexer). |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-304 | Integrate attestation verification into runtime policy metadata. |
| Sprint 12 | Runtime Guardrails | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-305 | Deliver shared fixtures + e2e validation with Zastava/CLI teams. |
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-AUTH-13-001 | Integrate Authority OIDC + DPoP flows with session management. |
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | DONE (2025-10-23) | UI Guild | UI-AUTH-13-001 | Integrate Authority OIDC + DPoP flows with session management. |
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-SCANS-13-002 | Build scans module (list/detail/SBOM/diff/attestation) with performance + accessibility targets. |
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-VEX-13-003 | Implement VEX explorer + policy editor with preview integration. |
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-ADMIN-13-004 | Deliver admin area (tenants/clients/quotas/licensing) with RBAC + audit hooks. |
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-SCHED-13-005 | Scheduler panel: schedules CRUD, run history, dry-run preview. |
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | DOING (2025-10-19) | UI Guild | UI-NOTIFY-13-006 | Notify panel: channels/rules CRUD, deliveries view, test send. |
| Sprint 13 | UX & CLI Experience | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI | CLI-RUNTIME-13-005 | Add runtime policy test verbs that consume `/policy/runtime` and display verdicts. |
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-001 | Wire up .NET 10 preview feeds/local mirrors so `dotnet restore` succeeds offline; document updated NuGet bootstrap. |
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | DOING (2025-10-24) | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-001 | Wire up .NET 10 preview feeds/local mirrors so `dotnet restore` succeeds offline; document updated NuGet bootstrap. |
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-NUGET-13-002 | Ensure all solutions/projects prioritize `local-nuget` before public feeds and add restore-order validation. |
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-003 | Upgrade `Microsoft.*` dependencies pinned to 8.* to their latest .NET 10 (or 9.x) releases and refresh guidance. |
| Sprint 14 | Release & Offline Ops | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-REL-14-001 | Deterministic build/release pipeline with SBOM/provenance, signing, and manifest generation. |
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild, UI Guild | DEVOPS-UI-13-006 | Add Playwright-based UI auth smoke job to CI/offline pipelines, wiring sample `/config.json` provisioning and reporting. |
| Sprint 14 | Release & Offline Ops | ops/devops/TASKS.md | DOING (2025-10-23) | DevOps Guild | DEVOPS-REL-14-001 | Deterministic build/release pipeline with SBOM/provenance, signing, and manifest generation. |
| Sprint 14 | Release & Offline Ops | ops/devops/TASKS.md | TODO | DevOps Guild, Scanner Guild | DEVOPS-REL-14-004 | Extend release/offline smoke jobs to cover Python analyzer plug-ins (warm/cold, determinism, signing). |
| Sprint 14 | Release & Offline Ops | ops/offline-kit/TASKS.md | TODO | Offline Kit Guild | DEVOPS-OFFLINE-14-002 | Offline kit packaging workflow with integrity verification and documentation. |
| Sprint 14 | Release & Offline Ops | ops/deployment/TASKS.md | TODO | Deployment Guild | DEVOPS-OPS-14-003 | Deployment/update/rollback automation and channel management documentation. |
@@ -43,17 +45,17 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Storage.Mongo/TASKS.md | TODO | Notify Storage Guild | NOTIFY-STORAGE-15-201 | Mongo schemas/indexes for rules, channels, deliveries, digests, locks, audit. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Storage.Mongo/TASKS.md | TODO | Notify Storage Guild | NOTIFY-STORAGE-15-202 | Repositories with tenant scoping, soft delete, TTL, causal consistency options. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Storage.Mongo/TASKS.md | TODO | Notify Storage Guild | NOTIFY-STORAGE-15-203 | Delivery history retention and query APIs. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Queue/TASKS.md | TODO | Notify Queue Guild | NOTIFY-QUEUE-15-401 | Bus abstraction + Redis Streams adapter with ordering/idempotency. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Queue/TASKS.md | TODO | Notify Queue Guild | NOTIFY-QUEUE-15-402 | NATS JetStream adapter with health probes and failover. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Queue/TASKS.md | TODO | Notify Queue Guild | NOTIFY-QUEUE-15-403 | Delivery queue with retry/dead-letter + metrics. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Engine/TASKS.md | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-301 | Rules evaluation core (filters, throttles, idempotency). |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Queue/TASKS.md | DONE (2025-10-23) | Notify Queue Guild | NOTIFY-QUEUE-15-401 | Bus abstraction + Redis Streams adapter with ordering/idempotency. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Queue/TASKS.md | DONE (2025-10-23) | Notify Queue Guild | NOTIFY-QUEUE-15-402 | NATS JetStream adapter with health probes and failover. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Queue/TASKS.md | DONE (2025-10-23) | Notify Queue Guild | NOTIFY-QUEUE-15-403 | Delivery queue with retry/dead-letter + metrics. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Engine/TASKS.md | DOING (2025-10-24) | Notify Engine Guild | NOTIFY-ENGINE-15-301 | Rules evaluation core (filters, throttles, idempotency). |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Engine/TASKS.md | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-302 | Action planner + digest coalescer. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Engine/TASKS.md | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-303 | Template rendering engine (Slack/Teams/Email/Webhook). |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Engine/TASKS.md | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-304 | Test-send sandbox + preview utilities. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.WebService/TASKS.md | TODO | Notify WebService Guild | NOTIFY-WEB-15-101 | Minimal API host with Authority enforcement and plug-in loading. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.WebService/TASKS.md | TODO | Notify WebService Guild | NOTIFY-WEB-15-102 | Rules/channel/template CRUD with audit logging. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.WebService/TASKS.md | TODO | Notify WebService Guild | NOTIFY-WEB-15-104 | Configuration binding + startup diagnostics. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Worker/TASKS.md | TODO | Notify Worker Guild | NOTIFY-WORKER-15-201 | Bus subscription + leasing loop with backoff. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Worker/TASKS.md | DONE (2025-10-23) | Notify Worker Guild | NOTIFY-WORKER-15-201 | Bus subscription + leasing loop with backoff. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Worker/TASKS.md | TODO | Notify Worker Guild | NOTIFY-WORKER-15-202 | Rules evaluation pipeline integration. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Worker/TASKS.md | TODO | Notify Worker Guild | NOTIFY-WORKER-15-203 | Channel dispatch orchestration with retries. |
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Worker/TASKS.md | TODO | Notify Worker Guild | NOTIFY-WORKER-15-204 | Metrics/telemetry for Notify workers. |

View File

@@ -61,6 +61,11 @@ graph LR
*All stages run in parallel where possible; max walltime <15min.*
**Implementation note.** `.gitea/workflows/release.yml` executes
`ops/devops/release/build_release.py` to build multi-arch images, attach
CycloneDX SBOMs and SLSA provenance with Cosign, and emit
`out/release/release.yaml` for downstream packaging (Helm, Compose, Offline Kit).
---
##3Container Image Strategy

View File

@@ -118,6 +118,12 @@ stellaops/zastava-agent # System service; watch Docker events; observer on
}
```
### 2.3 Schema negotiation & hashing guarantees
* Every payload is wrapped in an envelope with `schemaVersion` set to `"<schema>@v<major>.<minor>"`. Version negotiation keeps the **major** line in lockstep (`zastava.runtime.event@v1.x`, `zastava.admission.decision@v1.x`) and selects the highest mutually supported **minor**. If no overlap exists, the local default (`@v1.0`) is used.
* Components use the shared `ZastavaContractVersions` helper for parsing/negotiation and the canonical JSON serializer to guarantee identical byte sequences prior to hashing, ensuring multihash IDs such as `sha256-<base64url>` are reproducible across observers, webhooks, and backend jobs.
* Schema evolution rules: backwards-compatible fields append to the end of the canonical property order; breaking changes bump the **major** and require dual-writer/reader rollout per deployment playbook.
---
## 3) Observer — node agent (DaemonSet)
@@ -214,6 +220,8 @@ sequenceDiagram
`POST /api/v1/scanner/policy/runtime`
The webhook reuses the shared runtime stack (`AddZastavaRuntimeCore` + `IZastavaAuthorityTokenProvider`) so OpTok caching, DPoP enforcement, and telemetry behave identically to the observer plane.
Request:
```json
@@ -256,15 +264,36 @@ zastava:
mode:
observer: true
webhook: true
authority:
issuer: "https://authority.internal"
aud: ["scanner","zastava"] # tokens for backend and self-id
backend:
url: "https://scanner-web.internal"
connectTimeoutMs: 500
requestTimeoutMs: 1500
retry: { attempts: 3, backoffMs: 200 }
baseAddress: "https://scanner-web.internal"
policyPath: "/api/v1/scanner/policy/runtime"
requestTimeoutSeconds: 5
allowInsecureHttp: false
runtime:
authority:
issuer: "https://authority.internal"
clientId: "zastava-observer"
audience: ["scanner","zastava"]
scopes:
- "api:scanner.runtime.write"
refreshSkewSeconds: 120
requireDpop: true
requireMutualTls: true
allowStaticTokenFallback: false
staticTokenPath: null # Optional bootstrap secret
tenant: "tenant-01"
environment: "prod"
deployment: "cluster-a"
logging:
includeScopes: true
includeActivityTracking: true
staticScope:
plane: "runtime"
metrics:
meterName: "StellaOps.Zastava"
meterVersion: "1.0.0"
commonTags:
cluster: "prod-cluster"
engine: "auto" # containerd|cri-o|docker|auto
procfs: "/host/proc"
collect:
@@ -293,6 +322,8 @@ zastava:
runtimeState: "/var/lib/containerd:ro"
```
> Implementation note: both `zastava-observer` and `zastava-webhook` call `services.AddZastavaRuntimeCore(configuration, "<component>")` during start-up to bind the `zastava:runtime` section, enforce validation, and register canonical log scopes + meters.
---
## 7) Security posture
@@ -303,6 +334,7 @@ zastava:
* **Data minimization**: do not exfiltrate env vars or command arguments unless policy explicitly enables diagnostic mode.
* **Rate limiting**: pernode caps; pertenant caps at backend.
* **Hard caps**: bytes hashed, files inspected, depth of shell parsing.
* **Authority guardrails**: `AddZastavaRuntimeCore` binds `zastava.runtime.authority` and refuses tokens without `aud:<tenant>` scope; optional knobs (`requireDpop`, `requireMutualTls`, `allowStaticTokenFallback`) emit structured warnings when relaxed.
---
@@ -310,18 +342,19 @@ zastava:
**Observer**
* `zastava.events_emitted_total{kind}`
* `zastava.proc_maps_samples_total{result}`
* `zastava.entrytrace_depth{p99}`
* `zastava.hash_bytes_total`
* `zastava.buffer_drops_total`
* `zastava.runtime.events.total{kind}`
* `zastava.runtime.backend.latency.ms{endpoint="events"}`
* `zastava.proc_maps.samples.total{result}`
* `zastava.entrytrace.depth{p99}`
* `zastava.hash.bytes.total`
* `zastava.buffer.drops.total`
**Webhook**
* `zastava.admission_requests_total{decision}`
* `zastava.admission_latency_seconds`
* `zastava.cache_hits_total`
* `zastava.backend_failures_total`
* `zastava.admission.decisions.total{decision}`
* `zastava.runtime.backend.latency.ms{endpoint="policy"}`
* `zastava.admission.cache.hits.total`
* `zastava.backend.failures.total`
**Logs** (structured): node, pod, image digest, decision, reasons.
**Tracing**: spans for observe→batch→post; webhook request→resolve→respond.

View File

@@ -82,6 +82,7 @@ Everything here is opensource and versioned— when you check out a git ta
- **31[Concelier MSRC Connector AAD Onboarding](ops/concelier-msrc-operations.md)**
- **32[Scanner Analyzer Bench Operations](ops/scanner-analyzers-operations.md)**
- **33[Scanner Artifact Store Migration](ops/scanner-rustfs-migration.md)**
- **34[Zastava Runtime Operations Runbook](ops/zastava-runtime-operations.md)**
### Legal & licence
- **32[Legal & Quota FAQ](29_LEGAL_FAQ_QUOTA.md)**

32
docs/ops/ui-auth-smoke.md Normal file
View File

@@ -0,0 +1,32 @@
# UI Auth Smoke Job (Playwright)
The DevOps Guild tracks **DEVOPS-UI-13-006** to wire the new Playwright auth
smoke checks into CI and the Offline Kit pipeline. These tests exercise the
Angular UI login flow against a stubbed Authority instance to verify that
`/config.json` is discovered, DPoP proofs are minted, and error handling is
surfaced when the backend rejects a request.
## What the job does
1. Builds the UI bundle (or consumes the artifact from the release pipeline).
2. Copies the environment stub from `src/config/config.sample.json` into the
runtime directory as `config.json` so the UI can bootstrap without a live
gateway.
3. Runs `npm run test:e2e`, which launches Playwright with the auth fixtures
under `tests/e2e/auth.spec.ts`:
- Validates that the Sign-in button generates an Authorization Code + PKCE
redirect to `https://authority.local/connect/authorize`.
- Confirms the callback view shows an actionable error when the redirect is
missing the pending login state.
4. Publishes JUnit + Playwright traces (retain-on-failure) for troubleshooting.
## Pipeline integration notes
- Chromium must already be available (`npx playwright install --with-deps`).
- Set `PLAYWRIGHT_BASE_URL` if the UI serves on a non-default host/port.
- For Offline Kit packaging, bundle the Playwright browser cache under
`.cache/ms-playwright/` so the job runs without network access.
- Failures should block release promotion; export the traces to the artifacts
tab for debugging.
Refer to `ops/devops/TASKS.md` (DEVOPS-UI-13-006) for progress and ownership.

View File

@@ -0,0 +1,205 @@
{
"title": "Zastava Runtime Plane",
"uid": "zastava-runtime",
"timezone": "utc",
"schemaVersion": 38,
"version": 1,
"refresh": "30s",
"time": {
"from": "now-6h",
"to": "now"
},
"panels": [
{
"id": 1,
"type": "timeseries",
"title": "Observer Event Rate",
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"targets": [
{
"expr": "sum by (tenant,component,kind) (rate(zastava_runtime_events_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "{{tenant}}/{{component}}/{{kind}}"
}
],
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"fieldConfig": {
"defaults": {
"unit": "1/s",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
}
]
}
},
"overrides": []
},
"options": {
"legend": {
"showLegend": true,
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 2,
"type": "timeseries",
"title": "Admission Decisions",
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"targets": [
{
"expr": "sum by (decision) (rate(zastava_admission_decisions_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "{{decision}}"
}
],
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"fieldConfig": {
"defaults": {
"unit": "1/s",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 20
}
]
}
},
"overrides": []
},
"options": {
"legend": {
"showLegend": true,
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 3,
"type": "timeseries",
"title": "Backend Latency P95",
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"targets": [
{
"expr": "histogram_quantile(0.95, sum by (le) (rate(zastava_runtime_backend_latency_ms_bucket{tenant=~\"$tenant\"}[5m])))",
"legendFormat": "p95 latency"
}
],
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"fieldConfig": {
"defaults": {
"unit": "ms",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "orange",
"value": 500
},
{
"color": "red",
"value": 750
}
]
}
},
"overrides": []
},
"options": {
"legend": {
"showLegend": true,
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
}
],
"templating": {
"list": [
{
"name": "datasource",
"type": "datasource",
"query": "prometheus",
"label": "Prometheus",
"current": {
"text": "Prometheus",
"value": "Prometheus"
}
},
{
"name": "tenant",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"definition": "label_values(zastava_runtime_events_total, tenant)",
"refresh": 1,
"hide": 0,
"current": {
"text": ".*",
"value": ".*"
},
"regex": "",
"includeAll": true,
"multi": true,
"sort": 1
}
]
},
"annotations": {
"list": [
{
"name": "Deployments",
"type": "tags",
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"enable": true,
"iconColor": "rgba(255, 96, 96, 1)"
}
]
}
}

View File

@@ -0,0 +1,131 @@
# Zastava Runtime Operations Runbook
This runbook covers the runtime plane (Observer DaemonSet + Admission Webhook).
It aligns with `Sprint 12 Runtime Guardrails` and assumes components consume
`StellaOps.Zastava.Core` (`AddZastavaRuntimeCore(...)`).
## 1. Prerequisites
- **Authority client credentials** service principal `zastava-runtime` with scopes
`aud:scanner` and `api:scanner.runtime.write`. Provision DPoP keys and mTLS client
certs before rollout.
- **Scanner/WebService reachability** cluster DNS entry (e.g. `scanner.internal`)
resolvable from every node running Observer/Webhook.
- **Host mounts** read-only access to `/proc`, container runtime state
(`/var/lib/containerd`, `/var/run/containerd/containerd.sock`) and scratch space
(`/var/run/zastava`).
- **Offline kit bundle** operators staging air-gapped installs must download
`offline-kit/zastava-runtime-{version}.tar.zst` containing container images,
Grafana dashboards, and Prometheus rules referenced below.
- **Secrets** Authority OpTok cache dir, DPoP private keys, and webhook TLS secrets
live outside git. For air-gapped installs copy them to the sealed secrets vault.
### 1.1 Telemetry quick reference
| Metric | Description | Notes |
|--------|-------------|-------|
| `zastava.runtime.events.total{tenant,component,kind}` | Rate of observer events sent to Scanner | Expect >0 on busy nodes. |
| `zastava.runtime.backend.latency.ms` | Histogram (ms) for `/runtime/events` and `/policy/runtime` calls | P95 & P99 drive alerting. |
| `zastava.admission.decisions.total{decision}` | Admission verdict counts | Track deny spikes or fail-open fallbacks. |
| `zastava.admission.cache.hits.total` | (future) Cache utilisation once Observer batches land | Placeholder until Observer tasks 12-004 complete. |
## 2. Deployment workflows
### 2.1 Fresh install (Helm overlay)
1. Load offline kit bundle: `oras cp offline-kit/zastava-runtime-*.tar.zst oci:registry.internal/zastava`.
2. Render values:
- `zastava.runtime.tenant`, `environment`, `deployment` (cluster identifier).
- `zastava.runtime.authority` block (issuer, clientId, audience, DPoP toggle).
- `zastava.runtime.metrics.commonTags.cluster` for Prometheus labels.
3. Pre-create secrets:
- `zastava-authority-dpop` (JWK + private key).
- `zastava-authority-mtls` (client cert/key chain).
- `zastava-webhook-tls` (serving cert; CSR bundle if using auto-approval).
4. Deploy Observer DaemonSet and Webhook chart:
```sh
helm upgrade --install zastava-runtime deploy/helm/zastava \
-f values/zastava-runtime.yaml \
--namespace stellaops \
--create-namespace
```
5. Verify:
- `kubectl -n stellaops get pods -l app=zastava-observer` ready.
- `kubectl -n stellaops logs ds/zastava-observer --tail=20` shows
`Issued runtime OpTok` audit line with DPoP token type.
- Admission webhook registered: `kubectl get validatingwebhookconfiguration zastava-webhook`.
### 2.2 Upgrades
1. Scale webhook deployment to `--replicas=3` (rolling).
2. Drain one node per AZ to ensure Observer tolerates disruption.
3. Apply chart upgrade; watch `zastava.runtime.backend.latency.ms` P95 (<250 ms).
4. Post-upgrade, run smoke tests:
- Apply unsigned Pod manifest → expect `deny` (policy fail).
- Apply signed Pod manifest → expect `allow`.
5. Record upgrade in ops log with Git SHA + Helm chart version.
### 2.3 Rollback
1. Use Helm revision history: `helm history zastava-runtime`.
2. Rollback: `helm rollback zastava-runtime <revision>`.
3. Invalidate cached OpToks:
```sh
kubectl -n stellaops exec deploy/zastava-webhook -- \
zastava-webhook invalidate-op-token --audience scanner
```
4. Confirm observers reconnect via metrics (`rate(zastava_runtime_events_total[5m])`).
## 3. Authority & security guardrails
- Tokens must be `DPoP` type when `requireDpop=true`. Logs emit
`authority.token.issue` scope with decision data; absence indicates misconfig.
- `requireMutualTls=true` enforces mTLS during token acquisition. Disable only in
lab clusters; expect warning log `Mutual TLS requirement disabled`.
- Static fallback tokens (`allowStaticTokenFallback=true`) should exist only during
initial bootstrap. Rotate nightly; preference is to disable once Authority reachable.
- Audit every change in `zastava.runtime.authority` through change management.
Use `kubectl get secret zastava-authority-dpop -o jsonpath='{.metadata.annotations.revision}'`
to confirm key rotation.
## 4. Incident response
### 4.1 Authority offline
1. Check Prometheus alert `ZastavaAuthorityTokenStale`.
2. Inspect Observer logs for `authority.token.fallback` scope.
3. If fallback engaged, verify static token validity duration; rotate secret if older than 24 h.
4. Once Authority restored, delete static fallback secret and restart pods to rebind DPoP keys.
### 4.2 Scanner/WebService latency spike
1. Alert `ZastavaRuntimeBackendLatencyHigh` fires at P95 > 750 ms for 5 minutes.
2. Run backend health: `kubectl -n scanner exec deploy/scanner-web -- curl -f localhost:8080/healthz/ready`.
3. If backend degraded, auto buffer may throttle. Confirm disk-backed queue size via
`kubectl logs ds/zastava-observer | grep buffer.drops`.
4. Consider enabling fail-open for namespaces listed in runbook Appendix B (temporary).
### 4.3 Admission deny storm
1. Alert `ZastavaAdmissionDenySpike` indicates >20 denies/minute.
2. Pull sample: `kubectl logs deploy/zastava-webhook --since=10m | jq '.decision'`.
3. Cross-check policy backlog in Scanner (`/policy/runtime` logs). Engage application
owner; optionally set namespace to `failOpenNamespaces` after risk assessment.
## 5. Offline kit & air-gapped notes
- Bundle contents:
- Observer/Webhook container images (multi-arch).
- `docs/ops/zastava-runtime-prometheus-rules.yaml` + Grafana dashboard JSON.
- Sample `zastava-runtime.values.yaml`.
- Verification:
- Validate signature: `cosign verify-blob offline-kit/zastava-runtime-*.tar.zst --certificate offline-kit/zastava-runtime.cert`.
- Extract Prometheus rules into offline monitoring cluster (`/etc/prometheus/rules.d`).
- Import Grafana dashboard via `grafana-cli --config ...`.
## 6. Observability assets
- Prometheus alert rules: `docs/ops/zastava-runtime-prometheus-rules.yaml`.
- Grafana dashboard JSON: `docs/ops/zastava-runtime-grafana-dashboard.json`.
- Add both to the monitoring repo (`ops/monitoring/zastava`) and reference them in
the Offline Kit manifest.

View File

@@ -0,0 +1,31 @@
groups:
- name: zastava-runtime
interval: 30s
rules:
- alert: ZastavaRuntimeEventsSilent
expr: sum(rate(zastava_runtime_events_total[10m])) == 0
for: 15m
labels:
severity: warning
service: zastava-runtime
annotations:
summary: "Observer events stalled"
description: "No runtime events emitted in the last 15 minutes. Check observer DaemonSet health and container runtime mounts."
- alert: ZastavaRuntimeBackendLatencyHigh
expr: histogram_quantile(0.95, sum by (le) (rate(zastava_runtime_backend_latency_ms_bucket[5m]))) > 0.75
for: 10m
labels:
severity: critical
service: zastava-runtime
annotations:
summary: "Runtime backend latency p95 above 750 ms"
description: "Latency to Scanner runtime APIs is elevated. Inspect Scanner.WebService readiness, Authority OpTok issuance, and cluster network."
- alert: ZastavaAdmissionDenySpike
expr: sum(rate(zastava_admission_decisions_total{decision="deny"}[5m])) > 20
for: 5m
labels:
severity: warning
service: zastava-runtime
annotations:
summary: "Admission webhook denies exceeding threshold"
description: "Webhook is denying more than 20 pod admissions per minute. Confirm policy verdicts and consider fail-open exception for impacted namespaces."

Binary file not shown.

41
ops/devops/README.md Normal file
View File

@@ -0,0 +1,41 @@
# DevOps Release Automation
The **release** workflow builds and signs the StellaOps service containers,
generates SBOM + provenance attestations, and emits a canonical
`release.yaml`. The logic lives under `ops/devops/release/` and is invoked
by the new `.gitea/workflows/release.yml` pipeline.
## Local dry run
```bash
./ops/devops/release/build_release.py \
--version 2025.10.0-edge \
--channel edge \
--dry-run
```
Outputs land under `out/release/`. Use `--no-push` to run full builds without
pushing to the registry.
## Required tooling
- Docker 25+ with Buildx
- .NET 10 preview SDK (builds container stages and the SBOM generator)
- Node.js 20 (Angular UI build)
- Helm 3.16+
- Cosign 2.2+
Supply signing material via environment variables:
- `COSIGN_KEY_REF` e.g. `file:./keys/cosign.key` or `azurekms://…`
- `COSIGN_PASSWORD` password protecting the above key
The workflow defaults to multi-arch (`linux/amd64,linux/arm64`), SBOM in
CycloneDX, and SLSA provenance (`https://slsa.dev/provenance/v1`).
## UI auth smoke (Playwright)
As part of **DEVOPS-UI-13-006** the pipelines will execute the UI auth smoke
tests (`npm run test:e2e`) after building the Angular bundle. See
`docs/ops/ui-auth-smoke.md` for the job design, environment stubs, and
offline runner considerations.

View File

@@ -7,7 +7,7 @@
| DEVOPS-SCANNER-09-205 | DONE (2025-10-21) | DevOps Guild, Notify Guild | DEVOPS-SCANNER-09-204 | Add Notify smoke stage that tails the Redis stream and asserts `scanner.report.ready`/`scanner.scan.completed` reach Notify WebService in staging. | CI job reads Redis stream during scanner smoke deploy, confirms Notify ingestion via API, alerts on failure. |
| DEVOPS-PERF-10-001 | DONE | DevOps Guild | BENCH-SCANNER-10-001 | Add perf smoke job (SBOM compose <5s target) to CI. | CI job runs sample build verifying <5s; alerts configured. |
| DEVOPS-PERF-10-002 | DONE (2025-10-23) | DevOps Guild | BENCH-SCANNER-10-002 | Publish analyzer bench metrics to Grafana/perf workbook and alarm on 20% regressions. | CI exports JSON for dashboards; Grafana panel wired; Ops on-call doc updated with alert hook. |
| DEVOPS-REL-14-001 | TODO | DevOps Guild | SIGNER-API-11-101, ATTESTOR-API-11-201 | Deterministic build/release pipeline with SBOM/provenance, signing, manifest generation. | CI pipeline produces signed images + SBOM/attestations, manifests published with verified hashes, docs updated. |
| DEVOPS-REL-14-001 | DOING (2025-10-23) | DevOps Guild | SIGNER-API-11-101, ATTESTOR-API-11-201 | Deterministic build/release pipeline with SBOM/provenance, signing, manifest generation. | CI pipeline produces signed images + SBOM/attestations, manifests published with verified hashes, docs updated. |
| DEVOPS-REL-14-004 | TODO | DevOps Guild, Scanner Guild | DEVOPS-REL-14-001, SCANNER-ANALYZERS-LANG-10-309P | Extend release/offline smoke jobs to exercise the Python analyzer plug-in (warm/cold scans, determinism, signature checks). | Release/Offline pipelines run Python analyzer smoke suite; alerts hooked; docs updated with new coverage matrix. |
| DEVOPS-REL-17-002 | TODO | DevOps Guild | DEVOPS-REL-14-001, SCANNER-EMIT-17-701 | Persist stripped-debug artifacts organised by GNU build-id and bundle them into release/offline kits with checksum manifests. | CI job writes `.debug` files under `artifacts/debug/.build-id/`, manifest + checksums published, offline kit includes cache, smoke job proves symbol lookup via build-id. |
| DEVOPS-MIRROR-08-001 | DONE (2025-10-19) | DevOps Guild | DEVOPS-REL-14-001 | Stand up managed mirror profiles for `*.stella-ops.org` (Concelier/Excititor), including Helm/Compose overlays, multi-tenant secrets, CDN caching, and sync documentation. | Infra overlays committed, CI smoke deploy hits mirror endpoints, runbooks published for downstream sync and quota management. |
@@ -15,8 +15,9 @@
| DEVOPS-LAUNCH-18-100 | TODO | DevOps Guild | - | Finalise production environment footprint (clusters, secrets, network overlays) for full-platform go-live. | IaC/compose overlays committed, secrets placeholders documented, dry-run deploy succeeds in staging. |
| DEVOPS-LAUNCH-18-900 | TODO | DevOps Guild, Module Leads | Wave 0 completion | Collect full implementation sign-off from module owners and consolidate launch readiness checklist. | Sign-off record stored under `docs/ops/launch-readiness.md`; outstanding gaps triaged; checklist approved. |
| DEVOPS-LAUNCH-18-001 | TODO | DevOps Guild | DEVOPS-LAUNCH-18-100, DEVOPS-LAUNCH-18-900 | Production launch cutover rehearsal and runbook publication. | `docs/ops/launch-cutover.md` drafted, rehearsal executed with rollback drill, approvals captured. |
| DEVOPS-NUGET-13-001 | TODO | DevOps Guild, Platform Leads | DEVOPS-REL-14-001 | Add .NET 10 preview feeds / local mirrors so `Microsoft.Extensions.*` 10.0 preview packages restore offline; refresh restore docs. | NuGet.config maps preview feeds (or local mirrored packages), `dotnet restore` succeeds for Excititor/Concelier solutions without ad-hoc feed edits, docs updated for offline bootstrap. |
| DEVOPS-NUGET-13-001 | DOING (2025-10-24) | DevOps Guild, Platform Leads | DEVOPS-REL-14-001 | Add .NET 10 preview feeds / local mirrors so `Microsoft.Extensions.*` 10.0 preview packages restore offline; refresh restore docs. | NuGet.config maps preview feeds (or local mirrored packages), `dotnet restore` succeeds for Excititor/Concelier solutions without ad-hoc feed edits, docs updated for offline bootstrap. |
| DEVOPS-NUGET-13-002 | TODO | DevOps Guild | DEVOPS-NUGET-13-001 | Ensure all solutions/projects prefer `local-nuget` before public sources and document restore order validation. | `NuGet.config` and solution-level configs resolve from `local-nuget` first; automated check verifies priority; docs updated for restore ordering. |
| DEVOPS-NUGET-13-003 | TODO | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-002 | Sweep `Microsoft.*` NuGet dependencies pinned to 8.* and upgrade to latest .NET 10 equivalents (or .NET 9 when 10 unavailable), updating restore guidance. | Dependency audit shows no 8.* `Microsoft.*` packages remaining; CI builds green; changelog/doc sections capture upgrade rationale. |
| DEVOPS-UI-13-006 | TODO | DevOps Guild, UI Guild | UI-AUTH-13-001 | Add Playwright-based UI auth smoke job to CI/offline pipelines, wiring sample `/config.json` provisioning and reporting. | CI + Offline Kit run Playwright auth smoke (headless Chromium) post-build; job reuses stub config artifact, exports junit + trace on failure, docs updated under `docs/ops/ui-auth-smoke.md`. |
> Remark (2025-10-20): Repacked `Mongo2Go` local feed to require MongoDB.Driver 3.5.0 + SharpCompress 0.41.0; cache regression tests green and NU1902/NU1903 suppressed.
> Remark (2025-10-21): Compose/Helm profiles now surface `SCANNER__EVENTS__*` toggles with docs pointing at new `.env` placeholders.

View File

@@ -0,0 +1,16 @@
# Package,Version,SHA256
Microsoft.Extensions.Caching.Memory,10.0.0-preview.7.25380.108,8721fd1420fea6e828963c8343cd83605902b663385e8c9060098374139f9b2f
Microsoft.Extensions.Configuration,10.0.0-preview.7.25380.108,5a17ba4ba47f920a04ae51d80560833da82a0926d1e462af0d11c16b5da969f4
Microsoft.Extensions.Configuration.Binder,10.0.0-preview.7.25380.108,5a3af17729241e205fe8fbb1d458470e9603935ab2eb67cbbb06ce51265ff68f
Microsoft.Extensions.DependencyInjection.Abstractions,10.0.0-preview.7.25380.108,1e9cd330d7833a3a850a7a42bbe0c729906c60bf1c359ad30a8622b50da4399b
Microsoft.Extensions.Hosting,10.0.0-preview.7.25380.108,3123bb019bbc0182cf7ac27f30018ca620929f8027e137bd5bdfb952037c7d29
Microsoft.Extensions.Hosting.Abstractions,10.0.0-preview.7.25380.108,b57625436c9eb53e3aa27445b680bb93285d0d2c91007bbc221b0c378ab016a3
Microsoft.Extensions.Http,10.0.0-preview.7.25380.108,daec142b7c7bd09ec1f2a86bfc3d7fe009825f5b653d310bc9e959c0a98a0f19
Microsoft.Extensions.Logging.Abstractions,10.0.0-preview.7.25380.108,87a495fa0b7054e134a5cf44ec8b071fe2bc3ddfb27e9aefc6375701dca2a33a
Microsoft.Extensions.Options,10.0.0-preview.7.25380.108,c0657c2be3b7b894024586cf6e46a2ebc0e710db64d2645c4655b893b8487d8a
Microsoft.Extensions.DependencyInjection.Abstractions,9.0.0,0a7715c24299e42b081b63b4f8e33da97b985e1de9e941b2b9e4c748b0d52fe7
Microsoft.Extensions.Logging.Abstractions,9.0.0,8814ecf6dc2359715e111b78084ae42087282595358eb775456088f15e63eca5
Microsoft.Extensions.Options,9.0.0,0d3e5eb80418fc8b41e4b3c8f16229e839ddd254af0513f7e6f1643970baf1c9
Microsoft.Extensions.Options.ConfigurationExtensions,9.0.0,af5677b04552223787d942a3f8a323f3a85aafaf20ff3c9b4aaa128c44817280
Microsoft.Data.Sqlite,9.0.0-rc.1.24451.1,770b637317e1e924f1b13587b31af0787c8c668b1d9f53f2fccae8ee8704e167
Microsoft.AspNetCore.Authentication.JwtBearer,10.0.0-rc.1.25451.107,05f168c2db7ba79230e3fd77e84f6912bc73721c6656494df0b227867a6c2d3c
1 # Package Version SHA256
2 Microsoft.Extensions.Caching.Memory 10.0.0-preview.7.25380.108 8721fd1420fea6e828963c8343cd83605902b663385e8c9060098374139f9b2f
3 Microsoft.Extensions.Configuration 10.0.0-preview.7.25380.108 5a17ba4ba47f920a04ae51d80560833da82a0926d1e462af0d11c16b5da969f4
4 Microsoft.Extensions.Configuration.Binder 10.0.0-preview.7.25380.108 5a3af17729241e205fe8fbb1d458470e9603935ab2eb67cbbb06ce51265ff68f
5 Microsoft.Extensions.DependencyInjection.Abstractions 10.0.0-preview.7.25380.108 1e9cd330d7833a3a850a7a42bbe0c729906c60bf1c359ad30a8622b50da4399b
6 Microsoft.Extensions.Hosting 10.0.0-preview.7.25380.108 3123bb019bbc0182cf7ac27f30018ca620929f8027e137bd5bdfb952037c7d29
7 Microsoft.Extensions.Hosting.Abstractions 10.0.0-preview.7.25380.108 b57625436c9eb53e3aa27445b680bb93285d0d2c91007bbc221b0c378ab016a3
8 Microsoft.Extensions.Http 10.0.0-preview.7.25380.108 daec142b7c7bd09ec1f2a86bfc3d7fe009825f5b653d310bc9e959c0a98a0f19
9 Microsoft.Extensions.Logging.Abstractions 10.0.0-preview.7.25380.108 87a495fa0b7054e134a5cf44ec8b071fe2bc3ddfb27e9aefc6375701dca2a33a
10 Microsoft.Extensions.Options 10.0.0-preview.7.25380.108 c0657c2be3b7b894024586cf6e46a2ebc0e710db64d2645c4655b893b8487d8a
11 Microsoft.Extensions.DependencyInjection.Abstractions 9.0.0 0a7715c24299e42b081b63b4f8e33da97b985e1de9e941b2b9e4c748b0d52fe7
12 Microsoft.Extensions.Logging.Abstractions 9.0.0 8814ecf6dc2359715e111b78084ae42087282595358eb775456088f15e63eca5
13 Microsoft.Extensions.Options 9.0.0 0d3e5eb80418fc8b41e4b3c8f16229e839ddd254af0513f7e6f1643970baf1c9
14 Microsoft.Extensions.Options.ConfigurationExtensions 9.0.0 af5677b04552223787d942a3f8a323f3a85aafaf20ff3c9b4aaa128c44817280
15 Microsoft.Data.Sqlite 9.0.0-rc.1.24451.1 770b637317e1e924f1b13587b31af0787c8c668b1d9f53f2fccae8ee8704e167
16 Microsoft.AspNetCore.Authentication.JwtBearer 10.0.0-rc.1.25451.107 05f168c2db7ba79230e3fd77e84f6912bc73721c6656494df0b227867a6c2d3c

View File

@@ -0,0 +1,630 @@
#!/usr/bin/env python3
"""Deterministic release pipeline helper for StellaOps.
This script builds service containers, generates SBOM and provenance artefacts,
signs them with cosign, and writes a channel-specific release manifest.
The workflow expects external tooling to be available on PATH:
- docker (with buildx)
- cosign
- helm
- npm / node (for the UI build)
- dotnet SDK (for BuildX plugin publication)
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import pathlib
import re
import shlex
import subprocess
import sys
import tempfile
from collections import OrderedDict
from typing import Any, Dict, Iterable, List, Mapping, MutableMapping, Optional, Sequence
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
DEFAULT_CONFIG = REPO_ROOT / "ops/devops/release/components.json"
class CommandError(RuntimeError):
pass
def run(cmd: Sequence[str], *, cwd: Optional[pathlib.Path] = None, env: Optional[Mapping[str, str]] = None, capture: bool = True) -> str:
"""Run a subprocess command, returning stdout (text)."""
process_env = os.environ.copy()
if env:
process_env.update(env)
result = subprocess.run(
list(cmd),
cwd=str(cwd) if cwd else None,
env=process_env,
check=False,
capture_output=capture,
text=True,
)
if process_env.get("STELLAOPS_RELEASE_DEBUG"):
sys.stderr.write(f"[debug] {' '.join(shlex.quote(c) for c in cmd)}\n")
if capture:
sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr)
if result.returncode != 0:
stdout = result.stdout if capture else ""
stderr = result.stderr if capture else ""
raise CommandError(f"Command failed ({result.returncode}): {' '.join(cmd)}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
return result.stdout if capture else ""
def load_json_config(path: pathlib.Path) -> Dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def ensure_directory(path: pathlib.Path) -> pathlib.Path:
path.mkdir(parents=True, exist_ok=True)
return path
def compute_sha256(path: pathlib.Path) -> str:
sha = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
sha.update(chunk)
return sha.hexdigest()
def format_scalar(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return "null"
if isinstance(value, (int, float)):
return str(value)
text = str(value)
if text == "":
return '""'
if re.search(r"[\s:#\-\[\]\{\}]", text):
return json.dumps(text, ensure_ascii=False)
return text
def _yaml_lines(value: Any, indent: int = 0) -> List[str]:
pad = " " * indent
if isinstance(value, Mapping):
lines: List[str] = []
for key, val in value.items():
if isinstance(val, (Mapping, list)):
lines.append(f"{pad}{key}:")
lines.extend(_yaml_lines(val, indent + 1))
else:
lines.append(f"{pad}{key}: {format_scalar(val)}")
if not lines:
lines.append(f"{pad}{{}}")
return lines
if isinstance(value, list):
lines = []
if not value:
lines.append(f"{pad}[]")
return lines
for item in value:
if isinstance(item, (Mapping, list)):
lines.append(f"{pad}-")
lines.extend(_yaml_lines(item, indent + 1))
else:
lines.append(f"{pad}- {format_scalar(item)}")
return lines
return [f"{pad}{format_scalar(value)}"]
def dump_yaml(data: Mapping[str, Any]) -> str:
lines: List[str] = _yaml_lines(data)
return "\n".join(lines) + "\n"
def utc_now_iso() -> str:
return dt.datetime.now(tz=dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def sanitize_calendar(version: str, explicit: Optional[str]) -> str:
if explicit:
return explicit
# Expect version like 2025.10.0-edge or 2.4.1
parts = re.findall(r"\d+", version)
if len(parts) >= 2:
return f"{parts[0]}.{parts[1]}"
return dt.datetime.now(tz=dt.timezone.utc).strftime("%Y.%m")
class ReleaseBuilder:
def __init__(
self,
*,
repo_root: pathlib.Path,
config: Mapping[str, Any],
version: str,
channel: str,
calendar: str,
release_date: str,
git_sha: str,
output_dir: pathlib.Path,
push: bool,
dry_run: bool,
registry_override: Optional[str] = None,
platforms_override: Optional[Sequence[str]] = None,
skip_signing: bool = False,
cosign_key_ref: Optional[str] = None,
cosign_password: Optional[str] = None,
cosign_identity_token: Optional[str] = None,
tlog_upload: bool = True,
) -> None:
self.repo_root = repo_root
self.config = config
self.version = version
self.channel = channel
self.calendar = calendar
self.release_date = release_date
self.git_sha = git_sha
self.output_dir = ensure_directory(output_dir)
self.push = push
self.dry_run = dry_run
self.registry = registry_override or config.get("registry")
if not self.registry:
raise ValueError("Config missing 'registry'")
platforms = list(platforms_override) if platforms_override else config.get("platforms")
if not platforms:
platforms = ["linux/amd64", "linux/arm64"]
self.platforms = list(platforms)
self.source_date_epoch = str(int(dt.datetime.fromisoformat(release_date.replace("Z", "+00:00")).timestamp()))
self.artifacts_dir = ensure_directory(self.output_dir / "artifacts")
self.sboms_dir = ensure_directory(self.artifacts_dir / "sboms")
self.provenance_dir = ensure_directory(self.artifacts_dir / "provenance")
self.signature_dir = ensure_directory(self.artifacts_dir / "signatures")
self.metadata_dir = ensure_directory(self.artifacts_dir / "metadata")
self.temp_dir = pathlib.Path(tempfile.mkdtemp(prefix="stellaops-release-"))
self.skip_signing = skip_signing
self.tlog_upload = tlog_upload
self.cosign_key_ref = cosign_key_ref or os.environ.get("COSIGN_KEY_REF")
self.cosign_identity_token = cosign_identity_token or os.environ.get("COSIGN_IDENTITY_TOKEN")
password = cosign_password if cosign_password is not None else os.environ.get("COSIGN_PASSWORD", "")
self.cosign_env = {
"COSIGN_PASSWORD": password,
"COSIGN_EXPERIMENTAL": "1",
"COSIGN_ALLOW_HTTP_REGISTRY": os.environ.get("COSIGN_ALLOW_HTTP_REGISTRY", "1"),
"COSIGN_DOCKER_MEDIA_TYPES": os.environ.get("COSIGN_DOCKER_MEDIA_TYPES", "1"),
}
# ----------------
# Build steps
# ----------------
def run(self) -> Dict[str, Any]:
components_result = []
if self.dry_run:
print("⚠️ Dry-run enabled; commands will be skipped")
self._prime_buildx_plugin()
for component in self.config.get("components", []):
result = self._build_component(component)
components_result.append(result)
helm_meta = self._package_helm()
compose_meta = self._digest_compose_files()
manifest = self._compose_manifest(components_result, helm_meta, compose_meta)
return manifest
def _prime_buildx_plugin(self) -> None:
plugin_cfg = self.config.get("buildxPlugin")
if not plugin_cfg:
return
project = plugin_cfg.get("project")
if not project:
return
out_dir = ensure_directory(self.temp_dir / "buildx")
if not self.dry_run:
run([
"dotnet",
"publish",
project,
"-c",
"Release",
"-o",
str(out_dir),
])
cas_dir = ensure_directory(self.temp_dir / "cas")
run([
"dotnet",
str(out_dir / "StellaOps.Scanner.Sbomer.BuildXPlugin.dll"),
"handshake",
"--manifest",
str(out_dir),
"--cas",
str(cas_dir),
])
def _component_tags(self, repo: str) -> List[str]:
base = f"{self.registry}/{repo}"
tags = [f"{base}:{self.version}"]
if self.channel:
tags.append(f"{base}:{self.channel}")
return tags
def _component_ref(self, repo: str, digest: str) -> str:
return f"{self.registry}/{repo}@{digest}"
def _build_component(self, component: Mapping[str, Any]) -> Mapping[str, Any]:
name = component["name"]
repo = component.get("repository", name)
kind = component.get("kind", "dotnet-service")
dockerfile = component.get("dockerfile")
if not dockerfile:
raise ValueError(f"Component {name} missing dockerfile")
context = component.get("context", ".")
iid_file = self.temp_dir / f"{name}.iid"
metadata_file = self.metadata_dir / f"{name}.metadata.json"
build_args = {
"VERSION": self.version,
"CHANNEL": self.channel,
"GIT_SHA": self.git_sha,
"SOURCE_DATE_EPOCH": self.source_date_epoch,
}
docker_cfg = self.config.get("docker", {})
if kind == "dotnet-service":
build_args.update({
"PROJECT": component["project"],
"ENTRYPOINT_DLL": component["entrypoint"],
"SDK_IMAGE": docker_cfg.get("sdkImage", "mcr.microsoft.com/dotnet/nightly/sdk:10.0"),
"RUNTIME_IMAGE": docker_cfg.get("runtimeImage", "gcr.io/distroless/dotnet/aspnet:latest"),
})
elif kind == "angular-ui":
build_args.update({
"NODE_IMAGE": docker_cfg.get("nodeImage", "node:20.14.0-bookworm"),
"NGINX_IMAGE": docker_cfg.get("nginxImage", "nginx:1.27-alpine"),
})
else:
raise ValueError(f"Unsupported component kind {kind}")
tags = self._component_tags(repo)
build_cmd = [
"docker",
"buildx",
"build",
"--file",
dockerfile,
"--metadata-file",
str(metadata_file),
"--iidfile",
str(iid_file),
"--progress",
"plain",
"--platform",
",".join(self.platforms),
]
for key, value in build_args.items():
build_cmd.extend(["--build-arg", f"{key}={value}"])
for tag in tags:
build_cmd.extend(["--tag", tag])
build_cmd.extend([
"--attest",
"type=sbom",
"--attest",
"type=provenance,mode=max",
])
if self.push:
build_cmd.append("--push")
else:
build_cmd.append("--load")
build_cmd.append(context)
if not self.dry_run:
run(build_cmd, cwd=self.repo_root)
digest = iid_file.read_text(encoding="utf-8").strip() if iid_file.exists() else ""
image_ref = self._component_ref(repo, digest) if digest else ""
bundle_info = self._sign_image(name, image_ref, tags)
sbom_info = self._generate_sbom(name, image_ref)
provenance_info = self._attach_provenance(name, image_ref)
component_entry = OrderedDict()
component_entry["name"] = name
if digest:
component_entry["image"] = image_ref
component_entry["tags"] = tags
if sbom_info:
component_entry["sbom"] = sbom_info
if provenance_info:
component_entry["provenance"] = provenance_info
if bundle_info:
component_entry["signature"] = bundle_info
if metadata_file.exists():
component_entry["metadata"] = str(metadata_file.relative_to(self.output_dir.parent)) if metadata_file.is_relative_to(self.output_dir.parent) else str(metadata_file)
return component_entry
def _sign_image(self, name: str, image_ref: str, tags: Sequence[str]) -> Optional[Mapping[str, Any]]:
if self.skip_signing:
return None
if not image_ref:
return None
if not (self.cosign_key_ref or self.cosign_identity_token):
raise ValueError("Signing requested but no cosign key or identity token provided. Use --skip-signing to bypass.")
signature_path = self.signature_dir / f"{name}.signature"
cmd = ["cosign", "sign", "--yes"]
if self.cosign_key_ref:
cmd.extend(["--key", self.cosign_key_ref])
if self.cosign_identity_token:
cmd.extend(["--identity-token", self.cosign_identity_token])
if not self.tlog_upload:
cmd.append("--tlog-upload=false")
cmd.append("--allow-http-registry")
cmd.append(image_ref)
if self.dry_run:
return None
run(cmd, env=self.cosign_env)
signature_data = run([
"cosign",
"download",
"signature",
"--allow-http-registry",
image_ref,
])
signature_path.write_text(signature_data, encoding="utf-8")
signature_ref = run([
"cosign",
"triangulate",
"--allow-http-registry",
image_ref,
]).strip()
return OrderedDict(
(
("signature", OrderedDict((
("path", str(signature_path.relative_to(self.output_dir.parent)) if signature_path.is_relative_to(self.output_dir.parent) else str(signature_path)),
("ref", signature_ref),
("tlogUploaded", self.tlog_upload),
))),
)
)
def _generate_sbom(self, name: str, image_ref: str) -> Optional[Mapping[str, Any]]:
if not image_ref or self.dry_run:
return None
sbom_path = self.sboms_dir / f"{name}.cyclonedx.json"
run([
"docker",
"sbom",
image_ref,
"--format",
"cyclonedx-json",
"--output",
str(sbom_path),
])
entry = OrderedDict((
("path", str(sbom_path.relative_to(self.output_dir.parent)) if sbom_path.is_relative_to(self.output_dir.parent) else str(sbom_path)),
("sha256", compute_sha256(sbom_path)),
))
if self.skip_signing:
return entry
attach_cmd = [
"cosign",
"attach",
"sbom",
"--sbom",
str(sbom_path),
"--type",
"cyclonedx",
]
if self.cosign_key_ref:
attach_cmd.extend(["--key", self.cosign_key_ref])
attach_cmd.append("--allow-http-registry")
attach_cmd.append(image_ref)
run(attach_cmd, env=self.cosign_env)
reference = run(["cosign", "triangulate", "--type", "sbom", "--allow-http-registry", image_ref]).strip()
entry["ref"] = reference
return entry
def _attach_provenance(self, name: str, image_ref: str) -> Optional[Mapping[str, Any]]:
if not image_ref or self.dry_run:
return None
predicate = OrderedDict()
predicate["buildDefinition"] = OrderedDict(
(
("buildType", "https://git.stella-ops.org/stellaops/release"),
("externalParameters", OrderedDict((
("component", name),
("version", self.version),
("channel", self.channel),
))),
)
)
predicate["runDetails"] = OrderedDict(
(
("builder", OrderedDict((("id", "https://github.com/actions"),))),
("metadata", OrderedDict((("finishedOn", self.release_date),))),
)
)
predicate_path = self.provenance_dir / f"{name}.provenance.json"
with predicate_path.open("w", encoding="utf-8") as handle:
json.dump(predicate, handle, indent=2, sort_keys=True)
handle.write("\n")
entry = OrderedDict((
("path", str(predicate_path.relative_to(self.output_dir.parent)) if predicate_path.is_relative_to(self.output_dir.parent) else str(predicate_path)),
("sha256", compute_sha256(predicate_path)),
))
if self.skip_signing:
return entry
cmd = [
"cosign",
"attest",
"--predicate",
str(predicate_path),
"--type",
"https://slsa.dev/provenance/v1",
]
if self.cosign_key_ref:
cmd.extend(["--key", self.cosign_key_ref])
if not self.tlog_upload:
cmd.append("--tlog-upload=false")
cmd.append("--allow-http-registry")
cmd.append(image_ref)
run(cmd, env=self.cosign_env)
ref = run([
"cosign",
"triangulate",
"--type",
"https://slsa.dev/provenance/v1",
"--allow-http-registry",
image_ref,
]).strip()
entry["ref"] = ref
return entry
# ----------------
# Helm + compose
# ----------------
def _package_helm(self) -> Optional[Mapping[str, Any]]:
helm_cfg = self.config.get("helm")
if not helm_cfg:
return None
chart_path = helm_cfg.get("chartPath")
if not chart_path:
return None
chart_dir = self.repo_root / chart_path
output_dir = ensure_directory(self.output_dir / "helm")
archive_path = output_dir / f"stellaops-{self.version}.tgz"
if not self.dry_run:
cmd = [
"helm",
"package",
str(chart_dir),
"--destination",
str(output_dir),
"--version",
self.version,
"--app-version",
self.version,
]
run(cmd)
packaged = next(output_dir.glob("*.tgz"), None)
if packaged and packaged != archive_path:
packaged.rename(archive_path)
digest = compute_sha256(archive_path) if archive_path.exists() else None
if archive_path.exists() and archive_path.is_relative_to(self.output_dir):
manifest_path = str(archive_path.relative_to(self.output_dir))
elif archive_path.exists() and archive_path.is_relative_to(self.output_dir.parent):
manifest_path = str(archive_path.relative_to(self.output_dir.parent))
else:
manifest_path = f"helm/{archive_path.name}"
return OrderedDict((
("name", "stellaops"),
("version", self.version),
("path", manifest_path),
("sha256", digest),
))
def _digest_compose_files(self) -> List[Mapping[str, Any]]:
compose_cfg = self.config.get("compose", {})
files = compose_cfg.get("files", [])
entries: List[Mapping[str, Any]] = []
for rel_path in files:
src = self.repo_root / rel_path
if not src.exists():
continue
digest = compute_sha256(src)
entries.append(OrderedDict((
("name", pathlib.Path(rel_path).name),
("path", rel_path),
("sha256", digest),
)))
return entries
# ----------------
# Manifest assembly
# ----------------
def _compose_manifest(
self,
components: List[Mapping[str, Any]],
helm_meta: Optional[Mapping[str, Any]],
compose_meta: List[Mapping[str, Any]],
) -> Dict[str, Any]:
manifest = OrderedDict()
manifest["release"] = OrderedDict((
("version", self.version),
("channel", self.channel),
("date", self.release_date),
("calendar", self.calendar),
))
manifest["components"] = components
if helm_meta:
manifest["charts"] = [helm_meta]
if compose_meta:
manifest["compose"] = compose_meta
return manifest
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build StellaOps release artefacts deterministically")
parser.add_argument("--config", type=pathlib.Path, default=DEFAULT_CONFIG, help="Path to release config JSON")
parser.add_argument("--version", required=True, help="Release version string (e.g. 2025.10.0-edge)")
parser.add_argument("--channel", required=True, help="Release channel (edge|stable|lts)")
parser.add_argument("--calendar", help="Calendar tag (YYYY.MM); defaults derived from version")
parser.add_argument("--git-sha", default=os.environ.get("GIT_COMMIT", "unknown"), help="Git revision to embed")
parser.add_argument("--output", type=pathlib.Path, default=REPO_ROOT / "out/release", help="Output directory for artefacts")
parser.add_argument("--no-push", action="store_true", help="Do not push images (use docker load)")
parser.add_argument("--dry-run", action="store_true", help="Print steps without executing commands")
parser.add_argument("--registry", help="Override registry root (e.g. localhost:5000/stellaops)")
parser.add_argument("--platform", dest="platforms", action="append", metavar="PLATFORM", help="Override build platforms (repeatable)")
parser.add_argument("--skip-signing", action="store_true", help="Skip cosign signing/attestation steps")
parser.add_argument("--cosign-key", dest="cosign_key", help="Override COSIGN_KEY_REF value")
parser.add_argument("--cosign-password", dest="cosign_password", help="Password for cosign key")
parser.add_argument("--cosign-identity-token", dest="cosign_identity_token", help="Identity token for keyless cosign flows")
parser.add_argument("--no-transparency", action="store_true", help="Disable Rekor transparency log upload during signing")
return parser.parse_args(argv)
def write_manifest(manifest: Mapping[str, Any], output_dir: pathlib.Path) -> pathlib.Path:
# Copy manifest to avoid mutating input when computing checksum
base_manifest = OrderedDict(manifest)
yaml_without_checksum = dump_yaml(base_manifest)
digest = hashlib.sha256(yaml_without_checksum.encode("utf-8")).hexdigest()
manifest_with_checksum = OrderedDict(base_manifest)
manifest_with_checksum["checksums"] = OrderedDict((("sha256", digest),))
final_yaml = dump_yaml(manifest_with_checksum)
output_path = output_dir / "release.yaml"
with output_path.open("w", encoding="utf-8") as handle:
handle.write(final_yaml)
return output_path
def main(argv: Optional[Sequence[str]] = None) -> int:
args = parse_args(argv)
config = load_json_config(args.config)
release_date = utc_now_iso()
calendar = sanitize_calendar(args.version, args.calendar)
builder = ReleaseBuilder(
repo_root=REPO_ROOT,
config=config,
version=args.version,
channel=args.channel,
calendar=calendar,
release_date=release_date,
git_sha=args.git_sha,
output_dir=args.output,
push=not args.no_push,
dry_run=args.dry_run,
registry_override=args.registry,
platforms_override=args.platforms,
skip_signing=args.skip_signing,
cosign_key_ref=args.cosign_key,
cosign_password=args.cosign_password,
cosign_identity_token=args.cosign_identity_token,
tlog_upload=not args.no_transparency,
)
manifest = builder.run()
manifest_path = write_manifest(manifest, builder.output_dir)
print(f"✅ Release manifest written to {manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,97 @@
{
"registry": "registry.stella-ops.org/stellaops",
"platforms": ["linux/amd64", "linux/arm64"],
"defaultChannel": "edge",
"docker": {
"sdkImage": "mcr.microsoft.com/dotnet/nightly/sdk:10.0",
"runtimeImage": "mcr.microsoft.com/dotnet/nightly/aspnet:10.0",
"nodeImage": "node:20.14.0-bookworm",
"nginxImage": "nginx:1.27-alpine"
},
"components": [
{
"name": "authority",
"repository": "authority",
"kind": "dotnet-service",
"context": ".",
"dockerfile": "ops/devops/release/docker/Dockerfile.dotnet-service",
"project": "src/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj",
"entrypoint": "StellaOps.Authority.dll"
},
{
"name": "signer",
"repository": "signer",
"kind": "dotnet-service",
"context": ".",
"dockerfile": "ops/devops/release/docker/Dockerfile.dotnet-service",
"project": "src/StellaOps.Signer/StellaOps.Signer.WebService/StellaOps.Signer.WebService.csproj",
"entrypoint": "StellaOps.Signer.WebService.dll"
},
{
"name": "attestor",
"repository": "attestor",
"kind": "dotnet-service",
"context": ".",
"dockerfile": "ops/devops/release/docker/Dockerfile.dotnet-service",
"project": "src/StellaOps.Attestor/StellaOps.Attestor.WebService/StellaOps.Attestor.WebService.csproj",
"entrypoint": "StellaOps.Attestor.WebService.dll"
},
{
"name": "scanner-web",
"repository": "scanner-web",
"kind": "dotnet-service",
"context": ".",
"dockerfile": "ops/devops/release/docker/Dockerfile.dotnet-service",
"project": "src/StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj",
"entrypoint": "StellaOps.Scanner.WebService.dll"
},
{
"name": "scanner-worker",
"repository": "scanner-worker",
"kind": "dotnet-service",
"context": ".",
"dockerfile": "ops/devops/release/docker/Dockerfile.dotnet-service",
"project": "src/StellaOps.Scanner.Worker/StellaOps.Scanner.Worker.csproj",
"entrypoint": "StellaOps.Scanner.Worker.dll"
},
{
"name": "concelier",
"repository": "concelier",
"kind": "dotnet-service",
"context": ".",
"dockerfile": "ops/devops/release/docker/Dockerfile.dotnet-service",
"project": "src/StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj",
"entrypoint": "StellaOps.Concelier.WebService.dll"
},
{
"name": "excititor",
"repository": "excititor",
"kind": "dotnet-service",
"context": ".",
"dockerfile": "ops/devops/release/docker/Dockerfile.dotnet-service",
"project": "src/StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj",
"entrypoint": "StellaOps.Excititor.WebService.dll"
},
{
"name": "web-ui",
"repository": "web-ui",
"kind": "angular-ui",
"context": ".",
"dockerfile": "ops/devops/release/docker/Dockerfile.angular-ui"
}
],
"helm": {
"chartPath": "deploy/helm/stellaops",
"outputDir": "out/release/helm"
},
"compose": {
"files": [
"deploy/compose/docker-compose.dev.yaml",
"deploy/compose/docker-compose.stage.yaml",
"deploy/compose/docker-compose.airgap.yaml"
]
},
"buildxPlugin": {
"project": "src/StellaOps.Scanner.Sbomer.BuildXPlugin/StellaOps.Scanner.Sbomer.BuildXPlugin.csproj"
}
}

View File

@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1.7-labs
ARG NODE_IMAGE=node:20.14.0-bookworm
ARG NGINX_IMAGE=nginx:1.27-alpine
ARG VERSION=0.0.0
ARG CHANNEL=dev
ARG GIT_SHA=0000000
ARG SOURCE_DATE_EPOCH=0
FROM ${NODE_IMAGE} AS build
WORKDIR /workspace
ENV CI=1 \
SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
COPY src/StellaOps.Web/package.json src/StellaOps.Web/package-lock.json ./
RUN npm ci --prefer-offline --no-audit --no-fund
COPY src/StellaOps.Web/ ./
RUN npm run build -- --configuration=production
FROM ${NGINX_IMAGE} AS runtime
ARG VERSION
ARG CHANNEL
ARG GIT_SHA
WORKDIR /usr/share/nginx/html
RUN rm -rf ./*
COPY --from=build /workspace/dist/stellaops-web/ /usr/share/nginx/html/
COPY ops/devops/release/docker/nginx-default.conf /etc/nginx/conf.d/default.conf
LABEL org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.source="https://git.stella-ops.org/stella-ops/feedser" \
org.stellaops.release.channel="${CHANNEL}"
EXPOSE 8080

View File

@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1.7-labs
ARG SDK_IMAGE=mcr.microsoft.com/dotnet/nightly/sdk:10.0
ARG RUNTIME_IMAGE=gcr.io/distroless/dotnet/aspnet:latest
ARG PROJECT
ARG ENTRYPOINT_DLL
ARG VERSION=0.0.0
ARG CHANNEL=dev
ARG GIT_SHA=0000000
ARG SOURCE_DATE_EPOCH=0
FROM ${SDK_IMAGE} AS build
ARG PROJECT
ARG GIT_SHA
ARG SOURCE_DATE_EPOCH
WORKDIR /src
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 \
NUGET_XMLDOC_MODE=skip \
SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
COPY . .
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore "${PROJECT}"
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish "${PROJECT}" \
-c Release \
-o /app/publish \
/p:UseAppHost=false \
/p:ContinuousIntegrationBuild=true \
/p:SourceRevisionId=${GIT_SHA} \
/p:Deterministic=true \
/p:TreatWarningsAsErrors=true
FROM ${RUNTIME_IMAGE} AS runtime
WORKDIR /app
ARG ENTRYPOINT_DLL
ARG VERSION
ARG CHANNEL
ARG GIT_SHA
ENV DOTNET_EnableDiagnostics=0 \
ASPNETCORE_URLS=http://0.0.0.0:8080
COPY --from=build /app/publish/ ./
RUN set -eu; \
printf '#!/usr/bin/env sh\nset -e\nexec dotnet %s "$@"\n' "${ENTRYPOINT_DLL}" > /entrypoint.sh; \
chmod +x /entrypoint.sh
EXPOSE 8080
LABEL org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.source="https://git.stella-ops.org/stella-ops/feedser" \
org.stellaops.release.channel="${CHANNEL}"
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,22 @@
server {
listen 8080;
listen [::]:8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|css|svg|png|jpg|jpeg|gif|ico|woff2?)$ {
add_header Cache-Control "public, max-age=2592000";
}
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 'ok';
}
}

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Sync preview NuGet packages into the local offline feed.
# Reads package metadata from ops/devops/nuget-preview-packages.csv
# and ensures ./local-nuget holds the expected artefacts (with SHA-256 verification).
set -euo pipefail
repo_root="$(git -C "${BASH_SOURCE%/*}/.." rev-parse --show-toplevel 2>/dev/null || pwd)"
manifest="${repo_root}/ops/devops/nuget-preview-packages.csv"
dest="${repo_root}/local-nuget"
if [[ ! -f "$manifest" ]]; then
echo "Manifest not found: $manifest" >&2
exit 1
fi
mkdir -p "$dest"
fetch_package() {
local package="$1"
local version="$2"
local expected_sha="$3"
local target="$dest/${package}.${version}.nupkg"
local url="https://www.nuget.org/api/v2/package/${package}/${version}"
echo "[sync-nuget] Fetching ${package} ${version}"
local tmp
tmp="$(mktemp)"
trap 'rm -f "$tmp"' RETURN
curl -fsSL --retry 3 --retry-delay 1 "$url" -o "$tmp"
local actual_sha
actual_sha="$(sha256sum "$tmp" | awk '{print $1}')"
if [[ "$actual_sha" != "$expected_sha" ]]; then
echo "Checksum mismatch for ${package} ${version}" >&2
echo " expected: $expected_sha" >&2
echo " actual: $actual_sha" >&2
exit 1
fi
mv "$tmp" "$target"
trap - RETURN
}
while IFS=',' read -r package version sha; do
[[ -z "$package" || "$package" == \#* ]] && continue
local_path="$dest/${package}.${version}.nupkg"
if [[ -f "$local_path" ]]; then
current_sha="$(sha256sum "$local_path" | awk '{print $1}')"
if [[ "$current_sha" == "$sha" ]]; then
echo "[sync-nuget] OK ${package} ${version}"
continue
fi
echo "[sync-nuget] SHA mismatch for ${package} ${version}, refreshing"
else
echo "[sync-nuget] Missing ${package} ${version}"
fi
fetch_package "$package" "$version" "$sha"
done < "$manifest"

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Engine;
/// <summary>
/// Evaluates Notify rules against platform events.
/// </summary>
public interface INotifyRuleEvaluator
{
/// <summary>
/// Evaluates a single rule against an event and returns the match outcome.
/// </summary>
NotifyRuleEvaluationOutcome Evaluate(
NotifyRule rule,
NotifyEvent @event,
DateTimeOffset? evaluationTimestamp = null);
/// <summary>
/// Evaluates a collection of rules against an event.
/// </summary>
ImmutableArray<NotifyRuleEvaluationOutcome> Evaluate(
IEnumerable<NotifyRule> rules,
NotifyEvent @event,
DateTimeOffset? evaluationTimestamp = null);
}

View File

@@ -2,7 +2,7 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| NOTIFY-ENGINE-15-301 | TODO | Notify Engine Guild | NOTIFY-MODELS-15-101 | Rules evaluation core: tenant/kind filters, severity/delta gates, VEX gating, throttling, idempotency key generation. | Unit tests cover rule permutations; idempotency keys deterministic; documentation updated. |
| NOTIFY-ENGINE-15-301 | DOING (2025-10-24) | Notify Engine Guild | NOTIFY-MODELS-15-101 | Rules evaluation core: tenant/kind filters, severity/delta gates, VEX gating, throttling, idempotency key generation. | Unit tests cover rule permutations; idempotency keys deterministic; documentation updated. |
| NOTIFY-ENGINE-15-302 | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-301 | Action planner + digest coalescer with window management and dedupe per architecture §4. | Digest windows tested; throttles and digests recorded; metrics counters exposed. |
| NOTIFY-ENGINE-15-303 | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-302 | Template rendering engine (Slack, Teams, Email, Webhook) with helpers and i18n support. | Rendering fixtures validated; helpers documented; deterministic output proven via golden tests. |
| NOTIFY-ENGINE-15-304 | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-303 | Test-send sandbox + preview utilities for WebService. | Preview/test functions validated; sample outputs returned; no state persisted. |

View File

@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Client.JetStream.Models;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Nats;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
{
private readonly TestcontainersContainer _nats;
private string? _skipReason;
public NatsNotifyDeliveryQueueTests()
{
_nats = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-delivery-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
.Build();
}
public async Task InitializeAsync()
{
try
{
await _nats.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"NATS-backed delivery tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _nats.DisposeAsync().ConfigureAwait(false);
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByDeliveryId()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var delivery = TestData.CreateDelivery("tenant-a");
var message = new NotifyDeliveryQueueMessage(
delivery,
channelId: "chan-a",
channelType: NotifyChannelType.Slack);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Release_Retry_ShouldReschedule()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "chan-retry",
channelType: NotifyChannelType.Teams));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
retried.Attempt.Should().BeGreaterThan(lease.Attempt);
await retried.AcknowledgeAsync();
}
[Fact]
public async Task Release_RetryBeyondMax_ShouldDeadLetter()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions(static opts =>
{
opts.MaxDeliveryAttempts = 2;
opts.Nats.DeadLetterStream = "NOTIFY_DELIVERY_DEAD_TEST";
opts.Nats.DeadLetterSubject = "notify.delivery.dead.test";
});
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "chan-dead",
channelType: NotifyChannelType.Webhook));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
await Task.Delay(200);
await using var connection = new NatsConnection(new NatsOpts { Url = options.Nats.Url! });
await connection.ConnectAsync();
var js = new NatsJSContext(connection);
var consumerConfig = new ConsumerConfig
{
DurableName = "notify-delivery-dead-test",
DeliverPolicy = ConsumerConfigDeliverPolicy.All,
AckPolicy = ConsumerConfigAckPolicy.Explicit
};
var consumer = await js.CreateConsumerAsync(options.Nats.DeadLetterStream, consumerConfig);
var fetchOpts = new NatsJSFetchOpts { MaxMsgs = 1, Expires = TimeSpan.FromSeconds(1) };
NatsJSMsg<byte[]>? dlqMsg = null;
await foreach (var msg in consumer.FetchAsync(NatsRawSerializer<byte[]>.Default, fetchOpts))
{
dlqMsg = msg;
await msg.AckAsync(new AckOpts());
break;
}
dlqMsg.Should().NotBeNull();
}
private NatsNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options)
{
return new NatsNotifyDeliveryQueue(
options,
options.Nats,
NullLogger<NatsNotifyDeliveryQueue>.Instance,
TimeProvider.System);
}
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? configure = null)
{
var url = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
var opts = new NotifyDeliveryQueueOptions
{
Transport = NotifyQueueTransportKind.Nats,
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(20),
RetryMaxBackoff = TimeSpan.FromMilliseconds(200),
Nats = new NotifyNatsDeliveryQueueOptions
{
Url = url,
Stream = "NOTIFY_DELIVERY_TEST",
Subject = "notify.delivery.test",
DeadLetterStream = "NOTIFY_DELIVERY_TEST_DEAD",
DeadLetterSubject = "notify.delivery.test.dead",
DurableConsumer = "notify-delivery-tests",
MaxAckPending = 32,
AckWait = TimeSpan.FromSeconds(2),
RetryDelay = TimeSpan.FromMilliseconds(100),
IdleHeartbeat = TimeSpan.FromMilliseconds(200)
}
};
configure?.Invoke(opts);
return opts;
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyDelivery CreateDelivery(string tenantId = "tenant-1")
{
return NotifyDelivery.Create(
deliveryId: Guid.NewGuid().ToString("n"),
tenantId: tenantId,
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: "scanner.report.ready",
status: NotifyDeliveryStatus.Pending,
createdAt: DateTimeOffset.UtcNow);
}
}
}

View File

@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Nats;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
{
private readonly TestcontainersContainer _nats;
private string? _skipReason;
public NatsNotifyEventQueueTests()
{
_nats = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-tests-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
.Build();
}
public async Task InitializeAsync()
{
try
{
await _nats.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"NATS-backed tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _nats.DisposeAsync().ConfigureAwait(false);
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent("tenant-a");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Nats.Subject,
traceId: "trace-1");
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Lease_Acknowledge_ShouldRemoveMessage()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent("tenant-b");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Nats.Subject,
traceId: "trace-xyz",
attributes: new Dictionary<string, string> { { "source", "scanner" } });
await queue.PublishAsync(message);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(2)));
leases.Should().ContainSingle();
var lease = leases[0];
lease.Attempt.Should().BeGreaterThanOrEqualTo(1);
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
lease.TraceId.Should().Be("trace-xyz");
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
await lease.AcknowledgeAsync();
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(1)));
afterAck.Should().BeEmpty();
}
[Fact]
public async Task Lease_ShouldPreserveOrdering()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var first = TestData.CreateEvent();
var second = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(first, options.Nats.Subject));
await queue.PublishAsync(new NotifyQueueEventMessage(second, options.Nats.Subject));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(2)));
leases.Should().HaveCount(2);
leases.Select(x => x.Message.Event.EventId)
.Should()
.ContainInOrder(first.EventId, second.EventId);
}
[Fact]
public async Task ClaimExpired_ShouldReassignLease()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Nats.Subject));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromMilliseconds(500)));
leases.Should().ContainSingle();
await Task.Delay(200);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.FromMilliseconds(100)));
claimed.Should().ContainSingle();
var lease = claimed[0];
lease.Consumer.Should().Be("worker-reclaim");
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
await lease.AcknowledgeAsync();
}
private NatsNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
{
return new NatsNotifyEventQueue(
options,
options.Nats,
NullLogger<NatsNotifyEventQueue>.Instance,
TimeProvider.System);
}
private NotifyEventQueueOptions CreateOptions()
{
var connectionUrl = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
return new NotifyEventQueueOptions
{
Transport = NotifyQueueTransportKind.Nats,
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(50),
RetryMaxBackoff = TimeSpan.FromSeconds(1),
Nats = new NotifyNatsEventQueueOptions
{
Url = connectionUrl,
Stream = "NOTIFY_TEST",
Subject = "notify.test.events",
DeadLetterStream = "NOTIFY_TEST_DEAD",
DeadLetterSubject = "notify.test.events.dead",
DurableConsumer = "notify-test-consumer",
MaxAckPending = 32,
AckWait = TimeSpan.FromSeconds(2),
RetryDelay = TimeSpan.FromMilliseconds(100),
IdleHeartbeat = TimeSpan.FromMilliseconds(100)
}
};
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyEvent CreateEvent(string tenant = "tenant-1")
{
return NotifyEvent.Create(
Guid.NewGuid(),
kind: "scanner.report.ready",
tenant: tenant,
ts: DateTimeOffset.UtcNow,
payload: new JsonObject
{
["summary"] = "event"
});
}
}
}

View File

@@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Redis;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class RedisNotifyDeliveryQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private string? _skipReason;
public RedisNotifyDeliveryQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
.Build();
}
public async Task InitializeAsync()
{
try
{
await _redis.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"Redis-backed delivery tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByDeliveryId()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var delivery = TestData.CreateDelivery();
var message = new NotifyDeliveryQueueMessage(
delivery,
channelId: "channel-1",
channelType: NotifyChannelType.Slack);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Release_Retry_ShouldRescheduleDelivery()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "channel-retry",
channelType: NotifyChannelType.Teams));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
lease.Attempt.Should().Be(1);
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
retried.Attempt.Should().Be(2);
await retried.AcknowledgeAsync();
}
[Fact]
public async Task Release_RetryBeyondMax_ShouldDeadLetter()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions(static opts =>
{
opts.MaxDeliveryAttempts = 2;
opts.Redis.DeadLetterStreamName = "notify:deliveries:testdead";
});
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "channel-dead",
channelType: NotifyChannelType.Email));
var first = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
await first.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
await Task.Delay(100);
var mux = await ConnectionMultiplexer.ConnectAsync(_redis.ConnectionString);
var db = mux.GetDatabase();
var deadLetters = await db.StreamReadAsync(options.Redis.DeadLetterStreamName, "0-0");
deadLetters.Should().NotBeEmpty();
}
private RedisNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options)
{
return new RedisNotifyDeliveryQueue(
options,
options.Redis,
NullLogger<RedisNotifyDeliveryQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
}
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? configure = null)
{
var opts = new NotifyDeliveryQueueOptions
{
Transport = NotifyQueueTransportKind.Redis,
DefaultLeaseDuration = TimeSpan.FromSeconds(1),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(10),
RetryMaxBackoff = TimeSpan.FromMilliseconds(50),
ClaimIdleThreshold = TimeSpan.FromSeconds(1),
Redis = new NotifyRedisDeliveryQueueOptions
{
ConnectionString = _redis.ConnectionString,
StreamName = "notify:deliveries:test",
ConsumerGroup = "notify-delivery-tests",
IdempotencyKeyPrefix = "notify:deliveries:test:idemp:"
}
};
configure?.Invoke(opts);
return opts;
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyDelivery CreateDelivery()
{
var now = DateTimeOffset.UtcNow;
return NotifyDelivery.Create(
deliveryId: Guid.NewGuid().ToString("n"),
tenantId: "tenant-1",
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: "scanner.report.ready",
status: NotifyDeliveryStatus.Pending,
createdAt: now,
metadata: new Dictionary<string, string>
{
["integration"] = "tests"
});
}
}
}

View File

@@ -0,0 +1,220 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Redis;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class RedisNotifyEventQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private string? _skipReason;
public RedisNotifyEventQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
.Build();
}
public async Task InitializeAsync()
{
try
{
await _redis.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"Redis-backed tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent(tenant: "tenant-a");
var message = new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Lease_Acknowledge_ShouldRemoveMessage()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent(tenant: "tenant-b");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Redis.Streams[0].Stream,
traceId: "trace-123",
attributes: new Dictionary<string, string> { { "source", "scanner" } });
await queue.PublishAsync(message);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
leases.Should().ContainSingle();
var lease = leases[0];
lease.Attempt.Should().Be(1);
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
lease.TraceId.Should().Be("trace-123");
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
await lease.AcknowledgeAsync();
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
afterAck.Should().BeEmpty();
}
[Fact]
public async Task Lease_ShouldPreserveOrdering()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var stream = options.Redis.Streams[0].Stream;
var firstEvent = TestData.CreateEvent();
var secondEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(firstEvent, stream));
await queue.PublishAsync(new NotifyQueueEventMessage(secondEvent, stream));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(5)));
leases.Should().HaveCount(2);
leases.Select(l => l.Message.Event.EventId)
.Should()
.ContainInOrder(new[] { firstEvent.EventId, secondEvent.EventId });
}
[Fact]
public async Task ClaimExpired_ShouldReassignLease()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromSeconds(1)));
leases.Should().ContainSingle();
// Ensure the message has been pending long enough for claim.
await Task.Delay(50);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.Zero));
claimed.Should().ContainSingle();
var lease = claimed[0];
lease.Consumer.Should().Be("worker-reclaim");
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
await lease.AcknowledgeAsync();
}
private RedisNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
{
return new RedisNotifyEventQueue(
options,
options.Redis,
NullLogger<RedisNotifyEventQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
}
private NotifyEventQueueOptions CreateOptions()
{
var streamOptions = new NotifyRedisEventStreamOptions
{
Stream = "notify:test:events",
ConsumerGroup = "notify-test-consumers",
IdempotencyKeyPrefix = "notify:test:idemp:",
ApproximateMaxLength = 1024
};
var redisOptions = new NotifyRedisEventQueueOptions
{
ConnectionString = _redis.ConnectionString,
Streams = new List<NotifyRedisEventStreamOptions> { streamOptions }
};
return new NotifyEventQueueOptions
{
Transport = NotifyQueueTransportKind.Redis,
DefaultLeaseDuration = TimeSpan.FromSeconds(5),
Redis = redisOptions
};
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyEvent CreateEvent(string tenant = "tenant-1")
{
return NotifyEvent.Create(
Guid.NewGuid(),
kind: "scanner.report.ready",
tenant: tenant,
ts: DateTimeOffset.UtcNow,
payload: new JsonObject
{
["summary"] = "event"
});
}
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="DotNet.Testcontainers" Version="1.7.0-beta.2269" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
<ProjectReference Include="..\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NATS.Client.JetStream;
namespace StellaOps.Notify.Queue.Nats;
internal sealed class NatsNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
{
private readonly NatsNotifyDeliveryQueue _queue;
private readonly NatsJSMsg<byte[]> _message;
private int _completed;
internal NatsNotifyDeliveryLease(
NatsNotifyDeliveryQueue queue,
NatsJSMsg<byte[]> message,
string messageId,
NotifyDeliveryQueueMessage payload,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string idempotencyKey)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_message = message;
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = payload ?? throw new ArgumentNullException(nameof(payload));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
IdempotencyKey = idempotencyKey ?? payload.IdempotencyKey;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey { get; }
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyDeliveryQueueMessage Message { get; }
internal NatsJSMsg<byte[]> RawMessage => _message;
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}

View File

@@ -0,0 +1,697 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Client.JetStream.Models;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Queue.Nats;
internal sealed class NatsNotifyDeliveryQueue : INotifyDeliveryQueue, IAsyncDisposable
{
private const string TransportName = "nats";
private static readonly INatsSerializer<byte[]> PayloadSerializer = NatsRawSerializer<byte[]>.Default;
private readonly NotifyDeliveryQueueOptions _queueOptions;
private readonly NotifyNatsDeliveryQueueOptions _options;
private readonly ILogger<NatsNotifyDeliveryQueue> _logger;
private readonly TimeProvider _timeProvider;
private readonly SemaphoreSlim _connectionGate = new(1, 1);
private readonly Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>> _connectionFactory;
private NatsConnection? _connection;
private NatsJSContext? _jsContext;
private INatsJSConsumer? _consumer;
private bool _disposed;
public NatsNotifyDeliveryQueue(
NotifyDeliveryQueueOptions queueOptions,
NotifyNatsDeliveryQueueOptions options,
ILogger<NatsNotifyDeliveryQueue> logger,
TimeProvider timeProvider,
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
{
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_connectionFactory = connectionFactory ?? ((opts, token) => new ValueTask<NatsConnection>(new NatsConnection(opts)));
if (string.IsNullOrWhiteSpace(_options.Url))
{
throw new InvalidOperationException("NATS connection URL must be configured for the Notify delivery queue.");
}
if (string.IsNullOrWhiteSpace(_options.Stream) || string.IsNullOrWhiteSpace(_options.Subject))
{
throw new InvalidOperationException("NATS stream and subject must be configured for the Notify delivery queue.");
}
}
public async ValueTask<NotifyQueueEnqueueResult> PublishAsync(
NotifyDeliveryQueueMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
var payload = Encoding.UTF8.GetBytes(NotifyCanonicalJsonSerializer.Serialize(message.Delivery));
var headers = BuildHeaders(message);
var publishOpts = new NatsJSPubOpts
{
MsgId = message.IdempotencyKey,
RetryAttempts = 0
};
var ack = await js.PublishAsync(
_options.Subject,
payload,
PayloadSerializer,
publishOpts,
headers,
cancellationToken)
.ConfigureAwait(false);
if (ack.Duplicate)
{
NotifyQueueMetrics.RecordDeduplicated(TransportName, _options.Stream);
_logger.LogDebug(
"Duplicate Notify delivery enqueue detected for delivery {DeliveryId}.",
message.Delivery.DeliveryId);
return new NotifyQueueEnqueueResult(ack.Seq.ToString(), true);
}
NotifyQueueMetrics.RecordEnqueued(TransportName, _options.Stream);
_logger.LogDebug(
"Enqueued Notify delivery {DeliveryId} into NATS stream {Stream} (sequence {Sequence}).",
message.Delivery.DeliveryId,
ack.Stream,
ack.Seq);
return new NotifyQueueEnqueueResult(ack.Seq.ToString(), false);
}
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyDeliveryQueueMessage>>> LeaseAsync(
NotifyQueueLeaseRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
var fetchOpts = new NatsJSFetchOpts
{
MaxMsgs = request.BatchSize,
Expires = request.LeaseDuration,
IdleHeartbeat = _options.IdleHeartbeat
};
var now = _timeProvider.GetUtcNow();
var leases = new List<INotifyQueueLease<NotifyDeliveryQueueMessage>>(request.BatchSize);
await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false))
{
var lease = CreateLease(msg, request.Consumer, now, request.LeaseDuration);
if (lease is null)
{
await msg.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
return leases;
}
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyDeliveryQueueMessage>>> ClaimExpiredAsync(
NotifyQueueClaimOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
var fetchOpts = new NatsJSFetchOpts
{
MaxMsgs = options.BatchSize,
Expires = options.MinIdleTime,
IdleHeartbeat = _options.IdleHeartbeat
};
var now = _timeProvider.GetUtcNow();
var leases = new List<INotifyQueueLease<NotifyDeliveryQueueMessage>>(options.BatchSize);
await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false))
{
var deliveries = (int)(msg.Metadata?.NumDelivered ?? 1);
if (deliveries <= 1)
{
await msg.NakAsync(new AckOpts(), TimeSpan.Zero, cancellationToken).ConfigureAwait(false);
continue;
}
var lease = CreateLease(msg, options.ClaimantConsumer, now, _queueOptions.DefaultLeaseDuration);
if (lease is null)
{
await msg.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
return leases;
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_connection is not null)
{
await _connection.DisposeAsync().ConfigureAwait(false);
}
_connectionGate.Dispose();
GC.SuppressFinalize(this);
}
internal async Task AcknowledgeAsync(
NatsNotifyDeliveryLease lease,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
await lease.RawMessage.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
NotifyQueueMetrics.RecordAck(TransportName, _options.Stream);
_logger.LogDebug(
"Acknowledged Notify delivery {DeliveryId} (sequence {Sequence}).",
lease.Message.Delivery.DeliveryId,
lease.MessageId);
}
internal async Task RenewLeaseAsync(
NatsNotifyDeliveryLease lease,
TimeSpan leaseDuration,
CancellationToken cancellationToken)
{
await lease.RawMessage.AckProgressAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
lease.RefreshLease(expires);
_logger.LogDebug(
"Renewed NATS lease for Notify delivery {DeliveryId} until {Expires:u}.",
lease.Message.Delivery.DeliveryId,
expires);
}
internal async Task ReleaseAsync(
NatsNotifyDeliveryLease lease,
NotifyQueueReleaseDisposition disposition,
CancellationToken cancellationToken)
{
if (disposition == NotifyQueueReleaseDisposition.Retry
&& lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
{
_logger.LogWarning(
"Notify delivery {DeliveryId} reached max delivery attempts ({Attempts}); moving to dead-letter stream.",
lease.Message.Delivery.DeliveryId,
lease.Attempt);
await DeadLetterAsync(
lease,
$"max-delivery-attempts:{lease.Attempt}",
cancellationToken).ConfigureAwait(false);
return;
}
if (!lease.TryBeginCompletion())
{
return;
}
if (disposition == NotifyQueueReleaseDisposition.Retry)
{
var delay = CalculateBackoff(lease.Attempt);
await lease.RawMessage.NakAsync(new AckOpts(), delay, cancellationToken).ConfigureAwait(false);
NotifyQueueMetrics.RecordRetry(TransportName, _options.Stream);
_logger.LogInformation(
"Scheduled Notify delivery {DeliveryId} for retry with delay {Delay} (attempt {Attempt}).",
lease.Message.Delivery.DeliveryId,
delay,
lease.Attempt);
}
else
{
await lease.RawMessage.AckTerminateAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
NotifyQueueMetrics.RecordAck(TransportName, _options.Stream);
_logger.LogInformation(
"Abandoned Notify delivery {DeliveryId} after {Attempt} attempt(s).",
lease.Message.Delivery.DeliveryId,
lease.Attempt);
}
}
internal async Task DeadLetterAsync(
NatsNotifyDeliveryLease lease,
string reason,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
await lease.RawMessage.AckTerminateAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
var payload = Encoding.UTF8.GetBytes(NotifyCanonicalJsonSerializer.Serialize(lease.Message.Delivery));
var headers = BuildDeadLetterHeaders(lease, reason);
await js.PublishAsync(
_options.DeadLetterSubject,
payload,
PayloadSerializer,
new NatsJSPubOpts(),
headers,
cancellationToken)
.ConfigureAwait(false);
NotifyQueueMetrics.RecordDeadLetter(TransportName, _options.DeadLetterStream);
_logger.LogError(
"Dead-lettered Notify delivery {DeliveryId} (attempt {Attempt}): {Reason}",
lease.Message.Delivery.DeliveryId,
lease.Attempt,
reason);
}
internal async Task PingAsync(CancellationToken cancellationToken)
{
var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false);
await connection.PingAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<NatsJSContext> GetJetStreamAsync(CancellationToken cancellationToken)
{
if (_jsContext is not null)
{
return _jsContext;
}
var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false);
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_jsContext ??= new NatsJSContext(connection);
return _jsContext;
}
finally
{
_connectionGate.Release();
}
}
private async ValueTask<INatsJSConsumer> EnsureStreamAndConsumerAsync(
NatsJSContext js,
CancellationToken cancellationToken)
{
if (_consumer is not null)
{
return _consumer;
}
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_consumer is not null)
{
return _consumer;
}
await EnsureStreamAsync(js, cancellationToken).ConfigureAwait(false);
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
var consumerConfig = new ConsumerConfig
{
DurableName = _options.DurableConsumer,
AckPolicy = ConsumerConfigAckPolicy.Explicit,
ReplayPolicy = ConsumerConfigReplayPolicy.Instant,
DeliverPolicy = ConsumerConfigDeliverPolicy.All,
AckWait = ToNanoseconds(_options.AckWait),
MaxAckPending = _options.MaxAckPending,
MaxDeliver = Math.Max(1, _queueOptions.MaxDeliveryAttempts),
FilterSubjects = new[] { _options.Subject }
};
try
{
_consumer = await js.CreateConsumerAsync(
_options.Stream,
consumerConfig,
cancellationToken)
.ConfigureAwait(false);
}
catch (NatsJSApiException apiEx)
{
_logger.LogDebug(
apiEx,
"CreateConsumerAsync failed with code {Code}; attempting to fetch existing durable consumer {Durable}.",
apiEx.Error?.Code,
_options.DurableConsumer);
_consumer = await js.GetConsumerAsync(
_options.Stream,
_options.DurableConsumer,
cancellationToken)
.ConfigureAwait(false);
}
return _consumer;
}
finally
{
_connectionGate.Release();
}
}
private async Task<NatsConnection> EnsureConnectionAsync(CancellationToken cancellationToken)
{
if (_connection is not null)
{
return _connection;
}
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_connection is not null)
{
return _connection;
}
var opts = new NatsOpts
{
Url = _options.Url!,
Name = "stellaops-notify-delivery",
CommandTimeout = TimeSpan.FromSeconds(10),
RequestTimeout = TimeSpan.FromSeconds(20),
PingInterval = TimeSpan.FromSeconds(30)
};
_connection = await _connectionFactory(opts, cancellationToken).ConfigureAwait(false);
await _connection.ConnectAsync().ConfigureAwait(false);
return _connection;
}
finally
{
_connectionGate.Release();
}
}
private async Task EnsureStreamAsync(NatsJSContext js, CancellationToken cancellationToken)
{
try
{
await js.GetStreamAsync(_options.Stream, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
{
var config = new StreamConfig(name: _options.Stream, subjects: new[] { _options.Subject })
{
Retention = StreamConfigRetention.Workqueue,
Storage = StreamConfigStorage.File,
MaxConsumers = -1,
MaxMsgs = -1,
MaxBytes = -1
};
await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Created NATS Notify delivery stream {Stream} ({Subject}).", _options.Stream, _options.Subject);
}
}
private async Task EnsureDeadLetterStreamAsync(NatsJSContext js, CancellationToken cancellationToken)
{
try
{
await js.GetStreamAsync(_options.DeadLetterStream, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
{
var config = new StreamConfig(name: _options.DeadLetterStream, subjects: new[] { _options.DeadLetterSubject })
{
Retention = StreamConfigRetention.Workqueue,
Storage = StreamConfigStorage.File,
MaxConsumers = -1,
MaxMsgs = -1,
MaxBytes = -1
};
await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Created NATS Notify delivery dead-letter stream {Stream} ({Subject}).", _options.DeadLetterStream, _options.DeadLetterSubject);
}
}
private NatsNotifyDeliveryLease? CreateLease(
NatsJSMsg<byte[]> message,
string consumer,
DateTimeOffset now,
TimeSpan leaseDuration)
{
var payloadBytes = message.Data ?? Array.Empty<byte>();
if (payloadBytes.Length == 0)
{
return null;
}
NotifyDelivery delivery;
try
{
var json = Encoding.UTF8.GetString(payloadBytes);
delivery = NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(json);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to deserialize Notify delivery payload for NATS message {Sequence}.",
message.Metadata?.Sequence.Stream);
return null;
}
var headers = message.Headers ?? new NatsHeaders();
var deliveryId = TryGetHeader(headers, NotifyQueueFields.DeliveryId) ?? delivery.DeliveryId;
var channelId = TryGetHeader(headers, NotifyQueueFields.ChannelId);
var channelTypeRaw = TryGetHeader(headers, NotifyQueueFields.ChannelType);
if (channelId is null || channelTypeRaw is null)
{
return null;
}
if (!Enum.TryParse<NotifyChannelType>(channelTypeRaw, ignoreCase: true, out var channelType))
{
_logger.LogWarning("Unknown channel type '{ChannelType}' for delivery {DeliveryId}.", channelTypeRaw, deliveryId);
return null;
}
var traceId = TryGetHeader(headers, NotifyQueueFields.TraceId);
var partitionKey = TryGetHeader(headers, NotifyQueueFields.PartitionKey) ?? channelId;
var idempotencyKey = TryGetHeader(headers, NotifyQueueFields.IdempotencyKey) ?? delivery.DeliveryId;
var enqueuedAt = TryGetHeader(headers, NotifyQueueFields.EnqueuedAt) is { } enqueuedRaw
&& long.TryParse(enqueuedRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var unix)
? DateTimeOffset.FromUnixTimeMilliseconds(unix)
: now;
var attempt = TryGetHeader(headers, NotifyQueueFields.Attempt) is { } attemptRaw
&& int.TryParse(attemptRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedAttempt)
? parsedAttempt
: 1;
if (message.Metadata?.NumDelivered is ulong delivered && delivered > 0)
{
var deliveredInt = delivered > int.MaxValue ? int.MaxValue : (int)delivered;
if (deliveredInt > attempt)
{
attempt = deliveredInt;
}
}
var attributes = ExtractAttributes(headers);
var leaseExpires = now.Add(leaseDuration);
var messageId = message.Metadata?.Sequence.Stream.ToString() ?? Guid.NewGuid().ToString("n");
var queueMessage = new NotifyDeliveryQueueMessage(
delivery,
channelId,
channelType,
_options.Subject,
traceId,
attributes);
return new NatsNotifyDeliveryLease(
this,
message,
messageId,
queueMessage,
attempt,
consumer,
enqueuedAt,
leaseExpires,
idempotencyKey);
}
private NatsHeaders BuildHeaders(NotifyDeliveryQueueMessage message)
{
var headers = new NatsHeaders
{
{ NotifyQueueFields.DeliveryId, message.Delivery.DeliveryId },
{ NotifyQueueFields.ChannelId, message.ChannelId },
{ NotifyQueueFields.ChannelType, message.ChannelType.ToString() },
{ NotifyQueueFields.Tenant, message.Delivery.TenantId },
{ NotifyQueueFields.Attempt, "1" },
{ NotifyQueueFields.EnqueuedAt, _timeProvider.GetUtcNow().ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture) },
{ NotifyQueueFields.IdempotencyKey, message.IdempotencyKey },
{ NotifyQueueFields.PartitionKey, message.PartitionKey }
};
if (!string.IsNullOrWhiteSpace(message.TraceId))
{
headers.Add(NotifyQueueFields.TraceId, message.TraceId!);
}
foreach (var kvp in message.Attributes)
{
headers.Add(NotifyQueueFields.AttributePrefix + kvp.Key, kvp.Value);
}
return headers;
}
private NatsHeaders BuildDeadLetterHeaders(NatsNotifyDeliveryLease lease, string reason)
{
var headers = new NatsHeaders
{
{ NotifyQueueFields.DeliveryId, lease.Message.Delivery.DeliveryId },
{ NotifyQueueFields.ChannelId, lease.Message.ChannelId },
{ NotifyQueueFields.ChannelType, lease.Message.ChannelType.ToString() },
{ NotifyQueueFields.Tenant, lease.Message.Delivery.TenantId },
{ NotifyQueueFields.Attempt, lease.Attempt.ToString(CultureInfo.InvariantCulture) },
{ NotifyQueueFields.IdempotencyKey, lease.Message.IdempotencyKey },
{ "deadletter-reason", reason }
};
if (!string.IsNullOrWhiteSpace(lease.Message.TraceId))
{
headers.Add(NotifyQueueFields.TraceId, lease.Message.TraceId!);
}
foreach (var kvp in lease.Message.Attributes)
{
headers.Add(NotifyQueueFields.AttributePrefix + kvp.Key, kvp.Value);
}
return headers;
}
private static string? TryGetHeader(NatsHeaders headers, string key)
{
if (headers.TryGetValue(key, out var values) && values.Count > 0)
{
var value = values[0];
return string.IsNullOrWhiteSpace(value) ? null : value;
}
return null;
}
private static IReadOnlyDictionary<string, string> ExtractAttributes(NatsHeaders headers)
{
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var key in headers.Keys)
{
if (!key.StartsWith(NotifyQueueFields.AttributePrefix, StringComparison.Ordinal))
{
continue;
}
if (headers.TryGetValue(key, out var values) && values.Count > 0)
{
attributes[key[NotifyQueueFields.AttributePrefix.Length..]] = values[0]!;
}
}
return attributes.Count == 0
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(attributes);
}
private TimeSpan CalculateBackoff(int attempt)
{
var initial = _queueOptions.RetryInitialBackoff > TimeSpan.Zero
? _queueOptions.RetryInitialBackoff
: _options.RetryDelay;
if (initial <= TimeSpan.Zero)
{
return TimeSpan.Zero;
}
if (attempt <= 1)
{
return initial;
}
var max = _queueOptions.RetryMaxBackoff > TimeSpan.Zero
? _queueOptions.RetryMaxBackoff
: initial;
var exponent = attempt - 1;
var scaledTicks = initial.Ticks * Math.Pow(2, exponent - 1);
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
var resultTicks = Math.Max(initial.Ticks, (long)cappedTicks);
return TimeSpan.FromTicks(resultTicks);
}
private static long ToNanoseconds(TimeSpan value)
=> value <= TimeSpan.Zero ? 0 : value.Ticks * 100L;
private static class EmptyReadOnlyDictionary<TKey, TValue>
where TKey : notnull
{
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
}
}

View File

@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NATS.Client.JetStream;
namespace StellaOps.Notify.Queue.Nats;
internal sealed class NatsNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
{
private readonly NatsNotifyEventQueue _queue;
private readonly NatsJSMsg<byte[]> _message;
private int _completed;
internal NatsNotifyEventLease(
NatsNotifyEventQueue queue,
NatsJSMsg<byte[]> message,
string messageId,
NotifyQueueEventMessage payload,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
if (EqualityComparer<NatsJSMsg<byte[]>>.Default.Equals(message, default))
{
throw new ArgumentException("Message must be provided.", nameof(message));
}
_message = message;
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = payload ?? throw new ArgumentNullException(nameof(payload));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey => Message.IdempotencyKey;
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyQueueEventMessage Message { get; }
internal NatsJSMsg<byte[]> RawMessage => _message;
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}

View File

@@ -0,0 +1,698 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Client.JetStream.Models;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Queue.Nats;
internal sealed class NatsNotifyEventQueue : INotifyEventQueue, IAsyncDisposable
{
private const string TransportName = "nats";
private static readonly INatsSerializer<byte[]> PayloadSerializer = NatsRawSerializer<byte[]>.Default;
private readonly NotifyEventQueueOptions _queueOptions;
private readonly NotifyNatsEventQueueOptions _options;
private readonly ILogger<NatsNotifyEventQueue> _logger;
private readonly TimeProvider _timeProvider;
private readonly SemaphoreSlim _connectionGate = new(1, 1);
private readonly Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>> _connectionFactory;
private NatsConnection? _connection;
private NatsJSContext? _jsContext;
private INatsJSConsumer? _consumer;
private bool _disposed;
public NatsNotifyEventQueue(
NotifyEventQueueOptions queueOptions,
NotifyNatsEventQueueOptions options,
ILogger<NatsNotifyEventQueue> logger,
TimeProvider timeProvider,
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
{
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_connectionFactory = connectionFactory ?? ((opts, cancellationToken) => new ValueTask<NatsConnection>(new NatsConnection(opts)));
if (string.IsNullOrWhiteSpace(_options.Url))
{
throw new InvalidOperationException("NATS connection URL must be configured for the Notify event queue.");
}
if (string.IsNullOrWhiteSpace(_options.Stream) || string.IsNullOrWhiteSpace(_options.Subject))
{
throw new InvalidOperationException("NATS stream and subject must be configured for the Notify event queue.");
}
}
public async ValueTask<NotifyQueueEnqueueResult> PublishAsync(
NotifyQueueEventMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
var idempotencyKey = string.IsNullOrWhiteSpace(message.IdempotencyKey)
? message.Event.EventId.ToString("N")
: message.IdempotencyKey;
var payload = Encoding.UTF8.GetBytes(NotifyCanonicalJsonSerializer.Serialize(message.Event));
var headers = BuildHeaders(message, idempotencyKey);
var publishOpts = new NatsJSPubOpts
{
MsgId = idempotencyKey,
RetryAttempts = 0
};
var ack = await js.PublishAsync(
_options.Subject,
payload,
PayloadSerializer,
publishOpts,
headers,
cancellationToken)
.ConfigureAwait(false);
if (ack.Duplicate)
{
_logger.LogDebug(
"Duplicate Notify event enqueue detected for idempotency token {Token}.",
idempotencyKey);
NotifyQueueMetrics.RecordDeduplicated(TransportName, _options.Stream);
return new NotifyQueueEnqueueResult(ack.Seq.ToString(), true);
}
NotifyQueueMetrics.RecordEnqueued(TransportName, _options.Stream);
_logger.LogDebug(
"Enqueued Notify event {EventId} into NATS stream {Stream} (sequence {Sequence}).",
message.Event.EventId,
ack.Stream,
ack.Seq);
return new NotifyQueueEnqueueResult(ack.Seq.ToString(), false);
}
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(
NotifyQueueLeaseRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
var fetchOpts = new NatsJSFetchOpts
{
MaxMsgs = request.BatchSize,
Expires = request.LeaseDuration,
IdleHeartbeat = _options.IdleHeartbeat
};
var now = _timeProvider.GetUtcNow();
var leases = new List<INotifyQueueLease<NotifyQueueEventMessage>>(request.BatchSize);
await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false))
{
var lease = CreateLease(msg, request.Consumer, now, request.LeaseDuration);
if (lease is null)
{
await msg.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
return leases;
}
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(
NotifyQueueClaimOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
var fetchOpts = new NatsJSFetchOpts
{
MaxMsgs = options.BatchSize,
Expires = options.MinIdleTime,
IdleHeartbeat = _options.IdleHeartbeat
};
var now = _timeProvider.GetUtcNow();
var leases = new List<INotifyQueueLease<NotifyQueueEventMessage>>(options.BatchSize);
await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false))
{
var deliveries = (int)(msg.Metadata?.NumDelivered ?? 1);
if (deliveries <= 1)
{
await msg.NakAsync(new AckOpts(), TimeSpan.Zero, cancellationToken).ConfigureAwait(false);
continue;
}
var lease = CreateLease(msg, options.ClaimantConsumer, now, _queueOptions.DefaultLeaseDuration);
if (lease is null)
{
await msg.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
return leases;
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_connection is not null)
{
await _connection.DisposeAsync().ConfigureAwait(false);
}
_connectionGate.Dispose();
GC.SuppressFinalize(this);
}
internal async Task AcknowledgeAsync(
NatsNotifyEventLease lease,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
await lease.RawMessage.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
NotifyQueueMetrics.RecordAck(TransportName, _options.Stream);
_logger.LogDebug(
"Acknowledged Notify event {EventId} (sequence {Sequence}).",
lease.Message.Event.EventId,
lease.MessageId);
}
internal async Task RenewLeaseAsync(
NatsNotifyEventLease lease,
TimeSpan leaseDuration,
CancellationToken cancellationToken)
{
await lease.RawMessage.AckProgressAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
lease.RefreshLease(expires);
_logger.LogDebug(
"Renewed NATS lease for Notify event {EventId} until {Expires:u}.",
lease.Message.Event.EventId,
expires);
}
internal async Task ReleaseAsync(
NatsNotifyEventLease lease,
NotifyQueueReleaseDisposition disposition,
CancellationToken cancellationToken)
{
if (disposition == NotifyQueueReleaseDisposition.Retry
&& lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
{
_logger.LogWarning(
"Notify event {EventId} reached max delivery attempts ({Attempts}); moving to dead-letter stream.",
lease.Message.Event.EventId,
lease.Attempt);
await DeadLetterAsync(
lease,
$"max-delivery-attempts:{lease.Attempt}",
cancellationToken).ConfigureAwait(false);
return;
}
if (!lease.TryBeginCompletion())
{
return;
}
if (disposition == NotifyQueueReleaseDisposition.Retry)
{
var delay = CalculateBackoff(lease.Attempt);
await lease.RawMessage.NakAsync(new AckOpts(), delay, cancellationToken).ConfigureAwait(false);
NotifyQueueMetrics.RecordRetry(TransportName, _options.Stream);
_logger.LogInformation(
"Scheduled Notify event {EventId} for retry with delay {Delay} (attempt {Attempt}).",
lease.Message.Event.EventId,
delay,
lease.Attempt);
}
else
{
await lease.RawMessage.AckTerminateAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
NotifyQueueMetrics.RecordAck(TransportName, _options.Stream);
_logger.LogInformation(
"Abandoned Notify event {EventId} after {Attempt} attempt(s).",
lease.Message.Event.EventId,
lease.Attempt);
}
}
internal async Task DeadLetterAsync(
NatsNotifyEventLease lease,
string reason,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
await lease.RawMessage.AckTerminateAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
var headers = BuildDeadLetterHeaders(lease, reason);
var payload = Encoding.UTF8.GetBytes(NotifyCanonicalJsonSerializer.Serialize(lease.Message.Event));
await js.PublishAsync(
_options.DeadLetterSubject,
payload,
PayloadSerializer,
new NatsJSPubOpts(),
headers,
cancellationToken)
.ConfigureAwait(false);
NotifyQueueMetrics.RecordDeadLetter(TransportName, _options.DeadLetterStream);
_logger.LogError(
"Dead-lettered Notify event {EventId} (attempt {Attempt}): {Reason}",
lease.Message.Event.EventId,
lease.Attempt,
reason);
}
internal async Task PingAsync(CancellationToken cancellationToken)
{
var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false);
await connection.PingAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<NatsJSContext> GetJetStreamAsync(CancellationToken cancellationToken)
{
if (_jsContext is not null)
{
return _jsContext;
}
var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false);
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_jsContext ??= new NatsJSContext(connection);
return _jsContext;
}
finally
{
_connectionGate.Release();
}
}
private async ValueTask<INatsJSConsumer> EnsureStreamAndConsumerAsync(
NatsJSContext js,
CancellationToken cancellationToken)
{
if (_consumer is not null)
{
return _consumer;
}
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_consumer is not null)
{
return _consumer;
}
await EnsureStreamAsync(js, cancellationToken).ConfigureAwait(false);
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
var consumerConfig = new ConsumerConfig
{
DurableName = _options.DurableConsumer,
AckPolicy = ConsumerConfigAckPolicy.Explicit,
ReplayPolicy = ConsumerConfigReplayPolicy.Instant,
DeliverPolicy = ConsumerConfigDeliverPolicy.All,
AckWait = ToNanoseconds(_options.AckWait),
MaxAckPending = _options.MaxAckPending,
MaxDeliver = Math.Max(1, _queueOptions.MaxDeliveryAttempts),
FilterSubjects = new[] { _options.Subject }
};
try
{
_consumer = await js.CreateConsumerAsync(
_options.Stream,
consumerConfig,
cancellationToken)
.ConfigureAwait(false);
}
catch (NatsJSApiException apiEx)
{
_logger.LogDebug(
apiEx,
"CreateConsumerAsync failed with code {Code}; attempting to fetch existing durable consumer {Durable}.",
apiEx.Error?.Code,
_options.DurableConsumer);
_consumer = await js.GetConsumerAsync(
_options.Stream,
_options.DurableConsumer,
cancellationToken)
.ConfigureAwait(false);
}
return _consumer;
}
finally
{
_connectionGate.Release();
}
}
private async Task<NatsConnection> EnsureConnectionAsync(CancellationToken cancellationToken)
{
if (_connection is not null)
{
return _connection;
}
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_connection is not null)
{
return _connection;
}
var opts = new NatsOpts
{
Url = _options.Url!,
Name = "stellaops-notify-queue",
CommandTimeout = TimeSpan.FromSeconds(10),
RequestTimeout = TimeSpan.FromSeconds(20),
PingInterval = TimeSpan.FromSeconds(30)
};
_connection = await _connectionFactory(opts, cancellationToken).ConfigureAwait(false);
await _connection.ConnectAsync().ConfigureAwait(false);
return _connection;
}
finally
{
_connectionGate.Release();
}
}
private async Task EnsureStreamAsync(NatsJSContext js, CancellationToken cancellationToken)
{
try
{
await js.GetStreamAsync(_options.Stream, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
{
var config = new StreamConfig(name: _options.Stream, subjects: new[] { _options.Subject })
{
Retention = StreamConfigRetention.Workqueue,
Storage = StreamConfigStorage.File,
MaxConsumers = -1,
MaxMsgs = -1,
MaxBytes = -1
};
await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Created NATS Notify stream {Stream} ({Subject}).", _options.Stream, _options.Subject);
}
}
private async Task EnsureDeadLetterStreamAsync(NatsJSContext js, CancellationToken cancellationToken)
{
try
{
await js.GetStreamAsync(_options.DeadLetterStream, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
{
var config = new StreamConfig(name: _options.DeadLetterStream, subjects: new[] { _options.DeadLetterSubject })
{
Retention = StreamConfigRetention.Workqueue,
Storage = StreamConfigStorage.File,
MaxConsumers = -1,
MaxMsgs = -1,
MaxBytes = -1
};
await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Created NATS Notify dead-letter stream {Stream} ({Subject}).", _options.DeadLetterStream, _options.DeadLetterSubject);
}
}
private NatsNotifyEventLease? CreateLease(
NatsJSMsg<byte[]> message,
string consumer,
DateTimeOffset now,
TimeSpan leaseDuration)
{
var payloadBytes = message.Data ?? Array.Empty<byte>();
if (payloadBytes.Length == 0)
{
return null;
}
NotifyEvent notifyEvent;
try
{
var json = Encoding.UTF8.GetString(payloadBytes);
notifyEvent = NotifyCanonicalJsonSerializer.Deserialize<NotifyEvent>(json);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to deserialize Notify event payload for NATS message {Sequence}.",
message.Metadata?.Sequence.Stream);
return null;
}
var headers = message.Headers ?? new NatsHeaders();
var idempotencyKey = TryGetHeader(headers, NotifyQueueFields.IdempotencyKey)
?? notifyEvent.EventId.ToString("N");
var partitionKey = TryGetHeader(headers, NotifyQueueFields.PartitionKey);
var traceId = TryGetHeader(headers, NotifyQueueFields.TraceId);
var enqueuedAt = TryGetHeader(headers, NotifyQueueFields.EnqueuedAt) is { } enqueuedRaw
&& long.TryParse(enqueuedRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var unix)
? DateTimeOffset.FromUnixTimeMilliseconds(unix)
: now;
var attempt = TryGetHeader(headers, NotifyQueueFields.Attempt) is { } attemptRaw
&& int.TryParse(attemptRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedAttempt)
? parsedAttempt
: 1;
if (message.Metadata?.NumDelivered is ulong delivered && delivered > 0)
{
var deliveredInt = delivered > int.MaxValue ? int.MaxValue : (int)delivered;
if (deliveredInt > attempt)
{
attempt = deliveredInt;
}
}
var attributes = ExtractAttributes(headers);
var leaseExpires = now.Add(leaseDuration);
var messageId = message.Metadata?.Sequence.Stream.ToString() ?? Guid.NewGuid().ToString("n");
var queueMessage = new NotifyQueueEventMessage(
notifyEvent,
_options.Subject,
idempotencyKey,
partitionKey,
traceId,
attributes);
return new NatsNotifyEventLease(
this,
message,
messageId,
queueMessage,
attempt,
consumer,
enqueuedAt,
leaseExpires);
}
private NatsHeaders BuildHeaders(NotifyQueueEventMessage message, string idempotencyKey)
{
var headers = new NatsHeaders
{
{ NotifyQueueFields.EventId, message.Event.EventId.ToString("D") },
{ NotifyQueueFields.Tenant, message.TenantId },
{ NotifyQueueFields.Kind, message.Event.Kind },
{ NotifyQueueFields.Attempt, "1" },
{ NotifyQueueFields.EnqueuedAt, _timeProvider.GetUtcNow().ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture) },
{ NotifyQueueFields.IdempotencyKey, idempotencyKey }
};
if (!string.IsNullOrWhiteSpace(message.TraceId))
{
headers.Add(NotifyQueueFields.TraceId, message.TraceId!);
}
if (!string.IsNullOrWhiteSpace(message.PartitionKey))
{
headers.Add(NotifyQueueFields.PartitionKey, message.PartitionKey!);
}
foreach (var kvp in message.Attributes)
{
headers.Add(NotifyQueueFields.AttributePrefix + kvp.Key, kvp.Value);
}
return headers;
}
private NatsHeaders BuildDeadLetterHeaders(NatsNotifyEventLease lease, string reason)
{
var headers = new NatsHeaders
{
{ NotifyQueueFields.EventId, lease.Message.Event.EventId.ToString("D") },
{ NotifyQueueFields.Tenant, lease.Message.TenantId },
{ NotifyQueueFields.Kind, lease.Message.Event.Kind },
{ NotifyQueueFields.Attempt, lease.Attempt.ToString(CultureInfo.InvariantCulture) },
{ NotifyQueueFields.IdempotencyKey, lease.Message.IdempotencyKey },
{ "deadletter-reason", reason }
};
if (!string.IsNullOrWhiteSpace(lease.Message.TraceId))
{
headers.Add(NotifyQueueFields.TraceId, lease.Message.TraceId!);
}
if (!string.IsNullOrWhiteSpace(lease.Message.PartitionKey))
{
headers.Add(NotifyQueueFields.PartitionKey, lease.Message.PartitionKey!);
}
foreach (var kvp in lease.Message.Attributes)
{
headers.Add(NotifyQueueFields.AttributePrefix + kvp.Key, kvp.Value);
}
return headers;
}
private static string? TryGetHeader(NatsHeaders headers, string key)
{
if (headers.TryGetValue(key, out var values) && values.Count > 0)
{
var value = values[0];
return string.IsNullOrWhiteSpace(value) ? null : value;
}
return null;
}
private static IReadOnlyDictionary<string, string> ExtractAttributes(NatsHeaders headers)
{
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var key in headers.Keys)
{
if (!key.StartsWith(NotifyQueueFields.AttributePrefix, StringComparison.Ordinal))
{
continue;
}
if (headers.TryGetValue(key, out var values) && values.Count > 0)
{
attributes[key[NotifyQueueFields.AttributePrefix.Length..]] = values[0]!;
}
}
return attributes.Count == 0
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(attributes);
}
private TimeSpan CalculateBackoff(int attempt)
{
var initial = _queueOptions.RetryInitialBackoff > TimeSpan.Zero
? _queueOptions.RetryInitialBackoff
: _options.RetryDelay;
if (initial <= TimeSpan.Zero)
{
return TimeSpan.Zero;
}
if (attempt <= 1)
{
return initial;
}
var max = _queueOptions.RetryMaxBackoff > TimeSpan.Zero
? _queueOptions.RetryMaxBackoff
: initial;
var exponent = attempt - 1;
var scaledTicks = initial.Ticks * Math.Pow(2, exponent - 1);
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
var resultTicks = Math.Max(initial.Ticks, (long)cappedTicks);
return TimeSpan.FromTicks(resultTicks);
}
private static long ToNanoseconds(TimeSpan value)
=> value <= TimeSpan.Zero ? 0 : value.Ticks * 100L;
private static class EmptyReadOnlyDictionary<TKey, TValue>
where TKey : notnull
{
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
}
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public sealed class NotifyDeliveryQueueHealthCheck : IHealthCheck
{
private readonly INotifyDeliveryQueue _queue;
private readonly ILogger<NotifyDeliveryQueueHealthCheck> _logger;
public NotifyDeliveryQueueHealthCheck(
INotifyDeliveryQueue queue,
ILogger<NotifyDeliveryQueueHealthCheck> logger)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
switch (_queue)
{
case RedisNotifyDeliveryQueue redisQueue:
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("Redis Notify delivery queue reachable.");
case NatsNotifyDeliveryQueue natsQueue:
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("NATS Notify delivery queue reachable.");
default:
return HealthCheckResult.Healthy("Notify delivery queue transport without dedicated ping returned healthy.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Notify delivery queue health check failed.");
return new HealthCheckResult(
context.Registration.FailureStatus,
"Notify delivery queue transport unreachable.",
ex);
}
}
}

View File

@@ -0,0 +1,69 @@
using System;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Configuration options for the Notify delivery queue abstraction.
/// </summary>
public sealed class NotifyDeliveryQueueOptions
{
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
public NotifyRedisDeliveryQueueOptions Redis { get; set; } = new();
public NotifyNatsDeliveryQueueOptions Nats { get; set; } = new();
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
public int MaxDeliveryAttempts { get; set; } = 5;
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
}
public sealed class NotifyRedisDeliveryQueueOptions
{
public string? ConnectionString { get; set; }
public int? Database { get; set; }
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
public string StreamName { get; set; } = "notify:deliveries";
public string ConsumerGroup { get; set; } = "notify-deliveries";
public string IdempotencyKeyPrefix { get; set; } = "notify:deliveries:idemp:";
public int? ApproximateMaxLength { get; set; }
public string DeadLetterStreamName { get; set; } = "notify:deliveries:dead";
public TimeSpan DeadLetterRetention { get; set; } = TimeSpan.FromDays(7);
}
public sealed class NotifyNatsDeliveryQueueOptions
{
public string? Url { get; set; }
public string Stream { get; set; } = "NOTIFY_DELIVERIES";
public string Subject { get; set; } = "notify.deliveries";
public string DurableConsumer { get; set; } = "notify-deliveries";
public string DeadLetterStream { get; set; } = "NOTIFY_DELIVERIES_DEAD";
public string DeadLetterSubject { get; set; } = "notify.deliveries.dead";
public int MaxAckPending { get; set; } = 128;
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
}

View File

@@ -0,0 +1,177 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Configuration options for the Notify event queue abstraction.
/// </summary>
public sealed class NotifyEventQueueOptions
{
/// <summary>
/// Transport backing the queue.
/// </summary>
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
/// <summary>
/// Redis-specific configuration.
/// </summary>
public NotifyRedisEventQueueOptions Redis { get; set; } = new();
/// <summary>
/// NATS JetStream-specific configuration.
/// </summary>
public NotifyNatsEventQueueOptions Nats { get; set; } = new();
/// <summary>
/// Default lease duration to use when consumers do not specify one explicitly.
/// </summary>
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Maximum number of deliveries before a message should be considered failed.
/// </summary>
public int MaxDeliveryAttempts { get; set; } = 5;
/// <summary>
/// Initial retry backoff applied when a message is released for retry.
/// </summary>
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Cap applied to exponential retry backoff.
/// </summary>
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Minimum idle window before a pending message becomes eligible for claim.
/// </summary>
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
}
/// <summary>
/// Redis transport options for the Notify event queue.
/// </summary>
public sealed class NotifyRedisEventQueueOptions
{
private IReadOnlyList<NotifyRedisEventStreamOptions> _streams = new List<NotifyRedisEventStreamOptions>
{
NotifyRedisEventStreamOptions.ForDefaultStream()
};
/// <summary>
/// Connection string for the Redis instance.
/// </summary>
public string? ConnectionString { get; set; }
/// <summary>
/// Optional logical database to select when connecting.
/// </summary>
public int? Database { get; set; }
/// <summary>
/// Time allowed for initial connection/consumer-group creation.
/// </summary>
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// TTL applied to idempotency keys stored alongside events.
/// </summary>
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
/// <summary>
/// Streams consumed by Notify. Ordering is preserved during leasing.
/// </summary>
public IReadOnlyList<NotifyRedisEventStreamOptions> Streams
{
get => _streams;
set => _streams = value is null || value.Count == 0
? new List<NotifyRedisEventStreamOptions> { NotifyRedisEventStreamOptions.ForDefaultStream() }
: value;
}
}
/// <summary>
/// Per-Redis-stream options for the Notify event queue.
/// </summary>
public sealed class NotifyRedisEventStreamOptions
{
/// <summary>
/// Name of the Redis stream containing events.
/// </summary>
public string Stream { get; set; } = "notify:events";
/// <summary>
/// Consumer group used by Notify workers.
/// </summary>
public string ConsumerGroup { get; set; } = "notify-workers";
/// <summary>
/// Prefix used when storing idempotency keys in Redis.
/// </summary>
public string IdempotencyKeyPrefix { get; set; } = "notify:events:idemp:";
/// <summary>
/// Approximate maximum length for the stream; when set Redis will trim entries.
/// </summary>
public int? ApproximateMaxLength { get; set; }
public static NotifyRedisEventStreamOptions ForDefaultStream()
=> new();
}
/// <summary>
/// NATS JetStream options for the Notify event queue.
/// </summary>
public sealed class NotifyNatsEventQueueOptions
{
/// <summary>
/// URL for the JetStream-enabled NATS cluster.
/// </summary>
public string? Url { get; set; }
/// <summary>
/// Stream name carrying Notify events.
/// </summary>
public string Stream { get; set; } = "NOTIFY_EVENTS";
/// <summary>
/// Subject that producers publish Notify events to.
/// </summary>
public string Subject { get; set; } = "notify.events";
/// <summary>
/// Durable consumer identifier for Notify workers.
/// </summary>
public string DurableConsumer { get; set; } = "notify-workers";
/// <summary>
/// Dead-letter stream name used when deliveries exhaust retry budget.
/// </summary>
public string DeadLetterStream { get; set; } = "NOTIFY_EVENTS_DEAD";
/// <summary>
/// Subject used for dead-letter publications.
/// </summary>
public string DeadLetterSubject { get; set; } = "notify.events.dead";
/// <summary>
/// Maximum pending messages before backpressure is applied.
/// </summary>
public int MaxAckPending { get; set; } = 256;
/// <summary>
/// Visibility timeout applied to leased events.
/// </summary>
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Delay applied when releasing a message for retry.
/// </summary>
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Idle heartbeat emitted by the server to detect consumer disconnects.
/// </summary>
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
}

View File

@@ -0,0 +1,231 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Message queued for Notify event processing.
/// </summary>
public sealed class NotifyQueueEventMessage
{
private readonly NotifyEvent _event;
private readonly IReadOnlyDictionary<string, string> _attributes;
public NotifyQueueEventMessage(
NotifyEvent @event,
string stream,
string? idempotencyKey = null,
string? partitionKey = null,
string? traceId = null,
IReadOnlyDictionary<string, string>? attributes = null)
{
_event = @event ?? throw new ArgumentNullException(nameof(@event));
if (string.IsNullOrWhiteSpace(stream))
{
throw new ArgumentException("Stream must be provided.", nameof(stream));
}
Stream = stream;
IdempotencyKey = string.IsNullOrWhiteSpace(idempotencyKey)
? @event.EventId.ToString("N")
: idempotencyKey!;
PartitionKey = string.IsNullOrWhiteSpace(partitionKey) ? null : partitionKey.Trim();
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
_attributes = attributes is null
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
}
public NotifyEvent Event => _event;
public string Stream { get; }
public string IdempotencyKey { get; }
public string TenantId => _event.Tenant;
public string? PartitionKey { get; }
public string? TraceId { get; }
public IReadOnlyDictionary<string, string> Attributes => _attributes;
}
/// <summary>
/// Message queued for channel delivery execution.
/// </summary>
public sealed class NotifyDeliveryQueueMessage
{
public const string DefaultStream = "notify:deliveries";
private readonly IReadOnlyDictionary<string, string> _attributes;
public NotifyDeliveryQueueMessage(
NotifyDelivery delivery,
string channelId,
NotifyChannelType channelType,
string? stream = null,
string? traceId = null,
IReadOnlyDictionary<string, string>? attributes = null)
{
Delivery = delivery ?? throw new ArgumentNullException(nameof(delivery));
ChannelId = NotifyValidation.EnsureNotNullOrWhiteSpace(channelId, nameof(channelId));
ChannelType = channelType;
Stream = string.IsNullOrWhiteSpace(stream) ? DefaultStream : stream!.Trim();
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
_attributes = attributes is null
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
}
public NotifyDelivery Delivery { get; }
public string ChannelId { get; }
public NotifyChannelType ChannelType { get; }
public string Stream { get; }
public string? TraceId { get; }
public string TenantId => Delivery.TenantId;
public string IdempotencyKey => Delivery.DeliveryId;
public string PartitionKey => ChannelId;
public IReadOnlyDictionary<string, string> Attributes => _attributes;
}
public readonly record struct NotifyQueueEnqueueResult(string MessageId, bool Deduplicated);
public sealed class NotifyQueueLeaseRequest
{
public NotifyQueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
{
if (string.IsNullOrWhiteSpace(consumer))
{
throw new ArgumentException("Consumer must be provided.", nameof(consumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (leaseDuration <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive.");
}
Consumer = consumer;
BatchSize = batchSize;
LeaseDuration = leaseDuration;
}
public string Consumer { get; }
public int BatchSize { get; }
public TimeSpan LeaseDuration { get; }
}
public sealed class NotifyQueueClaimOptions
{
public NotifyQueueClaimOptions(string claimantConsumer, int batchSize, TimeSpan minIdleTime)
{
if (string.IsNullOrWhiteSpace(claimantConsumer))
{
throw new ArgumentException("Consumer must be provided.", nameof(claimantConsumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (minIdleTime < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(minIdleTime), minIdleTime, "Minimum idle time cannot be negative.");
}
ClaimantConsumer = claimantConsumer;
BatchSize = batchSize;
MinIdleTime = minIdleTime;
}
public string ClaimantConsumer { get; }
public int BatchSize { get; }
public TimeSpan MinIdleTime { get; }
}
public enum NotifyQueueReleaseDisposition
{
Retry,
Abandon
}
public interface INotifyQueue<TMessage>
{
ValueTask<NotifyQueueEnqueueResult> PublishAsync(TMessage message, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default);
}
public interface INotifyQueueLease<out TMessage>
{
string MessageId { get; }
int Attempt { get; }
DateTimeOffset EnqueuedAt { get; }
DateTimeOffset LeaseExpiresAt { get; }
string Consumer { get; }
string Stream { get; }
string TenantId { get; }
string? PartitionKey { get; }
string IdempotencyKey { get; }
string? TraceId { get; }
IReadOnlyDictionary<string, string> Attributes { get; }
TMessage Message { get; }
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
}
public interface INotifyEventQueue : INotifyQueue<NotifyQueueEventMessage>
{
}
public interface INotifyDeliveryQueue : INotifyQueue<NotifyDeliveryQueueMessage>
{
}
internal static class EmptyReadOnlyDictionary<TKey, TValue>
where TKey : notnull
{
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Notify.Queue;
internal static class NotifyQueueFields
{
public const string Payload = "payload";
public const string EventId = "eventId";
public const string DeliveryId = "deliveryId";
public const string Tenant = "tenant";
public const string Kind = "kind";
public const string Attempt = "attempt";
public const string EnqueuedAt = "enqueuedAt";
public const string TraceId = "traceId";
public const string PartitionKey = "partitionKey";
public const string ChannelId = "channelId";
public const string ChannelType = "channelType";
public const string IdempotencyKey = "idempotency";
public const string AttributePrefix = "attr:";
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public sealed class NotifyQueueHealthCheck : IHealthCheck
{
private readonly INotifyEventQueue _queue;
private readonly ILogger<NotifyQueueHealthCheck> _logger;
public NotifyQueueHealthCheck(
INotifyEventQueue queue,
ILogger<NotifyQueueHealthCheck> logger)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
switch (_queue)
{
case RedisNotifyEventQueue redisQueue:
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("Redis Notify queue reachable.");
case NatsNotifyEventQueue natsQueue:
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("NATS Notify queue reachable.");
default:
return HealthCheckResult.Healthy("Notify queue transport without dedicated ping returned healthy.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Notify queue health check failed.");
return new HealthCheckResult(
context.Registration.FailureStatus,
"Notify queue transport unreachable.",
ex);
}
}
}

View File

@@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.Diagnostics.Metrics;
namespace StellaOps.Notify.Queue;
internal static class NotifyQueueMetrics
{
private const string TransportTag = "transport";
private const string StreamTag = "stream";
private static readonly Meter Meter = new("StellaOps.Notify.Queue");
private static readonly Counter<long> EnqueuedCounter = Meter.CreateCounter<long>("notify_queue_enqueued_total");
private static readonly Counter<long> DeduplicatedCounter = Meter.CreateCounter<long>("notify_queue_deduplicated_total");
private static readonly Counter<long> AckCounter = Meter.CreateCounter<long>("notify_queue_ack_total");
private static readonly Counter<long> RetryCounter = Meter.CreateCounter<long>("notify_queue_retry_total");
private static readonly Counter<long> DeadLetterCounter = Meter.CreateCounter<long>("notify_queue_deadletter_total");
public static void RecordEnqueued(string transport, string stream)
=> EnqueuedCounter.Add(1, BuildTags(transport, stream));
public static void RecordDeduplicated(string transport, string stream)
=> DeduplicatedCounter.Add(1, BuildTags(transport, stream));
public static void RecordAck(string transport, string stream)
=> AckCounter.Add(1, BuildTags(transport, stream));
public static void RecordRetry(string transport, string stream)
=> RetryCounter.Add(1, BuildTags(transport, stream));
public static void RecordDeadLetter(string transport, string stream)
=> DeadLetterCounter.Add(1, BuildTags(transport, stream));
private static KeyValuePair<string, object?>[] BuildTags(string transport, string stream)
=> new[]
{
new KeyValuePair<string, object?>(TransportTag, transport),
new KeyValuePair<string, object?>(StreamTag, stream)
};
}

View File

@@ -0,0 +1,146 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public static class NotifyQueueServiceCollectionExtensions
{
public static IServiceCollection AddNotifyEventQueue(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "notify:queue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var eventOptions = new NotifyEventQueueOptions();
configuration.GetSection(sectionName).Bind(eventOptions);
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton(eventOptions);
services.AddSingleton<INotifyEventQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var opts = sp.GetRequiredService<NotifyEventQueueOptions>();
return opts.Transport switch
{
NotifyQueueTransportKind.Redis => new RedisNotifyEventQueue(
opts,
opts.Redis,
loggerFactory.CreateLogger<RedisNotifyEventQueue>(),
timeProvider),
NotifyQueueTransportKind.Nats => new NatsNotifyEventQueue(
opts,
opts.Nats,
loggerFactory.CreateLogger<NatsNotifyEventQueue>(),
timeProvider),
_ => throw new InvalidOperationException($"Unsupported Notify queue transport kind '{opts.Transport}'.")
};
});
services.AddSingleton<NotifyQueueHealthCheck>();
return services;
}
public static IServiceCollection AddNotifyDeliveryQueue(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "notify:deliveryQueue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var deliveryOptions = new NotifyDeliveryQueueOptions();
configuration.GetSection(sectionName).Bind(deliveryOptions);
services.AddSingleton(deliveryOptions);
services.AddSingleton<INotifyDeliveryQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var opts = sp.GetRequiredService<NotifyDeliveryQueueOptions>();
var eventOpts = sp.GetService<NotifyEventQueueOptions>();
ApplyDeliveryFallbacks(opts, eventOpts);
return opts.Transport switch
{
NotifyQueueTransportKind.Redis => new RedisNotifyDeliveryQueue(
opts,
opts.Redis,
loggerFactory.CreateLogger<RedisNotifyDeliveryQueue>(),
timeProvider),
NotifyQueueTransportKind.Nats => new NatsNotifyDeliveryQueue(
opts,
opts.Nats,
loggerFactory.CreateLogger<NatsNotifyDeliveryQueue>(),
timeProvider),
_ => throw new InvalidOperationException($"Unsupported Notify delivery queue transport kind '{opts.Transport}'.")
};
});
services.AddSingleton<NotifyDeliveryQueueHealthCheck>();
return services;
}
public static IHealthChecksBuilder AddNotifyQueueHealthCheck(
this IHealthChecksBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<NotifyQueueHealthCheck>();
builder.AddCheck<NotifyQueueHealthCheck>(
name: "notify-queue",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "notify", "queue" });
return builder;
}
public static IHealthChecksBuilder AddNotifyDeliveryQueueHealthCheck(
this IHealthChecksBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<NotifyDeliveryQueueHealthCheck>();
builder.AddCheck<NotifyDeliveryQueueHealthCheck>(
name: "notify-delivery-queue",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "notify", "queue", "delivery" });
return builder;
}
private static void ApplyDeliveryFallbacks(
NotifyDeliveryQueueOptions deliveryOptions,
NotifyEventQueueOptions? eventOptions)
{
if (eventOptions is null)
{
return;
}
if (string.IsNullOrWhiteSpace(deliveryOptions.Redis.ConnectionString))
{
deliveryOptions.Redis.ConnectionString = eventOptions.Redis.ConnectionString;
deliveryOptions.Redis.Database ??= eventOptions.Redis.Database;
}
if (string.IsNullOrWhiteSpace(deliveryOptions.Nats.Url))
{
deliveryOptions.Nats.Url = eventOptions.Nats.Url;
}
}
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Notify.Queue;
/// <summary>
/// Supported transports for the Notify event queue.
/// </summary>
public enum NotifyQueueTransportKind
{
Redis,
Nats
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Notify.Queue.Tests")]

View File

@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Queue.Redis;
internal sealed class RedisNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
{
private readonly RedisNotifyDeliveryQueue _queue;
private int _completed;
internal RedisNotifyDeliveryLease(
RedisNotifyDeliveryQueue queue,
string messageId,
NotifyDeliveryQueueMessage message,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer,
string? idempotencyKey,
string partitionKey)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = message ?? throw new ArgumentNullException(nameof(message));
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
IdempotencyKey = idempotencyKey ?? message.IdempotencyKey;
PartitionKey = partitionKey ?? message.ChannelId;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string PartitionKey { get; }
public string IdempotencyKey { get; }
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyDeliveryQueueMessage Message { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}

View File

@@ -0,0 +1,788 @@
using System;
using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Queue.Redis;
internal sealed class RedisNotifyDeliveryQueue : INotifyDeliveryQueue, IAsyncDisposable
{
private const string TransportName = "redis";
private readonly NotifyDeliveryQueueOptions _options;
private readonly NotifyRedisDeliveryQueueOptions _redisOptions;
private readonly ILogger<RedisNotifyDeliveryQueue> _logger;
private readonly TimeProvider _timeProvider;
private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly SemaphoreSlim _groupLock = new(1, 1);
private readonly ConcurrentDictionary<string, bool> _streamInitialized = new(StringComparer.Ordinal);
private IConnectionMultiplexer? _connection;
private bool _disposed;
public RedisNotifyDeliveryQueue(
NotifyDeliveryQueueOptions options,
NotifyRedisDeliveryQueueOptions redisOptions,
ILogger<RedisNotifyDeliveryQueue> logger,
TimeProvider timeProvider,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_redisOptions = redisOptions ?? throw new ArgumentNullException(nameof(redisOptions));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_connectionFactory = connectionFactory ?? (async config =>
{
var connection = await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false);
return (IConnectionMultiplexer)connection;
});
if (string.IsNullOrWhiteSpace(_redisOptions.ConnectionString))
{
throw new InvalidOperationException("Redis connection string must be configured for the Notify delivery queue.");
}
}
public async ValueTask<NotifyQueueEnqueueResult> PublishAsync(
NotifyDeliveryQueueMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
cancellationToken.ThrowIfCancellationRequested();
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var attempt = 1;
var entries = BuildEntries(message, now, attempt);
var messageId = await AddToStreamAsync(
db,
_redisOptions.StreamName,
entries)
.ConfigureAwait(false);
var idempotencyKey = BuildIdempotencyKey(message.IdempotencyKey);
var stored = await db.StringSetAsync(
idempotencyKey,
messageId,
when: When.NotExists,
expiry: _options.ClaimIdleThreshold)
.ConfigureAwait(false);
if (!stored)
{
await db.StreamDeleteAsync(
_redisOptions.StreamName,
new RedisValue[] { messageId })
.ConfigureAwait(false);
var existing = await db.StringGetAsync(idempotencyKey).ConfigureAwait(false);
var duplicateId = existing.IsNullOrEmpty ? messageId : existing;
NotifyQueueMetrics.RecordDeduplicated(TransportName, _redisOptions.StreamName);
_logger.LogDebug(
"Duplicate Notify delivery enqueue detected for delivery {DeliveryId}.",
message.Delivery.DeliveryId);
return new NotifyQueueEnqueueResult(duplicateId.ToString()!, true);
}
NotifyQueueMetrics.RecordEnqueued(TransportName, _redisOptions.StreamName);
_logger.LogDebug(
"Enqueued Notify delivery {DeliveryId} (channel {ChannelId}) into stream {Stream}.",
message.Delivery.DeliveryId,
message.ChannelId,
_redisOptions.StreamName);
return new NotifyQueueEnqueueResult(messageId.ToString()!, false);
}
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyDeliveryQueueMessage>>> LeaseAsync(
NotifyQueueLeaseRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
var entries = await db.StreamReadGroupAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
request.Consumer,
StreamPosition.NewMessages,
request.BatchSize)
.ConfigureAwait(false);
if (entries is null || entries.Length == 0)
{
return Array.Empty<INotifyQueueLease<NotifyDeliveryQueueMessage>>();
}
var now = _timeProvider.GetUtcNow();
var leases = new List<INotifyQueueLease<NotifyDeliveryQueueMessage>>(entries.Length);
foreach (var entry in entries)
{
var lease = TryMapLease(entry, request.Consumer, now, request.LeaseDuration, attemptOverride: null);
if (lease is null)
{
await AckPoisonAsync(db, entry.Id).ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
return leases;
}
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyDeliveryQueueMessage>>> ClaimExpiredAsync(
NotifyQueueClaimOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
var pending = await db.StreamPendingMessagesAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
options.BatchSize,
RedisValue.Null,
(long)options.MinIdleTime.TotalMilliseconds)
.ConfigureAwait(false);
if (pending is null || pending.Length == 0)
{
return Array.Empty<INotifyQueueLease<NotifyDeliveryQueueMessage>>();
}
var eligible = pending
.Where(p => p.IdleTimeInMilliseconds >= options.MinIdleTime.TotalMilliseconds)
.ToArray();
if (eligible.Length == 0)
{
return Array.Empty<INotifyQueueLease<NotifyDeliveryQueueMessage>>();
}
var messageIds = eligible
.Select(static p => (RedisValue)p.MessageId)
.ToArray();
var entries = await db.StreamClaimAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
options.ClaimantConsumer,
0,
messageIds)
.ConfigureAwait(false);
if (entries is null || entries.Length == 0)
{
return Array.Empty<INotifyQueueLease<NotifyDeliveryQueueMessage>>();
}
var now = _timeProvider.GetUtcNow();
var attemptLookup = eligible
.Where(static info => !info.MessageId.IsNullOrEmpty)
.ToDictionary(
info => info.MessageId!.ToString(),
info => (int)Math.Max(1, info.DeliveryCount),
StringComparer.Ordinal);
var leases = new List<INotifyQueueLease<NotifyDeliveryQueueMessage>>(entries.Length);
foreach (var entry in entries)
{
attemptLookup.TryGetValue(entry.Id.ToString(), out var attempt);
var lease = TryMapLease(entry, options.ClaimantConsumer, now, _options.DefaultLeaseDuration, attempt == 0 ? null : attempt);
if (lease is null)
{
await AckPoisonAsync(db, entry.Id).ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
return leases;
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_connection is not null)
{
await _connection.CloseAsync().ConfigureAwait(false);
_connection.Dispose();
}
_connectionLock.Dispose();
_groupLock.Dispose();
GC.SuppressFinalize(this);
}
internal async Task AcknowledgeAsync(
RedisNotifyDeliveryLease lease,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamAcknowledgeAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await db.StreamDeleteAsync(
_redisOptions.StreamName,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
NotifyQueueMetrics.RecordAck(TransportName, _redisOptions.StreamName);
_logger.LogDebug(
"Acknowledged Notify delivery {DeliveryId} (message {MessageId}).",
lease.Message.Delivery.DeliveryId,
lease.MessageId);
}
internal async Task RenewLeaseAsync(
RedisNotifyDeliveryLease lease,
TimeSpan leaseDuration,
CancellationToken cancellationToken)
{
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamClaimAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
lease.Consumer,
0,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
lease.RefreshLease(expires);
_logger.LogDebug(
"Renewed Notify delivery lease {DeliveryId} until {Expires:u}.",
lease.Message.Delivery.DeliveryId,
expires);
}
internal async Task ReleaseAsync(
RedisNotifyDeliveryLease lease,
NotifyQueueReleaseDisposition disposition,
CancellationToken cancellationToken)
{
if (disposition == NotifyQueueReleaseDisposition.Retry
&& lease.Attempt >= _options.MaxDeliveryAttempts)
{
_logger.LogWarning(
"Notify delivery {DeliveryId} reached max delivery attempts ({Attempts}); moving to dead-letter stream.",
lease.Message.Delivery.DeliveryId,
lease.Attempt);
await DeadLetterAsync(
lease,
$"max-delivery-attempts:{lease.Attempt}",
cancellationToken).ConfigureAwait(false);
return;
}
if (!lease.TryBeginCompletion())
{
return;
}
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamAcknowledgeAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await db.StreamDeleteAsync(
_redisOptions.StreamName,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
if (disposition == NotifyQueueReleaseDisposition.Retry)
{
NotifyQueueMetrics.RecordRetry(TransportName, _redisOptions.StreamName);
var delay = CalculateBackoff(lease.Attempt);
if (delay > TimeSpan.Zero)
{
try
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
return;
}
}
var now = _timeProvider.GetUtcNow();
var entries = BuildEntries(lease.Message, now, lease.Attempt + 1);
await AddToStreamAsync(
db,
_redisOptions.StreamName,
entries)
.ConfigureAwait(false);
NotifyQueueMetrics.RecordEnqueued(TransportName, _redisOptions.StreamName);
_logger.LogInformation(
"Retrying Notify delivery {DeliveryId} (attempt {Attempt}).",
lease.Message.Delivery.DeliveryId,
lease.Attempt + 1);
}
else
{
NotifyQueueMetrics.RecordAck(TransportName, _redisOptions.StreamName);
_logger.LogInformation(
"Abandoned Notify delivery {DeliveryId} after {Attempt} attempt(s).",
lease.Message.Delivery.DeliveryId,
lease.Attempt);
}
}
internal async Task DeadLetterAsync(
RedisNotifyDeliveryLease lease,
string reason,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamAcknowledgeAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await db.StreamDeleteAsync(
_redisOptions.StreamName,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await EnsureDeadLetterStreamAsync(db, cancellationToken).ConfigureAwait(false);
var entries = BuildDeadLetterEntries(lease, reason);
await AddToStreamAsync(
db,
_redisOptions.DeadLetterStreamName,
entries)
.ConfigureAwait(false);
NotifyQueueMetrics.RecordDeadLetter(TransportName, _redisOptions.DeadLetterStreamName);
_logger.LogError(
"Dead-lettered Notify delivery {DeliveryId} (attempt {Attempt}): {Reason}",
lease.Message.Delivery.DeliveryId,
lease.Attempt,
reason);
}
internal async ValueTask PingAsync(CancellationToken cancellationToken)
{
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
_ = await db.PingAsync().ConfigureAwait(false);
}
private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
{
if (_connection is { IsConnected: true })
{
return _connection.GetDatabase(_redisOptions.Database ?? -1);
}
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_connection is { IsConnected: true })
{
return _connection.GetDatabase(_redisOptions.Database ?? -1);
}
var configuration = ConfigurationOptions.Parse(_redisOptions.ConnectionString!);
configuration.AbortOnConnectFail = false;
if (_redisOptions.Database.HasValue)
{
configuration.DefaultDatabase = _redisOptions.Database.Value;
}
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(_redisOptions.InitializationTimeout);
_connection = await _connectionFactory(configuration).WaitAsync(timeoutCts.Token).ConfigureAwait(false);
return _connection.GetDatabase(_redisOptions.Database ?? -1);
}
finally
{
_connectionLock.Release();
}
}
private async Task EnsureConsumerGroupAsync(
IDatabase database,
CancellationToken cancellationToken)
{
if (_streamInitialized.ContainsKey(_redisOptions.StreamName))
{
return;
}
await _groupLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_streamInitialized.ContainsKey(_redisOptions.StreamName))
{
return;
}
try
{
await database.StreamCreateConsumerGroupAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
StreamPosition.Beginning,
createStream: true)
.ConfigureAwait(false);
}
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
{
// group already exists
}
_streamInitialized[_redisOptions.StreamName] = true;
}
finally
{
_groupLock.Release();
}
}
private async Task EnsureDeadLetterStreamAsync(
IDatabase database,
CancellationToken cancellationToken)
{
if (_streamInitialized.ContainsKey(_redisOptions.DeadLetterStreamName))
{
return;
}
await _groupLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_streamInitialized.ContainsKey(_redisOptions.DeadLetterStreamName))
{
return;
}
try
{
await database.StreamCreateConsumerGroupAsync(
_redisOptions.DeadLetterStreamName,
_redisOptions.ConsumerGroup,
StreamPosition.Beginning,
createStream: true)
.ConfigureAwait(false);
}
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
{
// ignore
}
_streamInitialized[_redisOptions.DeadLetterStreamName] = true;
}
finally
{
_groupLock.Release();
}
}
private NameValueEntry[] BuildEntries(
NotifyDeliveryQueueMessage message,
DateTimeOffset enqueuedAt,
int attempt)
{
var json = NotifyCanonicalJsonSerializer.Serialize(message.Delivery);
var attributeCount = message.Attributes.Count;
var entries = ArrayPool<NameValueEntry>.Shared.Rent(8 + attributeCount);
var index = 0;
entries[index++] = new NameValueEntry(NotifyQueueFields.Payload, json);
entries[index++] = new NameValueEntry(NotifyQueueFields.DeliveryId, message.Delivery.DeliveryId);
entries[index++] = new NameValueEntry(NotifyQueueFields.ChannelId, message.ChannelId);
entries[index++] = new NameValueEntry(NotifyQueueFields.ChannelType, message.ChannelType.ToString());
entries[index++] = new NameValueEntry(NotifyQueueFields.Tenant, message.Delivery.TenantId);
entries[index++] = new NameValueEntry(NotifyQueueFields.Attempt, attempt);
entries[index++] = new NameValueEntry(NotifyQueueFields.EnqueuedAt, enqueuedAt.ToUnixTimeMilliseconds());
entries[index++] = new NameValueEntry(NotifyQueueFields.IdempotencyKey, message.IdempotencyKey);
entries[index++] = new NameValueEntry(NotifyQueueFields.TraceId, message.TraceId ?? string.Empty);
entries[index++] = new NameValueEntry(NotifyQueueFields.PartitionKey, message.PartitionKey);
if (attributeCount > 0)
{
foreach (var kvp in message.Attributes)
{
entries[index++] = new NameValueEntry(
NotifyQueueFields.AttributePrefix + kvp.Key,
kvp.Value);
}
}
return entries.AsSpan(0, index).ToArray();
}
private NameValueEntry[] BuildDeadLetterEntries(RedisNotifyDeliveryLease lease, string reason)
{
var json = NotifyCanonicalJsonSerializer.Serialize(lease.Message.Delivery);
var attributes = lease.Message.Attributes;
var attributeCount = attributes.Count;
var entries = ArrayPool<NameValueEntry>.Shared.Rent(9 + attributeCount);
var index = 0;
entries[index++] = new NameValueEntry(NotifyQueueFields.Payload, json);
entries[index++] = new NameValueEntry(NotifyQueueFields.DeliveryId, lease.Message.Delivery.DeliveryId);
entries[index++] = new NameValueEntry(NotifyQueueFields.ChannelId, lease.Message.ChannelId);
entries[index++] = new NameValueEntry(NotifyQueueFields.ChannelType, lease.Message.ChannelType.ToString());
entries[index++] = new NameValueEntry(NotifyQueueFields.Tenant, lease.Message.Delivery.TenantId);
entries[index++] = new NameValueEntry(NotifyQueueFields.Attempt, lease.Attempt);
entries[index++] = new NameValueEntry(NotifyQueueFields.IdempotencyKey, lease.Message.IdempotencyKey);
entries[index++] = new NameValueEntry("deadletter-reason", reason);
entries[index++] = new NameValueEntry(NotifyQueueFields.TraceId, lease.Message.TraceId ?? string.Empty);
foreach (var kvp in attributes)
{
entries[index++] = new NameValueEntry(
NotifyQueueFields.AttributePrefix + kvp.Key,
kvp.Value);
}
return entries.AsSpan(0, index).ToArray();
}
private RedisNotifyDeliveryLease? TryMapLease(
StreamEntry entry,
string consumer,
DateTimeOffset now,
TimeSpan leaseDuration,
int? attemptOverride)
{
if (entry.Values is null || entry.Values.Length == 0)
{
return null;
}
string? payload = null;
string? deliveryId = null;
string? channelId = null;
string? channelTypeRaw = null;
string? traceId = null;
string? idempotency = null;
string? partitionKey = null;
long? enqueuedAtUnix = null;
var attempt = attemptOverride ?? 1;
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var value in entry.Values)
{
var name = value.Name.ToString();
var data = value.Value;
if (name.Equals(NotifyQueueFields.Payload, StringComparison.Ordinal))
{
payload = data.ToString();
}
else if (name.Equals(NotifyQueueFields.DeliveryId, StringComparison.Ordinal))
{
deliveryId = data.ToString();
}
else if (name.Equals(NotifyQueueFields.ChannelId, StringComparison.Ordinal))
{
channelId = data.ToString();
}
else if (name.Equals(NotifyQueueFields.ChannelType, StringComparison.Ordinal))
{
channelTypeRaw = data.ToString();
}
else if (name.Equals(NotifyQueueFields.Attempt, StringComparison.Ordinal))
{
if (int.TryParse(data.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
attempt = Math.Max(parsed, attempt);
}
}
else if (name.Equals(NotifyQueueFields.EnqueuedAt, StringComparison.Ordinal))
{
if (long.TryParse(data.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var unix))
{
enqueuedAtUnix = unix;
}
}
else if (name.Equals(NotifyQueueFields.IdempotencyKey, StringComparison.Ordinal))
{
idempotency = data.ToString();
}
else if (name.Equals(NotifyQueueFields.TraceId, StringComparison.Ordinal))
{
var text = data.ToString();
traceId = string.IsNullOrWhiteSpace(text) ? null : text;
}
else if (name.Equals(NotifyQueueFields.PartitionKey, StringComparison.Ordinal))
{
partitionKey = data.ToString();
}
else if (name.StartsWith(NotifyQueueFields.AttributePrefix, StringComparison.Ordinal))
{
attributes[name[NotifyQueueFields.AttributePrefix.Length..]] = data.ToString();
}
}
if (payload is null || deliveryId is null || channelId is null || channelTypeRaw is null)
{
return null;
}
NotifyDelivery delivery;
try
{
delivery = NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(payload);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to deserialize Notify delivery payload for entry {EntryId}.",
entry.Id.ToString());
return null;
}
if (!Enum.TryParse<NotifyChannelType>(channelTypeRaw, ignoreCase: true, out var channelType))
{
_logger.LogWarning(
"Unknown channel type '{ChannelType}' for delivery {DeliveryId}; acknowledging as poison.",
channelTypeRaw,
deliveryId);
return null;
}
var attributeView = attributes.Count == 0
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(attributes);
var enqueuedAt = enqueuedAtUnix is null
? now
: DateTimeOffset.FromUnixTimeMilliseconds(enqueuedAtUnix.Value);
var message = new NotifyDeliveryQueueMessage(
delivery,
channelId,
channelType,
_redisOptions.StreamName,
traceId,
attributeView);
var leaseExpires = now.Add(leaseDuration);
return new RedisNotifyDeliveryLease(
this,
entry.Id.ToString(),
message,
attempt,
enqueuedAt,
leaseExpires,
consumer,
idempotency,
partitionKey ?? channelId);
}
private async Task AckPoisonAsync(IDatabase database, RedisValue messageId)
{
await database.StreamAcknowledgeAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
new RedisValue[] { messageId })
.ConfigureAwait(false);
await database.StreamDeleteAsync(
_redisOptions.StreamName,
new RedisValue[] { messageId })
.ConfigureAwait(false);
}
private static async Task<RedisValue> AddToStreamAsync(
IDatabase database,
string stream,
IReadOnlyList<NameValueEntry> entries)
{
return await database.StreamAddAsync(
stream,
entries.ToArray())
.ConfigureAwait(false);
}
private string BuildIdempotencyKey(string token)
=> string.Concat(_redisOptions.IdempotencyKeyPrefix, token);
private TimeSpan CalculateBackoff(int attempt)
{
var initial = _options.RetryInitialBackoff > TimeSpan.Zero
? _options.RetryInitialBackoff
: TimeSpan.FromSeconds(1);
if (initial <= TimeSpan.Zero)
{
return TimeSpan.Zero;
}
if (attempt <= 1)
{
return initial;
}
var max = _options.RetryMaxBackoff > TimeSpan.Zero
? _options.RetryMaxBackoff
: initial;
var exponent = attempt - 1;
var scaledTicks = initial.Ticks * Math.Pow(2, exponent - 1);
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
var resultTicks = Math.Max(initial.Ticks, (long)cappedTicks);
return TimeSpan.FromTicks(resultTicks);
}
}

View File

@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Queue.Redis;
internal sealed class RedisNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
{
private readonly RedisNotifyEventQueue _queue;
private int _completed;
internal RedisNotifyEventLease(
RedisNotifyEventQueue queue,
NotifyRedisEventStreamOptions streamOptions,
string messageId,
NotifyQueueEventMessage message,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
StreamOptions = streamOptions ?? throw new ArgumentNullException(nameof(streamOptions));
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = message ?? throw new ArgumentNullException(nameof(message));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
}
internal NotifyRedisEventStreamOptions StreamOptions { get; }
public string MessageId { get; }
public int Attempt { get; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => StreamOptions.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey => Message.IdempotencyKey;
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyQueueEventMessage Message { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}

View File

@@ -0,0 +1,655 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Queue.Redis;
internal sealed class RedisNotifyEventQueue : INotifyEventQueue, IAsyncDisposable
{
private const string TransportName = "redis";
private readonly NotifyEventQueueOptions _options;
private readonly NotifyRedisEventQueueOptions _redisOptions;
private readonly ILogger<RedisNotifyEventQueue> _logger;
private readonly TimeProvider _timeProvider;
private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly SemaphoreSlim _groupInitLock = new(1, 1);
private readonly IReadOnlyDictionary<string, NotifyRedisEventStreamOptions> _streamsByName;
private readonly ConcurrentDictionary<string, bool> _initializedStreams = new(StringComparer.Ordinal);
private IConnectionMultiplexer? _connection;
private bool _disposed;
public RedisNotifyEventQueue(
NotifyEventQueueOptions options,
NotifyRedisEventQueueOptions redisOptions,
ILogger<RedisNotifyEventQueue> logger,
TimeProvider timeProvider,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_redisOptions = redisOptions ?? throw new ArgumentNullException(nameof(redisOptions));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_connectionFactory = connectionFactory ?? (async config =>
{
var connection = await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false);
return (IConnectionMultiplexer)connection;
});
if (string.IsNullOrWhiteSpace(_redisOptions.ConnectionString))
{
throw new InvalidOperationException("Redis connection string must be configured for Notify event queue.");
}
_streamsByName = _redisOptions.Streams.ToDictionary(
stream => stream.Stream,
stream => stream,
StringComparer.Ordinal);
}
public async ValueTask<NotifyQueueEnqueueResult> PublishAsync(
NotifyQueueEventMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
cancellationToken.ThrowIfCancellationRequested();
var streamOptions = GetStreamOptions(message.Stream);
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureStreamInitializedAsync(db, streamOptions, cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var entries = BuildEntries(message, now, attempt: 1);
var messageId = await AddToStreamAsync(
db,
streamOptions,
entries)
.ConfigureAwait(false);
var idempotencyToken = string.IsNullOrWhiteSpace(message.IdempotencyKey)
? message.Event.EventId.ToString("N")
: message.IdempotencyKey;
var idempotencyKey = streamOptions.IdempotencyKeyPrefix + idempotencyToken;
var stored = await db.StringSetAsync(
idempotencyKey,
messageId,
when: When.NotExists,
expiry: _redisOptions.IdempotencyWindow)
.ConfigureAwait(false);
if (!stored)
{
await db.StreamDeleteAsync(
streamOptions.Stream,
new RedisValue[] { messageId })
.ConfigureAwait(false);
var existing = await db.StringGetAsync(idempotencyKey).ConfigureAwait(false);
var duplicateId = existing.IsNullOrEmpty ? messageId : existing;
_logger.LogDebug(
"Duplicate Notify event enqueue detected for idempotency token {Token}; returning existing stream id {StreamId}.",
idempotencyToken,
duplicateId.ToString());
NotifyQueueMetrics.RecordDeduplicated(TransportName, streamOptions.Stream);
return new NotifyQueueEnqueueResult(duplicateId.ToString()!, true);
}
NotifyQueueMetrics.RecordEnqueued(TransportName, streamOptions.Stream);
_logger.LogDebug(
"Enqueued Notify event {EventId} for tenant {Tenant} on stream {Stream} (id {StreamId}).",
message.Event.EventId,
message.TenantId,
streamOptions.Stream,
messageId.ToString());
return new NotifyQueueEnqueueResult(messageId.ToString()!, false);
}
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(
NotifyQueueLeaseRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var leases = new List<INotifyQueueLease<NotifyQueueEventMessage>>(request.BatchSize);
foreach (var streamOptions in _streamsByName.Values)
{
await EnsureStreamInitializedAsync(db, streamOptions, cancellationToken).ConfigureAwait(false);
var remaining = request.BatchSize - leases.Count;
if (remaining <= 0)
{
break;
}
var entries = await db.StreamReadGroupAsync(
streamOptions.Stream,
streamOptions.ConsumerGroup,
request.Consumer,
StreamPosition.NewMessages,
remaining)
.ConfigureAwait(false);
if (entries is null || entries.Length == 0)
{
continue;
}
foreach (var entry in entries)
{
var lease = TryMapLease(
streamOptions,
entry,
request.Consumer,
now,
request.LeaseDuration,
attemptOverride: null);
if (lease is null)
{
await AckPoisonAsync(db, streamOptions, entry.Id).ConfigureAwait(false);
continue;
}
leases.Add(lease);
if (leases.Count >= request.BatchSize)
{
break;
}
}
}
return leases;
}
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(
NotifyQueueClaimOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var leases = new List<INotifyQueueLease<NotifyQueueEventMessage>>(options.BatchSize);
foreach (var streamOptions in _streamsByName.Values)
{
await EnsureStreamInitializedAsync(db, streamOptions, cancellationToken).ConfigureAwait(false);
var pending = await db.StreamPendingMessagesAsync(
streamOptions.Stream,
streamOptions.ConsumerGroup,
options.BatchSize,
RedisValue.Null,
(long)options.MinIdleTime.TotalMilliseconds)
.ConfigureAwait(false);
if (pending is null || pending.Length == 0)
{
continue;
}
var eligible = pending
.Where(p => p.IdleTimeInMilliseconds >= options.MinIdleTime.TotalMilliseconds)
.ToArray();
if (eligible.Length == 0)
{
continue;
}
var messageIds = eligible
.Select(static p => (RedisValue)p.MessageId)
.ToArray();
var entries = await db.StreamClaimAsync(
streamOptions.Stream,
streamOptions.ConsumerGroup,
options.ClaimantConsumer,
0,
messageIds)
.ConfigureAwait(false);
if (entries is null || entries.Length == 0)
{
continue;
}
var attemptById = eligible
.Where(static info => !info.MessageId.IsNullOrEmpty)
.ToDictionary(
info => info.MessageId!.ToString(),
info => (int)Math.Max(1, info.DeliveryCount),
StringComparer.Ordinal);
foreach (var entry in entries)
{
var entryId = entry.Id.ToString();
attemptById.TryGetValue(entryId, out var attempt);
var lease = TryMapLease(
streamOptions,
entry,
options.ClaimantConsumer,
now,
_options.DefaultLeaseDuration,
attempt == 0 ? null : attempt);
if (lease is null)
{
await AckPoisonAsync(db, streamOptions, entry.Id).ConfigureAwait(false);
continue;
}
leases.Add(lease);
if (leases.Count >= options.BatchSize)
{
return leases;
}
}
}
return leases;
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_connection is not null)
{
await _connection.CloseAsync();
_connection.Dispose();
}
_connectionLock.Dispose();
_groupInitLock.Dispose();
GC.SuppressFinalize(this);
}
internal async Task AcknowledgeAsync(
RedisNotifyEventLease lease,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
var streamOptions = lease.StreamOptions;
await db.StreamAcknowledgeAsync(
streamOptions.Stream,
streamOptions.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await db.StreamDeleteAsync(
streamOptions.Stream,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
NotifyQueueMetrics.RecordAck(TransportName, streamOptions.Stream);
_logger.LogDebug(
"Acknowledged Notify event {EventId} on consumer {Consumer} (stream {Stream}, id {MessageId}).",
lease.Message.Event.EventId,
lease.Consumer,
streamOptions.Stream,
lease.MessageId);
}
internal async Task RenewLeaseAsync(
RedisNotifyEventLease lease,
TimeSpan leaseDuration,
CancellationToken cancellationToken)
{
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
var streamOptions = lease.StreamOptions;
await db.StreamClaimAsync(
streamOptions.Stream,
streamOptions.ConsumerGroup,
lease.Consumer,
0,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
lease.RefreshLease(expires);
_logger.LogDebug(
"Renewed Notify event lease for {EventId} until {Expires:u}.",
lease.Message.Event.EventId,
expires);
}
internal Task ReleaseAsync(
RedisNotifyEventLease lease,
NotifyQueueReleaseDisposition disposition,
CancellationToken cancellationToken)
=> Task.FromException(new NotSupportedException("Retry/abandon is not supported for Notify event streams."));
internal async Task DeadLetterAsync(
RedisNotifyEventLease lease,
string reason,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
var streamOptions = lease.StreamOptions;
await db.StreamAcknowledgeAsync(
streamOptions.Stream,
streamOptions.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await db.StreamDeleteAsync(
streamOptions.Stream,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
_logger.LogWarning(
"Dead-lettered Notify event {EventId} on stream {Stream} with reason '{Reason}'.",
lease.Message.Event.EventId,
streamOptions.Stream,
reason);
}
internal async ValueTask PingAsync(CancellationToken cancellationToken)
{
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
_ = await db.PingAsync().ConfigureAwait(false);
}
private NotifyRedisEventStreamOptions GetStreamOptions(string stream)
{
if (!_streamsByName.TryGetValue(stream, out var options))
{
throw new InvalidOperationException($"Stream '{stream}' is not configured for the Notify event queue.");
}
return options;
}
private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
{
if (_connection is { IsConnected: true })
{
return _connection.GetDatabase(_redisOptions.Database ?? -1);
}
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_connection is { IsConnected: true })
{
return _connection.GetDatabase(_redisOptions.Database ?? -1);
}
var configuration = ConfigurationOptions.Parse(_redisOptions.ConnectionString!);
configuration.AbortOnConnectFail = false;
if (_redisOptions.Database.HasValue)
{
configuration.DefaultDatabase = _redisOptions.Database;
}
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(_redisOptions.InitializationTimeout);
_connection = await _connectionFactory(configuration).WaitAsync(timeoutCts.Token).ConfigureAwait(false);
return _connection.GetDatabase(_redisOptions.Database ?? -1);
}
finally
{
_connectionLock.Release();
}
}
private async Task EnsureStreamInitializedAsync(
IDatabase database,
NotifyRedisEventStreamOptions streamOptions,
CancellationToken cancellationToken)
{
if (_initializedStreams.ContainsKey(streamOptions.Stream))
{
return;
}
await _groupInitLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_initializedStreams.ContainsKey(streamOptions.Stream))
{
return;
}
try
{
await database.StreamCreateConsumerGroupAsync(
streamOptions.Stream,
streamOptions.ConsumerGroup,
StreamPosition.Beginning,
createStream: true)
.ConfigureAwait(false);
}
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
{
// Consumer group already exists — nothing to do.
}
_initializedStreams[streamOptions.Stream] = true;
}
finally
{
_groupInitLock.Release();
}
}
private static async Task<RedisValue> AddToStreamAsync(
IDatabase database,
NotifyRedisEventStreamOptions streamOptions,
IReadOnlyList<NameValueEntry> entries)
{
return await database.StreamAddAsync(
streamOptions.Stream,
entries.ToArray(),
maxLength: streamOptions.ApproximateMaxLength,
useApproximateMaxLength: streamOptions.ApproximateMaxLength is not null)
.ConfigureAwait(false);
}
private IReadOnlyList<NameValueEntry> BuildEntries(
NotifyQueueEventMessage message,
DateTimeOffset enqueuedAt,
int attempt)
{
var payload = NotifyCanonicalJsonSerializer.Serialize(message.Event);
var entries = new List<NameValueEntry>(8 + message.Attributes.Count)
{
new(NotifyQueueFields.Payload, payload),
new(NotifyQueueFields.EventId, message.Event.EventId.ToString("D")),
new(NotifyQueueFields.Tenant, message.TenantId),
new(NotifyQueueFields.Kind, message.Event.Kind),
new(NotifyQueueFields.Attempt, attempt),
new(NotifyQueueFields.EnqueuedAt, enqueuedAt.ToUnixTimeMilliseconds()),
new(NotifyQueueFields.IdempotencyKey, message.IdempotencyKey),
new(NotifyQueueFields.PartitionKey, message.PartitionKey ?? string.Empty),
new(NotifyQueueFields.TraceId, message.TraceId ?? string.Empty)
};
foreach (var kvp in message.Attributes)
{
entries.Add(new NameValueEntry(
NotifyQueueFields.AttributePrefix + kvp.Key,
kvp.Value));
}
return entries;
}
private RedisNotifyEventLease? TryMapLease(
NotifyRedisEventStreamOptions streamOptions,
StreamEntry entry,
string consumer,
DateTimeOffset now,
TimeSpan leaseDuration,
int? attemptOverride)
{
if (entry.Values is null || entry.Values.Length == 0)
{
return null;
}
string? payloadJson = null;
string? eventIdRaw = null;
long? enqueuedAtUnix = null;
string? idempotency = null;
string? partitionKey = null;
string? traceId = null;
var attempt = attemptOverride ?? 1;
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var field in entry.Values)
{
var name = field.Name.ToString();
var value = field.Value;
if (name.Equals(NotifyQueueFields.Payload, StringComparison.Ordinal))
{
payloadJson = value.ToString();
}
else if (name.Equals(NotifyQueueFields.EventId, StringComparison.Ordinal))
{
eventIdRaw = value.ToString();
}
else if (name.Equals(NotifyQueueFields.Attempt, StringComparison.Ordinal))
{
if (int.TryParse(value.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
attempt = Math.Max(parsed, attempt);
}
}
else if (name.Equals(NotifyQueueFields.EnqueuedAt, StringComparison.Ordinal))
{
if (long.TryParse(value.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var unix))
{
enqueuedAtUnix = unix;
}
}
else if (name.Equals(NotifyQueueFields.IdempotencyKey, StringComparison.Ordinal))
{
var text = value.ToString();
idempotency = string.IsNullOrWhiteSpace(text) ? null : text;
}
else if (name.Equals(NotifyQueueFields.PartitionKey, StringComparison.Ordinal))
{
var text = value.ToString();
partitionKey = string.IsNullOrWhiteSpace(text) ? null : text;
}
else if (name.Equals(NotifyQueueFields.TraceId, StringComparison.Ordinal))
{
var text = value.ToString();
traceId = string.IsNullOrWhiteSpace(text) ? null : text;
}
else if (name.StartsWith(NotifyQueueFields.AttributePrefix, StringComparison.Ordinal))
{
var key = name[NotifyQueueFields.AttributePrefix.Length..];
attributes[key] = value.ToString();
}
}
if (payloadJson is null || enqueuedAtUnix is null)
{
return null;
}
NotifyEvent notifyEvent;
try
{
notifyEvent = NotifyCanonicalJsonSerializer.Deserialize<NotifyEvent>(payloadJson);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to deserialize Notify event payload for stream {Stream} entry {EntryId}.",
streamOptions.Stream,
entry.Id.ToString());
return null;
}
var attributeView = attributes.Count == 0
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(attributes);
var message = new NotifyQueueEventMessage(
notifyEvent,
streamOptions.Stream,
idempotencyKey: idempotency ?? notifyEvent.EventId.ToString("N"),
partitionKey: partitionKey,
traceId: traceId,
attributes: attributeView);
var enqueuedAt = DateTimeOffset.FromUnixTimeMilliseconds(enqueuedAtUnix.Value);
var leaseExpiresAt = now.Add(leaseDuration);
return new RedisNotifyEventLease(
this,
streamOptions,
entry.Id.ToString(),
message,
attempt,
consumer,
enqueuedAt,
leaseExpiresAt);
}
private async Task AckPoisonAsync(
IDatabase database,
NotifyRedisEventStreamOptions streamOptions,
RedisValue messageId)
{
await database.StreamAcknowledgeAsync(
streamOptions.Stream,
streamOptions.ConsumerGroup,
new RedisValue[] { messageId })
.ConfigureAwait(false);
await database.StreamDeleteAsync(
streamOptions.Stream,
new RedisValue[] { messageId })
.ConfigureAwait(false);
}
}

View File

@@ -4,4 +4,20 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.1" />
<PackageReference Include="NATS.Client.Core" Version="2.0.0" />
<PackageReference Include="NATS.Client.JetStream" Version="2.0.0" />
<PackageReference Include="StackExchange.Redis" Version="2.7.33" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
</ItemGroup>
</Project>

View File

@@ -2,6 +2,6 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| NOTIFY-QUEUE-15-401 | TODO | Notify Queue Guild | NOTIFY-MODELS-15-101 | Build queue abstraction + Redis Streams adapter with ack/claim APIs, idempotency tokens, serialization contracts. | Adapter integration tests cover enqueue/dequeue/ack; ordering preserved; idempotency tokens supported. |
| NOTIFY-QUEUE-15-402 | TODO | Notify Queue Guild | NOTIFY-QUEUE-15-401 | Add NATS JetStream adapter with configuration binding, health probes, failover. | Health endpoints verified; failover documented; integration tests exercise both adapters. |
| NOTIFY-QUEUE-15-403 | TODO | Notify Queue Guild | NOTIFY-QUEUE-15-401 | Delivery queue for channel actions with retry schedules, poison queues, and metrics instrumentation. | Delivery queue integration tests cover retries/dead-letter; metrics/logging emitted per spec. |
| NOTIFY-QUEUE-15-401 | DONE (2025-10-23) | Notify Queue Guild | NOTIFY-MODELS-15-101 | Build queue abstraction + Redis Streams adapter with ack/claim APIs, idempotency tokens, serialization contracts. | Adapter integration tests cover enqueue/dequeue/ack; ordering preserved; idempotency tokens supported. |
| NOTIFY-QUEUE-15-402 | DONE (2025-10-23) | Notify Queue Guild | NOTIFY-QUEUE-15-401 | Add NATS JetStream adapter with configuration binding, health probes, failover. | Health endpoints verified; failover documented; integration tests exercise both adapters. |
| NOTIFY-QUEUE-15-403 | DONE (2025-10-23) | Notify Queue Guild | NOTIFY-QUEUE-15-401 | Delivery queue for channel actions with retry schedules, poison queues, and metrics instrumentation. | Delivery queue integration tests cover retries/dead-letter; metrics/logging emitted per spec. |

View File

@@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Worker;
using StellaOps.Notify.Worker.Handlers;
using StellaOps.Notify.Worker.Processing;
using Xunit;
namespace StellaOps.Notify.Worker.Tests;
public sealed class NotifyEventLeaseProcessorTests
{
[Fact]
public async Task ProcessOnce_ShouldAcknowledgeSuccessfulLease()
{
var lease = new FakeLease();
var queue = new FakeEventQueue(lease);
var handler = new TestHandler();
var options = Options.Create(new NotifyWorkerOptions { LeaseBatchSize = 1, LeaseDuration = TimeSpan.FromSeconds(5) });
var processor = new NotifyEventLeaseProcessor(queue, handler, options, NullLogger<NotifyEventLeaseProcessor>.Instance, TimeProvider.System);
var processed = await processor.ProcessOnceAsync(CancellationToken.None);
processed.Should().Be(1);
lease.AcknowledgeCount.Should().Be(1);
lease.ReleaseCount.Should().Be(0);
}
[Fact]
public async Task ProcessOnce_ShouldRetryOnHandlerFailure()
{
var lease = new FakeLease();
var queue = new FakeEventQueue(lease);
var handler = new TestHandler(shouldThrow: true);
var options = Options.Create(new NotifyWorkerOptions { LeaseBatchSize = 1, LeaseDuration = TimeSpan.FromSeconds(5) });
var processor = new NotifyEventLeaseProcessor(queue, handler, options, NullLogger<NotifyEventLeaseProcessor>.Instance, TimeProvider.System);
var processed = await processor.ProcessOnceAsync(CancellationToken.None);
processed.Should().Be(1);
lease.AcknowledgeCount.Should().Be(0);
lease.ReleaseCount.Should().Be(1);
lease.LastDisposition.Should().Be(NotifyQueueReleaseDisposition.Retry);
}
private sealed class FakeEventQueue : INotifyEventQueue
{
private readonly Queue<INotifyQueueLease<NotifyQueueEventMessage>> _leases;
public FakeEventQueue(params INotifyQueueLease<NotifyQueueEventMessage>[] leases)
{
_leases = new Queue<INotifyQueueLease<NotifyQueueEventMessage>>(leases);
}
public ValueTask<NotifyQueueEnqueueResult> PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default)
{
if (_leases.Count == 0)
{
return ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
}
return ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(new[] { _leases.Dequeue() });
}
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
}
private sealed class FakeLease : INotifyQueueLease<NotifyQueueEventMessage>
{
private readonly NotifyQueueEventMessage _message;
public FakeLease()
{
var notifyEvent = NotifyEvent.Create(
Guid.NewGuid(),
kind: "test.event",
tenant: "tenant-1",
ts: DateTimeOffset.UtcNow,
payload: null);
_message = new NotifyQueueEventMessage(notifyEvent, "notify:events", traceId: "trace-123");
}
public string MessageId { get; } = Guid.NewGuid().ToString("n");
public int Attempt { get; internal set; } = 1;
public DateTimeOffset EnqueuedAt { get; } = DateTimeOffset.UtcNow;
public DateTimeOffset LeaseExpiresAt { get; private set; } = DateTimeOffset.UtcNow.AddSeconds(30);
public string Consumer { get; } = "worker-1";
public string Stream => _message.Stream;
public string TenantId => _message.TenantId;
public string? PartitionKey => _message.PartitionKey;
public string IdempotencyKey => _message.IdempotencyKey;
public string? TraceId => _message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => _message.Attributes;
public NotifyQueueEventMessage Message => _message;
public int AcknowledgeCount { get; private set; }
public int ReleaseCount { get; private set; }
public NotifyQueueReleaseDisposition? LastDisposition { get; private set; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
{
AcknowledgeCount++;
return Task.CompletedTask;
}
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
{
LeaseExpiresAt = DateTimeOffset.UtcNow.Add(leaseDuration);
return Task.CompletedTask;
}
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
{
LastDisposition = disposition;
ReleaseCount++;
Attempt++;
return Task.CompletedTask;
}
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
private sealed class TestHandler : INotifyEventHandler
{
private readonly bool _shouldThrow;
public TestHandler(bool shouldThrow = false)
{
_shouldThrow = shouldThrow;
}
public Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken)
{
if (_shouldThrow)
{
throw new InvalidOperationException("handler failure");
}
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Notify.Worker\StellaOps.Notify.Worker.csproj" />
<ProjectReference Include="..\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj" />
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Queue;
namespace StellaOps.Notify.Worker.Handlers;
public interface INotifyEventHandler
{
Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue;
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;
}
}

View File

@@ -0,0 +1,52 @@
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()
{
if (!string.IsNullOrWhiteSpace(WorkerId))
{
return WorkerId!;
}
var host = Environment.MachineName;
return $"{host}-{Guid.NewGuid():n}";
}
}

View File

@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Worker.Handlers;
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.
}
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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;
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
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;
var builder = Host.CreateApplicationBuilder(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>();
await builder.Build().RunAsync().ConfigureAwait(false);

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Notify.Worker.Tests")]

View File

@@ -5,4 +5,20 @@
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj" />
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -2,7 +2,7 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| NOTIFY-WORKER-15-201 | TODO | Notify Worker Guild | NOTIFY-QUEUE-15-401 | Implement bus subscription + leasing loop with correlation IDs, backoff, dead-letter handling (§1§5). | Worker consumes events from queue, ack/retry behaviour proven in integration tests; logs include correlation IDs. |
| NOTIFY-WORKER-15-202 | TODO | Notify Worker Guild | NOTIFY-ENGINE-15-301 | Wire rules evaluation pipeline (tenant scoping, filters, throttles, digests, idempotency) with deterministic decisions. | Evaluation unit tests cover rule combinations; throttles/digests produce expected suppression; idempotency keys validated. |
| NOTIFY-WORKER-15-203 | TODO | Notify Worker Guild | NOTIFY-ENGINE-15-302 | Channel dispatch orchestration: invoke connectors, manage retries/jitter, record delivery outcomes. | Connector mocks show retries/backoff; delivery results stored; metrics incremented per outcome. |
| NOTIFY-WORKER-15-201 | DONE (2025-10-23) | Notify Worker Guild | NOTIFY-QUEUE-15-401 | Implement bus subscription + leasing loop with correlation IDs, backoff, dead-letter handling (§1§5). | Worker consumes events from queue, ack/retry behaviour proven in integration tests; logs include correlation IDs. |
| NOTIFY-WORKER-15-202 | TODO | Notify Worker Guild | NOTIFY-WORKER-15-201 | Wire rules evaluation pipeline (tenant scoping, filters, throttles, digests, idempotency) with deterministic decisions. | Evaluation unit tests cover rule combinations; throttles/digests produce expected suppression; idempotency keys validated. |
| NOTIFY-WORKER-15-203 | TODO | Notify Worker Guild | NOTIFY-WORKER-15-202 | Channel dispatch orchestration: invoke connectors, manage retries/jitter, record delivery outcomes. | Connector mocks show retries/backoff; delivery results stored; metrics incremented per outcome. |
| NOTIFY-WORKER-15-204 | TODO | Notify Worker Guild | NOTIFY-WORKER-15-203 | Metrics/telemetry: `notify.sent_total`, `notify.dropped_total`, latency histograms, tracing integration. | Metrics emitted per spec; OTLP spans annotated; dashboards documented. |

View File

@@ -0,0 +1,43 @@
{
"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"
}
}
}
}

View File

@@ -2,11 +2,11 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| UI-AUTH-13-001 | TODO | UI Guild | AUTH-DPOP-11-001, AUTH-MTLS-11-002 | Integrate Authority OIDC + DPoP flows with session management. | Login/logout flows pass e2e tests; tokens refreshed; DPoP nonce handling validated. |
| UI-AUTH-13-001 | DONE (2025-10-23) | UI Guild | AUTH-DPOP-11-001, AUTH-MTLS-11-002 | Integrate Authority OIDC + DPoP flows with session management. | Login/logout flows pass e2e tests; tokens refreshed; DPoP nonce handling validated. |
| UI-SCANS-13-002 | TODO | UI Guild | SCANNER-WEB-09-102, SIGNER-API-11-101 | Build scans module (list/detail/SBOM/diff/attestation) with performance + accessibility targets. | Cypress tests cover SBOM/diff; performance budgets met; accessibility checks pass. |
| UI-VEX-13-003 | TODO | UI Guild | EXCITITOR-CORE-02-001, EXCITITOR-EXPORT-01-005 | Implement VEX explorer + policy editor with preview integration. | VEX views render consensus/conflicts; staged policy preview works; accessibility checks pass. |
| UI-ADMIN-13-004 | TODO | UI Guild | AUTH-MTLS-11-002 | Deliver admin area (tenants/clients/quotas/licensing) with RBAC + audit hooks. | Admin e2e tests pass; unauthorized access blocked; telemetry wired. |
| UI-ATTEST-11-005 | TODO | UI Guild | SIGNER-API-11-101, ATTESTOR-API-11-201 | Attestation visibility (Rekor id, status) on Scan Detail. | UI shows Rekor UUID/status; mock attestation fixtures displayed; tests cover success/failure. |
| UI-ATTEST-11-005 | DONE (2025-10-23) | UI Guild | SIGNER-API-11-101, ATTESTOR-API-11-201 | Attestation visibility (Rekor id, status) on Scan Detail. | UI shows Rekor UUID/status; mock attestation fixtures displayed; tests cover success/failure. |
| UI-SCHED-13-005 | TODO | UI Guild | SCHED-WEB-16-101 | Scheduler panel: schedules CRUD, run history, dry-run preview using API/mocks. | Panel functional with mocked endpoints; UX signoff; integration tests added. |
| UI-NOTIFY-13-006 | DOING (2025-10-19) | UI Guild | NOTIFY-WEB-15-101 | Notify panel: channels/rules CRUD, deliveries view, test send integration. | Panel interacts with mocked Notify API; tests cover rule lifecycle; docs updated. |
| UI-POLICY-13-007 | TODO | UI Guild | POLICY-CORE-09-006, SCANNER-WEB-09-103 | Surface policy confidence metadata (band, age, quiet provenance) on preview and report views. | UI renders new columns/tooltips, accessibility and responsive checks pass, Cypress regression updated with confidence fixtures. |

View File

@@ -34,6 +34,26 @@ Run `ng build` to build the project. The build artifacts will be stored in the `
`verify:chromium` prints every location inspected (environment overrides, system paths, `.cache/chromium/`). Set `CHROME_BIN` or `STELLAOPS_CHROMIUM_BIN` if you host the binary in a non-standard path.
## Runtime configuration
The SPA loads environment details from `/config.json` at startup. During development we ship a stub configuration under `src/config/config.json`; adjust the issuer, client ID, and API base URLs to match your Authority instance. To reset, copy `src/config/config.sample.json` back to `src/config/config.json`:
```bash
cp src/config/config.sample.json src/config/config.json
```
When packaging for another environment, replace the file before building so the generated bundle contains the correct defaults. Gateways that rewrite `/config.json` at request time can override these settings without rebuilding.
## End-to-end tests
Playwright drives the high-level auth UX using the stub configuration above. Ensure the Angular dev server can bind to `127.0.0.1:4400`, then run:
```bash
npm run test:e2e
```
The Playwright config auto-starts `npm run serve:test` and intercepts Authority redirects, so no live IdP is required. For CI/offline nodes, pre-install the required browsers via `npx playwright install --with-deps` and cache the results alongside your npm cache.
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.

View File

@@ -6,3 +6,4 @@
| WEB1.TRIVY-SETTINGS-TESTS | DONE (2025-10-21) | UX Specialist, Angular Eng | WEB1.TRIVY-SETTINGS | **DONE (2025-10-21)** Added headless Karma harness (`ng test --watch=false`) wired to ChromeHeadless/CI launcher, created `karma.conf.cjs`, updated npm scripts + docs with Chromium prerequisites so CI/offline runners can execute specs deterministically. | Angular CLI available (npm scripts chained), Karma suite for Trivy DB components passing locally and in CI, docs note required prerequisites. |
| WEB1.DEPS-13-001 | DONE (2025-10-21) | UX Specialist, Angular Eng, DevEx | WEB1.TRIVY-SETTINGS-TESTS | Stabilise Angular workspace dependencies for CI/offline nodes: refresh `package-lock.json`, ensure Puppeteer/Chromium binaries optional, document deterministic install workflow. | `npm install` completes without manual intervention on air-gapped nodes, `npm test` headless run succeeds from clean checkout, README updated with lockfile + cache steps. |
| WEB-POLICY-FIXTURES-10-001 | DONE (2025-10-23) | Angular Eng | SAMPLES-13-004 | Wire policy preview/report doc fixtures into UI harness (test utility or Storybook substitute) with type bindings and validation guard so UI stays aligned with documented payloads. | JSON fixtures importable within Angular workspace, typed helpers exported for reuse, Karma spec validates critical fields (confidence band, unknown metrics, DSSE summary). |
| UI-AUTH-13-001 | DONE (2025-10-23) | UI Guild | AUTH-DPOP-11-001, AUTH-MTLS-11-002 | Integrate Authority OIDC + DPoP flows with session management (Angular SPA). | APP_INITIALIZER loads runtime config; login/logout flows drive Authority code flow; DPoP proofs generated/stored, nonce retries handled; unit specs cover proof binding + session persistence. |

View File

@@ -27,7 +27,12 @@
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
"src/assets",
{
"glob": "config.json",
"input": "src/config",
"output": "."
}
],
"styles": [
"src/styles.scss"
@@ -88,7 +93,12 @@
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
"src/assets",
{
"glob": "config.json",
"input": "src/config",
"output": "."
}
],
"styles": [
"src/styles.scss"

View File

@@ -24,6 +24,7 @@
"@angular-devkit/build-angular": "^17.3.17",
"@angular/cli": "^17.3.17",
"@angular/compiler-cli": "^17.3.0",
"@playwright/test": "^1.47.2",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
@@ -5074,6 +5075,21 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
"dev": true,
"dependencies": {
"playwright": "1.56.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.52.5",
"cpu": [
@@ -5313,9 +5329,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"dev": true
},
"node_modules/@types/node-forge": {
"version": "1.3.14",
"dev": true,
@@ -8233,6 +8246,20 @@
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"dev": true,
@@ -10928,6 +10955,36 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/playwright": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"dev": true,
"dependencies": {
"playwright-core": "1.56.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/postcss": {
"version": "8.4.35",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",

View File

@@ -9,6 +9,8 @@
"test": "npm run verify:chromium && ng test --watch=false",
"test:watch": "ng test --watch",
"test:ci": "npm run test",
"test:e2e": "playwright test",
"serve:test": "ng serve --configuration development --port 4400 --host 127.0.0.1",
"verify:chromium": "node ./scripts/verify-chromium.js",
"ci:install": "npm ci --prefer-offline --no-audit --no-fund"
},
@@ -34,6 +36,7 @@
"@angular-devkit/build-angular": "^17.3.17",
"@angular/cli": "^17.3.17",
"@angular/compiler-cli": "^17.3.0",
"@playwright/test": "^1.47.2",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",

View File

@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test';
const port = process.env.PLAYWRIGHT_PORT
? Number.parseInt(process.env.PLAYWRIGHT_PORT, 10)
: 4400;
export default defineConfig({
testDir: 'tests/e2e',
timeout: 30_000,
retries: process.env.CI ? 1 : 0,
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`,
trace: 'retain-on-failure',
},
webServer: {
command: 'npm run serve:test',
reuseExistingServer: !process.env.CI,
url: `http://127.0.0.1:${port}`,
stdout: 'ignore',
stderr: 'ignore',
},
});

View File

@@ -5,7 +5,19 @@
<a routerLink="/concelier/trivy-db-settings" routerLinkActive="active">
Trivy DB Export
</a>
<a routerLink="/scans/scan-verified-001" routerLinkActive="active">
Scan Detail
</a>
</nav>
<div class="app-auth">
<ng-container *ngIf="isAuthenticated(); else signIn">
<span class="app-user" aria-live="polite">{{ displayName() }}</span>
<button type="button" (click)="onSignOut()">Sign out</button>
</ng-container>
<ng-template #signIn>
<button type="button" (click)="onSignIn()">Sign in</button>
</ng-template>
</div>
</header>
<main class="app-content">

View File

@@ -50,6 +50,36 @@
}
}
.app-auth {
display: flex;
align-items: center;
gap: 0.75rem;
.app-user {
font-size: 0.9rem;
font-weight: 500;
}
button {
appearance: none;
border: none;
border-radius: 9999px;
padding: 0.35rem 0.9rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
color: #0f172a;
background-color: rgba(248, 250, 252, 0.9);
transition: transform 0.2s ease, background-color 0.2s ease;
&:hover,
&:focus-visible {
background-color: #facc15;
transform: translateY(-1px);
}
}
}
.app-content {
flex: 1;
padding: 2rem 1.5rem;

View File

@@ -1,11 +1,22 @@
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import { AuthorityAuthService } from './core/auth/authority-auth.service';
import { AuthSessionStore } from './core/auth/auth-session.store';
class AuthorityAuthServiceStub {
beginLogin = jasmine.createSpy('beginLogin');
logout = jasmine.createSpy('logout');
}
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent, RouterTestingModule],
providers: [
AuthSessionStore,
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
],
}).compileComponents();
});

View File

@@ -1,11 +1,51 @@
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { AuthorityAuthService } from './core/auth/authority-auth.service';
import { AuthSessionStore } from './core/auth/auth-session.store';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
styleUrl: './app.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {}
export class AppComponent {
private readonly router = inject(Router);
private readonly auth = inject(AuthorityAuthService);
private readonly sessionStore = inject(AuthSessionStore);
readonly status = this.sessionStore.status;
readonly identity = this.sessionStore.identity;
readonly subjectHint = this.sessionStore.subjectHint;
readonly isAuthenticated = this.sessionStore.isAuthenticated;
readonly displayName = computed(() => {
const identity = this.identity();
if (identity?.name) {
return identity.name;
}
if (identity?.email) {
return identity.email;
}
const hint = this.subjectHint();
return hint ?? 'anonymous';
});
onSignIn(): void {
const returnUrl = this.router.url === '/' ? undefined : this.router.url;
void this.auth.beginLogin(returnUrl);
}
onSignOut(): void {
void this.auth.logout();
}
}

View File

@@ -1,14 +1,28 @@
import { provideHttpClient } from '@angular/common/http';
import { ApplicationConfig } from '@angular/core';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { APP_INITIALIZER, ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client';
import { AppConfigService } from './core/config/app-config.service';
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(),
provideHttpClient(withInterceptorsFromDi()),
{
provide: APP_INITIALIZER,
multi: true,
useFactory: (configService: AppConfigService) => () =>
configService.load(),
deps: [AppConfigService],
},
{
provide: HTTP_INTERCEPTORS,
useClass: AuthHttpInterceptor,
multi: true,
},
{
provide: CONCELIER_EXPORTER_API_BASE_URL,
useValue: '/api/v1/concelier/exporters/trivy-db',

View File

@@ -8,6 +8,20 @@ export const routes: Routes = [
(m) => m.TrivyDbSettingsPageComponent
),
},
{
path: 'scans/:scanId',
loadComponent: () =>
import('./features/scans/scan-detail-page.component').then(
(m) => m.ScanDetailPageComponent
),
},
{
path: 'auth/callback',
loadComponent: () =>
import('./features/auth/auth-callback.component').then(
(m) => m.AuthCallbackComponent
),
},
{
path: '',
pathMatch: 'full',

View File

@@ -0,0 +1,17 @@
export type ScanAttestationStatusKind = 'verified' | 'pending' | 'failed';
export interface ScanAttestationStatus {
readonly uuid: string;
readonly status: ScanAttestationStatusKind;
readonly index?: number;
readonly logUrl?: string;
readonly checkedAt?: string;
readonly statusMessage?: string;
}
export interface ScanDetail {
readonly scanId: string;
readonly imageDigest: string;
readonly completedAt: string;
readonly attestation?: ScanAttestationStatus;
}

View File

@@ -0,0 +1,171 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, firstValueFrom, from, throwError } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { AppConfigService } from '../config/app-config.service';
import { DpopService } from './dpop/dpop.service';
import { AuthorityAuthService } from './authority-auth.service';
const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
@Injectable()
export class AuthHttpInterceptor implements HttpInterceptor {
private excludedOrigins: Set<string> | null = null;
private tokenEndpoint: string | null = null;
private authorityResolved = false;
constructor(
private readonly auth: AuthorityAuthService,
private readonly config: AppConfigService,
private readonly dpop: DpopService
) {
// lazy resolve authority configuration in intercept to allow APP_INITIALIZER to run first
}
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
this.ensureAuthorityInfo();
if (request.headers.has('Authorization') || this.shouldSkip(request.url)) {
return next.handle(request);
}
return from(
this.auth.getAuthHeadersForRequest(
this.resolveAbsoluteUrl(request.url),
request.method
)
).pipe(
switchMap((headers) => {
if (!headers) {
return next.handle(request);
}
const authorizedRequest = request.clone({
setHeaders: {
Authorization: headers.authorization,
DPoP: headers.dpop,
},
headers: request.headers.set(RETRY_HEADER, '0'),
});
return next.handle(authorizedRequest);
}),
catchError((error: HttpErrorResponse) =>
this.handleError(request, error, next)
)
);
}
private handleError(
request: HttpRequest<unknown>,
error: HttpErrorResponse,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
if (error.status !== 401) {
return throwError(() => error);
}
const nonce = error.headers?.get('DPoP-Nonce');
if (!nonce) {
return throwError(() => error);
}
if (request.headers.get(RETRY_HEADER) === '1') {
return throwError(() => error);
}
return from(this.retryWithNonce(request, nonce, next)).pipe(
catchError(() => throwError(() => error))
);
}
private async retryWithNonce(
request: HttpRequest<unknown>,
nonce: string,
next: HttpHandler
): Promise<HttpEvent<unknown>> {
await this.dpop.setNonce(nonce);
const headers = await this.auth.getAuthHeadersForRequest(
this.resolveAbsoluteUrl(request.url),
request.method
);
if (!headers) {
throw new Error('Unable to refresh authorization headers after nonce.');
}
const retried = request.clone({
setHeaders: {
Authorization: headers.authorization,
DPoP: headers.dpop,
},
headers: request.headers.set(RETRY_HEADER, '1'),
});
return firstValueFrom(next.handle(retried));
}
private shouldSkip(url: string): boolean {
this.ensureAuthorityInfo();
const absolute = this.resolveAbsoluteUrl(url);
if (!absolute) {
return false;
}
try {
const resolved = new URL(absolute);
if (resolved.pathname.endsWith('/config.json')) {
return true;
}
if (this.tokenEndpoint && absolute.startsWith(this.tokenEndpoint)) {
return true;
}
const origin = resolved.origin;
return this.excludedOrigins?.has(origin) ?? false;
} catch {
return false;
}
}
private resolveAbsoluteUrl(url: string): string {
try {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
const base =
typeof window !== 'undefined' && window.location
? window.location.origin
: undefined;
return base ? new URL(url, base).toString() : url;
} catch {
return url;
}
}
private ensureAuthorityInfo(): void {
if (this.authorityResolved) {
return;
}
try {
const authority = this.config.authority;
this.tokenEndpoint = new URL(
authority.tokenEndpoint,
authority.issuer
).toString();
this.excludedOrigins = new Set<string>([
this.tokenEndpoint,
new URL(authority.authorizeEndpoint, authority.issuer).origin,
]);
this.authorityResolved = true;
} catch {
// Configuration not yet loaded; interceptor will retry on the next request.
}
}
}

View File

@@ -0,0 +1,49 @@
export interface AuthTokens {
readonly accessToken: string;
readonly expiresAtEpochMs: number;
readonly refreshToken?: string;
readonly tokenType: 'Bearer';
readonly scope: string;
}
export interface AuthIdentity {
readonly subject: string;
readonly name?: string;
readonly email?: string;
readonly roles: readonly string[];
readonly idToken?: string;
}
export interface AuthSession {
readonly tokens: AuthTokens;
readonly identity: AuthIdentity;
/**
* SHA-256 JWK thumbprint of the active DPoP key pair.
*/
readonly dpopKeyThumbprint: string;
readonly issuedAtEpochMs: number;
}
export interface PersistedSessionMetadata {
readonly subject: string;
readonly expiresAtEpochMs: number;
readonly issuedAtEpochMs: number;
readonly dpopKeyThumbprint: string;
}
export type AuthStatus =
| 'unauthenticated'
| 'authenticated'
| 'refreshing'
| 'loading';
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
export type AuthErrorReason =
| 'invalid_state'
| 'token_exchange_failed'
| 'refresh_failed'
| 'dpop_generation_failed'
| 'configuration_missing';

View File

@@ -0,0 +1,48 @@
import { TestBed } from '@angular/core/testing';
import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
import { AuthSessionStore } from './auth-session.store';
describe('AuthSessionStore', () => {
let store: AuthSessionStore;
beforeEach(() => {
sessionStorage.clear();
TestBed.configureTestingModule({
providers: [AuthSessionStore],
});
store = TestBed.inject(AuthSessionStore);
});
it('persists minimal metadata when session is set', () => {
const tokens: AuthTokens = {
accessToken: 'token-abc',
expiresAtEpochMs: Date.now() + 120_000,
refreshToken: 'refresh-xyz',
scope: 'openid ui.read',
tokenType: 'Bearer',
};
const session: AuthSession = {
tokens,
identity: {
subject: 'user-123',
name: 'Alex Operator',
roles: ['ui.read'],
},
dpopKeyThumbprint: 'thumbprint-1',
issuedAtEpochMs: Date.now(),
};
store.setSession(session);
const persisted = sessionStorage.getItem(SESSION_STORAGE_KEY);
expect(persisted).toBeTruthy();
const parsed = JSON.parse(persisted ?? '{}');
expect(parsed.subject).toBe('user-123');
expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
store.clear();
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
});
});

View File

@@ -0,0 +1,107 @@
import { Injectable, computed, signal } from '@angular/core';
import {
AuthSession,
AuthStatus,
PersistedSessionMetadata,
SESSION_STORAGE_KEY,
} from './auth-session.model';
@Injectable({
providedIn: 'root',
})
export class AuthSessionStore {
private readonly sessionSignal = signal<AuthSession | null>(null);
private readonly statusSignal = signal<AuthStatus>('unauthenticated');
private readonly persistedSignal =
signal<PersistedSessionMetadata | null>(this.readPersistedMetadata());
readonly session = computed(() => this.sessionSignal());
readonly status = computed(() => this.statusSignal());
readonly identity = computed(() => this.sessionSignal()?.identity ?? null);
readonly subjectHint = computed(
() =>
this.sessionSignal()?.identity.subject ??
this.persistedSignal()?.subject ??
null
);
readonly expiresAtEpochMs = computed(
() => this.sessionSignal()?.tokens.expiresAtEpochMs ?? null
);
readonly isAuthenticated = computed(
() => this.sessionSignal() !== null && this.statusSignal() !== 'loading'
);
setStatus(status: AuthStatus): void {
this.statusSignal.set(status);
}
setSession(session: AuthSession | null): void {
this.sessionSignal.set(session);
if (!session) {
this.statusSignal.set('unauthenticated');
this.persistedSignal.set(null);
this.clearPersistedMetadata();
return;
}
this.statusSignal.set('authenticated');
const metadata: PersistedSessionMetadata = {
subject: session.identity.subject,
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
issuedAtEpochMs: session.issuedAtEpochMs,
dpopKeyThumbprint: session.dpopKeyThumbprint,
};
this.persistedSignal.set(metadata);
this.persistMetadata(metadata);
}
clear(): void {
this.sessionSignal.set(null);
this.statusSignal.set('unauthenticated');
this.persistedSignal.set(null);
this.clearPersistedMetadata();
}
private readPersistedMetadata(): PersistedSessionMetadata | null {
if (typeof sessionStorage === 'undefined') {
return null;
}
try {
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PersistedSessionMetadata;
if (
typeof parsed.subject !== 'string' ||
typeof parsed.expiresAtEpochMs !== 'number' ||
typeof parsed.issuedAtEpochMs !== 'number' ||
typeof parsed.dpopKeyThumbprint !== 'string'
) {
return null;
}
return parsed;
} catch {
return null;
}
}
private persistMetadata(metadata: PersistedSessionMetadata): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
}
private clearPersistedMetadata(): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.removeItem(SESSION_STORAGE_KEY);
}
}

View File

@@ -0,0 +1,45 @@
import { Injectable } from '@angular/core';
const LOGIN_REQUEST_KEY = 'stellaops.auth.login.request';
export interface PendingLoginRequest {
readonly state: string;
readonly codeVerifier: string;
readonly createdAtEpochMs: number;
readonly returnUrl?: string;
readonly nonce?: string;
}
@Injectable({
providedIn: 'root',
})
export class AuthStorageService {
savePendingLogin(request: PendingLoginRequest): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.setItem(LOGIN_REQUEST_KEY, JSON.stringify(request));
}
consumePendingLogin(expectedState: string): PendingLoginRequest | null {
if (typeof sessionStorage === 'undefined') {
return null;
}
const raw = sessionStorage.getItem(LOGIN_REQUEST_KEY);
if (!raw) {
return null;
}
sessionStorage.removeItem(LOGIN_REQUEST_KEY);
try {
const request = JSON.parse(raw) as PendingLoginRequest;
if (request.state !== expectedState) {
return null;
}
return request;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,430 @@
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { AppConfigService } from '../config/app-config.service';
import { AuthorityConfig } from '../config/app-config.model';
import {
ACCESS_TOKEN_REFRESH_THRESHOLD_MS,
AuthErrorReason,
AuthSession,
AuthTokens,
} from './auth-session.model';
import { AuthSessionStore } from './auth-session.store';
import {
AuthStorageService,
PendingLoginRequest,
} from './auth-storage.service';
import { DpopService } from './dpop/dpop.service';
import { base64UrlDecode } from './dpop/jose-utilities';
import { createPkcePair } from './pkce.util';
interface TokenResponse {
readonly access_token: string;
readonly token_type: string;
readonly expires_in: number;
readonly scope?: string;
readonly refresh_token?: string;
readonly id_token?: string;
}
interface RefreshTokenResponse extends TokenResponse {}
export interface AuthorizationHeaders {
readonly authorization: string;
readonly dpop: string;
}
export interface CompleteLoginResult {
readonly returnUrl?: string;
}
const TOKEN_CONTENT_TYPE = 'application/x-www-form-urlencoded';
@Injectable({
providedIn: 'root',
})
export class AuthorityAuthService {
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private refreshInFlight: Promise<void> | null = null;
private lastError: AuthErrorReason | null = null;
constructor(
private readonly http: HttpClient,
private readonly config: AppConfigService,
private readonly sessionStore: AuthSessionStore,
private readonly storage: AuthStorageService,
private readonly dpop: DpopService
) {}
get error(): AuthErrorReason | null {
return this.lastError;
}
async beginLogin(returnUrl?: string): Promise<void> {
const authority = this.config.authority;
const pkce = await createPkcePair();
const state = crypto.randomUUID ? crypto.randomUUID() : createRandomId();
const nonce = crypto.randomUUID ? crypto.randomUUID() : createRandomId();
// Generate the DPoP key pair up-front so the same key is bound to the token.
await this.dpop.getThumbprint();
const authorizeUrl = this.buildAuthorizeUrl(authority, {
state,
nonce,
codeChallenge: pkce.challenge,
codeChallengeMethod: pkce.method,
returnUrl,
});
const now = Date.now();
this.storage.savePendingLogin({
state,
codeVerifier: pkce.verifier,
createdAtEpochMs: now,
returnUrl,
nonce,
});
window.location.assign(authorizeUrl);
}
/**
* Completes the authorization code flow after the Authority redirects back with ?code & ?state.
*/
async completeLoginFromRedirect(
queryParams: URLSearchParams
): Promise<CompleteLoginResult> {
const code = queryParams.get('code');
const state = queryParams.get('state');
if (!code || !state) {
throw new Error('Missing authorization code or state.');
}
const pending = this.storage.consumePendingLogin(state);
if (!pending) {
this.lastError = 'invalid_state';
throw new Error('State parameter did not match pending login request.');
}
try {
const tokenResponse = await this.exchangeCodeForTokens(
code,
pending.codeVerifier
);
await this.onTokenResponse(tokenResponse, pending.nonce ?? null);
this.lastError = null;
return { returnUrl: pending.returnUrl };
} catch (error) {
this.lastError = 'token_exchange_failed';
this.sessionStore.clear();
throw error;
}
}
async ensureValidAccessToken(): Promise<string | null> {
const session = this.sessionStore.session();
if (!session) {
return null;
}
const now = Date.now();
if (now < session.tokens.expiresAtEpochMs - ACCESS_TOKEN_REFRESH_THRESHOLD_MS) {
return session.tokens.accessToken;
}
await this.refreshAccessToken();
const refreshed = this.sessionStore.session();
return refreshed?.tokens.accessToken ?? null;
}
async getAuthHeadersForRequest(
url: string,
method: string
): Promise<AuthorizationHeaders | null> {
const accessToken = await this.ensureValidAccessToken();
if (!accessToken) {
return null;
}
const dpopProof = await this.dpop.createProof({
htm: method,
htu: url,
accessToken,
});
return {
authorization: `DPoP ${accessToken}`,
dpop: dpopProof,
};
}
async refreshAccessToken(): Promise<void> {
const session = this.sessionStore.session();
const refreshToken = session?.tokens.refreshToken;
if (!refreshToken) {
return;
}
if (this.refreshInFlight) {
await this.refreshInFlight;
return;
}
this.refreshInFlight = this.executeRefresh(refreshToken)
.catch((error) => {
this.lastError = 'refresh_failed';
this.sessionStore.clear();
throw error;
})
.finally(() => {
this.refreshInFlight = null;
});
await this.refreshInFlight;
}
async logout(): Promise<void> {
const session = this.sessionStore.session();
this.cancelRefreshTimer();
this.sessionStore.clear();
await this.dpop.setNonce(null);
const authority = this.config.authority;
if (!authority.logoutEndpoint) {
return;
}
if (session?.identity.idToken) {
const url = new URL(authority.logoutEndpoint, authority.issuer);
url.searchParams.set('post_logout_redirect_uri', authority.postLogoutRedirectUri ?? authority.redirectUri);
url.searchParams.set('id_token_hint', session.identity.idToken);
window.location.assign(url.toString());
} else {
window.location.assign(authority.postLogoutRedirectUri ?? authority.redirectUri);
}
}
private async exchangeCodeForTokens(
code: string,
codeVerifier: string
): Promise<HttpResponse<TokenResponse>> {
const authority = this.config.authority;
const tokenUrl = new URL(authority.tokenEndpoint, authority.issuer).toString();
const body = new URLSearchParams();
body.set('grant_type', 'authorization_code');
body.set('code', code);
body.set('redirect_uri', authority.redirectUri);
body.set('client_id', authority.clientId);
body.set('code_verifier', codeVerifier);
if (authority.audience) {
body.set('audience', authority.audience);
}
const dpopProof = await this.dpop.createProof({
htm: 'POST',
htu: tokenUrl,
});
const headers = new HttpHeaders({
'Content-Type': TOKEN_CONTENT_TYPE,
DPoP: dpopProof,
});
return firstValueFrom(
this.http.post<TokenResponse>(tokenUrl, body.toString(), {
headers,
withCredentials: true,
observe: 'response',
})
);
}
private async executeRefresh(refreshToken: string): Promise<void> {
const authority = this.config.authority;
const tokenUrl = new URL(authority.tokenEndpoint, authority.issuer).toString();
const body = new URLSearchParams();
body.set('grant_type', 'refresh_token');
body.set('refresh_token', refreshToken);
body.set('client_id', authority.clientId);
if (authority.audience) {
body.set('audience', authority.audience);
}
const proof = await this.dpop.createProof({
htm: 'POST',
htu: tokenUrl,
});
const headers = new HttpHeaders({
'Content-Type': TOKEN_CONTENT_TYPE,
DPoP: proof,
});
const response = await firstValueFrom(
this.http.post<RefreshTokenResponse>(tokenUrl, body.toString(), {
headers,
withCredentials: true,
observe: 'response',
})
);
await this.onTokenResponse(response, null);
}
private async onTokenResponse(
response: HttpResponse<TokenResponse>,
expectedNonce: string | null
): Promise<void> {
const nonce = response.headers.get('DPoP-Nonce');
if (nonce) {
await this.dpop.setNonce(nonce);
}
const payload = response.body;
if (!payload) {
throw new Error('Token response did not include a body.');
}
const tokens = this.toAuthTokens(payload);
const identity = this.parseIdentity(payload.id_token ?? '', expectedNonce);
const thumbprint = await this.dpop.getThumbprint();
if (!thumbprint) {
throw new Error('DPoP thumbprint unavailable.');
}
const session: AuthSession = {
tokens,
identity,
dpopKeyThumbprint: thumbprint,
issuedAtEpochMs: Date.now(),
};
this.sessionStore.setSession(session);
this.scheduleRefresh(tokens, this.config.authority);
}
private toAuthTokens(payload: TokenResponse): AuthTokens {
const expiresAtEpochMs = Date.now() + payload.expires_in * 1000;
return {
accessToken: payload.access_token,
tokenType: (payload.token_type ?? 'Bearer') as 'Bearer',
refreshToken: payload.refresh_token,
scope: payload.scope ?? '',
expiresAtEpochMs,
};
}
private parseIdentity(
idToken: string,
expectedNonce: string | null
): AuthSession['identity'] {
if (!idToken) {
return {
subject: 'unknown',
roles: [],
};
}
const claims = decodeJwt(idToken);
const nonceClaim = claims['nonce'];
if (
expectedNonce &&
typeof nonceClaim === 'string' &&
nonceClaim !== expectedNonce
) {
throw new Error('OIDC nonce mismatch.');
}
const subjectClaim = claims['sub'];
const nameClaim = claims['name'];
const emailClaim = claims['email'];
const rolesClaim = claims['role'];
return {
subject: typeof subjectClaim === 'string' ? subjectClaim : 'unknown',
name: typeof nameClaim === 'string' ? nameClaim : undefined,
email: typeof emailClaim === 'string' ? emailClaim : undefined,
roles: Array.isArray(rolesClaim)
? rolesClaim.filter((entry: unknown): entry is string =>
typeof entry === 'string'
)
: [],
idToken,
};
}
private scheduleRefresh(tokens: AuthTokens, authority: AuthorityConfig): void {
this.cancelRefreshTimer();
const leeway =
(authority.refreshLeewaySeconds ?? 60) * 1000 +
ACCESS_TOKEN_REFRESH_THRESHOLD_MS;
const now = Date.now();
const ttl = Math.max(tokens.expiresAtEpochMs - now - leeway, 5_000);
this.refreshTimer = setTimeout(() => {
void this.refreshAccessToken();
}, ttl);
}
private cancelRefreshTimer(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
private buildAuthorizeUrl(
authority: AuthorityConfig,
options: {
state: string;
nonce: string;
codeChallenge: string;
codeChallengeMethod: 'S256';
returnUrl?: string;
}
): string {
const authorizeUrl = new URL(
authority.authorizeEndpoint,
authority.issuer
);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', authority.clientId);
authorizeUrl.searchParams.set('redirect_uri', authority.redirectUri);
authorizeUrl.searchParams.set('scope', authority.scope);
authorizeUrl.searchParams.set('state', options.state);
authorizeUrl.searchParams.set('nonce', options.nonce);
authorizeUrl.searchParams.set('code_challenge', options.codeChallenge);
authorizeUrl.searchParams.set(
'code_challenge_method',
options.codeChallengeMethod
);
if (authority.audience) {
authorizeUrl.searchParams.set('audience', authority.audience);
}
if (options.returnUrl) {
authorizeUrl.searchParams.set('ui_return', options.returnUrl);
}
return authorizeUrl.toString();
}
}
function decodeJwt(token: string): Record<string, unknown> {
const parts = token.split('.');
if (parts.length < 2) {
return {};
}
const payload = base64UrlDecode(parts[1]);
const json = new TextDecoder().decode(payload);
try {
return JSON.parse(json) as Record<string, unknown>;
} catch {
return {};
}
}
function createRandomId(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, (value) =>
value.toString(16).padStart(2, '0')
).join('');
}

View File

@@ -0,0 +1,181 @@
import { Injectable } from '@angular/core';
import { DPoPAlgorithm } from '../../config/app-config.model';
import { computeJwkThumbprint } from './jose-utilities';
const DB_NAME = 'stellaops-auth';
const STORE_NAME = 'dpopKeys';
const PRIMARY_KEY = 'primary';
const DB_VERSION = 1;
interface PersistedKeyPair {
readonly id: string;
readonly algorithm: DPoPAlgorithm;
readonly publicJwk: JsonWebKey;
readonly privateJwk: JsonWebKey;
readonly thumbprint: string;
readonly createdAtIso: string;
}
export interface LoadedDpopKeyPair {
readonly algorithm: DPoPAlgorithm;
readonly privateKey: CryptoKey;
readonly publicKey: CryptoKey;
readonly publicJwk: JsonWebKey;
readonly thumbprint: string;
}
@Injectable({
providedIn: 'root',
})
export class DpopKeyStore {
private dbPromise: Promise<IDBDatabase> | null = null;
async load(): Promise<LoadedDpopKeyPair | null> {
const record = await this.read();
if (!record) {
return null;
}
const [privateKey, publicKey] = await Promise.all([
crypto.subtle.importKey(
'jwk',
record.privateJwk,
this.toKeyAlgorithm(record.algorithm),
true,
['sign']
),
crypto.subtle.importKey(
'jwk',
record.publicJwk,
this.toKeyAlgorithm(record.algorithm),
true,
['verify']
),
]);
return {
algorithm: record.algorithm,
privateKey,
publicKey,
publicJwk: record.publicJwk,
thumbprint: record.thumbprint,
};
}
async save(
keyPair: CryptoKeyPair,
algorithm: DPoPAlgorithm
): Promise<LoadedDpopKeyPair> {
const [publicJwk, privateJwk] = await Promise.all([
crypto.subtle.exportKey('jwk', keyPair.publicKey),
crypto.subtle.exportKey('jwk', keyPair.privateKey),
]);
if (!publicJwk) {
throw new Error('Failed to export public JWK for DPoP key pair.');
}
const thumbprint = await computeJwkThumbprint(publicJwk);
const record: PersistedKeyPair = {
id: PRIMARY_KEY,
algorithm,
publicJwk,
privateJwk,
thumbprint,
createdAtIso: new Date().toISOString(),
};
await this.write(record);
return {
algorithm,
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
publicJwk,
thumbprint,
};
}
async clear(): Promise<void> {
const db = await this.openDb();
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
store.delete(PRIMARY_KEY)
);
}
async generate(algorithm: DPoPAlgorithm): Promise<LoadedDpopKeyPair> {
const algo = this.toKeyAlgorithm(algorithm);
const keyPair = await crypto.subtle.generateKey(algo, true, [
'sign',
'verify',
]);
const stored = await this.save(keyPair, algorithm);
return stored;
}
private async read(): Promise<PersistedKeyPair | null> {
const db = await this.openDb();
return transactionPromise(db, STORE_NAME, 'readonly', (store) =>
store.get(PRIMARY_KEY)
);
}
private async write(record: PersistedKeyPair): Promise<void> {
const db = await this.openDb();
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
store.put(record)
);
}
private toKeyAlgorithm(algorithm: DPoPAlgorithm): EcKeyImportParams {
switch (algorithm) {
case 'ES384':
return { name: 'ECDSA', namedCurve: 'P-384' };
case 'EdDSA':
throw new Error('EdDSA DPoP keys are not yet supported.');
case 'ES256':
default:
return { name: 'ECDSA', namedCurve: 'P-256' };
}
}
private async openDb(): Promise<IDBDatabase> {
if (typeof indexedDB === 'undefined') {
throw new Error('IndexedDB is not available for DPoP key persistence.');
}
if (!this.dbPromise) {
this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
return this.dbPromise;
}
}
function transactionPromise<T>(
db: IDBDatabase,
storeName: string,
mode: IDBTransactionMode,
executor: (store: IDBObjectStore) => IDBRequest<T>
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const transaction = db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const request = executor(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
transaction.onabort = () => reject(transaction.error);
});
}

View File

@@ -0,0 +1,103 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { APP_CONFIG, AppConfig } from '../../config/app-config.model';
import { AppConfigService } from '../../config/app-config.service';
import { base64UrlDecode } from './jose-utilities';
import { DpopKeyStore } from './dpop-key-store';
import { DpopService } from './dpop.service';
describe('DpopService', () => {
const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
const config: AppConfig = {
authority: {
issuer: 'https://auth.stellaops.test/',
clientId: 'ui-client',
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
redirectUri: 'https://ui.stellaops.test/auth/callback',
scope: 'openid profile ui.read',
audience: 'https://scanner.stellaops.test',
},
apiBaseUrls: {
authority: 'https://auth.stellaops.test',
scanner: 'https://scanner.stellaops.test',
policy: 'https://policy.stellaops.test',
concelier: 'https://concelier.stellaops.test',
attestor: 'https://attestor.stellaops.test',
},
};
beforeEach(async () => {
jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
AppConfigService,
DpopKeyStore,
DpopService,
{
provide: APP_CONFIG,
useValue: config,
},
],
});
});
afterEach(async () => {
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
const store = TestBed.inject(DpopKeyStore);
try {
await store.clear();
} catch {
// ignore cleanup issues in test environment
}
});
it('creates a DPoP proof with expected header values', async () => {
const appConfig = TestBed.inject(AppConfigService);
appConfig.setConfigForTesting(config);
const service = TestBed.inject(DpopService);
const proof = await service.createProof({
htm: 'get',
htu: 'https://scanner.stellaops.test/api/v1/scans',
});
const [rawHeader, rawPayload] = proof.split('.');
const header = JSON.parse(
new TextDecoder().decode(base64UrlDecode(rawHeader))
);
const payload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(rawPayload))
);
expect(header.typ).toBe('dpop+jwt');
expect(header.alg).toBe('ES256');
expect(header.jwk.kty).toBe('EC');
expect(payload.htm).toBe('GET');
expect(payload.htu).toBe('https://scanner.stellaops.test/api/v1/scans');
expect(typeof payload.iat).toBe('number');
expect(typeof payload.jti).toBe('string');
});
it('binds access token hash when provided', async () => {
const appConfig = TestBed.inject(AppConfigService);
appConfig.setConfigForTesting(config);
const service = TestBed.inject(DpopService);
const accessToken = 'sample-access-token';
const proof = await service.createProof({
htm: 'post',
htu: 'https://scanner.stellaops.test/api/v1/scans',
accessToken,
});
const payload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(proof.split('.')[1]))
);
expect(payload.ath).toBeDefined();
expect(typeof payload.ath).toBe('string');
});
});

View File

@@ -0,0 +1,148 @@
import { Injectable, computed, signal } from '@angular/core';
import { AppConfigService } from '../../config/app-config.service';
import { DPoPAlgorithm } from '../../config/app-config.model';
import { sha256, base64UrlEncode, derToJoseSignature } from './jose-utilities';
import { DpopKeyStore, LoadedDpopKeyPair } from './dpop-key-store';
export interface DpopProofOptions {
readonly htm: string;
readonly htu: string;
readonly accessToken?: string;
readonly nonce?: string | null;
}
@Injectable({
providedIn: 'root',
})
export class DpopService {
private keyPairPromise: Promise<LoadedDpopKeyPair> | null = null;
private readonly nonceSignal = signal<string | null>(null);
readonly nonce = computed(() => this.nonceSignal());
constructor(
private readonly config: AppConfigService,
private readonly store: DpopKeyStore
) {}
async setNonce(nonce: string | null): Promise<void> {
this.nonceSignal.set(nonce);
}
async getThumbprint(): Promise<string | null> {
const key = await this.getOrCreateKeyPair();
return key.thumbprint ?? null;
}
async rotateKey(): Promise<void> {
const algorithm = this.resolveAlgorithm();
this.keyPairPromise = this.store.generate(algorithm);
}
async createProof(options: DpopProofOptions): Promise<string> {
const keyPair = await this.getOrCreateKeyPair();
const header = {
typ: 'dpop+jwt',
alg: keyPair.algorithm,
jwk: keyPair.publicJwk,
};
const nowSeconds = Math.floor(Date.now() / 1000);
const payload: Record<string, unknown> = {
htm: options.htm.toUpperCase(),
htu: normalizeHtu(options.htu),
iat: nowSeconds,
jti: crypto.randomUUID ? crypto.randomUUID() : createRandomId(),
};
const nonce = options.nonce ?? this.nonceSignal();
if (nonce) {
payload['nonce'] = nonce;
}
if (options.accessToken) {
const accessTokenHash = await sha256(
new TextEncoder().encode(options.accessToken)
);
payload['ath'] = base64UrlEncode(accessTokenHash);
}
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = await crypto.subtle.sign(
{
name: 'ECDSA',
hash: this.resolveHashAlgorithm(keyPair.algorithm),
},
keyPair.privateKey,
new TextEncoder().encode(signingInput)
);
const joseSignature = base64UrlEncode(derToJoseSignature(signature));
return `${signingInput}.${joseSignature}`;
}
private async getOrCreateKeyPair(): Promise<LoadedDpopKeyPair> {
if (!this.keyPairPromise) {
this.keyPairPromise = this.loadKeyPair();
}
try {
return await this.keyPairPromise;
} catch (error) {
// Reset the memoized promise so a subsequent call can retry.
this.keyPairPromise = null;
throw error;
}
}
private async loadKeyPair(): Promise<LoadedDpopKeyPair> {
const algorithm = this.resolveAlgorithm();
try {
const existing = await this.store.load();
if (existing && existing.algorithm === algorithm) {
return existing;
}
} catch {
// fall through to regeneration
}
return this.store.generate(algorithm);
}
private resolveAlgorithm(): DPoPAlgorithm {
const authority = this.config.authority;
return authority.dpopAlgorithms?.[0] ?? 'ES256';
}
private resolveHashAlgorithm(algorithm: DPoPAlgorithm): string {
switch (algorithm) {
case 'ES384':
return 'SHA-384';
case 'ES256':
default:
return 'SHA-256';
}
}
}
function normalizeHtu(value: string): string {
try {
const base =
typeof window !== 'undefined' && window.location
? window.location.origin
: undefined;
const url = base ? new URL(value, base) : new URL(value);
url.hash = '';
return url.toString();
} catch {
return value;
}
}
function createRandomId(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}

View File

@@ -0,0 +1,123 @@
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest('SHA-256', data);
return new Uint8Array(digest);
}
export function base64UrlEncode(
input: ArrayBuffer | Uint8Array | string
): string {
let bytes: Uint8Array;
if (typeof input === 'string') {
bytes = new TextEncoder().encode(input);
} else if (input instanceof Uint8Array) {
bytes = input;
} else {
bytes = new Uint8Array(input);
}
let binary = '';
for (let i = 0; i < bytes.byteLength; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function base64UrlDecode(value: string): Uint8Array {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padding = normalized.length % 4;
const padded =
padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
export async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
const canonical = canonicalizeJwk(jwk);
const digest = await sha256(new TextEncoder().encode(canonical));
return base64UrlEncode(digest);
}
function canonicalizeJwk(jwk: JsonWebKey): string {
if (!jwk.kty) {
throw new Error('JWK must include "kty"');
}
if (jwk.kty === 'EC') {
const { crv, kty, x, y } = jwk;
if (!crv || !x || !y) {
throw new Error('EC JWK must include "crv", "x", and "y".');
}
return JSON.stringify({ crv, kty, x, y });
}
if (jwk.kty === 'OKP') {
const { crv, kty, x } = jwk;
if (!crv || !x) {
throw new Error('OKP JWK must include "crv" and "x".');
}
return JSON.stringify({ crv, kty, x });
}
throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
}
export function derToJoseSignature(der: ArrayBuffer): Uint8Array {
const bytes = new Uint8Array(der);
if (bytes[0] !== 0x30) {
// Some implementations already return raw (r || s) signature bytes.
if (bytes.length === 64) {
return bytes;
}
throw new Error('Invalid DER signature: expected sequence.');
}
let offset = 2; // skip SEQUENCE header and length (assume short form)
if (bytes[1] & 0x80) {
const lengthBytes = bytes[1] & 0x7f;
offset = 2 + lengthBytes;
}
if (bytes[offset] !== 0x02) {
throw new Error('Invalid DER signature: expected INTEGER for r.');
}
const rLength = bytes[offset + 1];
let r = bytes.slice(offset + 2, offset + 2 + rLength);
offset = offset + 2 + rLength;
if (bytes[offset] !== 0x02) {
throw new Error('Invalid DER signature: expected INTEGER for s.');
}
const sLength = bytes[offset + 1];
let s = bytes.slice(offset + 2, offset + 2 + sLength);
r = trimLeadingZeros(r);
s = trimLeadingZeros(s);
const targetLength = 32;
const signature = new Uint8Array(targetLength * 2);
signature.set(padStart(r, targetLength), 0);
signature.set(padStart(s, targetLength), targetLength);
return signature;
}
function trimLeadingZeros(bytes: Uint8Array): Uint8Array {
let start = 0;
while (start < bytes.length - 1 && bytes[start] === 0x00) {
start += 1;
}
return bytes.subarray(start);
}
function padStart(bytes: Uint8Array, length: number): Uint8Array {
if (bytes.length >= length) {
return bytes;
}
const padded = new Uint8Array(length);
padded.set(bytes, length - bytes.length);
return padded;
}

View File

@@ -0,0 +1,24 @@
import { base64UrlEncode, sha256 } from './dpop/jose-utilities';
export interface PkcePair {
readonly verifier: string;
readonly challenge: string;
readonly method: 'S256';
}
const VERIFIER_BYTE_LENGTH = 32;
export async function createPkcePair(): Promise<PkcePair> {
const verifierBytes = new Uint8Array(VERIFIER_BYTE_LENGTH);
crypto.getRandomValues(verifierBytes);
const verifier = base64UrlEncode(verifierBytes);
const challengeBytes = await sha256(new TextEncoder().encode(verifier));
const challenge = base64UrlEncode(challengeBytes);
return {
verifier,
challenge,
method: 'S256',
};
}

Some files were not shown because too many files have changed in this diff Show More