70 Commits
Author SHA1 Message Date
zemion 1f039dd39c Retain Caddy file capability at ingress boundary 2026-08-03 20:02:29 +02:00
zemion d107d94fec Add standalone managed ingress diagnostics 2026-08-03 19:59:12 +02:00
zemion 6163c5992f Fix ingress probe image selection 2026-08-03 19:49:35 +02:00
zemion 2f28f22fd1 Probe managed ingress across Docker namespaces 2026-08-03 19:41:04 +02:00
zemion 909862afdb Fix managed ingress loopback publication 2026-08-03 19:28:10 +02:00
zemion eb04804d36 Handle Redis under arm64 CI emulation 2026-08-03 19:18:01 +02:00
zemion 017aa7a702 Enable arm64 runtime smoke execution 2026-08-03 19:11:45 +02:00
zemion af27b9fbdf Resolve runtime dependency platform digests 2026-08-03 19:04:06 +02:00
zemion cb45251c59 Fix read-only Web runtime publication 2026-08-03 18:56:23 +02:00
zemion 3b3d5b3386 Redact runtime smoke diagnostics
Dependency Audit / dependency-audit (push) Successful in 1m44s
Deployment Installer / deployment-installer (push) Successful in 7s
2026-08-03 18:36:00 +02:00
zemion 313249b8fc Exercise packaged runtime topology
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 6s
2026-08-03 18:26:04 +02:00
zemion 282c90c54b Make ingress drill Docker socket portable
Dependency Audit / dependency-audit (push) Successful in 1m44s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m56s
2026-08-03 17:55:48 +02:00
zemion 25424187a8 Package runtime migration scripts correctly
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m1s
2026-08-03 17:54:09 +02:00
zemion ff8ee991c3 Harden runtime distribution acceptance
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m49s
2026-08-03 17:32:52 +02:00
zemion a5a0731d20 Fix runtime distribution signing environment
Dependency Audit / dependency-audit (push) Successful in 1m48s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m50s
2026-08-03 17:09:50 +02:00
zemion a0f161041d Record Risk Compliance interface migration
Dependency Audit / dependency-audit (push) Successful in 1m46s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m42s
2026-08-03 15:47:41 +02:00
zemion cb85999a14 Record Notifications interface migration
Dependency Audit / dependency-audit (push) Successful in 1m52s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m42s
2026-08-03 15:34:18 +02:00
zemion cbfe8b03a7 Record Ops interface migration
Dependency Audit / dependency-audit (push) Successful in 1m57s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m5s
2026-08-03 15:19:07 +02:00
zemion 768e9a51c9 Record Calendar interface migration
Dependency Audit / dependency-audit (push) Successful in 1m59s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m5s
2026-08-03 15:09:01 +02:00
zemion 087561ee12 Record Cases interface migration
Dependency Audit / dependency-audit (push) Successful in 1m52s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 11m2s
2026-08-03 14:44:14 +02:00
zemion 11c1aa1815 Record Dashboard interface migration
Dependency Audit / dependency-audit (push) Successful in 1m47s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m49s
2026-08-03 14:25:45 +02:00
zemion 478ecb5d0e Record Dataflow interface migration
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m40s
2026-08-03 14:20:21 +02:00
zemion 32689d027a Repair missing packages during environment sync
Dependency Audit / dependency-audit (push) Successful in 1m50s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m50s
2026-08-03 14:06:41 +02:00
zemion b7cc2d2df4 Record Datasources interface migration
Dependency Audit / dependency-audit (push) Successful in 1m47s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 10m43s
2026-08-03 13:47:26 +02:00
zemion b70869e747 Record Addresses interface migration
Dependency Audit / dependency-audit (push) Successful in 1m43s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m43s
2026-08-03 13:41:04 +02:00
zemion 0c84afb158 Record Templates interface migration
Dependency Audit / dependency-audit (push) Successful in 1m47s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m56s
2026-08-03 13:35:11 +02:00
zemion 145aa58c11 Record Distribution Lists interface migration
Dependency Audit / dependency-audit (push) Successful in 1m58s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m58s
2026-08-03 13:28:33 +02:00
zemion a3566c9311 Record Voting interface migration
Dependency Audit / dependency-audit (push) Successful in 2m4s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m49s
2026-08-03 13:17:08 +02:00
zemion 3219460064 Record Forms interface migration
Dependency Audit / dependency-audit (push) Successful in 2m3s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m0s
2026-08-03 13:09:24 +02:00
zemion 4b2a15adb5 Record Forms Runtime interface migration
Dependency Audit / dependency-audit (push) Successful in 2m1s
Deployment Installer / deployment-installer (push) Successful in 7s
Security Audit / security-audit (push) Successful in 10m49s
2026-08-03 13:02:51 +02:00
zemion acc5ffc247 Record Approvals interface migration
Dependency Audit / dependency-audit (push) Successful in 2m0s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m0s
2026-08-03 12:56:07 +02:00
zemion f4f9836a09 Record Committee interface migration
Dependency Audit / dependency-audit (push) Successful in 1m57s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m53s
2026-08-03 12:49:58 +02:00
zemion 758fa1bba7 Record IDM interface migration
Dependency Audit / dependency-audit (push) Successful in 1m55s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m54s
2026-08-03 12:37:12 +02:00
zemion 0acc8cfc31 Record Postbox interface migration
Dependency Audit / dependency-audit (push) Successful in 1m51s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m52s
2026-08-03 12:26:52 +02:00
zemion 344bcaf1bc Record Organizations interface migration
Dependency Audit / dependency-audit (push) Successful in 1m47s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m59s
2026-08-03 12:10:13 +02:00
zemion 794622e4ed Record Views interface migration
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 7s
Security Audit / security-audit (push) Successful in 10m42s
2026-08-03 11:58:24 +02:00
zemion 7653e9851f Record Tenancy interface migration
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m56s
2026-08-03 11:43:28 +02:00
zemion 6f896d9c04 Record Admin interface migration
Dependency Audit / dependency-audit (push) Successful in 1m41s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m48s
2026-08-03 11:30:45 +02:00
zemion 8f5ac52b58 Record Access interface migration
Dependency Audit / dependency-audit (push) Successful in 1m42s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m47s
2026-08-03 11:01:57 +02:00
zemion 2bc9ad7f00 Record completed Audit interface migration
Dependency Audit / dependency-audit (push) Successful in 1m43s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m51s
2026-08-03 10:43:57 +02:00
zemion fa1a4bacfb Record completed Scheduling interface migration
Dependency Audit / dependency-audit (push) Successful in 1m43s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 10m43s
2026-08-03 10:36:39 +02:00
zemion 0c0669768d Record Policy interface migration
Dependency Audit / dependency-audit (push) Successful in 1m42s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m45s
2026-08-03 10:23:22 +02:00
zemion 7b6ceeb185 Record completed Core configuration patterns
Dependency Audit / dependency-audit (push) Successful in 1m47s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m37s
2026-08-03 10:17:18 +02:00
zemion ff12f676a1 Record Mail interface pattern migration
Dependency Audit / dependency-audit (push) Successful in 1m46s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m8s
2026-08-03 10:07:19 +02:00
zemion eb9ab9ef1c Record Files interface pattern migration
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m49s
2026-08-03 09:51:01 +02:00
zemion 935c1fe162 Track module interface migration work
Dependency Audit / dependency-audit (push) Successful in 1m38s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m19s
2026-08-03 08:31:34 +02:00
zemion c7d1cd0e8f Exercise recovery in datasource composition check
Dependency Audit / dependency-audit (push) Successful in 1m40s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m14s
2026-08-03 07:46:57 +02:00
zemion ac80d7e4e3 Record Campaign review pattern evidence
Dependency Audit / dependency-audit (push) Successful in 1m38s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m18s
2026-08-03 07:24:23 +02:00
zemion 5e449b0983 Record Core lifecycle recovery adoption
Dependency Audit / dependency-audit (push) Successful in 1m44s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m41s
2026-08-03 07:02:34 +02:00
zemion d4bf07b446 Record workflow recovery ledger adoption
Dependency Audit / dependency-audit (push) Successful in 1m47s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m35s
2026-08-03 06:37:45 +02:00
zemion adc4db9fdf Record Dataflow recovery adoption
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m16s
2026-08-03 06:09:53 +02:00
zemion 484f2af3ac Record Connectors recovery adoption
Dependency Audit / dependency-audit (push) Successful in 1m40s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 10m11s
2026-08-03 05:43:58 +02:00
zemion abf9564cee Record Mail recovery adoption
Dependency Audit / dependency-audit (push) Successful in 1m36s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 10m13s
2026-08-03 05:00:46 +02:00
zemion 5bef966119 Record Files recovery ledger adoption
Dependency Audit / dependency-audit (push) Successful in 2m0s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m13s
2026-08-03 04:22:28 +02:00
zemion 5e80b39bbd Record Campaign recovery ledger adoption
Dependency Audit / dependency-audit (push) Successful in 1m47s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 10m27s
2026-08-03 03:55:16 +02:00
zemion 9370f501a0 Inventory module recovery ledger adoption
Dependency Audit / dependency-audit (push) Successful in 1m36s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 9m54s
2026-08-03 03:02:53 +02:00
zemion e8f7e2c194 Repair release and interface CI gates
Dependency Audit / dependency-audit (push) Successful in 1m35s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m5s
2026-08-03 02:02:48 +02:00
zemion cbbe08d912 Enforce signed backup evidence before migrations 2026-08-03 02:01:13 +02:00
zemion 2c515f73c2 Implement supported ingress and TLS profiles
Dependency Audit / dependency-audit (push) Successful in 1m38s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 10m2s
2026-08-03 01:15:06 +02:00
zemion b40f1428fd Resolve backend inventory from checkout
Dependency Audit / dependency-audit (push) Successful in 1m38s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 10m2s
2026-08-03 01:12:41 +02:00
zemion 43380eb068 Add signed runtime distribution pipeline
Dependency Audit / dependency-audit (push) Successful in 1m39s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 10m3s
2026-08-03 00:54:06 +02:00
zemion 29acb55b7c Resolve inventory workspace from checkout
Dependency Audit / dependency-audit (push) Successful in 1m37s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m1s
2026-08-03 00:51:20 +02:00
zemion 4f08b52333 Allow tagged module build-tool peer drift
Dependency Audit / dependency-audit (push) Successful in 1m41s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 10m23s
2026-08-03 00:26:55 +02:00
zemion d3713bf2ee Automate worker runtime delivery drill
Dependency Audit / dependency-audit (push) Failing after 9s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Failing after 8s
2026-08-03 00:20:57 +02:00
zemion be4410ef1a Record Calendar outbox recovery surface
Dependency Audit / dependency-audit (push) Failing after 9s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Failing after 8s
2026-08-02 15:57:12 +02:00
zemion 29d07fe375 Integrate Templates into development profiles
Dependency Audit / dependency-audit (push) Failing after 9s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Failing after 8s
2026-08-02 12:38:55 +02:00
zemion d9003bf63a fix: authenticate private module bootstrap in CI
Dependency Audit / dependency-audit (push) Failing after 9s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Failing after 8s
2026-08-02 05:40:22 +02:00
zemion ed31409034 feat: add bounded Kubernetes runtime verification
Dependency Audit / dependency-audit (push) Failing after 9s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Failing after 8s
2026-08-02 05:30:26 +02:00
zemion 50e9607e72 feat: enforce backend endpoint classification 2026-08-02 05:30:20 +02:00
zemion f7a30682b3 refactor: reduce audited source duplication 2026-08-02 05:30:15 +02:00
72 changed files with 12960 additions and 470 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ GOVOPLAN_DB_MAX_OVERFLOW=10
GOVOPLAN_DB_POOL_TIMEOUT_SECONDS=30
GOVOPLAN_DB_POOL_RECYCLE_SECONDS=1800
ENABLED_MODULES=tenancy,organizations,identity,idm,access,admin,dashboard,policy,audit,files,mail,campaigns,calendar,poll,scheduling,connectors,datasources,dataflow,workflow_engine,workflow,views,search,risk_compliance,postbox,notifications,services,parties,mandates,decisions,portal,cases,committee,docs,ops
ENABLED_MODULES=tenancy,organizations,identity,idm,access,admin,dashboard,policy,audit,files,templates,mail,campaigns,calendar,poll,scheduling,connectors,datasources,dataflow,dist_lists,workflow_engine,workflow,views,search,risk_compliance,postbox,notifications,services,parties,mandates,decisions,portal,cases,committee,docs,ops
CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0
+4 -2
View File
@@ -1,5 +1,7 @@
name: Dependency Audit
permissions: read-all
on:
pull_request:
push:
@@ -21,13 +23,13 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Use anonymous HTTPS for public GovOPlaN repositories
- name: Use HTTPS for GovOPlaN repositories
run: |
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan"
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan"
- name: Bootstrap GovOPlaN repositories
working-directory: govoplan
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --reuse-checkout-auth --exclude-repo addideas-govoplan-website
- name: Install backend dev audit dependencies
working-directory: govoplan
run: |
+21 -2
View File
@@ -1,5 +1,7 @@
name: Module Matrix
permissions: read-all
on:
workflow_dispatch:
pull_request:
@@ -19,6 +21,13 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
@@ -29,13 +38,13 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Use anonymous HTTPS for public GovOPlaN repositories
- name: Use HTTPS for GovOPlaN repositories
run: |
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan"
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan"
- name: Bootstrap GovOPlaN repositories
working-directory: govoplan
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --reuse-checkout-auth --exclude-repo addideas-govoplan-website
- name: Install backend release dependencies
working-directory: govoplan
run: |
@@ -46,6 +55,9 @@ jobs:
- name: Install WebUI release dependencies with test scripts
working-directory: govoplan
run: bash tools/release/install-webui-release-dependencies.sh ../govoplan-core/webui
- name: Validate platform endpoint surface declarations
working-directory: govoplan
run: .venv/bin/python tools/inventory/platform-interface-inventory.py --strict
- name: Validate Search against PostgreSQL
working-directory: govoplan
env:
@@ -61,6 +73,13 @@ jobs:
-s ../govoplan-search/tests \
-p test_postgres_search.py \
-v
- name: Prove worker delivery and shutdown guarantees
working-directory: govoplan
env:
GOVOPLAN_WORKER_DRILL_REDIS_URL: redis://redis:6379/15
run: |
.venv/bin/python tools/checks/worker-runtime-drill.py \
--output audit-reports/worker-runtime.json
- name: Run module matrix and contract tests
working-directory: govoplan
run: GOVOPLAN_CORE_ROOT="$PWD/../govoplan-core" PYTHON="$PWD/.venv/bin/python" bash tools/checks/check-module-matrix.sh
+4 -2
View File
@@ -1,5 +1,7 @@
name: Release Integration
permissions: read-all
on:
workflow_dispatch:
@@ -16,13 +18,13 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Use anonymous HTTPS for public GovOPlaN repositories
- name: Use HTTPS for GovOPlaN repositories
run: |
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan"
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan"
- name: Bootstrap GovOPlaN repositories
working-directory: govoplan
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --reuse-checkout-auth --exclude-repo addideas-govoplan-website
- name: Install backend release integration dependencies
working-directory: govoplan
run: |
+369
View File
@@ -0,0 +1,369 @@
name: Runtime Distribution
on:
workflow_dispatch:
inputs:
version:
description: Release version without leading v
required: true
type: string
python_image:
description: Digest-pinned multi-architecture Python 3.12 slim image
required: true
type: string
nginx_image:
description: Digest-pinned multi-architecture nginx-unprivileged image
required: true
type: string
postgres_image:
description: Digest-pinned PostgreSQL image
required: true
type: string
redis_image:
description: Digest-pinned Redis image
required: true
type: string
load_balancer_image:
description: Digest-pinned HAProxy image
required: true
type: string
managed_ingress_image:
description: Digest-pinned Caddy image
required: true
type: string
garage_image:
description: Digest-pinned Garage image
required: true
type: string
test_mail_image:
description: Digest-pinned GreenMail image
required: true
type: string
binfmt_image:
description: Digest-pinned tonistiigi/binfmt image for arm64 CI execution
required: true
type: string
jobs:
publish-runtime:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
path: govoplan
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Validate immutable release inputs
env:
VERSION: ${{ inputs.version }}
PYTHON_IMAGE: ${{ inputs.python_image }}
NGINX_IMAGE: ${{ inputs.nginx_image }}
POSTGRES_IMAGE: ${{ inputs.postgres_image }}
REDIS_IMAGE: ${{ inputs.redis_image }}
LOAD_BALANCER_IMAGE: ${{ inputs.load_balancer_image }}
MANAGED_INGRESS_IMAGE: ${{ inputs.managed_ingress_image }}
GARAGE_IMAGE: ${{ inputs.garage_image }}
TEST_MAIL_IMAGE: ${{ inputs.test_mail_image }}
BINFMT_IMAGE: ${{ inputs.binfmt_image }}
run: |
python - <<'PY'
import os
import re
version = os.environ["VERSION"]
if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?", version) is None:
raise SystemExit("version must be a SemVer value without a leading v")
image_pattern = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$")
for name in (
"PYTHON_IMAGE",
"NGINX_IMAGE",
"POSTGRES_IMAGE",
"REDIS_IMAGE",
"LOAD_BALANCER_IMAGE",
"MANAGED_INGRESS_IMAGE",
"GARAGE_IMAGE",
"TEST_MAIL_IMAGE",
"BINFMT_IMAGE",
):
if image_pattern.fullmatch(os.environ[name]) is None:
raise SystemExit(f"{name} must be an exact sha256 image reference")
PY
- name: Use HTTPS for GovOPlaN repositories
run: |
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan"
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan"
- name: Bootstrap release sources
working-directory: govoplan
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --reuse-checkout-auth --exclude-repo addideas-govoplan-website
- name: Build release wheel roots and WebUI
working-directory: govoplan
run: |
python -m venv .runtime-build
.runtime-build/bin/python -m pip install --upgrade pip wheel cryptography
mkdir -p runtime-output/local-wheels
.runtime-build/bin/python -m pip wheel --no-deps --wheel-dir runtime-output/local-wheels --requirement requirements-release.txt
bash tools/release/install-webui-release-dependencies.sh ../govoplan-core/webui
npm --prefix ../govoplan-core/webui run build
.runtime-build/bin/python tools/release/prepare-runtime-context.py \
--wheelhouse runtime-output/local-wheels \
--web-dist ../govoplan-core/webui/dist \
--output runtime-output/common \
--required-module tenancy \
--required-module organizations \
--required-module identity \
--required-module idm \
--required-module access \
--required-module admin \
--required-module dashboard \
--required-module policy \
--required-module audit \
--required-module docs \
--required-module ops
- name: Resolve architecture-specific offline wheelhouses
working-directory: govoplan
run: |
mkdir -p runtime-output/wheels-amd64 runtime-output/wheels-arm64
cp runtime-output/local-wheels/*.whl runtime-output/wheels-amd64/
cp runtime-output/local-wheels/*.whl runtime-output/wheels-arm64/
.runtime-build/bin/python -m pip download --only-binary=:all: \
--platform manylinux_2_17_x86_64 --platform manylinux2014_x86_64 \
--implementation cp --python-version 3.12 --abi cp312 \
--find-links runtime-output/local-wheels \
--dest runtime-output/wheels-amd64 \
--requirement runtime-output/common/requirements-runtime.txt
.runtime-build/bin/python -m pip download --only-binary=:all: \
--platform manylinux_2_17_aarch64 --platform manylinux2014_aarch64 \
--implementation cp --python-version 3.12 --abi cp312 \
--find-links runtime-output/local-wheels \
--dest runtime-output/wheels-arm64 \
--requirement runtime-output/common/requirements-runtime.txt
.runtime-build/bin/python tools/release/prepare-runtime-context.py \
--wheelhouse runtime-output/wheels-amd64 \
--web-dist ../govoplan-core/webui/dist \
--output runtime-output/context-amd64
.runtime-build/bin/python tools/release/prepare-runtime-context.py \
--wheelhouse runtime-output/wheels-arm64 \
--web-dist ../govoplan-core/webui/dist \
--output runtime-output/context-arm64
cmp runtime-output/context-amd64/composition.json runtime-output/context-arm64/composition.json
- name: Build one-file deployer
working-directory: govoplan
run: python tools/deployment/build-deployer-zipapp.py --output runtime-output/govoplan-deploy.pyz
- name: Authenticate OCI publication
working-directory: govoplan
env:
REGISTRY_USERNAME: ${{ secrets.GOVOPLAN_REGISTRY_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.GOVOPLAN_REGISTRY_TOKEN }}
run: |
test -n "$REGISTRY_USERNAME"
test -n "$REGISTRY_TOKEN"
printf '%s' "$REGISTRY_TOKEN" | docker login git.add-ideas.de --username "$REGISTRY_USERNAME" --password-stdin
docker buildx create --name govoplan-runtime --use
- name: Build and publish architecture images
working-directory: govoplan
env:
VERSION: ${{ inputs.version }}
PYTHON_IMAGE: ${{ inputs.python_image }}
NGINX_IMAGE: ${{ inputs.nginx_image }}
run: |
COMPOSITION_SHA256="$(sha256sum runtime-output/context-amd64/composition.json | cut -d' ' -f1)"
for ARCH in amd64 arm64; do
docker buildx build --platform "linux/$ARCH" --push \
--file tools/release/runtime/Dockerfile.api \
--build-arg "PYTHON_IMAGE=$PYTHON_IMAGE" \
--build-arg "GOVOPLAN_RELEASE_VERSION=$VERSION" \
--build-arg "GOVOPLAN_COMPOSITION_SHA256=$COMPOSITION_SHA256" \
--tag "git.add-ideas.de/govoplan/runtime-api:$VERSION-$ARCH" \
"runtime-output/context-$ARCH"
docker buildx build --platform "linux/$ARCH" --push \
--file tools/release/runtime/Dockerfile.web \
--build-arg "NGINX_IMAGE=$NGINX_IMAGE" \
--build-arg "GOVOPLAN_RELEASE_VERSION=$VERSION" \
--build-arg "GOVOPLAN_COMPOSITION_SHA256=$COMPOSITION_SHA256" \
--tag "git.add-ideas.de/govoplan/runtime-web:$VERSION-$ARCH" \
"runtime-output/context-$ARCH"
done
docker buildx imagetools create \
--tag "git.add-ideas.de/govoplan/runtime-api:$VERSION" \
"git.add-ideas.de/govoplan/runtime-api:$VERSION-amd64" \
"git.add-ideas.de/govoplan/runtime-api:$VERSION-arm64"
docker buildx imagetools create \
--tag "git.add-ideas.de/govoplan/runtime-web:$VERSION" \
"git.add-ideas.de/govoplan/runtime-web:$VERSION-amd64" \
"git.add-ideas.de/govoplan/runtime-web:$VERSION-arm64"
docker buildx imagetools inspect "git.add-ideas.de/govoplan/runtime-api:$VERSION" --raw > runtime-output/api-index.json
docker buildx imagetools inspect "git.add-ideas.de/govoplan/runtime-web:$VERSION" --raw > runtime-output/web-index.json
API_DIGEST="sha256:$(sha256sum runtime-output/api-index.json | cut -d' ' -f1)"
WEB_DIGEST="sha256:$(sha256sum runtime-output/web-index.json | cut -d' ' -f1)"
python tools/release/resolve-oci-platforms.py --repository git.add-ideas.de/govoplan/runtime-api --index-digest "$API_DIGEST" --index runtime-output/api-index.json --output runtime-output/api-metadata.json
python tools/release/resolve-oci-platforms.py --repository git.add-ideas.de/govoplan/runtime-web --index-digest "$WEB_DIGEST" --index runtime-output/web-index.json --output runtime-output/web-metadata.json
- name: Resolve managed dependency platform images
working-directory: govoplan
env:
POSTGRES_IMAGE: ${{ inputs.postgres_image }}
REDIS_IMAGE: ${{ inputs.redis_image }}
run: |
docker buildx imagetools inspect "$POSTGRES_IMAGE" --raw > runtime-output/postgres-index.json
docker buildx imagetools inspect "$REDIS_IMAGE" --raw > runtime-output/redis-index.json
python tools/release/resolve-oci-platforms.py \
--repository "${POSTGRES_IMAGE%@*}" \
--index-digest "${POSTGRES_IMAGE##*@}" \
--index runtime-output/postgres-index.json \
--output runtime-output/postgres-metadata.json
python tools/release/resolve-oci-platforms.py \
--repository "${REDIS_IMAGE%@*}" \
--index-digest "${REDIS_IMAGE##*@}" \
--index runtime-output/redis-index.json \
--output runtime-output/redis-metadata.json
- name: Register arm64 execution for runtime smoke
working-directory: govoplan
env:
BINFMT_IMAGE: ${{ inputs.binfmt_image }}
run: docker run --privileged --rm "$BINFMT_IMAGE" --install arm64
- name: Exercise amd64 and arm64 runtime images
working-directory: govoplan
run: |
for ARCH in amd64 arm64; do
.runtime-build/bin/python tools/checks/runtime-image-smoke.py \
--api-metadata runtime-output/api-metadata.json \
--web-metadata runtime-output/web-metadata.json \
--postgres-metadata runtime-output/postgres-metadata.json \
--redis-metadata runtime-output/redis-metadata.json \
--platform "linux/$ARCH" \
--output "runtime-output/evidence/runtime-smoke-$ARCH.json"
done
- name: Generate and sign distribution evidence
working-directory: govoplan
env:
VERSION: ${{ inputs.version }}
SOURCE_COMMIT: ${{ gitea.sha }}
SIGNING_KEY: ${{ secrets.RUNTIME_DISTRIBUTION_SIGNING_KEY }}
SIGNING_KEY_ID: ${{ secrets.RUNTIME_DISTRIBUTION_SIGNING_KEY_ID }}
TRUSTED_KEYRING: ${{ secrets.RUNTIME_DISTRIBUTION_KEYRING }}
POSTGRES_IMAGE: ${{ inputs.postgres_image }}
REDIS_IMAGE: ${{ inputs.redis_image }}
LOAD_BALANCER_IMAGE: ${{ inputs.load_balancer_image }}
MANAGED_INGRESS_IMAGE: ${{ inputs.managed_ingress_image }}
GARAGE_IMAGE: ${{ inputs.garage_image }}
TEST_MAIL_IMAGE: ${{ inputs.test_mail_image }}
run: |
test -n "$SIGNING_KEY"
test -n "$SIGNING_KEY_ID"
test -n "$TRUSTED_KEYRING"
printf '%s\n' "$SIGNING_KEY" > runtime-output/signing-key.pem
printf '%s\n' "$TRUSTED_KEYRING" > runtime-output/distribution-keyring.json
chmod 600 runtime-output/signing-key.pem
ARTIFACT_BASE="https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/v$VERSION"
python tools/release/finalize-runtime-distribution.py \
--composition runtime-output/context-amd64/composition.json \
--api-metadata runtime-output/api-metadata.json \
--web-metadata runtime-output/web-metadata.json \
--deployer runtime-output/govoplan-deploy.pyz \
--deployer-url "$ARTIFACT_BASE/govoplan-deploy.pyz" \
--artifact-base-url "$ARTIFACT_BASE" \
--source-commit "$SOURCE_COMMIT" \
--version "$VERSION" \
--sequence "$(date -u +%Y%m%d%H%M)" \
--dependency "postgres=$POSTGRES_IMAGE" \
--dependency "redis=$REDIS_IMAGE" \
--dependency "load_balancer=$LOAD_BALANCER_IMAGE" \
--dependency "managed_ingress=$MANAGED_INGRESS_IMAGE" \
--dependency "garage=$GARAGE_IMAGE" \
--dependency "test_mail=$TEST_MAIL_IMAGE" \
--output-directory runtime-output/evidence \
--descriptor runtime-output/distribution-descriptor.json
.runtime-build/bin/python tools/release/generate-runtime-distribution.py \
--descriptor runtime-output/distribution-descriptor.json \
--signing-key "$SIGNING_KEY_ID=runtime-output/signing-key.pem" \
--output runtime-output/distribution-manifest.json
openssl pkeyutl -sign -inkey runtime-output/signing-key.pem -rawin \
-in runtime-output/govoplan-deploy.pyz \
-out runtime-output/govoplan-deploy.pyz.sig
(cd runtime-output && sha256sum govoplan-deploy.pyz > govoplan-deploy.pyz.sha256)
(cd runtime-output && sha256sum distribution-manifest.json > distribution-manifest.json.sha256)
rm runtime-output/signing-key.pem
- name: Verify the published bundle contract with the zipapp
working-directory: govoplan
env:
VERSION: ${{ inputs.version }}
SIGNING_KEY_ID: ${{ secrets.RUNTIME_DISTRIBUTION_SIGNING_KEY_ID }}
run: |
(cd runtime-output && sha256sum --check govoplan-deploy.pyz.sha256)
(cd runtime-output && sha256sum --check distribution-manifest.json.sha256)
.runtime-build/bin/python - <<'PY'
import json
import os
from pathlib import Path
keyring = json.loads(
Path("runtime-output/distribution-keyring.json").read_text(encoding="utf-8")
)
key_id = os.environ["SIGNING_KEY_ID"]
matches = [item for item in keyring["keys"] if item.get("key_id") == key_id]
if len(matches) != 1 or matches[0].get("status") != "active":
raise SystemExit("runtime signing key is not uniquely active in the keyring")
Path("runtime-output/runtime-release-public.pem").write_text(
matches[0]["public_key_pem"], encoding="utf-8"
)
PY
openssl pkeyutl -verify -pubin \
-inkey runtime-output/runtime-release-public.pem -rawin \
-in runtime-output/govoplan-deploy.pyz \
-sigfile runtime-output/govoplan-deploy.pyz.sig
cp runtime-output/govoplan-deploy.pyz runtime-output/govoplan-deploy.tampered.pyz
printf '\0' >> runtime-output/govoplan-deploy.tampered.pyz
if openssl pkeyutl -verify -pubin \
-inkey runtime-output/runtime-release-public.pem -rawin \
-in runtime-output/govoplan-deploy.tampered.pyz \
-sigfile runtime-output/govoplan-deploy.pyz.sig >/dev/null 2>&1; then
echo "Tampered deployment bootstrap unexpectedly verified" >&2
exit 1
fi
MANIFEST_SHA256="$(cut -d' ' -f1 runtime-output/distribution-manifest.json.sha256)"
python runtime-output/govoplan-deploy.pyz init \
--directory runtime-output/acceptance-install \
--non-interactive --module-set base
python runtime-output/govoplan-deploy.pyz verify-release \
--directory runtime-output/acceptance-install \
--manifest runtime-output/distribution-manifest.json \
--manifest-sha256 "$MANIFEST_SHA256" \
--trusted-keyring runtime-output/distribution-keyring.json \
--adopt
- name: Exercise the managed ingress boundary
working-directory: govoplan
env:
MANAGED_INGRESS_IMAGE: ${{ inputs.managed_ingress_image }}
LOAD_BALANCER_IMAGE: ${{ inputs.load_balancer_image }}
run: >-
python tools/checks/managed-ingress-drill.py
--caddy-image "$MANAGED_INGRESS_IMAGE"
--load-balancer-image "$LOAD_BALANCER_IMAGE"
--probe-image "$(jq -r '.platforms["linux/amd64"]' runtime-output/api-metadata.json)"
- name: Publish immutable Gitea release assets
working-directory: govoplan
env:
VERSION: ${{ inputs.version }}
SOURCE_COMMIT: ${{ gitea.sha }}
GITEA_RELEASE_TOKEN: ${{ secrets.GOVOPLAN_RELEASE_TOKEN }}
run: |
python tools/release/publish-runtime-release.py \
--tag "v$VERSION" \
--target-commit "$SOURCE_COMMIT" \
--title "GovOPlaN v$VERSION runtime distribution" \
--asset runtime-output/govoplan-deploy.pyz \
--asset runtime-output/govoplan-deploy.pyz.sig \
--asset runtime-output/govoplan-deploy.pyz.sha256 \
--asset runtime-output/distribution-manifest.json \
--asset runtime-output/distribution-manifest.json.sha256 \
--asset runtime-output/distribution-keyring.json \
--asset runtime-output/context-amd64/composition.json \
--asset runtime-output/evidence/api-sbom.cdx.json \
--asset runtime-output/evidence/web-sbom.cdx.json \
--asset runtime-output/evidence/api-provenance.json \
--asset runtime-output/evidence/web-provenance.json \
--asset runtime-output/evidence/runtime-smoke-amd64.json \
--asset runtime-output/evidence/runtime-smoke-arm64.json
@@ -0,0 +1,44 @@
name: Runtime Ingress Drill
on:
workflow_dispatch:
inputs:
caddy_image:
description: Digest-pinned Caddy image
required: true
type: string
load_balancer_image:
description: Digest-pinned HAProxy image
required: true
type: string
probe_image:
description: Digest-pinned amd64 GovOPlaN API image
required: true
type: string
jobs:
managed-ingress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
path: govoplan
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- name: Authenticate runtime image pull
env:
REGISTRY_USERNAME: ${{ secrets.GOVOPLAN_REGISTRY_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.GOVOPLAN_REGISTRY_TOKEN }}
run: echo "$REGISTRY_TOKEN" | docker login git.add-ideas.de --username "$REGISTRY_USERNAME" --password-stdin
- name: Exercise the managed ingress boundary
working-directory: govoplan
env:
CADDY_IMAGE: ${{ inputs.caddy_image }}
LOAD_BALANCER_IMAGE: ${{ inputs.load_balancer_image }}
PROBE_IMAGE: ${{ inputs.probe_image }}
run: >-
python tools/checks/managed-ingress-drill.py
--caddy-image "$CADDY_IMAGE"
--load-balancer-image "$LOAD_BALANCER_IMAGE"
--probe-image "$PROBE_IMAGE"
+3 -1
View File
@@ -1,5 +1,7 @@
name: Security Audit
permissions: read-all
on:
push:
branches:
@@ -30,7 +32,7 @@ jobs:
python-version: "3.12"
- name: Bootstrap GovOPlaN repositories
working-directory: govoplan
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --reuse-checkout-auth --exclude-repo addideas-govoplan-website
- name: Run whole-system security audit
working-directory: govoplan
run: tools/checks/security-audit/run.sh --mode "$SECURITY_AUDIT_MODE" --scope "$SECURITY_AUDIT_SCOPE" --reports-dir audit-reports
+5
View File
@@ -4,6 +4,11 @@
.ruff_cache/
.venv/
runtime/
!tools/release/runtime/
tools/release/runtime/*
!tools/release/runtime/Dockerfile.api
!tools/release/runtime/Dockerfile.web
!tools/release/runtime/nginx.conf
__pycache__/
audit-reports/
coverage/
+6
View File
@@ -70,6 +70,12 @@ Clone missing repositories listed in `repositories.json`:
./tools/repo/bootstrap-repositories.py
```
Gitea Actions jobs bootstrap the registered repositories over HTTPS and reuse
only the checkout job's short-lived authentication header. If registered
modules are private, allow the meta repository read access under
`GovOPlaN -> Settings -> Actions -> General -> Cross-Repository Access`; no
long-lived personal token is stored by the workflow or bootstrap tool.
Update generated repository type notes in all READMEs:
```sh
+133
View File
@@ -0,0 +1,133 @@
# Backup And Restore Evidence
## Boundary
`govoplan-deploy` verifies backup and restore evidence; it does not receive
database, object-store, KMS, or orchestrator administration credentials and it
does not create the backup. A provider-owned backup controller creates one
coordinated recovery point, a separate drill runner restores it into an
isolated target, and an evidence authority signs the resulting receipt.
The application containers receive only a sanitized projection: evidence,
recovery-point and drill identifiers, hashes, timestamps, component count, and
measured RPO/RTO. Artifact locations, provider credentials, encryption-key
references, the public trust keyring, and private signing keys remain in the
deployment/evidence boundary.
The machine-readable contracts are:
- [`backup-evidence.schema.json`](backup-evidence.schema.json);
- [`backup-evidence-keyring.schema.json`](backup-evidence-keyring.schema.json).
One evidence document is bound to the installation id, deployment profile,
topology subject, exact signed release manifest, image digests, and composition
digest. It covers PostgreSQL, objects, protected configuration, and recoverable
key custody at one recovery point. It contains references, never key material.
## Production Sequence
1. Establish the provider snapshot, application quiesce, or transaction
boundary and retain a hash of its fencing token.
2. Capture PostgreSQL, object storage, protected deployment configuration, and
key-custody state within five minutes of that recovery point.
3. Restore all four components into a target isolated from production write
endpoints and production queues.
4. Start the exact immutable release named in the evidence, verify migration
heads, verify a deterministic manifest of representative object hashes, and
execute the documented semantic journey checks.
5. Record actual data loss and elapsed recovery as measured RPO and RTO. A
measured RPO above the declared objective invalidates the evidence.
6. Sign the canonical receipt using an evidence-authority Ed25519 key held
outside the application and deployment host. During key rotation, include
both accepted signatures.
7. Transfer the evidence SHA-256 through an independent approved channel, then
verify and adopt it on the deployment host.
Provider automation can sign and validate an unsigned receipt with:
```sh
python tools/deployment/sign-backup-evidence.py \
--input unsigned-backup-evidence.json \
--output backup-evidence.json \
--trusted-keyring backup-evidence-keyring.json \
--signing-key backup-authority-2026=/run/keys/backup-authority.pem
```
The private key file must be owner-only. The tool refuses an unexpected key
type, an inactive/untrusted signer, malformed or partial evidence, stale
recovery points, failed drill checks, mismatched releases, and non-canonical
output.
Adopt the result using the independently obtained digest:
```sh
python3 govoplan-deploy.pyz verify-backup \
--directory /srv/govoplan/default \
--evidence ./backup-evidence.json \
--evidence-sha256 "$APPROVED_BACKUP_EVIDENCE_SHA256" \
--trusted-keyring ./backup-evidence-keyring.json \
--adopt
```
Evidence is fresh for at most 24 hours and may declare an earlier expiry. Every
self-hosted release identity change is conservatively treated as a migration
boundary. `doctor`, Compose `apply`, and `render-kubernetes` fail closed when
fresh evidence for the previously applied immutable release is unavailable.
Compose verifies once before changing runtime state and again after API/worker
quiescing immediately before migration. The exported Kubernetes migration Job
is generated only after verification and is annotated with the sanitized
evidence digest, recovery-point id, and drill id.
## Provider Runbooks
### PostgreSQL
Use a managed transaction-consistent snapshot or a base backup plus retained
WAL sufficient to reconstruct the declared point. Record the provider,
protected artifact reference and digest, snapshot identity, and PostgreSQL LSN.
The restore drill must connect only to the isolated database and must compare
the resulting migration-head digest with the release expectation.
### Object Storage
Use provider snapshots/versioning or an immutable object copy. Build a sorted
manifest containing object key, version, size, and content digest, then record
its digest, object count, total bytes, provider version identity, and protected
artifact reference. Verify representative objects from every owning module
after restore. Single-node managed Garage is persistent but not highly
available; copy its coordinated recovery material to an independent failure
domain.
### Configuration And Key Custody
Back up the private installation bundle and external secret-manager bindings as
an encrypted artifact. Record only its reference and digest. For KMS/HSM/vault
state, record the provider keyset reference, version, and a successful
recoverability assertion. Never put a key, recovery share, token, password, or
credential-bearing URL in evidence. The isolated drill must prove that the
restored release can decrypt representative protected content without
exporting the key material into the report.
## Ownership And Retention
The deployment owner approves the RPO/RTO objectives. State-service owners
operate backup capture and restoration. Module owners define representative
objects and semantic checks. Security owns evidence-authority keys and
revocation. Operations schedules drills and retains sanitized status.
Retain backup artifacts for the approved legal/operational period and at least
through the release's rollback window. Retain signed evidence, drill reports,
and deletion receipts for the audit period. Disposal must remove every backup
copy and provider version according to policy, then revoke or retire references
without deleting the audit receipt. Cryptographic erasure is valid only when
key-destruction evidence and provider-copy coverage are independently proven.
## Failure Handling
Missing components, component-time skew, stale or expired evidence, revocation,
signature/key mismatch, changed stored files, release mismatch, failed semantic
checks, or an RPO breach block migration. The deployment journal records the
rejection without private provider details. If migration has not started, the
operator may supply fresh evidence and retry. Once migration starts, recovery
is explicitly forward-only until the verified coordinated recovery point is
restored with its matching release.
+186 -31
View File
@@ -91,8 +91,15 @@ The private installation directory contains:
| `compose.json` | Deterministic generated Compose definition |
| `garage.toml` | Non-secret managed Garage server configuration |
| `load-balancer.cfg` | Non-secret HAProxy WebUI/API discovery configuration |
| `Caddyfile` | Non-secret managed-ingress route and ACME policy |
| `existing-proxy.json` | Exact upstream, trusted-source, header, and health contract for an operator-owned proxy |
| `plan.json` | Latest desired-state diff and readiness findings |
| `receipt.json` | Last successfully applied immutable identities |
| `distribution-manifest.json` | Canonical signed runtime/image selection adopted by the installer |
| `distribution-keyring.json` | Explicitly installed public trust anchor for runtime releases |
| `backup-evidence.json` | Signed provider-neutral coordinated backup and isolated-restore receipt |
| `backup-keyring.json` | Explicit public trust anchor for backup evidence authorities |
| `backup-verification.json` | Sanitized local verification/adoption receipt |
| `applied-state/` | Checksum-verified snapshot of the last healthy deployment bundle |
| `operations/<id>/` | Private hash-chained deployment progress and recovery evidence |
| `kubernetes.json` | Optional stateless multi-host Kubernetes export |
@@ -128,30 +135,112 @@ Those images must already contain the selected module set. The override exists
only to exercise local orchestration before release artifacts exist; it is
rejected for `self-hosted`.
## Runtime Distribution Boundary
The protected `Runtime Distribution` workflow builds GovOPlaN wheels first,
resolves architecture-specific third-party wheels into offline wheelhouses, and
then assembles the API images with `pip --no-index`. The target host never
clones Git repositories and neither runtime image performs network package
installation. Separate amd64/arm64 API and WebUI images are joined into OCI
indexes and run as non-root identities. The release assets include CycloneDX
application SBOMs, SLSA-style provenance, exact composition evidence, the
single-file deployer, its detached Ed25519 signature, and a signed, expiring
distribution manifest. Evidence generation and signing run through the
workflow's isolated release Python environment so their cryptographic tooling
is explicit and independent of packages preinstalled in the Actions runner.
The API image points Core at the migration scripts installed from the verified
wheel under `/opt/govoplan/runtime/govoplan_core_runtime`; migrations therefore
do not depend on a source checkout or the build host's Python installation
scheme.
Before publication, the exact amd64 and arm64 image manifests each run release
migrations against the pinned PostgreSQL image, reach API and WebUI readiness
as non-root/read-only processes, and complete a task through the pinned Redis
image and packaged worker. Sanitized per-platform smoke receipts are retained
as immutable release assets.
PostgreSQL and Redis indexes are resolved to untagged platform-child digests
before each smoke run. This keeps the evidence architecture-specific and
avoids retargeting one local Docker tag between incompatible platforms.
The CI host registers arm64 execution with an explicitly supplied,
digest-pinned `tonistiigi/binfmt` image immediately before the smoke. This
privileged helper is confined to the release runner and is never part of a
GovOPlaN target deployment or its runtime image set.
Because QEMU user-mode execution triggers Redis's arm64 host-kernel COW guard,
the arm64 smoke suppresses only `ARM64-COW-BUG` while persistence, snapshots,
and append-only files are disabled. Target Redis services never inherit this
test-only option.
The smoke also proves a bounded post-migration table contract and aborts as
soon as a required container exits, rather than allowing a dead process to
consume the full readiness timeout.
Ingress acceptance streams generated configuration into Docker-managed
volumes before starting the read-only containers. It therefore also works when
an Actions job reaches a host or remote Docker daemon through a mounted socket;
the drill never assumes that a job-container path is visible to that daemon.
The drill allocates explicit loopback-only host ports and verifies Docker's
host binding configuration, avoiding daemon-specific random-port shorthand
behavior. Because an Actions job and deployment containers may be Docker
siblings, functional HTTP/TLS checks run from the digest-pinned API image on
the deployment network instead of assuming the Docker host is job-local.
The dispatch-only `Runtime Ingress Drill` workflow exposes the same bounded
check independently so ingress changes can be diagnosed before an immutable
runtime publication; it accepts only digest-pinned Caddy, HAProxy, and API
images and has no push trigger.
The official Caddy binary carries the `NET_BIND_SERVICE` file capability. The
managed-ingress container therefore drops every capability and adds back only
`NET_BIND_SERVICE`; otherwise Linux rejects the binary at `execve` before its
high-port configuration can start. `no-new-privileges`, a read-only root
filesystem, and non-privileged container ports remain enforced.
The bounded setup helper writes only generated public configuration as root so
it can initialize a new volume; the actual HAProxy process retains the image's
non-root identity and runs read-only with all capabilities dropped.
The manifest contract is
[`runtime-distribution-manifest.schema.json`](runtime-distribution-manifest.schema.json),
and its separately distributed trust-anchor contract is
[`runtime-distribution-keyring.schema.json`](runtime-distribution-keyring.schema.json).
Publication is immutable: an existing Gitea release asset must have the same
size and SHA-256 digest or publication fails.
Adopt a downloaded or prefetched release only after obtaining the manifest
digest and trusted keyring through the documented independent channel:
```sh
python3 govoplan-deploy.pyz verify-release \
--directory /srv/govoplan/installation \
--manifest ./distribution-manifest.json \
--manifest-sha256 "$(cut -d' ' -f1 distribution-manifest.json.sha256)" \
--trusted-keyring ./distribution-keyring.json \
--adopt
```
`doctor` and `apply` rehash both stored files, re-run OpenSSL Ed25519
verification, enforce channel/expiry/revocation, compare every selected image,
and prove that all enabled module ids occur in the signed image composition.
An offline image index can bind prefetched OCI archives to the same exact image
references and archive hashes; mutable tags or incomplete bundles are rejected.
## Current Production Gates
The tool deliberately reports blockers instead of pretending the source tree is
a production distribution:
1. **OCI release artifacts.** The release pipeline does not yet publish pinned
multi-architecture API and WebUI images.
2. **Signed distribution manifest.** A channel manifest must bind exact image
digests, Compose compatibility, SBOM/provenance references, and revocation
state. Recording a URL and checksum is not signature verification.
1. **First publication.** The protected workflow and fail-closed artifact
contracts are implemented, but a release operator must configure the Gitea
registry/release tokens and runtime Ed25519 key, publish the first pinned
release, and retain its amd64/arm64 readiness evidence.
3. **First administrator.** Production needs a one-time, restricted enrollment
identity. The development bootstrap must not be enabled in production.
4. **Image/module composition.** The selected module set must be proven present
in the exact image or installed from verified offline artifacts before it is
4. **Image/module composition.** The deployer now enforces the signed
composition. A selected module not shipped by that release cannot be
enabled.
5. **Deployment agent.** Web updates need a separate privileged reconciler with
a typed command allowlist. The API and browser must never receive the Docker
socket or arbitrary shell access.
6. **Ingress and certificates.** The managed HAProxy service provides HTTP
load balancing inside the deployment boundary; it does not issue or renew
certificates. A self-hosted profile still needs an explicit choice
between an existing reverse proxy and a supported managed ingress, including
trusted-proxy boundaries, TLS certificate issuance, renewal, and health
probing through the public route.
6. **Ingress reachability evidence.** Managed Caddy ingress and the
existing-proxy contract are implemented. A production claim still requires
running `doctor` from the target host after public DNS/firewall changes and
retaining the first successful container drill and public TLS/readiness
evidence.
`apply --allow-unverified-images` is therefore restricted to the evaluation
profile. It explicitly acknowledges both mutable image identities and
@@ -225,7 +314,9 @@ must use a tested multi-node Garage cluster or another external S3 service.
### Load Balancing And Replicas
The generated Compose topology publishes only `load-balancer`. HAProxy uses
The generated Compose topology publishes only `load-balancer` for local or
existing-proxy profiles. With managed ingress, only Caddy publishes host ports
and HAProxy remains private. HAProxy uses
Docker DNS service discovery to distribute public traffic across WebUI replicas
and WebUI API proxy traffic across API replicas. The WebUI and API services do
not publish host ports. HAProxy has no Docker socket and discovers only the
@@ -249,6 +340,49 @@ PostgreSQL advisory lock. The Celery scheduler is run under a renewable,
fencing-token lease. Multiple API replicas are rejected when Redis is disabled
because distributed throttling and queued work cannot then be shared correctly.
### Public Ingress And TLS
A self-hosted installation is fail-closed until one of these boundaries is
selected:
- `existing-proxy` publishes HAProxy at `listen.address:listen.port` and emits
`existing-proxy.json`. The operator-owned proxy must use the recorded host,
upstream, and health paths. Only the exact CIDRs listed with repeated
`--trusted-proxy-cidr` values may supply `X-Forwarded-*` headers. Public
proxy addresses must be `/32` or `/128`; private ranges are limited to `/24`
or narrower for IPv4 and `/64` or narrower for IPv6.
- `managed` publishes Caddy on the selected HTTP/HTTPS ports, redirects HTTP to
HTTPS, obtains and renews certificates through ACME, and keeps certificate
material exclusively in the private `caddy-data` and `caddy-config` volumes.
The application containers receive no ACME account or TLS private keys.
Example existing-proxy configuration:
```sh
python govoplan-deploy.py configure \
--directory /srv/govoplan \
--ingress existing-proxy \
--trusted-proxy-cidr 172.20.0.7/32
```
Example managed configuration:
```sh
python govoplan-deploy.py configure \
--directory /srv/govoplan \
--ingress managed \
--acme-email operator@example.org
```
Before managed ingress starts, public A/AAAA records must resolve to the target
and inbound TCP 80/443 must reach it. Existing-proxy mode additionally requires
the public proxy and valid certificate to be reachable before apply. After a
successful receipt, `doctor` reports DNS resolution, certificate validity and
remaining lifetime, public `/health/ready`, and the private HAProxy/WebUI path
as separate checks. Reconfiguration retains the certificate volumes; bundle
rollback never deletes or exposes their contents. Include both Caddy volumes
in coordinated backup and restore evidence.
This is same-host scaling. Docker Compose uses a bridge network and does not
place containers on another machine. See
[Scaling And Multi-Host Deployment](SCALING_AND_MULTI_HOST_DEPLOYMENT.md) for
@@ -285,10 +419,12 @@ starts, recovery is forward-only unless an independently verified database
backup is restored. See
[Recovery And Rollback Guarantees](RECOVERY_AND_ROLLBACK_GUARANTEES.md).
Production updates still need an operator-provided database backup/restore
gate, database compatibility declaration, image signature verification, and
deployment-specific drain policy. The deployment journal proves its own
actions; it does not manufacture backup evidence.
Production updates still need operator/provider-created coordinated backup and
restore evidence, a database compatibility declaration, and a
deployment-specific drain policy. The deployer now verifies and enforces the
signed evidence before migration, but does not manufacture backups or receive
provider administration credentials. See
[Backup And Restore Evidence](BACKUP_AND_RESTORE_EVIDENCE.md).
## Stateless Kubernetes Runtime
@@ -307,7 +443,9 @@ The output includes a release-specific migration Job, database-head wait init
containers, API readiness/liveness probes, rolling Deployments, Services, Pod
disruption budgets, a tokenless ServiceAccount, and one fenced scheduler. Apply
the named Secret through the cluster's secret manager and review ingress proxy
CIDRs before deployment. Detailed rollout and scaling rules live in
CIDRs before deployment. A release-changing export requires adopted backup
evidence and carries only its sanitized digest and identifiers as Job
annotations. Detailed rollout and scaling rules live in
[Scaling And Multi-Host Deployment](SCALING_AND_MULTI_HOST_DEPLOYMENT.md).
## Recovery Commands
@@ -353,22 +491,37 @@ of the reviewed update recipe instead of a non-functional update button.
## Distribution Workflow
The downloadable entry point should eventually be:
The downloadable entry point is a reproducible release asset: sorted source
paths, fixed ZIP metadata, fixed compression settings, and identical source
bytes produce an identical zipapp regardless of checkout timestamps. Obtain the
zipapp, detached signature, checksum, and trusted public keyring through
independently authenticated paths before execution:
```sh
curl --proto '=https' --tlsv1.2 --fail --location \
https://govoplan.add-ideas.de/install/v1/bootstrap.pyz \
--output govoplan-bootstrap.pyz
python3 govoplan-bootstrap.pyz init
https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/vX.Y.Z/govoplan-deploy.pyz \
--output govoplan-deploy.pyz
sha256sum --check govoplan-deploy.pyz.sha256
python3 - <<'PY'
import json
from pathlib import Path
keyring = json.loads(Path("distribution-keyring.json").read_text())
active = [key for key in keyring["keys"] if key["status"] == "active"]
if len(active) != 1:
raise SystemExit("expected exactly one active runtime release key")
Path("runtime-release-public.pem").write_text(active[0]["public_key_pem"])
PY
openssl pkeyutl -verify -pubin -inkey runtime-release-public.pem -rawin \
-in govoplan-deploy.pyz -sigfile govoplan-deploy.pyz.sig
python3 govoplan-deploy.pyz init
```
The published documentation must include an independent checksum/signature
verification command before execution. The zipapp then downloads only a signed
distribution manifest, verifies it against an embedded or explicitly installed
keyring, and renders the same installation contract implemented here.
The source-tree script is the test harness for that future zipapp. It is not yet
the internet bootstrap artifact.
The zipapp has no GovOPlaN package dependency. It accepts a bounded HTTPS
manifest or a prefetched file, requires an independently supplied SHA-256
digest and explicit trusted keyring, and executes OpenSSL with a fixed argument
vector for Ed25519 verification. It never evaluates downloaded shell text or
accepts an arbitrary command string.
## Verification
@@ -378,7 +531,9 @@ Run the focused tests:
./.venv/bin/python -m unittest -v tests.test_deployment_installer
```
The tests cover profile restrictions, secret persistence, external endpoint
The tests cover signed release adoption, tamper/expiry/revocation/unknown-key
rejection, architecture composition, offline image integrity, profile
restrictions, secret persistence, external endpoint
requirements, managed Garage bootstrap, S3 policy, replica validation, HAProxy
discovery configuration, Compose service selection, secret non-disclosure,
service-specific environment isolation, private file modes, external endpoint
+184 -76
View File
@@ -15,7 +15,14 @@ machine-readable field, label, translation, route, API-reference, and module
manifest evidence. This hand-maintained document remains the reviewed product
interpretation and rollout ledger; generated evidence does not replace it.
Snapshot refreshed: 2026-07-22.
Snapshot refreshed: 2026-08-03.
The generated snapshot contains 65 module manifests, 35 WebUI-contributing
repositories, 40 statically declared module routes, 1,156 UI fields, and 836
backend endpoints. All backend endpoints are classified and no stale endpoint
declarations were found. The 234 endpoints without a static WebUI reference are
kept visible as review evidence; they may intentionally serve workers, public
clients, connectors, or external integrations.
Evidence was read from tracked Git `HEAD` in the local GovOPlaN checkouts:
@@ -55,33 +62,50 @@ Inventory states:
| Session/bootstrap state | `govoplan-core` `App.tsx` and `AppShell` | All browser sessions during bootstrap | Understand that session/platform state is loading; state contract | Unreviewed; core shell |
| `/` authenticated redirect | `govoplan-core` chooses the first visible navigation destination | Authenticated; result depends on visible nav contributions | Enter the actor's first accessible service area; navigation behavior, not a content page | Unreviewed; focused-view/default-route work must preserve this fallback |
| `/dashboard` fallback | `govoplan-core` `DashboardPage` only when the Dashboard module is absent | Authenticated; no route-specific scope in core | Cross-module starting point; dashboard | Unreviewed; compare with module dashboard before shared changes |
| `/settings` | `govoplan-core` `SettingsPage` | Authenticated; contributed sections and integrations filter internally | Profile, UI/workspace preference, local connection, and user-scoped integration settings; configuration | Unreviewed; Core #225 program |
| `/settings` | `govoplan-core` `SettingsPage` | Authenticated; contributed sections and integrations filter internally | Profile, UI/workspace preference, local connection, and user-scoped integration settings; configuration | Core-owned pattern migration complete in [Core #225](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/225), commit `fa32cca` |
| Shell chrome | `AppShell`, `Titlebar`, `IconRail`, `BreadcrumbBar`, `HelpMenu`, language menu, unsaved-change provider | Public/authenticated variants; nav filtered later | Tenant/actor context, global navigation, help, language, session and maintenance state | Unreviewed; platform-owned prerequisite for focused views |
## Direct Module Route Contributions
The access guard column reports only the route-level declaration in
`module.ts`. Inner APIs and controls may impose additional checks.
The access column summarizes only the route-level declaration in `module.ts`.
Inner APIs and controls may impose additional checks. Public and compatibility
routes are called out explicitly because they do not have the same manifest
semantics as authenticated navigation routes.
| Route | Owner / render evidence | Route-level access evidence | Primary task | Target archetype | Status / priority |
| --- | --- | --- | --- | --- | --- |
| `/admin` | `govoplan-access` `AdminPage` | Any core `adminReadScopes` | Administer system and tenant concerns assembled from module sections | Administration/configuration | Contributed; unreviewed; P1 under [Core #225](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/225) |
| `/address-book` | `govoplan-addresses` `AddressBookPage` | `addresses:contact:read` | Browse and manage contacts, address books, and lists | Directory/list-detail | Contributed; unreviewed; P2 after Campaign |
| `/calendar` | `govoplan-calendar` `CalendarPage` | `calendar:event:read` | Browse calendars/events and act on calendar data | Directory/list-detail | Contributed; metadata gap; unreviewed; P2 after Campaign |
| `/campaigns` | `govoplan-campaign` `CampaignListPage` | `campaigns:campaign:read` | Find, compare, create, and open campaigns | List-detail entry | Pilot; P1 [Campaign #74](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/74) |
| `/campaigns/:campaignId/*` | `govoplan-campaign` `CampaignResourceRoute` and `CampaignWorkspace` | `campaigns:campaign:read`, plus resource probe | Configure, review, send, and inspect one campaign/version | List-detail workspace containing edit, review, monitoring, and evidence surfaces | Pilot; P1 Campaign #74 |
| `/operator` | `govoplan-campaign` `OperatorQueuePage` | `campaigns:campaign:read` and any of queue, control, retry, or reconcile | Monitor and intervene in campaign jobs through authority-specific controls | Monitoring/work queue | Pilot; durable queue controls delivered in [Campaign #78](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/78); #74 audit remains |
| `/reports` | `govoplan-campaign` `AggregateReportsPage` | `campaigns:report:read` | Compare privacy-protected cross-campaign outcome totals without recipient detail, diagnostics, export, or drill-down | Aggregate reporting | Pilot; aggregate-reader surface delivered in [Campaign #80](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/80); #74 audit remains |
| `/templates` | `govoplan-campaign` `TemplatesPage` | No route guard declared in `module.ts` | Browse/manage campaign templates | Directory/list-detail | Pilot audit; permission intent must be verified; P2 |
| `/dashboard` | `govoplan-dashboard` `DashboardPage` | No route-specific scope | Assemble module-provided actionable widgets | Dashboard | Contributed; unreviewed; P2 |
| `/docs` | `govoplan-docs` `DocsPage` | Docs read or system/tenant settings read scopes | Read configured, available, and evidence-aware documentation | Documentation directory/reference | Contributed; unreviewed; P1 [Docs #15](https://git.add-ideas.de/GovOPlaN/govoplan-docs/issues/15) after initial pattern content |
| `/files` | `govoplan-files` `FilesPage` | `files:file:read` | Browse folders/files and perform managed-file work | Directory/explorer | Contributed; metadata gap; unreviewed; P2 after Campaign |
| `/idm` | `govoplan-idm` `IdmPage` | Any IDM assignment/write or organization function-assign scope | Inspect and govern identity/function assignments | List-detail/configuration | Contributed; unreviewed; P2 |
| `/mail` | `govoplan-mail` `MailboxPage` | `mail:mailbox:read` | Browse mailboxes and messages | Directory/list-detail | Contributed; metadata gap; unreviewed; P2 after Campaign |
| `/notifications` | `govoplan-notifications` `NotificationCenterPage` | `notifications:notification:read` | Inspect and acknowledge notification state | List-detail/inbox | Contributed without a nav item or backend frontend metadata; navigation intent unknown; P2 discovery |
| `/ops` | `govoplan-ops` `OpsPage` | Ops read or system/tenant settings read scopes | Inspect runtime health and readiness | Monitoring | Contributed; unreviewed; P2 |
| `/organizations` | `govoplan-organizations` `OrganizationsPage` | Organization model/unit/function or admin settings read scopes | Model and inspect organizational structures/functions | Directory/list-detail | Contributed; unreviewed; P2 |
| `/scheduling` | `govoplan-scheduling` `SchedulingPage` | `scheduling:schedule:read` | Plan and decide scheduling requests and availability | List-detail/guided decision | Contributed; metadata gap; unreviewed; P2 |
| Routes | Owner | Route-level access | Primary archetype | Migration issue |
| --- | --- | --- | --- | --- |
| `/admin` | Access | Any declared administration/read scope | Administration/configuration host | Access pattern migration complete in [Access #19](https://git.add-ideas.de/GovOPlaN/govoplan-access/issues/19), commit `1409dbf`; shared host contract complete in [Core #225](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/225) |
| `/address-book` | Addresses | `addresses:contact:read` | Governed source directory, contact/list detail, external-provider operation, governance facts, and reversible correction | Addresses pattern migration complete in [Addresses #23](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/23), commit `f9a7185` |
| `/approvals` | Approvals | `approvals:workspace:read` | Work queue/guided decision | Approvals pattern migration complete in [Approvals #3](https://git.add-ideas.de/GovOPlaN/govoplan-approvals/issues/3), commit `24e9559` |
| `/calendar` | Calendar | `calendar:event:read` | Full-height calendar workspace with filterable collection/agenda sidebar, continuous and bounded date views, guarded VEVENT and source editors, synchronized-source status, durable outbox recovery, and destructive remote-move evidence | Calendar pattern migration complete in [Calendar #22](https://git.add-ideas.de/GovOPlaN/govoplan-calendar/issues/22), commit `d7fd944` |
| `/campaigns`, `/campaigns/:campaignId/*`, `/campaigns/queue`, `/campaigns/reports`, `/templates` | Campaign | Campaign read/report/control scopes; template route has no route guard | List-detail, guided review, monitoring, reporting | [Campaign #74](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/74) |
| `/operator` | Campaign | Campaign read plus queue/control scope | Compatibility redirect to `/campaigns/queue` | Campaign #74; retire under the compatibility policy |
| `/cases`, `/cases/:caseId` | Cases | `cases:case:read` | Governed case directory and detail workspace with guarded OCC lifecycle editor, provider-owned references, immutable timeline/history, and confirmed object-access editor | Cases pattern migration complete in [Cases #4](https://git.add-ideas.de/GovOPlaN/govoplan-cases/issues/4), commit `43b4cc8` |
| `/committee` | Committee | `committee:workspace:read` | Governed workspace | Committee pattern migration complete in [Committee #2](https://git.add-ideas.de/GovOPlaN/govoplan-committee/issues/2), commit `e64af30` |
| `/dashboard` | Dashboard | No route-specific scope | View-specific personal workspace with module/permission-filtered widget library, guarded four-column composition, nested widget settings, server/browser fallback, and optimistic layout persistence | Dashboard pattern migration complete in [Dashboard #3](https://git.add-ideas.de/GovOPlaN/govoplan-dashboard/issues/3), commit `da3947f` |
| `/dataflow` | Dataflow | Pipeline read/admin | Governed library, guarded graph/constrained-SQL definition editor, typed node inspector, bounded intermediate preview, automation triggers, and durable run/deployment evidence | Dataflow pattern migration complete in [Dataflow #20](https://git.add-ideas.de/GovOPlaN/govoplan-dataflow/issues/20), commit `109ddcd` |
| `/datasources` | Datasources | Catalogue read/source admin | Governed catalogue, staging preflight, optional-origin directory, authority editor, and immutable evidence | Datasources pattern migration complete in [Datasources #7](https://git.add-ideas.de/GovOPlaN/govoplan-datasources/issues/7), commit `6406ce7` |
| `/distribution-lists` | Distribution Lists | List read/write/admin | Governed directory, immutable-revision editor, expansion preview, and evidence register | Distribution Lists pattern migration complete in [Distribution Lists #8](https://git.add-ideas.de/GovOPlaN/govoplan-dist-lists/issues/8), commit `6cdd804` |
| `/docs` | Docs | Documentation or settings read | Documentation/reference | [Docs #15](https://git.add-ideas.de/GovOPlaN/govoplan-docs/issues/15) |
| `/files` | Files | `files:file:read` | Directory/explorer | Files pattern migration complete in [Files #42](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/42), commit `d8ae506` |
| `/forms` | Forms | `forms:definition:read` | Definition library/editor | Forms pattern migration complete in [Forms #4](https://git.add-ideas.de/GovOPlaN/govoplan-forms/issues/4), commit `e505536` |
| `/forms-runtime`, `/forms-runtime/:instanceId` | Forms Runtime | Participate or workspace read | Guided form execution | Forms Runtime pattern migration complete in [Forms Runtime #5](https://git.add-ideas.de/GovOPlaN/govoplan-forms-runtime/issues/5), commit `07dd35b` |
| `/idm` | IDM | Assignment, function-change, relationship, or organization scopes | Directory/governed change | IDM pattern migration complete in [IDM #12](https://git.add-ideas.de/GovOPlaN/govoplan-idm/issues/12), commit `d864317` |
| `/mail`, `/mail/bounces` | Mail | Mailbox or bounce read/manage | Directory/explorer, operational evidence | Mail pattern migration complete in [Mail #20](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/20), commit `7844d9c` |
| `/notifications` | Notifications | `notifications:notification:read` | Inbox/list-detail with guarded recipient state, confirmed local cancellation/dispatch, and sanitized delivery evidence | Notifications pattern migration complete in [Notifications #4](https://git.add-ideas.de/GovOPlaN/govoplan-notifications/issues/4), commit `ad6a31f` |
| `/ops` | Ops | Operations or settings read | Monitoring/evidence with contextual run, drain, readiness-blocker, and recovery guidance | Ops pattern migration complete in [Ops #4](https://git.add-ideas.de/GovOPlaN/govoplan-ops/issues/4), commit `2b32643` |
| `/organizations` | Organizations | Model/unit/function or settings read | Directory/hierarchy editor | Organizations pattern migration complete in [Organizations #7](https://git.add-ideas.de/GovOPlaN/govoplan-organizations/issues/7), commit `97acfcb` |
| `/portal` | Portal | `portal:service:read` | Service portal | [Portal #2](https://git.add-ideas.de/GovOPlaN/govoplan-portal/issues/2) |
| `/postbox` | Postbox | `postbox:postbox:read` | Inbox/list-detail | Postbox pattern migration complete in [Postbox #26](https://git.add-ideas.de/GovOPlaN/govoplan-postbox/issues/26), commit `a97eb3b` |
| `/projects` | Projects | `projects:project:read` | List-detail/project workspace | [Projects #2](https://git.add-ideas.de/GovOPlaN/govoplan-projects/issues/2) |
| `/reporting`, `/reports` | Reporting | `reporting:definition:read` | Reporting/definition library | [Reporting #8](https://git.add-ideas.de/GovOPlaN/govoplan-reporting/issues/8) |
| `/risk-compliance` | Risk Compliance | Workspace or sanctions read | Immutable source evidence, version-pinned screening, list-detail review, and revisioned assurance graph with explicit blockers and consequences | Risk Compliance pattern migration complete in [Risk Compliance #8](https://git.add-ideas.de/GovOPlaN/govoplan-risk-compliance/issues/8), commit `24d80a6` |
| `/scheduling` | Scheduling | `scheduling:schedule:read` | List-detail/guided decision | Scheduling pattern migration complete in [Scheduling #8](https://git.add-ideas.de/GovOPlaN/govoplan-scheduling/issues/8), commit `c17cbda` |
| `/scheduling/public/:requestId/:token` | Scheduling | Public signed token | Public participation | Scheduling #8 complete in `c17cbda` |
| `/search` | Search | `search:result:read` | Search overlay/results | [Search #4](https://git.add-ideas.de/GovOPlaN/govoplan-search/issues/4) |
| `/templates` | Templates | Template read/write/publish/render/admin | Governed library, immutable-revision editor, compatibility preview, and render evidence | Templates pattern migration complete in [Templates #5](https://git.add-ideas.de/GovOPlaN/govoplan-templates/issues/5), commit `72fafa2` |
| `/voting` | Voting | `voting:ballot:read` | Governed ballot workspace | Voting pattern migration complete in [Voting #1](https://git.add-ideas.de/GovOPlaN/govoplan-voting/issues/1), commit `2625990` |
| `/workflow` | Workflow | Definition read or instance admin | Graph editor/execution evidence | [Workflow #15](https://git.add-ideas.de/GovOPlaN/govoplan-workflow/issues/15) |
## Manifest And Runtime Route Alignment
@@ -91,28 +115,27 @@ loading reason about the configured interface without executing module UI code.
is recorded here as an evidence gap; this inventory does not infer whether each
gap is intentional.
| Module | `module.ts` routes | Backend manifest frontend routes | Backend nav alignment | Result |
| --- | --- | --- | --- | --- |
| Access | `/admin` | `/admin` | Aligned | Described |
| Addresses | `/address-book` | `/address-book` | Aligned | Described |
| Admin | No direct route; `admin.sections` | None | Not applicable | Composed surface |
| Audit | No direct route; `admin.sections` | None | Not applicable | Composed surface |
| Calendar | `/calendar` | None | `/calendar` nav exists | Metadata gap |
| Campaign | Five routes | None | Four top-level nav items exist | Metadata gap; wildcard resource route is also undescribed |
| Dashboard | `/dashboard` | `/dashboard` | Aligned | Described |
| Docs | `/docs` | `/docs` | Aligned | Described |
| Files | `/files` | None | `/files` nav exists | Metadata gap |
| IDM | `/idm` | `/idm` | Aligned | Described |
| Mail | `/mail` | None | `/mail` nav exists | Metadata gap |
| Notifications | `/notifications` | No frontend metadata | No nav item | Metadata and discovery gap |
| Ops | `/ops` | `/ops` | Aligned | Described |
| Organizations | `/organizations` | `/organizations` | Aligned | Described |
| Policy | No direct route; `admin.sections` | None | Not applicable | Composed surface |
| Scheduling | `/scheduling` | None | `/scheduling` nav exists | Metadata gap |
The generated comparison is aligned for all authenticated canonical routes.
Two deliberate exceptions remain visible:
Before a release claims a complete configured-system route inventory, add a
contract check or explicit exceptions so executable routes and manifest
metadata cannot silently diverge.
- Campaign contributes `/operator` as a compatibility redirect for saved View
projections; its canonical and manifest-declared destination is
`/campaigns/queue`.
- Scheduling contributes `/scheduling/public/:requestId/:token` through the
separate `publicRoutes` contract. Authenticated manifest routes intentionally
do not describe public signed-token entry points yet.
Admin, Audit, Policy, Tenancy, and Views contribute composed administration or
settings surfaces rather than direct routes. Their migration issues are
[Admin #8](https://git.add-ideas.de/GovOPlaN/govoplan-admin/issues/8),
[Audit #8](https://git.add-ideas.de/GovOPlaN/govoplan-audit/issues/8),
[Policy #11](https://git.add-ideas.de/GovOPlaN/govoplan-policy/issues/11),
[Tenancy #6](https://git.add-ideas.de/GovOPlaN/govoplan-tenancy/issues/6), and
[Views #2](https://git.add-ideas.de/GovOPlaN/govoplan-views/issues/2).
Release evidence must continue to run the generated inventory and manifest
shape checks so new executable routes, public routes, aliases, and composed
surfaces cannot silently diverge from their declared metadata.
## Composed Surfaces And Extension Points
@@ -121,25 +144,108 @@ enabled and the actor passes the declared filters.
| Host surface | Contributor and evidence | Contributed regions/actions | Pattern implication | Audit |
| --- | --- | --- | --- | --- |
| `/admin` | Access host (`AdminPage`) | System tenants/users/roles, tenant users/groups/roles/API keys/settings, function-role mappings, user/group mail and file connector scopes | One stable admin information architecture must contain both host-owned and contributed sections | Unreviewed; P1 Core #225 |
| `/admin` | `govoplan-admin` `admin.sections` | Overview; system settings; configuration changes; configuration packages; role/group templates; module management | Configuration, guided operations, review/preflight, consequence | In progress under Core #225; surface-level evidence still needed |
| `/admin` | `govoplan-audit` `admin.sections` | System audit; tenant audit | Evidence/provenance and reporting | Unreviewed |
| `/admin` | `govoplan-files` `admin.sections` and `files.connectors` | System and tenant file connections plus scoped connector managers used by Access | Adaptive configuration, discovery/test, policy and credentials | First migration family in Core #225; verification incomplete in this inventory |
| `/admin` | `govoplan-organizations` `admin.sections` | Tenant organization settings | Configuration/list-detail | Unreviewed |
| `/admin` | `govoplan-policy` `admin.sections` | System, tenant, group, and user retention | Effective value, source/provenance, consequential configuration | Unreviewed; Core #225 phase 4 |
| `/admin` and `/settings` | `govoplan-mail` `mail.profiles` | System/tenant/group/user mail profile and policy managers | Same server/credential/policy grammar as file connectors | Unreviewed; Core #225 mail migration |
| `/settings` | Core host | Profile; interface; workspace; local connection | Personal configuration with adaptive forms and immediate feedback | Unreviewed |
| `/settings` | Files and Mail named capabilities | User-scoped file connections and mail profiles/policy | Optional integration regions disappear cleanly when capability absent | Unreviewed |
| `/admin` | Access host (`AdminPage`) | System tenants/users/roles, tenant users/groups/roles/API keys/settings, function-role mappings, user/group mail and file connector scopes | One stable admin information architecture must contain both host-owned and contributed sections | Pattern migration, contextual help, explained permission/protection states, optional-module blockers, localization, and focused evidence complete in Access #19 (`1409dbf`); Core #225 shared host contract complete |
| `/admin` | `govoplan-admin` `admin.sections` | Overview; system settings; configuration changes; configuration packages; role/group templates; module management | Configuration, guided operations, review/preflight, consequence | Pattern migration, contextual help, explained permission/protection/applicability states, guarded consequential actions, localization, and focused evidence complete in Admin #8 (`d428f33`) |
| `/admin` | `govoplan-tenancy` `admin.sections` | System tenant registry and active-tenant settings | Administration directory, effective configuration, lifecycle consequence | Pattern migration, contextual help, explained permission/lifecycle/system-policy states, dirty-state guards, localization, and focused evidence complete in Tenancy #6 (`e76fe16`) |
| `/admin` | `govoplan-audit` `admin.sections` | System audit; tenant audit | Evidence/provenance and reporting | Pattern migration, localized evidence projection, contextual help, and focused tests complete in Audit #8 (`6d3fcc1`) |
| `/admin` | `govoplan-files` `admin.sections` and `files.connectors` | System and tenant file connections plus scoped connector managers used by Access | Adaptive configuration, discovery/test, policy and credentials | Pattern migration, contextual help, blocker explanations and focused evidence complete in Files #42 (`d8ae506`) |
| `/admin` | `govoplan-organizations` `admin.sections` | Tenant organization settings | Configuration/list-detail | Pattern migration, tenant-owned provenance, contextual help, guarded settings/editor drafts, explained permission states, localization and focused evidence complete in Organizations #7 (`97acfcb`) |
| `/admin` | `govoplan-policy` `admin.sections` | System, tenant, group, and user retention | Effective value, source/provenance, consequential configuration | Pattern migration complete in Policy #11 (`f964ed7`) with Core editor contract `fa32cca` |
| `/admin` and `/settings` | `govoplan-mail` `mail.profiles` | System/tenant/group/user mail profile and policy managers | Same server/credential/policy grammar as file connectors | Pattern migration, contextual help, policy/target/permission blockers and focused evidence complete in Mail #20 (`7844d9c`; shared test-reason contract Core `2d0551a`) |
| `/settings` | Core host | Profile; interface; workspace; local connection | Personal configuration with adaptive forms and immediate feedback | Pattern migration complete in Core #225 (`fa32cca`) |
| `/settings` | Files and Mail named capabilities | User-scoped file connections and mail profiles/policy | Optional integration regions disappear cleanly when capability absent | Files #42, Mail #20 and Core #225 complete |
| `/admin` and `/settings` | `govoplan-views` `admin.sections`, `settings.sections`, and `views.runtime` | System/tenant definition and assignment editors, personal/group editors, global selector | Versioned presentation projection with inheritance, lockout safeguards, optional directory targets, and no authorization effect | Pattern migration, contextual help, localized selector/editor, guarded drafts, explained inherited/permission/capability states, and focused evidence complete in Views #2 (`c125f33`) |
| `/settings` | `govoplan-notifications` `settings.sections` | Notification preferences | Personal configuration | Unreviewed |
| `/dashboard` | Dashboard host and `dashboard.widgets` | Installed-modules widget; Ops health widget when Ops contributes it | Widget ordering, staleness, permissions, destination behavior | Unreviewed |
| `/organizations` | IDM `organizations.functionActions` | Action leading to assignment view filtered by IDM scopes | Cross-module context action through explicit capability | Unreviewed |
| `/organizations` | IDM `organizations.functionActions` | Action leading to assignment view filtered by IDM scopes | Cross-module context action through explicit capability | IDM pattern migration complete in IDM #12 (`d864317`) |
| Campaign attachments/import | Files `files.fileExplorer` | Folder tree, managed chooser, file listing/pattern resolution/sharing | Optional domain composition without sibling-private imports | Pilot audit under Campaign #74 |
| Campaign review/send | Mail runtime `mail.devMailbox` | Mock-mail verification when backend advertises runtime capability | Optional review stage with unavailable/optional states | Pilot audit under Campaign #63/#62 |
| Campaign review/send | Mail runtime `mail.devMailbox` | Mock-mail verification when backend advertises runtime capability | Optional review stage with unavailable/optional states | Explicit intervention and review-progress vocabulary delivered in Campaign #63; send modes/progress delivered in #62/#79 |
Other named capability exports (`files.connectors`, `organizations.functionPicker`,
and mail profile validation) are contracts consumed inside the composed surfaces
above; they are not independent routes.
## Core Configuration Surface Map
Core #225 now supplies and verifies the platform-owned configuration contract.
The durable Core inventory is
`govoplan-core/docs/INTERFACE_PATTERN_MIGRATION.md`.
| Surface / code evidence | Primary task | Target pattern | Material consequence/state | Completion evidence |
| --- | --- | --- | --- | --- |
| `/settings` (`SettingsPage`) | Change personal profile, interface/workspace preferences, or local development connection | Two-zone typed settings workspace | Changes are user-scoped; save and test actions distinguish clean, busy, and active states | Contextual help, unsaved guard, typed controls and keyboard-explainable disabled actions in Core `fa32cca` |
| Reusable credentials (`CredentialEnvelopeManager`) | Compare and configure scoped reusable authentication material | Repeated administration plus adaptive create/edit | Secret values are write-only; permission and missing-owner states block mutation explicitly; deletion can break dependent connections | Actionable blocker, stable row actions, typed references, unsaved guard and shared destructive confirmation |
| Retention (`RetentionPolicyManagement`) | Inspect effective retention and narrow permitted local values | Effective-policy editor | Parent locks, source paths and write authority control whether sensitive evidence can be retained | Typed narrowing controls, source-path help, lock/target/permission blockers and clean/loading/save reasons |
| Shared configuration primitives | Compose module-owned settings without sibling-private imports | Platform behavior contract | Consequence, focus, help, async, confirmation and permission semantics remain consistent | Core component suites, 121 module-system tests and full-product type/build/bundle gates |
No primary Core configuration flow requires raw JSON. Expert JSON remains
limited to diagnostics, interchange, conflict evidence, or read-only inspection.
## Policy Surface Map
Policy #11 verifies the four composed retention sections. The durable
module-level inventory is
`govoplan-policy/docs/INTERFACE_PATTERN_MIGRATION.md`.
| Surface / code evidence | Primary task | Target pattern | Material consequence/state | Completion evidence |
| --- | --- | --- | --- | --- |
| System retention | Set the instance ceiling and run retention | Effective-policy editor plus destructive operation | An applied run can irreversibly redact/delete retained content; dry-run and applied evidence remain distinct | Core source-path/lock contract, permission and busy reasons, shared confirmation, typed/filterable outcome grid and audit-oriented wording |
| Tenant retention | Narrow the inherited system ceiling | Effective-policy editor | Tenant policy cannot silently loosen its parent | Core typed controls, effective path and parent-lock explanation |
| Group and user retention | Select an authorized target and narrow inherited policy | Targeted effective-policy editor | Selection exposes only bounded account/group labels; no retained content is returned | Delta-backed target loading, retry, missing-target blocker and responsive shared admin composition |
Automated evidence for Policy `f964ed7` comprises 50 backend/manifest tests,
the Policy interface structural gate, 65 manifest-shape checks, and the
full-product TypeScript/Vite build with structural localization, theme and
bundle-budget gates. Policy uses no sibling-private imports.
## Files Surface Map
Files #42 classifies and verifies the complete Files-owned route and composition
boundary. The durable module-level inventory is
`govoplan-files/docs/INTERFACE_PATTERN_MIGRATION.md`.
| Surface / code evidence | Primary task | Target pattern | Material consequence/state | Completion evidence |
| --- | --- | --- | --- | --- |
| `/files` (`FilesPage`) | Browse spaces/folders and repeatedly act on current content | Full-height directory/explorer | Navigation is low consequence; upload, synchronize, move, copy and share are medium; delete is high | Stable two-pane composition, contextual help, selection/permission/state-specific disabled reasons, shared confirmation and responsive collapse |
| Upload/archive, transfer, rename and connector-import dialogs | Supply, validate and review one bounded change | Adaptive create/edit or guided import | Writes managed content and may resolve conflicts or import untrusted bytes | Shared dialogs/drop zone, bounded archive preflight, conflict review, explicit confirmation and no browser-native confirmation |
| Share/access explanation | Inspect or change who can use a resource | Review/decision | Grants can disclose content; delete/revoke changes access | Shared access explanation, action components and destructive confirmation; backend redaction remains authoritative |
| File connector tree and connection/credential dialogs | Compare and configure external endpoints and reusable credentials | Administration plus adaptive create/edit | Endpoint, secret and capability changes can enable remote access | Shared connection tree/forms/advanced panel, endpoint discovery and login test, unsaved-change guard, read-only deployment provenance and actionable disabled reasons |
| Connector policy card | Narrow effective connector use | Effective-policy editor | Inherited deny/allow rules affect lower scopes | Typed selectors, deny-precedence warning, effective sources, contextual admin help and permission blocker |
| `files.widget.spaces` | See available spaces and enter Files | Dashboard widget | Space/provider names remain permission-filtered | Shared loading, alert and status components; bounded configuration and refresh |
| `files.fileExplorer` capability | Select a governed managed snapshot for another module | Directory chooser | Exact file/version becomes another module's governed input | Capability-only composition, no sibling-private import, stable chooser/confirmation and exact snapshot evidence |
Automated evidence for commit `d8ae506` comprises 104 Files backend tests,
three focused Files WebUI structure tests, the full-product TypeScript/Vite
build, structural localization audit, theme contract and bundle budget. Shared
Dialog and disabled-tooltip behavior provide focus entry/return and
keyboard-reachable explanations; responsive source order is guarded at 1050 px
and 760 px. Secrets are not returned to the WebUI, and JSON remains only an
advanced provider-compatibility escape hatch rather than the primary editor.
## Mail Surface Map
Mail #20 classifies and verifies the complete Mail-owned route and composition
boundary. The durable module-level inventory is
`govoplan-mail/docs/INTERFACE_PATTERN_MIGRATION.md`.
| Surface / code evidence | Primary task | Target pattern | Material consequence/state | Completion evidence |
| --- | --- | --- | --- | --- |
| `/mail` (`MailboxPage`) | Browse an authorized provider mailbox without changing it | Full-height directory/explorer | Message metadata and content are private; every provider read is bounded and non-mutating | Stable three-pane composition, contextual help, explicit no-profile blocker, refresh reasons, keyboard rows, paging and responsive collapse |
| Mail profile tree and profile/server/credential dialogs | Compare and configure reusable transport identities | Administration plus guided/adaptive create/edit | Endpoint and credential changes can enable external effects | Shared connection tree/dialog/stage rail/forms, focused hierarchy editors, unsaved guard, connection tests, permission/target blockers and disabled-save reasons |
| Mail policy card | Narrow profile visibility, lower-scope definitions and transport/address patterns | Effective-policy editor | Inherited allow/deny rules affect delivery and lower scopes | Typed selectors and controls, effective source path, lock/read-only blocker, dirty-save state and contextual admin help |
| `/mail/bounces` watcher table | Configure and explicitly scan bounded IMAP evidence sources | Operational administration | Provider access changes durable source cursors and evidence | Shared grid/status/loading/alerts, actionable no-profile and busy states, field help and stable row actions |
| `/mail/bounces` observations and watcher removal | Review sanitized delivery outcomes or stop future scans | Evidence/reporting plus destructive confirmation | Recipient diagnostics are sensitive; watcher removal retains existing evidence | Bounded sanitized rows and shared confirmation with retained-evidence consequence |
| `mail.profiles` and reference-selector capabilities | Select/validate Mail-owned transport from another module | Governed capability composition | A selected identity can perform external effects | Stable references, Mail-owned authorization/secret resolution, no sibling-private imports and clean optional absence |
Automated evidence for Mail commit `7844d9c` and Core commit `2d0551a`
comprises 114 Mail backend tests, Mail's focused UI/model/structure suite, the
Core shared mail-component suite, 65 manifest-shape checks and the full-product
TypeScript/Vite build with structural localization, theme and bundle-budget
gates. Shared Dialog and disabled-tooltip behavior provides focus containment,
return and keyboard-reachable explanations. Responsive source order is guarded
at 1250 px, 900 px and 760 px. Passwords remain write-only, mailbox responses
are bounded, and bounce evidence excludes raw provider messages.
## Campaign Pilot Surface Map
Campaign is detailed first because it exercises almost every archetype. The
@@ -166,7 +272,7 @@ prove that the composition or states satisfy the pattern.
| Campaign settings (`GlobalSettingsPage` settings view) | Configure campaign behavior | Adaptive configuration | Can alter validation/build/send behavior | #74 audit |
| Mail policy (`MailSettingsPage` policy view) | Inspect/override effective mail policy | Effective policy/provenance editor | Inheritance and locks affect allowed delivery | #74; Core #225 policy pattern |
| Campaign policy (`GlobalSettingsPage` policy view) | Inspect/override campaign policy | Effective policy/provenance editor | Inheritance, actor authority, and blocked edits | #74; Core #225 policy pattern |
| Review/send (`ReviewSendPage`) | Validate, build, mock-test, confirm/send, inspect results | Guided review/decision plus durable progress | External communication, bounded synchronous execution, persisted queue mode, partial effects, retries, evidence | Bounded synchronous and explicit/persisted queued modes delivered in [#62](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/62) and [#79](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/79); [#63](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/63) wording and #74 audit remain |
| Review/send (`ReviewSendPage`) | Validate, build, mock-test, confirm/send, inspect results | Guided review/decision plus durable progress | External communication, bounded synchronous execution, persisted queue mode, partial effects, retries, evidence | Blocking/non-blocking interventions and reviewed/remaining evidence delivered in [#63](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/63); bounded synchronous and explicit/persisted queued modes delivered in [#62](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/62) and [#79](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/79); #74 audit remains |
| Message and attachment detail overlays | Inspect one built/mock message and its attachment links | Stable detail/review dialog | Personal data, exact outbound content, reviewed state | Delivered and verified in [#59](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/59) and [#73](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/73) |
| Campaign report (`CampaignReportPage`) | Filter and inspect delivery outcomes | Reporting/list-detail | Partial, failed, explicitly excluded/skipped, SMTP/IMAP outcomes and retries | Server-owned filtering and counts delivered in [#65](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/65) with the full-result DataGrid contract from [Core #263](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/263); excluded semantics in [#66](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/66) |
| Audit (`CampaignAuditPage`) | Inspect campaign evidence/history | Provenance timeline/report | Actor/action/effect trace | #74 audit |
@@ -182,24 +288,26 @@ The five review stages currently named in code are `Validate and inspect`,
results`. Campaign #63 owns the intervention and status vocabulary; Workflow is
not required to define or implement it.
## Repositories Without A WebUI Route Contribution
## Repositories Without A WebUI Package
The following local repositories contain a backend manifest but no
`webui/src/module.ts` at this snapshot:
The generated manifest snapshot reports no WebUI package for:
`govoplan-approvals`, `govoplan-assets`, `govoplan-booking`,
`govoplan-certificates`, `govoplan-committee`, `govoplan-consultation`,
`govoplan-contracts`, `govoplan-dist-lists`, `govoplan-evaluation`,
`govoplan-facilities`, `govoplan-forms-runtime`, `govoplan-grants`,
`govoplan-helpdesk`, `govoplan-identity`, `govoplan-inspections`,
`govoplan-tickets`, `govoplan-learning`, `govoplan-permits`,
`govoplan-poll`, `govoplan-procurement`, `govoplan-records`,
`govoplan-resources`, `govoplan-rest`, `govoplan-risk-compliance`,
`govoplan-soap`, `govoplan-tenancy`, and `govoplan-transparency`.
`govoplan-assets`, `govoplan-booking`, `govoplan-certificates`,
`govoplan-connectors`, `govoplan-consultation`, `govoplan-contracts`,
`govoplan-decisions`, `govoplan-encryption`, `govoplan-evaluation`,
`govoplan-facilities`, `govoplan-grants`, `govoplan-helpdesk`,
`govoplan-identity`, `govoplan-identity-trust`, `govoplan-inspections`,
`govoplan-learning`, `govoplan-mandates`, `govoplan-parties`,
`govoplan-permits`, `govoplan-poll`, `govoplan-procurement`,
`govoplan-records`, `govoplan-resources`, `govoplan-rest`,
`govoplan-services`, `govoplan-soap`, `govoplan-tickets`,
`govoplan-transparency`, `govoplan-wiki`, and `govoplan-workflow-engine`.
This is only negative route evidence. It does not classify the backend module's
maturity or decide that it needs a WebUI. Connector-only, capability-only, or
backend-only modules may remain intentionally headless.
Tenancy does provide composed administration surfaces despite having no direct
route. This section is only negative package evidence; connector-only,
capability-only, runtime-only, and backend-only modules may intentionally remain
headless. A new WebUI should be created only for a concrete user task, not to
make every module symmetrical.
## Rollout Matrix
@@ -208,17 +316,17 @@ backend-only modules may remain intentionally headless.
| 0 | Product grammar and route inventory | Doctrine, ledger, layout rules, module contract, current route sources | One reconciled pattern language and evidence inventory | Meta [#11](https://git.add-ideas.de/GovOPlaN/govoplan/issues/11) | Docs links/diff checks; issue/wiki sync after integration | Initial slice in this document |
| 1 | Campaign baseline integration | Recipient-editor WIP and tracker state have been reconciled with remote `main` | Integrated, testable baseline before migration claims | Campaign #67 and tracker cleanup | Backend and focused WebUI suites; issue evidence | Complete 2026-07-22 |
| 2 | Campaign previews/details | Stable shared dialog with bounded scrolling and fixed responsive preview workspace | Stable header/body/footer, accessible long-content detail | Campaign #59 and #73 | Review-preview and overlay structure tests | Complete 2026-07-22 |
| 3 | Campaign review/interventions | Five domain-owned stages with unresolved intervention language | Clear stages, outcomes, blockers, next actor/action, reviewed evidence | Campaign #63 | State matrix behavior/accessibility tests and agreed vocabulary | P1 needs product wording decision |
| 3 | Campaign review/interventions | Five domain-owned stages use central blocker and guided-review primitives; validation/build warnings name action, actor, and destination; hard blockers, individual review, and group review remain distinct; reviewed/remaining counts survive reload through build-bound review evidence | Clear stages, outcomes, blockers, next actor/action, reviewed evidence | Campaign #63 | `reviewProgress` state tests, shared-component structure contract, TypeScript build, configured-system help topic, and Campaign documentation tests | Complete 2026-08-03 (`d635f3a`; Core primitives and contextual help `b823a22`) |
| 4 | Campaign send/progress | A hard deployment ceiling bounds synchronous delivery; the selected synchronous, worker-queue, or database-queue mode is explicit and persisted; progress and recovery survive navigation; immediate-send response and audit evidence are allowlisted | Pre-send mode/consequence plus durable leave/return progress, retry and reconciliation without recipient/provider leakage | Campaign #62 and #79 | Boundary/concurrency/preflight, async selection, persisted mode, sanitized response/audit, partial/failure/retry and reload/return tests | Complete 2026-07-22 (`7e16603`, `60efd1c`, `62a6879`, `b0282eb`, `f095a3e`) |
| 5 | Campaign report filtering | Core DataGrid distinguishes client/full-result from server-owned queries; Campaign applies filter/sort/count before pagination and synchronizes count shortcuts with the grid query | One shared server-owned status/list/filter/count model | Campaign #65 and Core #263 | DataGrid contract/build tests plus exact shortcut/query/filter/count and large-result behavior | Complete 2026-07-22 (`e6062fe`, `cece71d`, `aa4ec66`, `4eb651c`) |
| 6 | Campaign operator recovery | A durable campaign/version queue page exposes historical work, exact non-overlapping state counts, persisted mode, permission-safe controls, server-paged job evidence, bounded refresh and active-state recovery | Fixed-position actions, disabled explanations, leave/return state, version-scoped retry/queue/reconcile and explicit campaign-wide pause/resume/cancel | Campaign #78 | Queue model/structure, historical-version, permission, paging, recovery-control, stale-response and delta tests | Complete 2026-07-22 (`21f3014`, `99d44ee`, `735e874`) |
| 7 | Campaign aggregate reports | A separate aggregate-reader projection and UI expose only policy-suppressed business totals with a stable status domain | Explicit denominator and exclusions, deployment floor plus tenant-strengthened small-cell threshold, complementary and overlapping-cell suppression, no detail/export/diagnostics | Campaign #80 | Aggregate query, cross-metric suppression, route/role/ACL, stable filter and UI structure tests | Complete 2026-07-22 (`06125cc`, `fc36aee`, `8ee87b7`, `ac3329c`, `1225802`) |
| 8 | Campaign excluded outcomes | Excluded build rows become explicit skipped transport outcomes and remain protected from queue/cancel/retry ambiguity | One durable source-to-job-to-report meaning with guarded historical normalization | Campaign #66 | Builder/persistence, migration, query/count, queue-control and report-explanation tests | Complete 2026-07-22 (`7229fb8`) |
| 9 | Guided first campaign | Existing wizard routes and ordinary workspace overlap | Task-oriented entry that hands off clearly to normal editing/review | Campaign #35 | First-run flow, resume/back, validation, optional modules, no implicit send | P1 after core pilot patterns stabilize |
| 10 | Prove/extract generic primitives | Core already exports many primitives; Campaign composition still unreviewed | Extract only contracts with a second consumer or clear platform ownership | Core #225 plus bounded follow-ups | Core behavior/accessibility tests and module-permutation tests | After Campaign proof |
| 11 | Configured-system pattern help | Docs route and classification exist | Role/config-aware pattern and route/field/blocker help | Docs #15 | Topic grouping, audience filtering, stable links/anchors | P1 after initial pattern IDs stabilize |
| 12 | Admin/configuration family | Phase inventory and connector primitives exist in the ledger | Apply the pattern to files, mail, policy, retention, packages, modules, API keys, settings | Core #225 and module children | Per-surface state/accessibility/consequence evidence | Parallel where independent of Campaign shared decisions |
| 13 | Remaining direct routes | Routes are contributed; most are unreviewed | Per-module bounded audit and migration plan | New module issues derived from this inventory | Applicable definition-of-done gates | P2 after Campaign, not a bulk rewrite |
| 10 | Prove/extract generic primitives | Shared consequence, focus, help, blocker, unsaved-change, confirmation, connection-tree and effective-policy contracts now have Core and multiple module consumers | Keep Core behavior-only and leave domain composition in owning modules | Core #225 plus bounded follow-ups | Core behavior/accessibility tests and module-permutation tests | Complete 2026-08-03 (`fa32cca`; Files `d8ae506`; Mail `7844d9c`) |
| 11 | Configured-system pattern help | Role/config-aware workflow, reference, pattern, and system topics are projected by Docs; shared route, field, blocker, and action links resolve to configured Docs or the hosted fallback | Stable configured-system guidance without feature-to-Docs imports | Docs #15 | Docs suite, shared component tests, Campaign review tests, 46 module permutations, full-product bundle budget | Complete 2026-08-03 (Docs `abe2f78`; Core `b823a22`; Campaign `d635f3a`) |
| 12 | Admin/configuration family | Core host/settings/credential/retention contracts, shared primitives, module lifecycle, Files, Mail, Policy, Access, Admin, Tenancy, Views, and Organizations are integrated and verified | Continue the same consequence/provenance grammar only through bounded module-owned migrations | Core #225 and module children | Per-surface state/accessibility/consequence evidence | Core #225 complete `fa32cca`; Access `1409dbf`; Files `d8ae506`; Mail `7844d9c`; Policy `f964ed7`; Admin `d428f33`; Tenancy `e76fe16`; Views `c125f33`; Organizations `97acfcb` |
| 13 | Remaining module surfaces | 33 bounded module-owned issues cover every WebUI contributor not already tracked by Campaign #74 or completed Docs #15 | Per-module audit and migration, ordered by user task and consequence rather than a bulk rewrite | Issues linked in the direct-route and composed-surface sections | Module-focused tests, manifest shapes, contextual Docs, and applicable definition-of-done gates | Scheduling `c17cbda`, Audit `6d3fcc1`, Access `1409dbf`, Files `d8ae506`, Mail `7844d9c`, Policy `f964ed7`, Admin `d428f33`, Tenancy `e76fe16`, Views `c125f33`, Organizations `97acfcb`, Postbox `a97eb3b`, IDM `d864317`, Committee `e64af30`, Approvals `24e9559`, Forms Runtime `07dd35b`, Forms `e505536`, Voting `2625990`, Distribution Lists `6cdd804`, Templates `72fafa2`, Addresses `f9a7185`, Datasources `6406ce7`, Dataflow `109ddcd`, Dashboard `da3947f`, Cases `43b4cc8`, Calendar `d7fd944`, Ops `2b32643`, Notifications `ad6a31f`, and Risk Compliance `24d80a6` complete |
| 14 | Manifest/runtime alignment | Several executable routes are absent from manifest metadata | Declared alignment or explicit validated exception | Core contract issue to create | Automated manifest/module route check and configured Docs verification | Discovery follow-up |
Workflow remains outside this rollout matrix because it has its own runtime and
+22
View File
@@ -45,6 +45,28 @@ The command writes:
- `audit-reports/platform-inventory/platform-interface-inventory.json`
- `audit-reports/platform-inventory/platform-interface-inventory.md`
Use `--strict` in CI. In addition to translation coverage, strict mode requires
every backend endpoint without a statically visible WebUI path to have an exact
entry in
`tools/inventory/endpoint-surface-declarations.json`. The registry is keyed by
repository, HTTP method, and canonical version-independent path. It accepts:
- `ui_reachable`: a mounted router, generic action, or provider path hides the
reference from static extraction;
- `intentionally_headless`: a capability/API is deliberately consumed without
its own UI;
- `public_integration`: a documented public or interoperability endpoint;
- `worker_internal`: a worker, scheduler, reconciliation, or monitoring path;
- `compatibility`: a retained transition endpoint with a current replacement;
- `missing_ui`: a real UI gap, which must include a Gitea tracking issue;
- `removable`: a reviewed dead endpoint pending removal.
Strict mode also rejects declarations that no longer match source. When an
endpoint is added, changed, or removed, update its declaration in the same
change. Do not classify an endpoint from a string mismatch alone: first check
mounted prefixes, dynamic action paths, public clients, worker use, and
capability consumers.
It combines:
1. loaded module manifests
+13 -5
View File
@@ -44,6 +44,10 @@ least one check. The ledger verifies its hash chain before evidence is trusted.
This is a platform contract, not an assertion that every existing module
operation has adopted it. Module operations with external or multi-resource
effects must be migrated to the ledger before claiming these guarantees.
The owning-module inventory and adoption state are maintained in
[Recovery Ledger Adoption](RECOVERY_LEDGER_ADOPTION.md); CI validates the
machine-readable inventory so newly identified boundaries cannot disappear from
the backlog silently.
## Deployment Journal
@@ -98,11 +102,15 @@ old code may not understand the new schema. Recovery then means one of:
3. restore a separately verified, coordinated database/object/key backup and
then deploy the matching release.
The deployment tool does not create or validate that database backup. A
`backup-required` annotation on the Kubernetes migration Job is an operator
gate, not backup evidence. Production automation must provide a backup hook or
external backup controller whose artifact, timestamp, scope, encryption key,
and restore test can be referenced from the recovery record.
The deployment tool does not create that backup. It does verify an externally
produced, signed evidence contract covering PostgreSQL, objects, protected
configuration, and key custody at one recovery point plus an isolated restore
drill. A self-hosted release change cannot reach the migration command or be
exported as a Kubernetes migration Job until fresh evidence bound to the
previous immutable release has been adopted. Compose verifies it again after
runtime quiescing. See
[Backup And Restore Evidence](BACKUP_AND_RESTORE_EVIDENCE.md) for the contract,
provider runbooks, RPO/RTO ownership, retention, and disposal rules.
## Scaled Nodes
+89
View File
@@ -0,0 +1,89 @@
# Recovery Ledger Adoption
The Core recovery ledger is a platform primitive, not automatic protection for
module-owned effects. The canonical, machine-checked inventory is
[`recovery-operation-inventory.json`](recovery-operation-inventory.json).
## Classification Rules
- Use `atomic` only when every mutation commits in one database transaction and
no external effect occurs.
- Use `compensation` when every completed effect has a bounded, verifiable
inverse action. A best-effort delete is not proof of compensation.
- Use `snapshot_restore` only with fresh, signed backup evidence that covers all
affected state services at one recovery point.
- Use `forward_recovery` for provider acceptance, queue publication, cursor
advancement, and other effects that may be resumable but cannot safely be
undone.
- Use `irreversible` for approved purge or destruction where no automated
recovery is claimed.
One feature may cross more than one boundary. Module installation is
compensatable before schema migration, forward-only after migration starts, and
snapshot-restorable for an approved destructive retirement. Mail submission is
forward recovery because losing the response after provider acceptance must not
cause an automatic resend.
## Adoption Order
1. Campaign build is the reference implementation for a database plus object
storage operation. Its operation reserves a build-specific object prefix,
persists request and precondition evidence before writes, records the final
object manifest, and verifies database/object state before success.
2. Campaign delivery and Mail provider effects adopt outcome-unknown semantics
without weakening their existing provider-specific idempotency records.
3. Files applies the same contract to uploads, purge, integrity reconciliation,
and writable connector synchronization.
4. Connectors, Dataflow, and Workflow Engine consume the contract at their
registry/capability boundaries so optional providers remain optional.
5. Core module lifecycle uses the ledger in addition to, not instead of, signed
deployment and backup evidence.
Every fenced operation uses a process incarnation and distributed lease. A
stale process cannot append a checkpoint or report success. An expired operation
is claimed for recovery through an explicit takeover that preserves the prior
fence in the checkpoint chain; it is never resumed as a normal retry.
Connectors read-only sanctions and feed acquisitions are adopted: source
revision/cursor and dry-run evidence are recorded before provider I/O, while
the immutable snapshot and terminal checkpoint commit atomically. The generic
external-mutation contract is conformance-tested but remains `planned` until a
production connector actually publishes, updates, or deletes provider state.
Dataflow runs are adopted. Database-only execution uses one atomic terminal
commit for the run projection and recovery checkpoint. Output publication uses
forward recovery: source and output digests are checkpointed before dispatch,
a conclusive provider result commits with the run projection, and an expired
or failed attempt after dispatch becomes `outcome_unknown`. A stale attempt may
be retried only when its durable boundary proves dispatch had not started.
Workflow Engine is adopted at both declared boundaries. Instance workers,
trigger deliveries, and timer resumptions use process-bound distributed fences.
Every module-action invocation records the pinned definition, input, preview,
authority, provider-idempotency, and action-contract hashes before dispatch.
Conclusive results commit with the Workflow projection. A lost acknowledgement,
invalid result, or unannounced non-atomic effect becomes `outcome_unknown` and
cannot be retried until evidence confirms either that the effect occurred or is
absent. Linked Dataflow uncertainty blocks the Workflow without duplicating
Dataflow's recovery authority.
Core module lifecycle is adopted at four boundaries. Installer recovery is
prepared before snapshots so a full database restore preserves the attempted
operation. Pre-migration package changes use compensation, migrated changes use
forward recovery, destructive retirement requires a hashed and restore-checked
snapshot, and live graph changes restore the prior registry when no migration
ran. A deployment-wide database fence serializes these effects; any unresolved
predecessor blocks a differently keyed retry until explicit reconciliation.
Supervised installs become successful only after restart and health evidence is
recorded.
## Operator Contract
Ops lists non-terminal and manual-intervention operations. Operators must verify
the checkpoint chain before trusting evidence, distinguish `outcome_unknown`
from rejection, and use the owning module's documented reconciliation action.
No evidence payload may contain credentials or resolved secrets.
The parent adoption issue remains open until all inventory rows are adopted and
the module matrix proves crash, retry, stale-fence, tamper, and optional-module
behavior for each consequential path.
+76 -4
View File
@@ -150,8 +150,29 @@ versioning, and lifecycle controls.
## Capacity
- Scale API replicas only within the PostgreSQL connection budget.
- Set `GOVOPLAN_DB_CONNECTION_LIMIT` to the PostgreSQL role's effective
connection limit. The Kubernetes export reserves
`GOVOPLAN_DB_CONNECTION_RESERVE` connections and rejects a topology whose
calculated rolling-update peak would exceed the remainder. The calculation
includes API pools, every Celery parent and prefork child, the scheduler,
migration, and one surge replica per deployment. Role-specific pool and
overflow values are emitted into each workload rather than inherited from one
unconstrained global default.
- Scale workers by queue, with upper bounds based on external provider limits.
`GOVOPLAN_WORKER_POOLS` may contain a JSON list of exact queue owners, for
example:
```json
[
{"name":"delivery","queues":["send_email","append_sent"],"replicas":2,"concurrency":2},
{"name":"platform","queues":["events","workflow","default"],"replicas":2,"concurrency":2}
]
```
Pool replica totals must equal `replicas.worker`, and the pools must cover
`CELERY_QUEUES` exactly without duplicate ownership. Each pool receives its
own Deployment, disruption budget, topology-spread selector, runtime identity,
and declared concurrency.
- Keep one fenced scheduler rather than load-balancing schedulers.
- Increase WebUI replicas for asset/proxy capacity.
- Measure request latency, database query time and locks, active connections,
@@ -169,15 +190,66 @@ access, node visibility, drain controls, migration serialization, and scheduler
fencing. It does not by itself provide:
- a highly available PostgreSQL, Redis, or object-store deployment;
- automatic PostgreSQL backup, point-in-time recovery, or restore verification;
- automatic PostgreSQL/object backup creation or point-in-time recovery;
- autoscaling policy;
- central logs, metrics, traces, or alert routing;
- managed ingress certificates;
- certificate portability between independently managed ingress providers;
- automatic reconciliation of every possible module side effect;
- a service-level availability guarantee.
Those are deployment and module-adoption requirements. Before claiming high
The deployer verifies and gates migrations on signed coordinated backup and
isolated-restore evidence, but backup capture and restoration remain owned by
the selected state-service providers. Before claiming high
availability, drill replica loss, rolling replacement, session continuity, job
redelivery, scheduler failover, migration exclusion, object-store outage, and a
coordinated database/object/key restore. Recovery rules and evidence are
defined in [Recovery And Rollback Guarantees](RECOVERY_AND_ROLLBACK_GUARANTEES.md).
## Worker Delivery Evidence
The module-matrix workflow runs `tools/checks/worker-runtime-drill.py` against a
real isolated Redis database. The drill starts supervised Celery worker
processes and records four guarantees without accessing tenant data:
1. a task published through the broker is consumed exactly once;
2. an application retry is delivered again and completes;
3. warm `SIGTERM` lets an in-flight late-ack task complete before shutdown; and
4. loss of a worker after task start causes the unacknowledged task to be
redelivered after the configured visibility timeout.
Run the same drill with the release Python environment and target Redis before
promoting a worker composition. Use a dedicated Redis database, retain the JSON
evidence, and set `CELERY_VISIBILITY_TIMEOUT_SECONDS` above the longest supported
business-task duration. The short visibility timeout used by CI is an isolated
test setting, not a production recommendation.
```bash
GOVOPLAN_WORKER_DRILL_REDIS_URL=redis://redis.example.test:6379/15 \
.venv/bin/python tools/checks/worker-runtime-drill.py \
--output evidence/worker-runtime.json
```
## Live Multi-Host Evidence
After deploying a pinned release on at least two Kubernetes nodes, create an API
key with Ops read scope and run:
```bash
export GOVOPLAN_OPS_API_KEY='...'
python tools/deployment/govoplan-deploy.py verify-kubernetes \
--directory /srv/govoplan/installation \
--namespace govoplan
```
The command fails unless API and WebUI pods are ready on at least two nodes,
all rendered deployments are available, Ops reports a consistent release and
module composition, every declared worker queue is served, and the calculated
database peak remains below its budget. It writes a private, sanitized JSON
record under the installation evidence directory and never retains the API key.
Use `--exercise-api-pod-loss` in an approved drill window to delete one API pod,
observe the public readiness path continuously, and record its replacement.
This proves the bounded stateless-node-loss slice only. Session continuity,
accepted-job redelivery, state-service failover, and coordinated restore remain
separate target exercises whose signed evidence is governed by
`docs/TARGET_MATURITY_EVIDENCE_RUNBOOK.md` and GovOPlaN #37.
+14 -4
View File
@@ -163,10 +163,20 @@ important scanner counts in the tracker issue.
The jscpd step is intentionally scoped to application and test source. It
excludes documentation snippets, package manifests, generated translations,
public SVG assets, workflow YAML, and declarative backend schema JSON because
those reports produce metadata or asset repetition rather than actionable source
duplication. Keep exclusions narrow and create child issues for source-code
clusters that cross module ownership or make behavior harder to change safely.
public SVG assets and catalog output, workflow YAML, declarative backend schema
JSON, the generated migration baseline, and mirrored development migration
directories because those reports produce metadata or generated-source
repetition rather than actionable source duplication. Keep exclusions narrow
and create child issues for source-code clusters that cross module ownership or
make behavior harder to change safely.
The 2026-08-02 full-workspace baseline covered 64 repositories and reported
1.82% duplicated lines before those generated-source exclusions. The reviewed
high-value clusters were catalog acceptance persistence, release publication
result assembly, and local WebUI JSON mutation wrappers. Similar Dataflow and
Workflow graph/governance code remains independently owned until its shared
contract is stable enough for Core; a raw similarity score is not grounds for a
module-to-module dependency.
## Image Freshness
@@ -141,6 +141,12 @@ The canonical backlog item is
Implementation status as of the current source tree:
- Slice 1 now has the source-controlled production artifact boundary: offline
per-architecture wheel resolution, non-root API/Web image definitions,
multi-architecture OCI publication, signed composition/SBOM/provenance,
immutable Gitea assets, a signed one-file deployer, and fail-closed manifest
adoption. The first real published release and cross-architecture runtime
evidence remain release-operator work rather than source-code claims.
- Slice 6 has a working application-tier foundation: state profiles, shared
object storage, runtime node registration/heartbeats/drain, fenced scheduler,
migration serialization, exact-head startup waiting, Ops visibility, and a
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://govoplan.add-ideas.de/schemas/backup-evidence-keyring-v1.json",
"title": "GovOPlaN backup evidence trust keyring",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "purpose", "keys"],
"properties": {
"schema_version": { "const": "1" },
"purpose": { "const": "govoplan-backup-evidence" },
"keys": {
"type": "array",
"minItems": 1,
"maxItems": 64,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"key_id",
"algorithm",
"status",
"public_key_pem",
"not_before",
"expires_at"
],
"properties": {
"key_id": { "type": "string", "minLength": 1, "maxLength": 128 },
"algorithm": { "const": "ed25519" },
"status": { "enum": ["active", "retired", "revoked"] },
"public_key_pem": { "type": "string", "minLength": 1, "maxLength": 8192 },
"not_before": { "type": "string", "format": "date-time" },
"expires_at": { "type": "string", "format": "date-time" }
}
}
}
}
}
+255
View File
@@ -0,0 +1,255 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://govoplan.add-ideas.de/schemas/backup-evidence-v1.json",
"title": "GovOPlaN coordinated backup and restore evidence",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"evidence_id",
"installation_id",
"deployment_subject",
"release",
"recovery_point",
"components",
"restore_drill",
"issued_at",
"expires_at",
"revoked",
"signatures"
],
"properties": {
"schema_version": { "const": "1" },
"evidence_id": { "$ref": "#/$defs/token" },
"installation_id": { "$ref": "#/$defs/token" },
"deployment_subject": {
"type": "object",
"additionalProperties": false,
"required": ["profile", "topology", "subject_ref"],
"properties": {
"profile": { "enum": ["evaluation", "self-hosted"] },
"topology": { "$ref": "#/$defs/token" },
"subject_ref": { "$ref": "#/$defs/reference" }
}
},
"release": {
"type": "object",
"additionalProperties": false,
"required": [
"channel",
"version",
"manifest_sha256",
"composition_sha256",
"api_image",
"web_image"
],
"properties": {
"channel": { "$ref": "#/$defs/token" },
"version": { "$ref": "#/$defs/token" },
"manifest_sha256": { "$ref": "#/$defs/sha256" },
"composition_sha256": { "$ref": "#/$defs/sha256" },
"api_image": { "$ref": "#/$defs/digest_image" },
"web_image": { "$ref": "#/$defs/digest_image" }
}
},
"recovery_point": {
"type": "object",
"additionalProperties": false,
"required": ["id", "captured_at", "consistency", "rpo_seconds", "write_fence"],
"properties": {
"id": { "$ref": "#/$defs/token" },
"captured_at": { "type": "string", "format": "date-time" },
"consistency": {
"enum": ["provider-atomic", "application-quiesced", "transaction-consistent"]
},
"rpo_seconds": { "$ref": "#/$defs/duration" },
"write_fence": {
"type": "object",
"additionalProperties": false,
"required": ["mode", "token_sha256", "established_at"],
"properties": {
"mode": {
"enum": ["provider-snapshot", "application-quiesce", "transaction-boundary"]
},
"token_sha256": { "$ref": "#/$defs/sha256" },
"established_at": { "type": "string", "format": "date-time" }
}
}
}
},
"components": {
"type": "object",
"additionalProperties": false,
"required": ["database", "objects", "configuration", "key_custody"],
"properties": {
"database": { "$ref": "#/$defs/database" },
"objects": { "$ref": "#/$defs/objects" },
"configuration": { "$ref": "#/$defs/configuration" },
"key_custody": { "$ref": "#/$defs/key_custody" }
}
},
"restore_drill": {
"type": "object",
"additionalProperties": false,
"required": [
"drill_id",
"recovery_point_id",
"started_at",
"completed_at",
"isolated_target_ref",
"release_manifest_sha256",
"migration_heads_sha256",
"representative_object_manifest_sha256",
"database_verified",
"objects_verified",
"configuration_verified",
"key_custody_verified",
"semantic_checks",
"measured_rpo_seconds",
"measured_rto_seconds",
"evidence_ref"
],
"properties": {
"drill_id": { "$ref": "#/$defs/token" },
"recovery_point_id": { "$ref": "#/$defs/token" },
"started_at": { "type": "string", "format": "date-time" },
"completed_at": { "type": "string", "format": "date-time" },
"isolated_target_ref": { "$ref": "#/$defs/reference" },
"release_manifest_sha256": { "$ref": "#/$defs/sha256" },
"migration_heads_sha256": { "$ref": "#/$defs/sha256" },
"representative_object_manifest_sha256": { "$ref": "#/$defs/sha256" },
"database_verified": { "const": true },
"objects_verified": { "const": true },
"configuration_verified": { "const": true },
"key_custody_verified": { "const": true },
"semantic_checks": {
"type": "array",
"minItems": 1,
"maxItems": 128,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "status", "evidence_ref"],
"properties": {
"id": { "$ref": "#/$defs/token" },
"status": { "const": "passed" },
"evidence_ref": { "$ref": "#/$defs/reference" }
}
}
},
"measured_rpo_seconds": { "$ref": "#/$defs/duration" },
"measured_rto_seconds": { "$ref": "#/$defs/duration" },
"evidence_ref": { "$ref": "#/$defs/reference" }
}
},
"issued_at": { "type": "string", "format": "date-time" },
"expires_at": { "type": "string", "format": "date-time" },
"revoked": { "const": false },
"signatures": {
"type": "array",
"minItems": 1,
"maxItems": 16,
"items": { "$ref": "#/$defs/signature" }
}
},
"$defs": {
"token": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
},
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"digest_image": {
"type": "string",
"maxLength": 300,
"pattern": "^[^@\\s]+@sha256:[0-9a-f]{64}$"
},
"reference": {
"type": "string",
"minLength": 3,
"maxLength": 2048,
"pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s]+$"
},
"duration": { "type": "integer", "minimum": 0, "maximum": 2592000 },
"protected_key": {
"type": "object",
"properties": {
"protected": { "const": true },
"encryption_key_ref": { "$ref": "#/$defs/reference" },
"captured_at": { "type": "string", "format": "date-time" }
}
},
"database": {
"type": "object",
"additionalProperties": false,
"required": [
"provider", "artifact_ref", "artifact_sha256", "snapshot_id", "lsn",
"protected", "encryption_key_ref", "captured_at"
],
"properties": {
"provider": { "$ref": "#/$defs/token" },
"artifact_ref": { "$ref": "#/$defs/reference" },
"artifact_sha256": { "$ref": "#/$defs/sha256" },
"snapshot_id": { "$ref": "#/$defs/token" },
"lsn": { "type": "string", "minLength": 1, "maxLength": 256 },
"protected": { "const": true },
"encryption_key_ref": { "$ref": "#/$defs/reference" },
"captured_at": { "type": "string", "format": "date-time" }
}
},
"objects": {
"type": "object",
"additionalProperties": false,
"required": [
"provider", "artifact_ref", "manifest_sha256", "version_id",
"object_count", "total_bytes", "protected", "encryption_key_ref", "captured_at"
],
"properties": {
"provider": { "$ref": "#/$defs/token" },
"artifact_ref": { "$ref": "#/$defs/reference" },
"manifest_sha256": { "$ref": "#/$defs/sha256" },
"version_id": { "$ref": "#/$defs/token" },
"object_count": { "type": "integer", "minimum": 0 },
"total_bytes": { "type": "integer", "minimum": 0 },
"protected": { "const": true },
"encryption_key_ref": { "$ref": "#/$defs/reference" },
"captured_at": { "type": "string", "format": "date-time" }
}
},
"configuration": {
"type": "object",
"additionalProperties": false,
"required": ["artifact_ref", "sha256", "protected", "encryption_key_ref", "captured_at"],
"properties": {
"artifact_ref": { "$ref": "#/$defs/reference" },
"sha256": { "$ref": "#/$defs/sha256" },
"protected": { "const": true },
"encryption_key_ref": { "$ref": "#/$defs/reference" },
"captured_at": { "type": "string", "format": "date-time" }
}
},
"key_custody": {
"type": "object",
"additionalProperties": false,
"required": ["provider", "keyset_ref", "keyset_version", "recoverable", "captured_at"],
"properties": {
"provider": { "$ref": "#/$defs/token" },
"keyset_ref": { "$ref": "#/$defs/reference" },
"keyset_version": { "$ref": "#/$defs/token" },
"recoverable": { "const": true },
"captured_at": { "type": "string", "format": "date-time" }
}
},
"signature": {
"type": "object",
"additionalProperties": false,
"required": ["key_id", "algorithm", "value"],
"properties": {
"key_id": { "$ref": "#/$defs/token" },
"algorithm": { "const": "ed25519" },
"value": { "type": "string", "minLength": 1, "maxLength": 256 }
}
}
}
}
+61
View File
@@ -83,6 +83,18 @@
"type": "string",
"pattern": "^$|^[0-9a-f]{64}$"
},
"manifest_keyring_sha256": {
"type": "string",
"pattern": "^$|^[0-9a-f]{64}$"
},
"manifest_signature_key_id": {
"type": "string",
"pattern": "^$|^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
},
"composition_sha256": {
"type": "string",
"pattern": "^$|^[0-9a-f]{64}$"
},
"api_image": {
"type": "string",
"minLength": 1,
@@ -165,6 +177,55 @@
}
}
},
"ingress": {
"type": "object",
"additionalProperties": false,
"required": [
"mode",
"image",
"trusted_proxy_cidrs",
"http_port",
"https_port",
"acme_email"
],
"properties": {
"mode": {
"enum": [
"local",
"existing-proxy",
"managed",
"unconfigured"
]
},
"image": {
"type": "string",
"maxLength": 300
},
"trusted_proxy_cidrs": {
"type": "array",
"maxItems": 16,
"uniqueItems": true,
"items": {
"type": "string",
"maxLength": 64
}
},
"http_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"https_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"acme_email": {
"type": "string",
"maxLength": 254
}
}
},
"enabled_modules": {
"type": "array",
"uniqueItems": true,
+195
View File
@@ -0,0 +1,195 @@
{
"schema_version": 1,
"parent_issue": "https://git.add-ideas.de/GovOPlaN/govoplan/issues/36",
"operations": [
{
"id": "campaign.build.publish-artifacts",
"repository": "govoplan-campaign",
"resources": ["postgresql", "object-storage", "templates-capability", "files-capability"],
"mode": "compensation",
"fenced": true,
"adoption": "reference-implementation",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/92"
},
{
"id": "campaign.delivery.external-channels",
"repository": "govoplan-campaign",
"resources": ["postgresql", "queue", "smtp", "imap", "postbox", "print-provider"],
"mode": "forward_recovery",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/92"
},
{
"id": "campaign.retention.generated-artifacts",
"repository": "govoplan-campaign",
"resources": ["postgresql", "object-storage"],
"mode": "forward_recovery",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/92"
},
{
"id": "files.upload.finalize",
"repository": "govoplan-files",
"resources": ["postgresql", "object-storage", "filesystem-staging"],
"mode": "compensation",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/41"
},
{
"id": "files.retention.purge",
"repository": "govoplan-files",
"resources": ["postgresql", "object-storage", "encryption-key-custody"],
"mode": "irreversible",
"fenced": true,
"adoption": "planned",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/41"
},
{
"id": "files.integrity.reconcile",
"repository": "govoplan-files",
"resources": ["postgresql", "object-storage"],
"mode": "forward_recovery",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/41"
},
{
"id": "files.connector.write-sync",
"repository": "govoplan-files",
"resources": ["postgresql", "object-storage", "external-connector"],
"mode": "forward_recovery",
"fenced": true,
"adoption": "planned",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/41"
},
{
"id": "mail.outbox.smtp-submit",
"repository": "govoplan-mail",
"resources": ["postgresql", "queue", "smtp"],
"mode": "forward_recovery",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/19"
},
{
"id": "mail.sent.imap-append",
"repository": "govoplan-mail",
"resources": ["postgresql", "imap"],
"mode": "forward_recovery",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/19"
},
{
"id": "mail.mailbox.imap-mutate",
"repository": "govoplan-mail",
"resources": ["postgresql", "imap"],
"mode": "forward_recovery",
"fenced": true,
"adoption": "planned",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/19"
},
{
"id": "mail.mailbox.sync-cursor",
"repository": "govoplan-mail",
"resources": ["postgresql", "imap"],
"mode": "atomic",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/19"
},
{
"id": "connectors.sync.read-snapshot",
"repository": "govoplan-connectors",
"resources": ["postgresql", "external-provider"],
"mode": "atomic",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-connectors/issues/15"
},
{
"id": "connectors.sync.external-mutation",
"repository": "govoplan-connectors",
"resources": ["postgresql", "queue", "external-provider"],
"mode": "forward_recovery",
"fenced": true,
"adoption": "planned",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-connectors/issues/15"
},
{
"id": "dataflow.run.database-only",
"repository": "govoplan-dataflow",
"resources": ["postgresql"],
"mode": "atomic",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-dataflow/issues/19"
},
{
"id": "dataflow.run.publish-output",
"repository": "govoplan-dataflow",
"resources": ["postgresql", "queue", "object-storage", "external-sink"],
"mode": "forward_recovery",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-dataflow/issues/19"
},
{
"id": "workflow-engine.instance.state-transition",
"repository": "govoplan-workflow-engine",
"resources": ["postgresql"],
"mode": "atomic",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-workflow-engine/issues/1"
},
{
"id": "workflow-engine.activity.external-effect",
"repository": "govoplan-workflow-engine",
"resources": ["postgresql", "queue", "module-capability", "external-provider"],
"mode": "forward_recovery",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-workflow-engine/issues/1"
},
{
"id": "core.module-lifecycle.pre-migration",
"repository": "govoplan-core",
"resources": ["postgresql", "package-environment", "webui-bundle", "filesystem"],
"mode": "compensation",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/281"
},
{
"id": "core.module-lifecycle.post-migration",
"repository": "govoplan-core",
"resources": ["postgresql", "package-environment", "webui-bundle", "runtime-nodes"],
"mode": "forward_recovery",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/281"
},
{
"id": "core.module-retirement.destroy-data",
"repository": "govoplan-core",
"resources": ["postgresql", "object-storage", "package-environment"],
"mode": "snapshot_restore",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/281"
},
{
"id": "core.module-runtime.apply-graph",
"repository": "govoplan-core",
"resources": ["postgresql", "runtime-nodes", "module-registry"],
"mode": "compensation",
"fenced": true,
"adoption": "adopted",
"issue": "https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/281"
}
]
}
@@ -0,0 +1,36 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://govoplan.add-ideas.de/schemas/runtime-distribution-keyring-v1.json",
"title": "GovOPlaN runtime distribution trust keyring",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "purpose", "keys"],
"properties": {
"schema_version": { "const": "1" },
"purpose": { "const": "govoplan-runtime-distribution" },
"keys": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"key_id",
"algorithm",
"status",
"public_key_pem",
"not_before",
"expires_at"
],
"properties": {
"key_id": { "type": "string", "minLength": 1, "maxLength": 128 },
"algorithm": { "const": "ed25519" },
"status": { "enum": ["active", "retired", "revoked"] },
"public_key_pem": { "type": "string", "minLength": 1, "maxLength": 8192 },
"not_before": { "type": "string", "format": "date-time" },
"expires_at": { "type": "string", "format": "date-time" }
}
}
}
}
}
@@ -0,0 +1,123 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://govoplan.add-ideas.de/schemas/runtime-distribution-manifest-v1.json",
"title": "GovOPlaN runtime distribution manifest",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"channel",
"sequence",
"version",
"issued_at",
"expires_at",
"revoked",
"deployer",
"images",
"dependencies",
"composition",
"signatures"
],
"properties": {
"schema_version": { "const": "1" },
"channel": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" },
"sequence": { "type": "integer", "minimum": 1 },
"version": { "type": "string", "minLength": 1, "maxLength": 128 },
"issued_at": { "type": "string", "format": "date-time" },
"expires_at": { "type": "string", "format": "date-time" },
"revoked": { "const": false },
"deployer": { "$ref": "#/$defs/artifact" },
"images": {
"type": "object",
"additionalProperties": false,
"required": ["api", "web"],
"properties": {
"api": { "$ref": "#/$defs/image" },
"web": { "$ref": "#/$defs/image" }
}
},
"dependencies": {
"type": "object",
"minProperties": 1,
"propertyNames": { "pattern": "^[a-z][a-z0-9_]{1,63}$" },
"additionalProperties": { "$ref": "#/$defs/imageReference" }
},
"composition": {
"type": "object",
"additionalProperties": false,
"required": ["sha256", "module_ids", "packages"],
"properties": {
"sha256": { "$ref": "#/$defs/sha256" },
"module_ids": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" }
},
"packages": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "version", "wheel_sha256"],
"properties": {
"name": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
"version": { "type": "string", "minLength": 1, "maxLength": 128 },
"wheel_sha256": { "$ref": "#/$defs/sha256" }
}
}
}
}
},
"signatures": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key_id", "algorithm", "value"],
"properties": {
"key_id": { "type": "string", "minLength": 1, "maxLength": 128 },
"algorithm": { "const": "ed25519" },
"value": { "type": "string", "minLength": 1, "maxLength": 256 }
}
}
}
},
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"imageReference": {
"type": "string",
"pattern": "^[^@\\s]+@sha256:[0-9a-f]{64}$",
"maxLength": 300
},
"artifact": {
"type": "object",
"additionalProperties": false,
"required": ["url", "sha256"],
"properties": {
"url": { "type": "string", "format": "uri", "pattern": "^https://" },
"sha256": { "$ref": "#/$defs/sha256" }
}
},
"image": {
"type": "object",
"additionalProperties": false,
"required": ["index", "platforms", "sbom", "provenance"],
"properties": {
"index": { "$ref": "#/$defs/imageReference" },
"platforms": {
"type": "object",
"additionalProperties": false,
"required": ["linux/amd64", "linux/arm64"],
"properties": {
"linux/amd64": { "$ref": "#/$defs/imageReference" },
"linux/arm64": { "$ref": "#/$defs/imageReference" }
}
},
"sbom": { "$ref": "#/$defs/artifact" },
"provenance": { "$ref": "#/$defs/artifact" }
}
}
}
}
+1
View File
@@ -16,6 +16,7 @@
-e ../govoplan-dashboard
-e ../govoplan-addresses
-e ../govoplan-dist-lists
-e ../govoplan-templates
-e ../govoplan-files
-e ../govoplan-forms
-e ../govoplan-forms-runtime
+430
View File
@@ -0,0 +1,430 @@
from __future__ import annotations
import base64
from contextlib import redirect_stderr, redirect_stdout
from datetime import UTC, datetime, timedelta
import hashlib
import io
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
META_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(META_ROOT / "tools" / "deployment"))
from govoplan_deploy.backup_evidence import verify_backup_evidence # noqa: E402
from govoplan_deploy.bundle import ( # noqa: E402
atomic_write,
bundle_paths,
canonical_json,
read_env,
)
from govoplan_deploy.cli import main as deploy_main # noqa: E402
from govoplan_deploy.distribution import ( # noqa: E402
DistributionError,
canonical_signed_payload,
canonical_json as canonical_distribution_json,
)
from govoplan_deploy.model import default_spec, parse_spec # noqa: E402
from govoplan_deploy.planning import ( # noqa: E402
release_change_requires_backup,
verify_stored_backup_evidence,
)
class BackupEvidenceTests(unittest.TestCase):
def setUp(self) -> None:
self.now = datetime(2026, 8, 3, 12, tzinfo=UTC)
self.private = Ed25519PrivateKey.generate()
public = (
self.private.public_key()
.public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode("ascii")
)
self.keyring = {
"schema_version": "1",
"purpose": "govoplan-backup-evidence",
"keys": [
{
"key_id": "backup-controller-1",
"algorithm": "ed25519",
"status": "active",
"public_key_pem": public,
"not_before": (self.now - timedelta(days=1)).isoformat(),
"expires_at": (self.now + timedelta(days=365)).isoformat(),
}
],
}
self.release = {
"channel": "stable",
"version": "1.2.3",
"manifest_sha256": "a" * 64,
"composition_sha256": "b" * 64,
"api_image": "registry.example/api@sha256:" + "c" * 64,
"web_image": "registry.example/web@sha256:" + "d" * 64,
}
def test_verifies_coordinated_restore_drill_and_release_binding(self) -> None:
summary = verify_backup_evidence(
self._evidence(),
self.keyring,
installation_id="govoplan-test",
profile="self-hosted",
release=self.release,
now=self.now,
)
self.assertEqual("recovery-1", summary["recovery_point_id"])
self.assertEqual("restore-1", summary["restore_drill_id"])
self.assertEqual("backup-controller-1", summary["signature_key_id"])
def test_tampering_staleness_and_partial_restore_fail_closed(self) -> None:
tampered = self._evidence()
tampered["components"]["objects"]["object_count"] = 999
with self.assertRaisesRegex(DistributionError, "signature verification"):
verify_backup_evidence(
tampered,
self.keyring,
installation_id="govoplan-test",
profile="self-hosted",
release=self.release,
now=self.now,
)
stale = self._evidence(captured=self.now - timedelta(days=2))
with self.assertRaisesRegex(DistributionError, "stale"):
verify_backup_evidence(
stale,
self.keyring,
installation_id="govoplan-test",
profile="self-hosted",
release=self.release,
now=self.now,
)
partial = self._evidence()
partial["restore_drill"]["objects_verified"] = False
partial["signatures"] = [self._signature(partial)]
with self.assertRaisesRegex(DistributionError, "objects_verified"):
verify_backup_evidence(
partial,
self.keyring,
installation_id="govoplan-test",
profile="self-hosted",
release=self.release,
now=self.now,
)
def test_wrong_release_key_purpose_and_component_skew_fail_closed(self) -> None:
wrong_release = dict(self.release)
wrong_release["version"] = "1.2.4"
with self.assertRaisesRegex(DistributionError, "release field"):
verify_backup_evidence(
self._evidence(),
self.keyring,
installation_id="govoplan-test",
profile="self-hosted",
release=wrong_release,
now=self.now,
)
wrong_keyring = dict(self.keyring)
wrong_keyring["purpose"] = "govoplan-runtime-distribution"
with self.assertRaisesRegex(DistributionError, "wrong purpose"):
verify_backup_evidence(
self._evidence(),
wrong_keyring,
installation_id="govoplan-test",
profile="self-hosted",
release=self.release,
now=self.now,
)
skewed = self._evidence()
skewed["components"]["database"]["captured_at"] = (
self.now - timedelta(hours=1)
).isoformat()
skewed["signatures"] = [self._signature(skewed)]
with self.assertRaisesRegex(DistributionError, "one recovery point"):
verify_backup_evidence(
skewed,
self.keyring,
installation_id="govoplan-test",
profile="self-hosted",
release=self.release,
now=self.now,
)
false_rto = self._evidence()
false_rto["restore_drill"]["measured_rto_seconds"] = 1
false_rto["signatures"] = [self._signature(false_rto)]
with self.assertRaisesRegex(DistributionError, "RTO"):
verify_backup_evidence(
false_rto,
self.keyring,
installation_id="govoplan-test",
profile="self-hosted",
release=self.release,
now=self.now,
)
def test_provider_signing_tool_emits_canonical_verified_evidence(self) -> None:
self.now = datetime.now(UTC)
self.keyring["keys"][0]["not_before"] = (
self.now - timedelta(days=1)
).isoformat()
self.keyring["keys"][0]["expires_at"] = (
self.now + timedelta(days=365)
).isoformat()
evidence = self._evidence()
evidence["signatures"] = []
with tempfile.TemporaryDirectory(prefix="govoplan-backup-signer-") as value:
root = Path(value)
source = root / "unsigned.json"
output = root / "signed.json"
keyring = root / "keyring.json"
private_key = root / "private.pem"
atomic_write(source, canonical_json(evidence), mode=0o600)
atomic_write(keyring, canonical_json(self.keyring), mode=0o600)
atomic_write(
private_key,
self.private.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
),
mode=0o600,
)
result = subprocess.run(
[
sys.executable,
str(META_ROOT / "tools/deployment/sign-backup-evidence.py"),
"--input",
str(source),
"--output",
str(output),
"--trusted-keyring",
str(keyring),
"--signing-key",
f"backup-controller-1={private_key}",
],
cwd=META_ROOT,
check=False,
capture_output=True,
text=True,
)
self.assertEqual(0, result.returncode, result.stderr)
encoded = output.read_bytes()
signed = json.loads(encoded)
self.assertEqual(canonical_distribution_json(signed), encoded)
summary = verify_backup_evidence(
signed,
self.keyring,
installation_id="govoplan-test",
profile="self-hosted",
release=self.release,
)
self.assertEqual("backup-controller-1", summary["signature_key_id"])
def test_cli_adoption_gates_the_next_release_against_previous_receipt(self) -> None:
self.now = datetime.now(UTC)
self.keyring["keys"][0]["not_before"] = (
self.now - timedelta(days=1)
).isoformat()
self.keyring["keys"][0]["expires_at"] = (
self.now + timedelta(days=365)
).isoformat()
evidence = self._evidence()
encoded_evidence = canonical_distribution_json(evidence)
encoded_keyring = canonical_distribution_json(self.keyring)
with tempfile.TemporaryDirectory(prefix="govoplan-backup-evidence-") as value:
paths = bundle_paths(Path(value))
paths.root.chmod(0o700)
raw = default_spec(
installation_id="govoplan-test",
profile="self-hosted",
public_url="https://govoplan.example.test",
ingress_mode="existing-proxy",
trusted_proxy_cidrs=("127.0.0.1/32",),
).to_dict()
raw["release"] = {
**raw["release"],
**self.release,
}
current = parse_spec(raw)
atomic_write(paths.spec, canonical_json(current.to_dict()), mode=0o600)
source_evidence = paths.root / "source-backup.json"
source_keyring = paths.root / "source-keyring.json"
atomic_write(source_evidence, encoded_evidence, mode=0o600)
atomic_write(source_keyring, encoded_keyring, mode=0o600)
output = io.StringIO()
with redirect_stdout(output), redirect_stderr(output):
result = deploy_main(
[
"verify-backup",
"--directory",
str(paths.root),
"--evidence",
str(source_evidence),
"--evidence-sha256",
hashlib.sha256(encoded_evidence).hexdigest(),
"--trusted-keyring",
str(source_keyring),
"--adopt",
]
)
self.assertEqual(0, result, output.getvalue())
runtime_environment = read_env(paths.env)
self.assertEqual(
"verified",
runtime_environment["GOVOPLAN_BACKUP_EVIDENCE_STATE"],
)
self.assertEqual(
"recovery-1",
runtime_environment["GOVOPLAN_BACKUP_RECOVERY_POINT_ID"],
)
self.assertNotIn("snapshot:postgres", str(runtime_environment))
self.assertNotIn("urn:kms", str(runtime_environment))
receipt = {
"installation_id": current.installation_id,
"profile": current.profile,
"release": dict(self.release),
}
atomic_write(paths.receipt, canonical_json(receipt), mode=0o600)
target_raw = current.to_dict()
target_raw["release"]["version"] = "1.2.4"
target_raw["release"]["manifest_sha256"] = "9" * 64
target = parse_spec(target_raw)
self.assertTrue(release_change_requires_backup(target, receipt))
summary = verify_stored_backup_evidence(
target,
paths,
receipt=receipt,
)
self.assertEqual("recovery-1", summary["recovery_point_id"])
def _evidence(self, *, captured: datetime | None = None) -> dict[str, object]:
captured = captured or self.now - timedelta(hours=2)
started = captured + timedelta(minutes=15)
completed = captured + timedelta(minutes=30)
issued = completed + timedelta(minutes=10)
artifact_time = captured.isoformat()
payload: dict[str, object] = {
"schema_version": "1",
"evidence_id": "backup-1",
"installation_id": "govoplan-test",
"deployment_subject": {
"profile": "self-hosted",
"topology": "compose",
"subject_ref": "urn:govoplan:installation:govoplan-test",
},
"release": dict(self.release),
"recovery_point": {
"id": "recovery-1",
"captured_at": captured.isoformat(),
"consistency": "application-quiesced",
"rpo_seconds": 300,
"write_fence": {
"mode": "application-quiesce",
"token_sha256": "e" * 64,
"established_at": captured.isoformat(),
},
},
"components": {
"database": {
"provider": "postgres",
"artifact_ref": "snapshot:postgres:backup-1",
"artifact_sha256": "1" * 64,
"snapshot_id": "pg-snapshot-1",
"lsn": "0/16B6C50",
"protected": True,
"encryption_key_ref": "urn:kms:key:database-backup",
"captured_at": artifact_time,
},
"objects": {
"provider": "s3",
"artifact_ref": "s3://backup/govoplan-test/recovery-1",
"manifest_sha256": "2" * 64,
"version_id": "object-snapshot-1",
"object_count": 4,
"total_bytes": 1024,
"protected": True,
"encryption_key_ref": "urn:kms:key:object-backup",
"captured_at": artifact_time,
},
"configuration": {
"artifact_ref": "backup:configuration:recovery-1",
"sha256": "3" * 64,
"protected": True,
"encryption_key_ref": "urn:kms:key:configuration-backup",
"captured_at": artifact_time,
},
"key_custody": {
"provider": "kms",
"keyset_ref": "urn:kms:keyset:govoplan-test",
"keyset_version": "version-4",
"recoverable": True,
"captured_at": artifact_time,
},
},
"restore_drill": {
"drill_id": "restore-1",
"recovery_point_id": "recovery-1",
"started_at": started.isoformat(),
"completed_at": completed.isoformat(),
"isolated_target_ref": "urn:govoplan:restore-target:restore-1",
"release_manifest_sha256": self.release["manifest_sha256"],
"migration_heads_sha256": "4" * 64,
"representative_object_manifest_sha256": "2" * 64,
"database_verified": True,
"objects_verified": True,
"configuration_verified": True,
"key_custody_verified": True,
"semantic_checks": [
{
"id": "institutional-journey",
"status": "passed",
"evidence_ref": "evidence:journey:institutional-1",
}
],
"measured_rpo_seconds": 120,
"measured_rto_seconds": 900,
"evidence_ref": "evidence:restore:restore-1",
},
"issued_at": issued.isoformat(),
"expires_at": (self.now + timedelta(days=7)).isoformat(),
"revoked": False,
"signatures": [],
}
payload["signatures"] = [self._signature(payload)]
return payload
def _signature(self, payload: dict[str, object]) -> dict[str, str]:
return {
"key_id": "backup-controller-1",
"algorithm": "ed25519",
"value": base64.b64encode(
self.private.sign(canonical_signed_payload(payload))
).decode("ascii"),
}
if __name__ == "__main__":
unittest.main()
+283 -1
View File
@@ -25,12 +25,17 @@ from govoplan_deploy.bundle import ( # noqa: E402
initial_secrets,
read_env,
reconcile_runtime_environment,
render_caddy_config,
render_compose,
render_existing_proxy_contract,
render_load_balancer_config,
write_env,
)
from govoplan_deploy.cli import _receipt_uses_direct_web_port, main # noqa: E402
import govoplan_deploy.cli as deployment_cli # noqa: E402
from govoplan_deploy.cluster_evidence import ( # noqa: E402
collect_kubernetes_evidence,
)
from govoplan_deploy.kubernetes import render_kubernetes # noqa: E402
from govoplan_deploy.model import ( # noqa: E402
SpecError,
@@ -55,7 +60,104 @@ def run_cli(arguments: list[str]) -> tuple[int, str, str]:
return result, stdout.getvalue(), stderr.getvalue()
def _kubernetes_test_pod(name: str, component: str, node: str) -> dict:
return {
"metadata": {
"name": name,
"uid": f"uid-{name}",
"labels": {"app.kubernetes.io/component": component},
},
"spec": {"nodeName": node},
"status": {"conditions": [{"type": "Ready", "status": "True"}]},
}
def _kubernetes_test_deployment(component: str, replicas: int) -> dict:
return {
"metadata": {
"name": f"govoplan-cluster-{component}",
"labels": {"app.kubernetes.io/component": component},
},
"spec": {"replicas": replicas},
"status": {"availableReplicas": replicas, "updatedReplicas": replicas},
}
class DeploymentInstallerTests(unittest.TestCase):
def test_kubernetes_evidence_requires_two_node_spread_and_safe_runtime(
self,
) -> None:
resources = {
"nodes": {
"items": [
{
"metadata": {"name": name},
"spec": {},
"status": {"conditions": [{"type": "Ready", "status": "True"}]},
}
for name in ("node-a", "node-b")
]
},
"pods": {
"items": [
_kubernetes_test_pod("api-a", "api", "node-a"),
_kubernetes_test_pod("api-b", "api", "node-b"),
_kubernetes_test_pod("web-a", "web", "node-a"),
_kubernetes_test_pod("web-b", "web", "node-b"),
_kubernetes_test_pod("worker-a", "worker", "node-a"),
]
},
"deployments": {
"items": [
_kubernetes_test_deployment("api", 2),
_kubernetes_test_deployment("web", 2),
_kubernetes_test_deployment("worker", 1),
]
},
}
def run(arguments):
return resources[
"nodes"
if "nodes" in arguments
else "deployments"
if "deployments" in arguments
else "pods"
]
evidence = collect_kubernetes_evidence(
installation_id="govoplan-cluster",
namespace="govoplan",
ops_url="https://govoplan.example.test/api/v1/ops/status",
api_key="not-retained",
command_runner=run,
json_fetcher=lambda _url, _key: {
"readiness": {"ready": True},
"runtime_cluster": {
"expected": {"api": 2, "worker": 1},
"active": {"api": 2, "worker": 1},
"composition": {"skewed": False},
"software_versions": {"skewed": False},
"queues": {"missing": []},
},
"checks": [
{
"id": "database_capacity",
"state": "ok",
"detail": "Within budget",
"metrics": {"peak": 30, "available": 90},
}
],
},
)
self.assertEqual("passed", evidence["result"]["state"])
self.assertNotIn("not-retained", json.dumps(evidence))
self.assertEqual(
["node-a", "node-b"],
evidence["snapshot"]["ready_node_names"],
)
def test_pre_migration_failure_restores_checksum_verified_applied_bundle(
self,
) -> None:
@@ -212,10 +314,20 @@ class DeploymentInstallerTests(unittest.TestCase):
"FILE_STORAGE_S3_ACCESS_KEY_ID": "object-key",
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "object-secret",
"FILE_STORAGE_S3_BUCKET": "govoplan",
"GOVOPLAN_DB_CONNECTION_LIMIT": "100",
},
)
manifest = render_kubernetes(spec, environment)
manifest = render_kubernetes(
spec,
environment,
backup_required=True,
backup_evidence={
"evidence_sha256": "c" * 64,
"recovery_point_id": "recovery-1",
"restore_drill_id": "drill-1",
},
)
rendered = json.dumps(manifest, sort_keys=True)
kinds = [item["kind"] for item in manifest["items"]]
deployments = {
@@ -248,6 +360,18 @@ class DeploymentInstallerTests(unittest.TestCase):
"forward-recovery",
migration["metadata"]["annotations"]["govoplan.add-ideas.de/recovery-mode"],
)
self.assertEqual(
"c" * 64,
migration["metadata"]["annotations"][
"govoplan.add-ideas.de/backup-evidence-sha256"
],
)
self.assertEqual(
"recovery-1",
migration["metadata"]["annotations"][
"govoplan.add-ideas.de/recovery-point"
],
)
config = next(item for item in manifest["items"] if item["kind"] == "ConfigMap")
self.assertEqual("shared", config["data"]["GOVOPLAN_STATE_PROFILE"])
self.assertEqual("3", config["data"]["GOVOPLAN_EXPECTED_API_REPLICAS"])
@@ -257,6 +381,103 @@ class DeploymentInstallerTests(unittest.TestCase):
"containers"
][0]["readinessProbe"]["httpGet"]["httpHeaders"],
)
worker_command = deployments["govoplan-cluster-worker"]["spec"]["template"][
"spec"
]["containers"][0]["command"]
self.assertIn("--concurrency", worker_command)
self.assertEqual(
"62",
manifest["metadata"]["annotations"][
"govoplan.add-ideas.de/database-connection-peak"
],
)
def test_kubernetes_export_splits_worker_queues_and_rejects_capacity_overrun(
self,
) -> None:
spec = default_spec(
installation_id="govoplan-cluster",
postgres_mode="external",
redis_mode="external",
storage_mode="s3",
api_replicas=2,
web_replicas=2,
worker_replicas=3,
api_image="registry.example.test/govoplan-api@sha256:" + "a" * 64,
web_image="registry.example.test/govoplan-web@sha256:" + "b" * 64,
)
environment = initial_secrets(
spec,
supplied={
"DATABASE_URL": "postgresql+psycopg://user:db-secret@postgres.example.test/govoplan",
"GOVOPLAN_DATABASE_URL_PGTOOLS": "postgresql://user:db-secret@postgres.example.test/govoplan",
"REDIS_URL": "rediss://:redis-secret@redis.example.test/0",
"FILE_STORAGE_S3_ENDPOINT_URL": "https://s3.example.test",
"FILE_STORAGE_S3_REGION": "eu-test-1",
"FILE_STORAGE_S3_ACCESS_KEY_ID": "object-key",
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "object-secret",
"FILE_STORAGE_S3_BUCKET": "govoplan",
"GOVOPLAN_DB_CONNECTION_LIMIT": "100",
},
)
queues = environment["CELERY_QUEUES"].split(",")
environment["GOVOPLAN_WORKER_POOLS"] = json.dumps(
[
{
"name": "delivery",
"queues": queues[:3],
"replicas": 2,
"concurrency": 2,
},
{
"name": "platform",
"queues": queues[3:],
"replicas": 1,
"concurrency": 1,
},
]
)
manifest = render_kubernetes(spec, environment)
workers = [
item
for item in manifest["items"]
if item["kind"] == "Deployment"
and item["metadata"]["labels"].get("app.kubernetes.io/component")
== "worker"
]
self.assertEqual(2, len(workers))
self.assertEqual(
{"delivery", "platform"},
{
item["metadata"]["labels"]["govoplan.add-ideas.de/worker-pool"]
for item in workers
},
)
for deployment in workers:
container = deployment["spec"]["template"]["spec"]["containers"][0]
explicit_environment = {
item["name"]: item.get("value")
for item in container["env"]
if "value" in item
}
self.assertEqual(
deployment["metadata"]["labels"]["govoplan.add-ideas.de/worker-pool"],
explicit_environment["GOVOPLAN_WORKER_POOL"],
)
self.assertEqual(
set(
container["command"][
container["command"].index("--queues") + 1
].split(",")
),
set(explicit_environment["CELERY_QUEUES"].split(",")),
)
environment["GOVOPLAN_DB_CONNECTION_LIMIT"] = "40"
with self.assertRaisesRegex(ValueError, "connection peak"):
render_kubernetes(spec, environment)
def test_kubernetes_export_rejects_mutable_release_images(self) -> None:
spec = default_spec(
@@ -311,6 +532,65 @@ class DeploymentInstallerTests(unittest.TestCase):
self.assertEqual(1, compose["services"]["api"]["scale"])
self.assertEqual(1, compose["services"]["web"]["scale"])
def test_managed_ingress_persists_certificate_state_and_hides_upstream(
self,
) -> None:
spec = default_spec(
profile="self-hosted",
public_url="https://govoplan.example.test",
ingress_mode="managed",
acme_email="operator@example.test",
)
compose = render_compose(spec)
ingress = compose["services"]["ingress"]
self.assertNotIn("ports", compose["services"]["load-balancer"])
self.assertEqual(
["0.0.0.0:80:8080", "0.0.0.0:443:8443"],
ingress["ports"],
)
self.assertIn("caddy-data:/data", ingress["volumes"])
self.assertIn("caddy-config:/config", ingress["volumes"])
self.assertEqual(["ALL"], ingress["cap_drop"])
self.assertEqual(["NET_BIND_SERVICE"], ingress["cap_add"])
self.assertEqual(["no-new-privileges:true"], ingress["security_opt"])
self.assertIn("reverse_proxy load-balancer:8080", render_caddy_config(spec))
self.assertNotIn("operator@example.test", json.dumps(compose))
def test_existing_proxy_contract_and_header_trust_are_exact(self) -> None:
spec = default_spec(
profile="self-hosted",
public_url="https://govoplan.example.test",
ingress_mode="existing-proxy",
trusted_proxy_cidrs=("172.20.0.7/32",),
)
contract = render_existing_proxy_contract(spec)
load_balancer = render_load_balancer_config(spec)
self.assertEqual("http://127.0.0.1:8080", contract["upstream"])
self.assertEqual(["172.20.0.7/32"], contract["trusted_proxy_cidrs"])
self.assertIn("acl trusted_forward_proxy src 172.20.0.7/32", load_balancer)
self.assertIn("del-header X-Forwarded-Proto", load_balancer)
self.assertIn(
"del-header X-Forwarded-For unless trusted_forward_proxy", load_balancer
)
def test_ingress_rejects_unsafe_proxy_ranges_and_managed_ip_hosts(self) -> None:
with self.assertRaisesRegex(SpecError, "/24 or narrower"):
default_spec(
profile="self-hosted",
public_url="https://govoplan.example.test",
ingress_mode="existing-proxy",
trusted_proxy_cidrs=("10.0.0.0/8",),
)
with self.assertRaisesRegex(SpecError, "DNS hostname"):
default_spec(
profile="self-hosted",
public_url="https://192.0.2.10",
ingress_mode="managed",
acme_email="operator@example.test",
)
def test_disabled_redis_removes_workers_and_sets_single_process_acknowledgement(
self,
) -> None:
@@ -515,6 +795,7 @@ class DeploymentInstallerTests(unittest.TestCase):
def test_legacy_spec_defaults_new_topology_fields(self) -> None:
raw = default_spec().to_dict()
raw.pop("replicas")
raw.pop("ingress")
raw["components"].pop("load_balancer")
raw["components"]["storage"].pop("image")
@@ -524,6 +805,7 @@ class DeploymentInstallerTests(unittest.TestCase):
self.assertEqual(1, parsed.replicas.web)
self.assertEqual(1, parsed.replicas.worker)
self.assertEqual("managed", parsed.components.load_balancer.mode)
self.assertEqual("local", parsed.ingress.mode)
def test_compose_contains_no_secret_values(self) -> None:
spec = default_spec()
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
import base64
from datetime import UTC, datetime, timedelta
import hashlib
from pathlib import Path
import sys
import tempfile
import unittest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
META_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(META_ROOT / "tools" / "deployment"))
from govoplan_deploy.bundle import bundle_paths # noqa: E402
from govoplan_deploy.cli import main # noqa: E402
from govoplan_deploy.distribution import ( # noqa: E402
canonical_json,
canonical_signed_payload,
)
from govoplan_deploy.model import load_spec # noqa: E402
from govoplan_deploy.planning import static_checks # noqa: E402
class DeploymentReleaseAdoptionTests(unittest.TestCase):
def test_adopts_verified_manifest_and_makes_release_checks_pass(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-release-adopt-") as value:
root = Path(value)
self.assertEqual(
0,
main(
[
"init",
"--directory",
str(root),
"--non-interactive",
"--module-set",
"core",
]
),
)
manifest, keyring = self._signed_distribution()
manifest_path = root / "source-manifest.json"
keyring_path = root / "source-keyring.json"
encoded_manifest = canonical_json(manifest)
manifest_path.write_bytes(encoded_manifest)
keyring_path.write_bytes(canonical_json(keyring))
result = main(
[
"verify-release",
"--directory",
str(root),
"--manifest",
str(manifest_path),
"--manifest-sha256",
hashlib.sha256(encoded_manifest).hexdigest(),
"--trusted-keyring",
str(keyring_path),
"--adopt",
]
)
self.assertEqual(0, result)
paths = bundle_paths(root)
spec = load_spec(paths.spec)
self.assertEqual("1.2.3", spec.release.version)
self.assertEqual("release-1", spec.release.manifest_signature_key_id)
self.assertTrue(spec.release.api_image.endswith("a" * 64))
release_checks = {
item.id: item for item in static_checks(spec, paths)
if item.id.startswith("release.") or item.id == "modules.image_composition"
}
self.assertEqual("ok", release_checks["release.manifest"].level)
self.assertEqual(
"ok", release_checks["release.signature_verification"].level
)
self.assertEqual("ok", release_checks["modules.image_composition"].level)
def test_rejects_manifest_whose_independent_digest_does_not_match(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-release-adopt-") as value:
root = Path(value)
main(
[
"init",
"--directory",
str(root),
"--non-interactive",
"--module-set",
"core",
]
)
manifest, keyring = self._signed_distribution()
manifest_path = root / "source-manifest.json"
keyring_path = root / "source-keyring.json"
manifest_path.write_bytes(canonical_json(manifest))
keyring_path.write_bytes(canonical_json(keyring))
self.assertEqual(
1,
main(
[
"verify-release",
"--directory",
str(root),
"--manifest",
str(manifest_path),
"--manifest-sha256",
"0" * 64,
"--trusted-keyring",
str(keyring_path),
]
),
)
@staticmethod
def _signed_distribution() -> tuple[dict[str, object], dict[str, object]]:
now = datetime.now(UTC)
private = Ed25519PrivateKey.generate()
public = private.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
artifact = {
"url": "https://downloads.example.test/artifact.json",
"sha256": "f" * 64,
}
payload: dict[str, object] = {
"schema_version": "1",
"channel": "stable",
"sequence": 1,
"version": "1.2.3",
"issued_at": (now - timedelta(minutes=1)).isoformat(),
"expires_at": (now + timedelta(days=30)).isoformat(),
"revoked": False,
"deployer": {
"url": "https://downloads.example.test/govoplan-deploy.pyz",
"sha256": "e" * 64,
},
"images": {
"api": {
"index": "registry.example/govoplan/api@sha256:" + "a" * 64,
"platforms": {
"linux/amd64": "registry.example/govoplan/api@sha256:" + "1" * 64,
"linux/arm64": "registry.example/govoplan/api@sha256:" + "2" * 64,
},
"sbom": dict(artifact),
"provenance": dict(artifact),
},
"web": {
"index": "registry.example/govoplan/web@sha256:" + "b" * 64,
"platforms": {
"linux/amd64": "registry.example/govoplan/web@sha256:" + "3" * 64,
"linux/arm64": "registry.example/govoplan/web@sha256:" + "4" * 64,
},
"sbom": dict(artifact),
"provenance": dict(artifact),
},
},
"dependencies": {
"postgres": "docker.io/library/postgres@sha256:" + "5" * 64,
"redis": "docker.io/library/redis@sha256:" + "6" * 64,
"load_balancer": "docker.io/library/haproxy@sha256:" + "7" * 64,
},
"composition": {
"sha256": "c" * 64,
"module_ids": [],
"packages": [
{
"name": "govoplan-core",
"version": "1.2.3",
"wheel_sha256": "8" * 64,
}
],
},
}
payload["signatures"] = [
{
"key_id": "release-1",
"algorithm": "ed25519",
"value": base64.b64encode(
private.sign(canonical_signed_payload(payload))
).decode("ascii"),
}
]
keyring = {
"schema_version": "1",
"purpose": "govoplan-runtime-distribution",
"keys": [
{
"key_id": "release-1",
"algorithm": "ed25519",
"status": "active",
"public_key_pem": public,
"not_before": (now - timedelta(days=1)).isoformat(),
"expires_at": (now + timedelta(days=365)).isoformat(),
}
],
}
return payload, keyring
if __name__ == "__main__":
unittest.main()
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import json
import subprocess
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
def _load_module():
path = ROOT / "tools/checks/managed-ingress-drill.py"
spec = importlib.util.spec_from_file_location("managed_ingress_drill", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
INGRESS = _load_module()
class ManagedIngressDrillTests(unittest.TestCase):
def test_config_is_streamed_into_a_daemon_visible_volume(self) -> None:
completed = subprocess.CompletedProcess([], 0, "", "")
with patch.object(INGRESS, "_run", return_value=completed) as run:
INGRESS._write_volume_file(
image="registry.example/caddy@sha256:" + "1" * 64,
volume="config-volume",
filename="Caddyfile",
content=":8080 { respond /health 200 }\n",
)
argv = run.call_args.args[0]
self.assertIn("type=volume,src=config-volume,dst=/govoplan-config", argv)
self.assertIn("0:0", argv)
self.assertNotIn("type=bind", " ".join(argv))
self.assertEqual(
":8080 { respond /health 200 }\n",
run.call_args.kwargs["input_text"],
)
def test_config_filename_cannot_escape_the_volume(self) -> None:
with self.assertRaisesRegex(ValueError, "invalid config filename"):
INGRESS._write_volume_file(
image="registry.example/caddy@sha256:" + "1" * 64,
volume="config-volume",
filename="../Caddyfile",
content="",
)
def test_drill_has_no_runner_local_bind_mounts(self) -> None:
source = (ROOT / "tools/checks/managed-ingress-drill.py").read_text(
encoding="utf-8"
)
self.assertNotIn("type=bind", source)
self.assertIn('"--network-alias",\n "load-balancer"', source)
self.assertNotIn('"127.0.0.1::8080"', source)
self.assertIn("requested_http_port", source)
self.assertIn("requested_https_port", source)
self.assertIn('"--cap-add",\n "NET_BIND_SERVICE"', source)
def test_published_port_reads_the_docker_mapping(self) -> None:
completed = subprocess.CompletedProcess(
[],
0,
json.dumps(
{
"8443/tcp": [
{"HostIp": "127.0.0.1", "HostPort": "49152"}
]
}
),
"",
)
with patch.object(INGRESS, "_run", return_value=completed) as run:
port = INGRESS._published_port("ingress", 8443)
self.assertEqual(49152, port)
self.assertEqual(
[
"docker",
"inspect",
"--format",
"{{json .HostConfig.PortBindings}}",
"ingress",
],
run.call_args.args[0],
)
def test_published_port_rejects_non_loopback_binding(self) -> None:
completed = subprocess.CompletedProcess(
[],
0,
'{"8443/tcp":[{"HostIp":"0.0.0.0","HostPort":"49152"}]}',
"",
)
with patch.object(INGRESS, "_run", return_value=completed):
with self.assertRaisesRegex(RuntimeError, "loopback binding"):
INGRESS._published_port("ingress", 8443)
def test_probe_runs_as_a_network_sibling_from_a_digest_image(self) -> None:
completed = subprocess.CompletedProcess([], 0, "", "")
image = "registry.example/runtime-api@sha256:" + "1" * 64
with patch.object(INGRESS, "_run", return_value=completed) as run:
INGRESS._probe_ingress(
image=image,
network="deployment-network",
container="ingress",
)
argv = run.call_args.args[0]
self.assertEqual("docker", argv[0])
self.assertIn("deployment-network", argv)
self.assertIn(image, argv)
self.assertIn('(\"ingress\", port)', argv[-1])
self.assertIn("server_hostname=\"localhost\"", argv[-1])
self.assertNotIn("localhost:49152", argv[-1])
def test_probe_diagnostics_include_container_stderr(self) -> None:
probe_failure = subprocess.CalledProcessError(1, ["docker", "run"])
state = subprocess.CompletedProcess([], 0, '{"Running":false}', "")
logs = subprocess.CompletedProcess([], 0, "", "caddy startup failed")
with patch.object(
INGRESS,
"_run",
side_effect=[probe_failure, state, logs],
), patch.object(INGRESS.sys, "stderr") as stderr:
with self.assertRaises(subprocess.CalledProcessError):
INGRESS._probe_ingress(
image="registry.example/runtime-api@sha256:" + "1" * 64,
network="deployment-network",
container="ingress",
)
rendered = "".join(call.args[0] for call in stderr.write.call_args_list)
self.assertIn('"Running":false', rendered)
self.assertIn("caddy startup failed", rendered)
def test_standalone_workflow_is_dispatch_only_and_digest_bounded(self) -> None:
workflow = (
ROOT / ".gitea/workflows/runtime-ingress-drill.yml"
).read_text(encoding="utf-8")
self.assertIn("workflow_dispatch:", workflow)
self.assertNotIn("\n push:", workflow)
self.assertIn("--probe-image \"$PROBE_IMAGE\"", workflow)
self.assertIn("GOVOPLAN_REGISTRY_TOKEN", workflow)
if __name__ == "__main__":
unittest.main()
+110
View File
@@ -2,7 +2,9 @@ from __future__ import annotations
import ast
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
@@ -19,6 +21,28 @@ SPEC.loader.exec_module(inventory)
class PlatformInterfaceInventoryTests(unittest.TestCase):
def test_workspace_resolution_prefers_populated_checkout_siblings(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
sibling = root / "checkout"
meta = sibling / "govoplan"
configured = root / "configured"
(sibling / "govoplan-core" / "src").mkdir(parents=True)
(configured / "govoplan-core").mkdir(parents=True)
previous = inventory.META_ROOT
inventory.META_ROOT = meta
try:
resolved = inventory._resolve_workspace_root(
{
"default_parent": str(configured),
"repositories": [{"path": "govoplan-core"}],
}
)
finally:
inventory.META_ROOT = previous
self.assertEqual(sibling.resolve(), resolved)
def test_canonical_api_path_normalizes_versions_and_parameters(self) -> None:
self.assertEqual(
inventory.canonical_api_path(
@@ -30,6 +54,92 @@ class PlatformInterfaceInventoryTests(unittest.TestCase):
inventory.canonical_api_path("/api/v2/campaigns/{campaign_id}"),
"/campaigns/{}",
)
self.assertEqual(
inventory.canonical_api_path("/api/v1/calendar/events/delta${querySuffix}"),
"/calendar/events/delta",
)
self.assertEqual(
inventory.canonical_api_path("/api/v1/calendar/events/${eventId}"),
"/calendar/events/{}",
)
def test_endpoint_declarations_are_exact_and_require_missing_ui_issue(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "endpoints.json"
path.write_text(
json.dumps(
{
"schema_version": 1,
"endpoints": [
{
"repository": "govoplan-example",
"method": "GET",
"path": "/example/items/{}",
"category": "public_integration",
"rationale": "Published integration API.",
}
],
}
),
encoding="utf-8",
)
declarations = inventory._load_endpoint_declarations(path)
self.assertIn(
("govoplan-example", "GET", "/example/items/{}"),
declarations,
)
payload = json.loads(path.read_text(encoding="utf-8"))
payload["endpoints"][0]["category"] = "missing_ui"
path.write_text(json.dumps(payload), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "tracking_issue"):
inventory._load_endpoint_declarations(path)
def test_inventory_reports_unclassified_and_stale_endpoint_declarations(
self,
) -> None:
webui = {
"frontendApiReferences": [],
"translationUsages": [],
"translationCatalog": {"en": {}, "de": {}},
"fields": [],
"labels": [],
"visibleText": [],
"routes": [],
"navigation": [],
"uiCapabilities": [],
"dynamicTranslationUsages": [],
}
endpoint = {
"repository": "govoplan-example",
"method": "GET",
"path": "/api/v1/example/items",
"file": "src/example.py",
"line": 1,
"handler": "items",
"router": "router",
}
stale = {
"repository": "govoplan-example",
"method": "GET",
"path": "/example/removed",
"category": "removable",
"rationale": "Removal is pending.",
}
result = inventory._assemble_inventory(
webui=webui,
backend_endpoints=[endpoint],
manifests=[],
endpoint_declarations={
("govoplan-example", "GET", "/example/removed"): stale,
},
)
self.assertEqual(1, result["summary"]["unclassified_backend_endpoints"])
self.assertEqual(1, result["summary"]["stale_endpoint_declarations"])
self.assertIsNone(result["api"]["backend_endpoints"][0]["surface"])
def test_fastapi_route_scanner_includes_router_prefix(self) -> None:
tree = ast.parse(
+54
View File
@@ -113,6 +113,60 @@ class PythonEnvironmentSyncTests(unittest.TestCase):
self.assertEqual(plan.mode, "Selective Python environment repair")
self.assertEqual(plan.commands[0][-2:], ("-e", str(project_root)))
def test_metadata_sync_also_repairs_unrelated_missing_distribution(self) -> None:
sync = load_sync_module()
with tempfile.TemporaryDirectory(prefix="govoplan-python-sync-") as directory:
root = Path(directory)
requirements = root / "requirements-dev.txt"
requirements.write_text("-e ./govoplan-changed\n-e ./govoplan-missing\n", encoding="utf-8")
for project in ("govoplan-changed", "govoplan-missing"):
project_root = root / project
project_root.mkdir()
(project_root / "pyproject.toml").write_text(
f'[project]\nname = "{project}"\nversion = "0.1.10"\n',
encoding="utf-8",
)
entries = sync.local_requirement_entries(requirements)
fingerprint = sync.build_fingerprint(
requirements=requirements,
python="/test/venv/bin/python",
local_requirements=entries,
)
requirements_digest = hashlib.sha256(requirements.read_bytes()).hexdigest()
previous = {
"version": sync.STAMP_VERSION,
"python": "/test/venv/bin/python",
"inputs": [
{"path": str(requirements), "sha256": requirements_digest},
{"path": entries[0].pyproject, "sha256": "stale"},
{
"path": entries[1].pyproject,
"sha256": hashlib.sha256(Path(entries[1].pyproject).read_bytes()).hexdigest(),
},
],
"requirements_entries": [
entry.as_dict() for entry in sync.parse_requirement_entries(requirements)
],
}
plan = sync.build_install_plan(
previous=previous,
fingerprint=fingerprint,
requirements=requirements,
python="/test/venv/bin/python",
local_requirements=entries,
repair_requirements=(entries[1],),
force=False,
)
self.assertEqual(plan.mode, "Selective Python environment sync")
self.assertEqual(len(plan.commands), 1)
command = plan.commands[0]
self.assertEqual(command.count("-e"), 2)
self.assertIn(str(root / "govoplan-changed"), command)
self.assertIn(str(root / "govoplan-missing"), command)
def test_declared_module_entry_points_are_part_of_environment_validation(self) -> None:
sync = load_sync_module()
with tempfile.TemporaryDirectory(prefix="govoplan-python-sync-") as directory:
@@ -0,0 +1,61 @@
from __future__ import annotations
import json
from pathlib import Path
from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parents[1]
INVENTORY = ROOT / "docs" / "recovery-operation-inventory.json"
MODES = {
"atomic",
"compensation",
"snapshot_restore",
"forward_recovery",
"irreversible",
}
ADOPTION_STATES = {"planned", "reference-implementation", "adopted"}
REQUIRED_PREFIXES = {
"campaign.",
"files.",
"mail.",
"connectors.",
"dataflow.",
"workflow-engine.",
"core.module-lifecycle.",
"core.module-runtime.",
}
ATOMIC_EXTERNAL_READS = {
"connectors.sync.read-snapshot",
"mail.mailbox.sync-cursor",
}
def test_recovery_operation_inventory_is_complete_and_actionable() -> None:
payload = json.loads(INVENTORY.read_text(encoding="utf-8"))
assert payload["schema_version"] == 1
operations = payload["operations"]
ids = [item["id"] for item in operations]
assert len(ids) == len(set(ids))
assert all(any(item.startswith(prefix) for item in ids) for prefix in REQUIRED_PREFIXES)
for item in operations:
assert item["mode"] in MODES
assert item["adoption"] in ADOPTION_STATES
assert item["repository"].startswith("govoplan-")
assert item["resources"]
assert item["fenced"] is True
issue = urlparse(item["issue"])
assert issue.scheme == "https"
assert issue.netloc == "git.add-ideas.de"
assert issue.path.startswith(f"/GovOPlaN/{item['repository']}/issues/")
def test_non_atomic_operations_do_not_claim_plain_database_rollback() -> None:
operations = json.loads(INVENTORY.read_text(encoding="utf-8"))["operations"]
for item in operations:
if item["mode"] == "atomic":
assert (
item["resources"] == ["postgresql"]
or item["id"] in ATOMIC_EXTERNAL_READS
)
+30
View File
@@ -340,6 +340,7 @@ def add_scoped_workflow_manifest(repo: Path) -> None:
(backend / "__init__.py").write_text("", encoding="utf-8")
(backend / "manifest.py").write_text(
"""from govoplan_core.core.modules import DocumentationCondition, DocumentationTopic, ModuleManifest, PermissionDefinition
from govoplan_core.core.provider_governance import declared_module_architecture
def get_manifest():
@@ -368,6 +369,20 @@ def get_manifest():
conditions=(DocumentationCondition(required_scopes=("access:item:read",)),),
metadata={"kind": "workflow"},
),
DocumentationTopic(
id="access.admin.reference",
title="Administer access",
summary="Static administrator documentation for the release fixture.",
documentation_types=("admin",),
metadata={"kind": "reference"},
),
),
architecture=declared_module_architecture(
layer="institutional_foundation",
kind="foundation",
maturity="scaffold",
documentation_ref="pyproject.toml",
known_limits=("Release-test fixture only.",),
),
)
""",
@@ -387,6 +402,7 @@ def replace_with_unscoped_workflow_manifest(repo: Path) -> None:
manifest = repo / "src" / "govoplan_access" / "backend" / "manifest.py"
manifest.write_text(
"""from govoplan_core.core.modules import DocumentationTopic, ModuleManifest
from govoplan_core.core.provider_governance import declared_module_architecture
def get_manifest():
@@ -402,6 +418,20 @@ def get_manifest():
documentation_types=("user",),
metadata={"kind": "workflow"},
),
DocumentationTopic(
id="access.admin.reference",
title="Administer access",
summary="Static administrator documentation for the release fixture.",
documentation_types=("admin",),
metadata={"kind": "reference"},
),
),
architecture=declared_module_architecture(
layer="institutional_foundation",
kind="foundation",
maturity="scaffold",
documentation_ref="pyproject.toml",
known_limits=("Release-test fixture only.",),
),
)
""",
+69 -19
View File
@@ -94,8 +94,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
"name": "govoplan-core",
"path": "govoplan-core",
"remote": (
"git@git.add-ideas.de:"
"GovOPlaN/govoplan-core.git"
"git@git.add-ideas.de:GovOPlaN/govoplan-core.git"
),
}
],
@@ -134,6 +133,66 @@ class RepositoryBootstrapTests(unittest.TestCase):
self.assertEqual("/bin/false", environment["GIT_ASKPASS"])
self.assertEqual("0", environment["GIT_TERMINAL_PROMPT"])
def test_checkout_auth_is_forwarded_without_entering_clone_arguments(self) -> None:
bootstrap = load_bootstrap_module()
environment = {
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "url.https://example.test/.insteadOf",
"GIT_CONFIG_VALUE_0": "ssh://example.test/",
}
auth_header = "AUTHORIZATION: basic c2hvcnQtbGl2ZWQtam9iLXRva2Vu"
with patch.object(
bootstrap.subprocess,
"run",
return_value=bootstrap.subprocess.CompletedProcess(
args=[],
returncode=0,
stdout=auth_header + "\n",
),
) as runner:
bootstrap._add_checkout_auth(environment, root=Path("/workspace/meta"))
runner.assert_called_once_with(
[
"git",
"-C",
"/workspace/meta",
"config",
"--local",
"--get",
bootstrap.GITEA_CHECKOUT_AUTH_KEY,
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual("2", environment["GIT_CONFIG_COUNT"])
self.assertEqual(
bootstrap.GITEA_CHECKOUT_AUTH_KEY,
environment["GIT_CONFIG_KEY_1"],
)
self.assertEqual(auth_header, environment["GIT_CONFIG_VALUE_1"])
def test_checkout_auth_fails_closed_when_checkout_did_not_persist_it(self) -> None:
bootstrap = load_bootstrap_module()
with (
patch.object(
bootstrap.subprocess,
"run",
return_value=bootstrap.subprocess.CompletedProcess(
args=[],
returncode=1,
stdout="",
),
),
self.assertRaisesRegex(
ValueError, "checkout authentication is unavailable"
),
):
bootstrap._add_checkout_auth({}, root=Path("/workspace/meta"))
def test_main_validates_every_remote_before_cloning(self) -> None:
bootstrap = load_bootstrap_module()
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
@@ -149,8 +208,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
"name": "govoplan-core",
"path": "govoplan-core",
"remote": (
"git@git.add-ideas.de:"
"GovOPlaN/govoplan-core.git"
"git@git.add-ideas.de:GovOPlaN/govoplan-core.git"
),
},
{
@@ -197,9 +255,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
"git@git.add-ideas.de:"
"add-ideas/addideas-govoplan-website.git"
),
"bootstrap_transport": (
bootstrap.REGISTERED_TRANSPORT
),
"bootstrap_transport": (bootstrap.REGISTERED_TRANSPORT),
}
],
}
@@ -240,10 +296,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
{
"name": name,
"path": name,
"remote": (
"git@git.add-ideas.de:GovOPlaN/"
f"{name}.git"
),
"remote": (f"git@git.add-ideas.de:GovOPlaN/{name}.git"),
}
for name in ("govoplan-core", "govoplan-poll")
],
@@ -291,8 +344,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
"name": "govoplan-core",
"path": "govoplan-core",
"remote": (
"git@git.add-ideas.de:"
"GovOPlaN/govoplan-core.git"
"git@git.add-ideas.de:GovOPlaN/govoplan-core.git"
),
}
],
@@ -333,8 +385,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
"name": "govoplan-core",
"path": "govoplan-core",
"remote": (
"git@git.add-ideas.de:"
"GovOPlaN/govoplan-core.git"
"git@git.add-ideas.de:GovOPlaN/govoplan-core.git"
),
}
],
@@ -359,7 +410,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
self.assertEqual(1, status)
runner.assert_not_called()
def test_anonymous_ci_bootstrap_excludes_registered_transport_repositories(
def test_ci_bootstrap_reuses_checkout_auth_and_excludes_registered_transport_repositories(
self,
) -> None:
manifest = json.loads(
@@ -372,9 +423,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
}
self.assertTrue(registered_only)
for workflow in sorted(
(META_ROOT / ".gitea" / "workflows").glob("*.yml")
):
for workflow in sorted((META_ROOT / ".gitea" / "workflows").glob("*.yml")):
contents = workflow.read_text(encoding="utf-8")
if (
"bootstrap-repositories.py" not in contents
@@ -382,6 +431,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
):
continue
with self.subTest(workflow=workflow.name):
self.assertIn("--reuse-checkout-auth", contents)
for repository in registered_only:
self.assertIn(
f"--exclude-repo {repository}",
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
import base64
from datetime import UTC, datetime, timedelta
import hashlib
from pathlib import Path
import sys
import tempfile
import unittest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
META_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(META_ROOT / "tools" / "deployment"))
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
from govoplan_deploy.distribution import ( # noqa: E402
DistributionError,
canonical_signed_payload,
verify_manifest,
verify_manifest_binding,
verify_offline_image_index,
)
class RuntimeDistributionTests(unittest.TestCase):
def setUp(self) -> None:
self.now = datetime(2026, 8, 3, tzinfo=UTC)
self.private = Ed25519PrivateKey.generate()
public = self.private.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
self.keyring = {
"schema_version": "1",
"purpose": "govoplan-runtime-distribution",
"keys": [
{
"key_id": "release-1",
"algorithm": "ed25519",
"status": "active",
"public_key_pem": public,
"not_before": (self.now - timedelta(days=1)).isoformat(),
"expires_at": (self.now + timedelta(days=365)).isoformat(),
}
],
}
def test_verifies_signature_and_exact_runtime_binding(self) -> None:
payload = self._manifest()
key_id = verify_manifest(
payload,
self.keyring,
expected_channel="stable",
now=self.now,
)
verify_manifest_binding(
payload,
channel="stable",
version="1.2.3",
api_image=payload["images"]["api"]["index"],
web_image=payload["images"]["web"]["index"],
enabled_modules=("access", "files"),
composition_sha256="c" * 64,
dependencies=payload["dependencies"],
)
self.assertEqual("release-1", key_id)
def test_tamper_expiry_revocation_and_unknown_key_fail_closed(self) -> None:
payload = self._manifest()
payload["composition"]["module_ids"].append("mail")
with self.assertRaisesRegex(DistributionError, "signature verification"):
verify_manifest(payload, self.keyring, now=self.now)
expired = self._manifest()
expired["expires_at"] = (self.now - timedelta(seconds=1)).isoformat()
expired["signatures"] = [self._signature(expired)]
with self.assertRaisesRegex(DistributionError, "expired"):
verify_manifest(expired, self.keyring, now=self.now)
revoked = self._manifest()
revoked["revoked"] = True
revoked["signatures"] = [self._signature(revoked)]
with self.assertRaisesRegex(DistributionError, "revoked"):
verify_manifest(revoked, self.keyring, now=self.now)
unknown = self._manifest()
unknown["signatures"][0]["key_id"] = "other-key"
with self.assertRaisesRegex(DistributionError, "active trusted key"):
verify_manifest(unknown, self.keyring, now=self.now)
def test_offline_image_index_is_complete_and_digest_bound(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-offline-images-") as value:
root = Path(value)
api = root / "api.oci.tar"
web = root / "web.oci.tar"
api.write_bytes(b"api archive")
web.write_bytes(b"web archive")
api_ref = "registry.example/govoplan/api@sha256:" + "a" * 64
web_ref = "registry.example/govoplan/web@sha256:" + "b" * 64
index = {
"schema_version": "1",
"images": [
{
"reference": api_ref,
"archive": api.name,
"sha256": hashlib.sha256(api.read_bytes()).hexdigest(),
},
{
"reference": web_ref,
"archive": web.name,
"sha256": hashlib.sha256(web.read_bytes()).hexdigest(),
},
],
}
paths = verify_offline_image_index(
index,
root=root,
expected_references=(api_ref, web_ref),
)
self.assertEqual((api, web), paths)
index["images"][1]["sha256"] = "0" * 64
with self.assertRaisesRegex(DistributionError, "digest mismatch"):
verify_offline_image_index(
index,
root=root,
expected_references=(api_ref, web_ref),
)
def _manifest(self) -> dict[str, object]:
artifact = {"url": "https://downloads.example.test/artifact.json", "sha256": "d" * 64}
manifest: dict[str, object] = {
"schema_version": "1",
"channel": "stable",
"sequence": 1,
"version": "1.2.3",
"issued_at": (self.now - timedelta(minutes=1)).isoformat(),
"expires_at": (self.now + timedelta(days=30)).isoformat(),
"revoked": False,
"deployer": {
"url": "https://downloads.example.test/govoplan-deploy.pyz",
"sha256": "e" * 64,
},
"images": {
"api": {
"index": "registry.example/govoplan/api@sha256:" + "a" * 64,
"platforms": {
"linux/amd64": "registry.example/govoplan/api@sha256:" + "1" * 64,
"linux/arm64": "registry.example/govoplan/api@sha256:" + "2" * 64,
},
"sbom": dict(artifact),
"provenance": dict(artifact),
},
"web": {
"index": "registry.example/govoplan/web@sha256:" + "b" * 64,
"platforms": {
"linux/amd64": "registry.example/govoplan/web@sha256:" + "3" * 64,
"linux/arm64": "registry.example/govoplan/web@sha256:" + "4" * 64,
},
"sbom": dict(artifact),
"provenance": dict(artifact),
},
},
"dependencies": {
"postgres": "docker.io/library/postgres@sha256:" + "5" * 64,
"redis": "docker.io/library/redis@sha256:" + "6" * 64,
"load_balancer": "docker.io/library/haproxy@sha256:" + "7" * 64,
},
"composition": {
"sha256": "c" * 64,
"module_ids": ["access", "files"],
"packages": [
{
"name": "govoplan-core",
"version": "1.2.3",
"wheel_sha256": "8" * 64,
}
],
},
}
manifest["signatures"] = [self._signature(manifest)]
return manifest
def _signature(self, payload: dict[str, object]) -> dict[str, str]:
return {
"key_id": "release-1",
"algorithm": "ed25519",
"value": base64.b64encode(
self.private.sign(canonical_signed_payload(payload))
).decode("ascii"),
}
if __name__ == "__main__":
unittest.main()
+348
View File
@@ -0,0 +1,348 @@
from __future__ import annotations
import argparse
import importlib.util
import json
import os
from pathlib import Path
import shutil
import sys
import tempfile
import unittest
from unittest.mock import patch
from urllib.error import HTTPError
ROOT = Path(__file__).resolve().parents[1]
def _load(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
OCI = _load("resolve_oci_platforms", ROOT / "tools/release/resolve-oci-platforms.py")
FINALIZE = _load(
"finalize_runtime_distribution",
ROOT / "tools/release/finalize-runtime-distribution.py",
)
DEPLOYER_BUILD = _load(
"build_deployer_zipapp",
ROOT / "tools/deployment/build-deployer-zipapp.py",
)
PUBLISH = _load(
"publish_runtime_release",
ROOT / "tools/release/publish-runtime-release.py",
)
class RuntimeDistributionBuildTests(unittest.TestCase):
def test_deployment_zipapp_is_reproducible_across_source_mtimes(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-reproducible-zipapp-") as value:
root = Path(value)
source = root / "source"
shutil.copytree(ROOT / "tools/deployment", source)
first = root / "first.pyz"
second = root / "second.pyz"
original_root = DEPLOYER_BUILD.ROOT
try:
DEPLOYER_BUILD.ROOT = source
self.assertEqual(0, DEPLOYER_BUILD.main(["--output", str(first)]))
for path in source.rglob("*.py"):
os.utime(path, (2_000_000_000, 2_000_000_000))
self.assertEqual(0, DEPLOYER_BUILD.main(["--output", str(second)]))
finally:
DEPLOYER_BUILD.ROOT = original_root
self.assertEqual(first.read_bytes(), second.read_bytes())
def test_workflow_signs_with_the_release_environment(self) -> None:
workflow = (ROOT / ".gitea/workflows/runtime-distribution.yml").read_text(
encoding="utf-8"
)
self.assertIn(
".runtime-build/bin/python tools/release/generate-runtime-distribution.py",
workflow,
)
self.assertNotIn(
"\n python tools/release/generate-runtime-distribution.py",
workflow,
)
def test_workflow_rejects_missing_or_mutable_image_inputs_before_build(self) -> None:
workflow = (ROOT / ".gitea/workflows/runtime-distribution.yml").read_text(
encoding="utf-8"
)
validation = workflow.index("- name: Validate immutable release inputs")
bootstrap = workflow.index("- name: Bootstrap release sources")
self.assertLess(validation, bootstrap)
self.assertIn('image_pattern = re.compile(r"^[^@\\s]+@sha256:', workflow)
for input_name in (
"python_image",
"nginx_image",
"postgres_image",
"redis_image",
"load_balancer_image",
"managed_ingress_image",
"garage_image",
"test_mail_image",
"binfmt_image",
):
self.assertIn(f"inputs.{input_name}", workflow)
def test_api_runtime_points_core_at_packaged_migration_scripts(self) -> None:
dockerfile = (ROOT / "tools/release/runtime/Dockerfile.api").read_text(
encoding="utf-8"
)
self.assertIn(
"GOVOPLAN_CORE_SOURCE_ROOT=/opt/govoplan/runtime/govoplan_core_runtime",
dockerfile,
)
def test_web_runtime_uses_only_writable_tmpfs_for_nginx_temp_files(self) -> None:
nginx = (ROOT / "tools/release/runtime/nginx.conf").read_text(
encoding="utf-8"
)
for temporary_path in (
"client_body_temp_path /tmp/client_temp;",
"fastcgi_temp_path /tmp/fastcgi_temp;",
"proxy_temp_path /tmp/proxy_temp;",
"scgi_temp_path /tmp/scgi_temp;",
"uwsgi_temp_path /tmp/uwsgi_temp;",
):
self.assertIn(temporary_path, nginx)
def test_workflow_verifies_portable_bootstrap_artifacts_before_execution(
self,
) -> None:
workflow = (ROOT / ".gitea/workflows/runtime-distribution.yml").read_text(
encoding="utf-8"
)
self.assertIn(
"(cd runtime-output && sha256sum govoplan-deploy.pyz > "
"govoplan-deploy.pyz.sha256)",
workflow,
)
self.assertIn("openssl pkeyutl -verify -pubin", workflow)
self.assertIn("govoplan-deploy.tampered.pyz", workflow)
self.assertLess(
workflow.index("openssl pkeyutl -verify -pubin"),
workflow.index("python runtime-output/govoplan-deploy.pyz init"),
)
def test_workflow_retains_both_platform_runtime_smoke_receipts(self) -> None:
workflow = (ROOT / ".gitea/workflows/runtime-distribution.yml").read_text(
encoding="utf-8"
)
self.assertIn('for ARCH in amd64 arm64; do', workflow)
self.assertIn("tools/checks/runtime-image-smoke.py", workflow)
self.assertIn("Resolve managed dependency platform images", workflow)
self.assertIn("--postgres-metadata", workflow)
self.assertIn("--redis-metadata", workflow)
self.assertIn("Register arm64 execution for runtime smoke", workflow)
self.assertIn(
'docker run --privileged --rm "$BINFMT_IMAGE" --install arm64',
workflow,
)
self.assertIn("runtime-smoke-amd64.json", workflow)
self.assertIn("runtime-smoke-arm64.json", workflow)
self.assertIn(
"jq -r '.platforms[\"linux/amd64\"]' runtime-output/api-metadata.json",
workflow,
)
self.assertNotIn(".platforms[\\\"linux/amd64\\\"]", workflow)
def test_workflow_binds_the_release_tag_to_the_workflow_commit(self) -> None:
workflow = (ROOT / ".gitea/workflows/runtime-distribution.yml").read_text(
encoding="utf-8"
)
publisher = (ROOT / "tools/release/publish-runtime-release.py").read_text(
encoding="utf-8"
)
self.assertIn("SOURCE_COMMIT: ${{ gitea.sha }}", workflow)
self.assertIn('--target-commit "$SOURCE_COMMIT"', workflow)
self.assertIn('"target_commitish": target_commit', publisher)
self.assertIn("self._resolve_commit(tag) != target_commit", publisher)
def test_runtime_publisher_rejects_a_tag_on_another_commit(self) -> None:
publisher = PUBLISH.GiteaReleasePublisher(
base_url="https://git.example.test",
owner="GovOPlaN",
repo="govoplan",
token="secret",
)
target = "1" * 40
with (
patch.object(
publisher,
"_resolve_commit",
side_effect=(target, "2" * 40),
),
self.assertRaisesRegex(PUBLISH.PublishError, "another commit"),
):
publisher.release(
tag="v1.2.3",
target_commit=target,
title="Release",
body="Body",
)
def test_runtime_publisher_creates_the_tag_at_the_exact_commit(self) -> None:
publisher = PUBLISH.GiteaReleasePublisher(
base_url="https://git.example.test",
owner="GovOPlaN",
repo="govoplan",
token="secret",
)
target = "1" * 40
requests: list[tuple[str, dict[str, object] | None]] = []
def request(method: str, _url: str, **kwargs):
payload = kwargs.get("payload")
requests.append((method, payload))
if method == "GET":
raise HTTPError(_url, 404, "not found", {}, None)
return {"id": 1}
with (
patch.object(
publisher,
"_resolve_commit",
side_effect=(target, None, target),
),
patch.object(publisher, "_json", side_effect=request),
):
release = publisher.release(
tag="v1.2.3",
target_commit=target,
title="Release",
body="Body",
)
self.assertEqual({"id": 1}, release)
self.assertEqual("POST", requests[-1][0])
assert requests[-1][1] is not None
self.assertEqual(target, requests[-1][1]["target_commitish"])
def test_resolves_platforms_and_builds_evidence_descriptor(self) -> None:
index = {
"schemaVersion": 2,
"manifests": [
{
"digest": "sha256:" + "1" * 64,
"platform": {"os": "linux", "architecture": "amd64"},
},
{
"digest": "sha256:" + "2" * 64,
"platform": {"os": "linux", "architecture": "arm64"},
},
],
}
metadata = OCI.resolve_platforms(
index,
repository="registry.example/govoplan/api",
index_digest="sha256:" + "a" * 64,
)
self.assertEqual(
"registry.example/govoplan/api@sha256:" + "1" * 64,
metadata["platforms"]["linux/amd64"],
)
dependency_metadata = OCI.resolve_platforms(
index,
repository="registry.example:5000/library/postgres:16-alpine",
index_digest="sha256:" + "a" * 64,
)
self.assertEqual(
"registry.example:5000/library/postgres@sha256:" + "2" * 64,
dependency_metadata["platforms"]["linux/arm64"],
)
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-finalize-") as value:
root = Path(value)
composition = {
"schema_version": "1",
"python": {
"packages": [
{
"package": "govoplan-core",
"version": "1.2.3",
"sha256": "8" * 64,
}
],
"module_ids": ["access"],
"wheelhouse_sha256": "9" * 64,
"wheel_count": 1,
},
"web": {"sha256": "7" * 64, "file_count": 4},
}
(root / "composition.json").write_text(json.dumps(composition))
(root / "api.json").write_text(json.dumps(metadata))
web_metadata = {
"index": "registry.example/govoplan/web@sha256:" + "b" * 64,
"platforms": {
"linux/amd64": "registry.example/govoplan/web@sha256:" + "3" * 64,
"linux/arm64": "registry.example/govoplan/web@sha256:" + "4" * 64,
},
}
(root / "web.json").write_text(json.dumps(web_metadata))
deployer = root / "govoplan-deploy.pyz"
deployer.write_bytes(b"zipapp")
args = argparse.Namespace(
composition=root / "composition.json",
api_metadata=root / "api.json",
web_metadata=root / "web.json",
deployer=deployer,
deployer_url="https://downloads.example/govoplan-deploy.pyz",
artifact_base_url="https://downloads.example/runtime/v1.2.3",
source_commit="f" * 40,
version="1.2.3",
channel="stable",
sequence=1,
expires_days=30,
dependency=[
"postgres=docker.io/library/postgres@sha256:" + "5" * 64,
"redis=docker.io/library/redis@sha256:" + "6" * 64,
],
output_directory=root / "evidence",
descriptor=root / "descriptor.json",
)
descriptor = FINALIZE.finalize(args)
self.assertEqual(["access"], descriptor["composition"]["module_ids"])
self.assertEqual(
"registry.example/govoplan/api@sha256:" + "a" * 64,
descriptor["images"]["api"]["index"],
)
self.assertTrue((root / "evidence/api-sbom.cdx.json").is_file())
self.assertTrue((root / "evidence/web-provenance.json").is_file())
def test_rejects_incomplete_oci_index(self) -> None:
with self.assertRaisesRegex(ValueError, "linux/amd64 and linux/arm64"):
OCI.resolve_platforms(
{
"manifests": [
{
"digest": "sha256:" + "1" * 64,
"platform": {"os": "linux", "architecture": "amd64"},
}
]
},
repository="registry.example/govoplan/api",
index_digest="sha256:" + "a" * 64,
)
if __name__ == "__main__":
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
import zipfile
SCRIPT = (
Path(__file__).resolve().parents[1]
/ "tools"
/ "release"
/ "prepare-runtime-context.py"
)
SPEC = importlib.util.spec_from_file_location("prepare_runtime_context", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
class RuntimeImageContextTests(unittest.TestCase):
def test_builds_deterministic_network_free_context(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-context-") as value:
root = Path(value)
wheelhouse = root / "input-wheels"
web = root / "web"
wheelhouse.mkdir()
web.mkdir()
self._wheel(
wheelhouse / "govoplan_core-1.2.3-py3-none-any.whl",
package="govoplan-core",
version="1.2.3",
module_ids=(),
)
self._wheel(
wheelhouse / "govoplan_files-1.2.3-py3-none-any.whl",
package="govoplan-files",
version="1.2.3",
module_ids=("files",),
)
self._wheel(
wheelhouse / "sqlalchemy-2.0.0-py3-none-any.whl",
package="SQLAlchemy",
version="2.0.0",
module_ids=(),
)
(web / "index.html").write_text("<main>GovOPlaN</main>\n", encoding="utf-8")
composition = MODULE.prepare_context(
wheelhouse=wheelhouse,
web_dist=web,
output=root / "context",
required_modules=("files",),
source_date_epoch=1_700_000_000,
)
self.assertEqual(["files"], composition["python"]["module_ids"])
self.assertEqual(2, composition["python"]["wheel_count"])
requirements = (root / "context" / "requirements-runtime.txt").read_text()
self.assertEqual(
"govoplan-core[server]==1.2.3\ngovoplan-files==1.2.3\n",
requirements,
)
published = json.loads(
(
root
/ "context"
/ "web-dist"
/ ".well-known"
/ "govoplan-composition.json"
).read_text()
)
self.assertEqual(composition, published)
def test_rejects_missing_required_module(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-context-") as value:
root = Path(value)
wheelhouse = root / "wheels"
web = root / "web"
wheelhouse.mkdir()
web.mkdir()
self._wheel(
wheelhouse / "govoplan_core-1.0.0-py3-none-any.whl",
package="govoplan-core",
version="1.0.0",
module_ids=(),
)
(web / "index.html").write_text("ok", encoding="utf-8")
with self.assertRaisesRegex(MODULE.ContextError, "missing required"):
MODULE.prepare_context(
wheelhouse=wheelhouse,
web_dist=web,
output=root / "context",
required_modules=("mail",),
)
def test_rejects_symlinked_web_payload(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-context-") as value:
root = Path(value)
wheelhouse = root / "wheels"
web = root / "web"
wheelhouse.mkdir()
web.mkdir()
self._wheel(
wheelhouse / "govoplan_core-1.0.0-py3-none-any.whl",
package="govoplan-core",
version="1.0.0",
module_ids=(),
)
outside = root / "outside"
outside.write_text("not part of dist", encoding="utf-8")
(web / "index.html").symlink_to(outside)
with self.assertRaisesRegex(MODULE.ContextError, "symlink"):
MODULE.prepare_context(
wheelhouse=wheelhouse,
web_dist=web,
output=root / "context",
)
@staticmethod
def _wheel(
path: Path,
*,
package: str,
version: str,
module_ids: tuple[str, ...],
) -> None:
dist_info = package.replace("-", "_") + f"-{version}.dist-info"
with zipfile.ZipFile(path, "w") as archive:
archive.writestr(
f"{dist_info}/METADATA",
f"Metadata-Version: 2.1\nName: {package}\nVersion: {version}\n",
)
if module_ids:
rows = "\n".join(
f"{module_id} = example.module:manifest"
for module_id in module_ids
)
archive.writestr(
f"{dist_info}/entry_points.txt",
f"[govoplan.modules]\n{rows}\n",
)
archive.writestr(f"{package.replace('-', '_')}/__init__.py", "")
if __name__ == "__main__":
unittest.main()
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "tools/checks/runtime-image-smoke.py"
SPEC = importlib.util.spec_from_file_location("runtime_image_smoke", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
class RuntimeImageSmokeTests(unittest.TestCase):
def test_selects_the_exact_platform_digest(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-smoke-") as value:
path = Path(value) / "metadata.json"
path.write_text(
json.dumps(
{
"index": "registry.example/api@sha256:" + "a" * 64,
"platforms": {
"linux/amd64": "registry.example/api@sha256:" + "1" * 64,
"linux/arm64": "registry.example/api@sha256:" + "2" * 64,
},
}
),
encoding="utf-8",
)
self.assertEqual(
"registry.example/api@sha256:" + "2" * 64,
MODULE.platform_image(path, "linux/arm64", "API"),
)
def test_rejects_mutable_or_missing_platform_images(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-smoke-") as value:
path = Path(value) / "metadata.json"
path.write_text(
json.dumps({"platforms": {"linux/amd64": "registry.example/api:latest"}}),
encoding="utf-8",
)
with self.assertRaisesRegex(MODULE.SmokeError, "exact sha256"):
MODULE.platform_image(path, "linux/amd64", "API")
with self.assertRaisesRegex(MODULE.SmokeError, "exact sha256"):
MODULE.platform_image(path, "linux/arm64", "API")
def test_readiness_fails_immediately_when_container_exits(self) -> None:
exited = subprocess.CompletedProcess([], 0, "false\n", "")
logs = subprocess.CompletedProcess(
[], 0, "fatal startup error db-secret\n", ""
)
with patch.object(MODULE, "_run", side_effect=(exited, logs)):
with self.assertRaisesRegex(
MODULE.SmokeError,
r"container exited before readiness: fatal startup error \[redacted\]",
):
MODULE._wait_for(
"WebUI",
lambda: self.fail("probe must not run for an exited container"),
timeout=60,
container="web",
redactions=("db-secret",),
)
def test_smoke_supplies_the_packaged_web_upstream_and_schema_contract(self) -> None:
source = SCRIPT.read_text(encoding="utf-8")
self.assertIn('"--network-alias",\n "load-balancer"', source)
self.assertIn("'core_system_settings'", source)
self.assertIn("'core_runtime_nodes'", source)
self.assertIn('if platform == "linux/arm64"', source)
self.assertIn('"ARM64-COW-BUG"', source)
if __name__ == "__main__":
unittest.main()
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "tools" / "checks" / "worker-runtime-drill.py"
SPEC = importlib.util.spec_from_file_location("worker_runtime_drill", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
class WorkerRuntimeDrillTests(unittest.TestCase):
def test_redacts_redis_credentials_and_query(self) -> None:
self.assertEqual(
"rediss://redis.example.test:6380/9",
MODULE._redacted_redis_url(
"rediss://worker:secret@redis.example.test:6380/9?ssl=true"
),
)
def test_worker_command_uses_solo_default_queue_for_deterministic_drill(self) -> None:
command = MODULE._worker_command("/usr/bin/python", "worker-a@%h")
self.assertEqual("/usr/bin/python", command[0])
self.assertIn("solo", command)
self.assertIn("default", command)
self.assertIn("worker-a@%h", command)
def test_rejects_implicit_or_non_redis_broker(self) -> None:
args = MODULE.build_parser().parse_args(["--redis-url", "memory://"])
with self.assertRaisesRegex(ValueError, "explicit redis"):
MODULE.run_drill(args)
if __name__ == "__main__":
unittest.main()
+45 -10
View File
@@ -25,6 +25,12 @@ from govoplan_core.core.datasources import (
datasource_publication,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.recovery import RecoveryCheckpoint, RecoveryOperation
from govoplan_core.core.runtime_coordination import (
DistributedLease,
RuntimeIdentity,
bind_process_runtime_identity,
)
from govoplan_core.core.tabular_sources import (
TabularSnapshotInput,
tabular_snapshot_writer,
@@ -73,6 +79,9 @@ def main() -> int:
Base.metadata.create_all(
engine,
tables=[
DistributedLease.__table__,
RecoveryOperation.__table__,
RecoveryCheckpoint.__table__,
ConnectorTabularSource.__table__,
DatasourceRecord.__table__,
DatasourcePayloadRecord.__table__,
@@ -86,6 +95,8 @@ def main() -> int:
],
)
session_factory = sessionmaker(bind=engine)
bind_process_runtime_identity(_runtime_identity())
try:
with session_factory() as session:
principal = _principal()
writer = tabular_snapshot_writer(registry)
@@ -100,7 +111,9 @@ def main() -> int:
or publisher is None
or runner is None
):
raise RuntimeError("Datasource composition capabilities are incomplete.")
raise RuntimeError(
"Datasource composition capabilities are incomplete."
)
origin = writer.create_snapshot(
session,
@@ -141,11 +154,15 @@ def main() -> int:
{"id": 2, "amount": 15},
]
if result.status != "succeeded":
raise RuntimeError(f"Dataflow preview failed: {result.diagnostics}")
raise RuntimeError(
f"Dataflow preview failed: {result.diagnostics}"
)
if result.rows != expected_rows:
raise RuntimeError(f"Unexpected Dataflow rows: {result.rows!r}")
if result.source_fingerprints[0]["source_ref"] != datasource.ref:
raise RuntimeError("Dataflow lineage did not retain the datasource reference.")
raise RuntimeError(
"Dataflow lineage did not retain the datasource reference."
)
pipeline = create_pipeline(
session,
tenant_id="tenant-1",
@@ -193,9 +210,7 @@ def main() -> int:
worker_id="composition-worker",
)
if worker_result["succeeded"] != 1:
raise RuntimeError(
f"Dataflow worker failed: {worker_result!r}"
)
raise RuntimeError(f"Dataflow worker failed: {worker_result!r}")
completed = runner.get_run(
session,
principal,
@@ -205,14 +220,20 @@ def main() -> int:
raise RuntimeError("Dataflow run evidence disappeared.")
published = completed
if published.status != "succeeded":
raise RuntimeError(f"Dataflow publication failed: {published.error}")
raise RuntimeError(
f"Dataflow publication failed: {published.error}"
)
if replayed.ref != published.ref or not replayed.replayed:
raise RuntimeError("Dataflow run idempotency did not replay the prior run.")
raise RuntimeError(
"Dataflow run idempotency did not replay the prior run."
)
if (
not published.output_datasource_ref
or not published.output_materialization_ref
):
raise RuntimeError("Dataflow publication did not retain output references.")
raise RuntimeError(
"Dataflow publication did not retain output references."
)
output = catalogue.read_datasource(
session,
principal,
@@ -226,12 +247,15 @@ def main() -> int:
)
if (
output.materialization is None
or output.materialization.ref != published.output_materialization_ref
or output.materialization.ref
!= published.output_materialization_ref
or output.materialization.frozen_at is None
):
raise RuntimeError(
"Published Datasource materialization is not pinned and frozen."
)
finally:
bind_process_runtime_identity(None)
engine.dispose()
print(
"Connector -> Datasources -> pinned Dataflow publication composition passed."
@@ -252,6 +276,17 @@ class _AutomationProvider:
)
def _runtime_identity() -> RuntimeIdentity:
return RuntimeIdentity(
installation_id="datasource-composition-check",
node_id="composition-worker",
incarnation="composition-worker-incarnation",
role="worker",
software_version="test",
composition_hash="c" * 64,
)
class _AutomationRegistry:
def __init__(self, registry, principal: ApiPrincipal) -> None:
self.registry = registry
+3
View File
@@ -851,6 +851,9 @@ run_jscpd() {
"package.json"
"**/generatedTranslations.ts"
"**/docs/gitea-labels.json"
"**/docs/migration-release-baselines.json"
"**/public/catalogs/**"
"**/alembic/dev_versions/**"
"**/*.md"
"**/*.md:*"
"*.md"
+459
View File
@@ -0,0 +1,459 @@
#!/usr/bin/env python3
"""Exercise generated managed ingress with a real Caddy container."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import shutil
import socket
import subprocess
import sys
import tempfile
from uuid import uuid4
META_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(META_ROOT / "tools" / "deployment"))
from govoplan_deploy.bundle import ( # noqa: E402
render_caddy_config,
render_load_balancer_config,
)
from govoplan_deploy.model import default_spec # noqa: E402
DIGEST_IMAGE = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$")
def _run(
argv: list[str],
*,
check: bool = True,
input_text: str | None = None,
) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
argv,
check=check,
capture_output=True,
input=input_text,
text=True,
timeout=60,
)
except subprocess.CalledProcessError as exc:
stderr = exc.stderr.strip()
if stderr:
print(stderr, file=sys.stderr)
raise
def _write_volume_file(
*,
image: str,
volume: str,
filename: str,
content: str,
) -> None:
if not re.fullmatch(r"[A-Za-z0-9_.-]+", filename):
raise ValueError(f"invalid config filename: {filename!r}")
_run(
[
"docker",
"run",
"--rm",
"--interactive",
"--user",
"0:0",
"--mount",
f"type=volume,src={volume},dst=/govoplan-config",
"--entrypoint",
"sh",
image,
"-c",
f"umask 022; cat > /govoplan-config/{filename}",
],
input_text=content,
)
def _published_port(container: str, target: int) -> int:
output = _run(
[
"docker",
"inspect",
"--format",
"{{json .HostConfig.PortBindings}}",
container,
]
).stdout.strip()
try:
bindings = json.loads(output)[f"{target}/tcp"]
if not isinstance(bindings, list) or len(bindings) != 1:
raise ValueError("expected exactly one published binding")
binding = bindings[0]
if binding.get("HostIp") != "127.0.0.1":
raise ValueError("published binding is not loopback-only")
return int(binding["HostPort"])
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise RuntimeError(
f"cannot determine loopback binding for {target}/tcp from {output!r}"
) from exc
def _available_loopback_port(*, exclude: frozenset[int] = frozenset()) -> int:
for _attempt in range(10):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind(("127.0.0.1", 0))
port = int(listener.getsockname()[1])
if port not in exclude:
return port
raise RuntimeError("cannot allocate distinct loopback ports for ingress drill")
def _probe_ingress(*, image: str, network: str, container: str) -> None:
probe = r'''
import socket
import ssl
import time
def request(port, payload, *, tls):
connection = socket.create_connection(("ingress", port), timeout=3)
if tls:
connection = ssl._create_unverified_context().wrap_socket(
connection, server_hostname="localhost"
)
with connection:
connection.sendall(payload)
chunks = []
while True:
chunk = connection.recv(65536)
if not chunk:
break
chunks.append(chunk)
return b"".join(chunks)
deadline = time.monotonic() + 30
last_error = ""
while time.monotonic() < deadline:
try:
response = request(
8443,
b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
tls=True,
)
head, body_bytes = response.split(b"\r\n\r\n", 1)
status = int(head.split(b" ", 2)[1])
if status != 200:
raise RuntimeError(f"HTTPS returned {status}, expected 200")
body = body_bytes.decode("utf-8").strip()
if body != "proto=https":
raise RuntimeError(f"forwarded protocol was not normalized: {body!r}")
break
except Exception as exc:
last_error = f"{type(exc).__name__}: {exc}"
time.sleep(0.5)
else:
raise SystemExit(f"managed ingress did not become ready: {last_error}")
response = request(
8080,
b"HEAD /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
tls=False,
)
head = response.split(b"\r\n\r\n", 1)[0].decode("iso-8859-1")
lines = head.split("\r\n")
status = int(lines[0].split(" ", 2)[1])
if status != 308:
raise SystemExit(f"HTTP returned {status}, expected redirect 308")
headers = {
key.lower(): value.strip()
for key, separator, value in (line.partition(":") for line in lines[1:])
if separator
}
location = headers.get("location", "")
if not location.startswith("https://localhost"):
raise SystemExit(f"HTTP redirect had unexpected location: {location!r}")
'''
try:
_run(
[
"docker",
"run",
"--rm",
"--network",
network,
"--read-only",
"--security-opt",
"no-new-privileges",
"--cap-drop",
"ALL",
"--entrypoint",
"python",
image,
"-c",
probe,
]
)
except subprocess.CalledProcessError:
_print_container_diagnostics(container)
raise
def _print_container_diagnostics(container: str) -> None:
state = _run(
["docker", "inspect", "--format", "{{json .State}}", container],
check=False,
)
state_detail = (state.stdout + state.stderr).strip()
if state_detail:
print(f"managed ingress state:\n{state_detail}", file=sys.stderr)
logs = _run(["docker", "logs", container], check=False)
log_detail = (logs.stdout + logs.stderr).strip()
if log_detail:
print(f"managed ingress logs:\n{log_detail}", file=sys.stderr)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--caddy-image", required=True)
parser.add_argument("--load-balancer-image", required=True)
parser.add_argument("--probe-image", required=True)
args = parser.parse_args()
for label, image in (
("--caddy-image", args.caddy_image),
("--load-balancer-image", args.load_balancer_image),
("--probe-image", args.probe_image),
):
if DIGEST_IMAGE.fullmatch(image) is None:
parser.error(f"{label} must be pinned by sha256 digest")
if shutil.which("docker") is None:
parser.error("docker is required")
suffix = uuid4().hex[:10]
network = f"govoplan-ingress-drill-{suffix}"
ingress = f"govoplan-ingress-{suffix}"
backend = f"govoplan-ingress-backend-{suffix}"
data_volume = f"govoplan-ingress-data-{suffix}"
config_volume = f"govoplan-ingress-config-{suffix}"
backend_config_volume = f"govoplan-ingress-backend-config-{suffix}"
ingress_config_volume = f"govoplan-ingress-caddy-config-{suffix}"
load_balancer_config_volume = f"govoplan-ingress-haproxy-config-{suffix}"
cleanup = [
["docker", "rm", "--force", ingress, backend],
["docker", "network", "rm", network],
[
"docker",
"volume",
"rm",
data_volume,
config_volume,
backend_config_volume,
ingress_config_volume,
load_balancer_config_volume,
],
]
try:
_run(["docker", "network", "create", network])
for volume in (
data_volume,
config_volume,
backend_config_volume,
ingress_config_volume,
load_balancer_config_volume,
):
_run(["docker", "volume", "create", volume])
with tempfile.TemporaryDirectory(prefix="govoplan-ingress-") as directory:
root = Path(directory)
backend_config = root / "backend.Caddyfile"
backend_config.write_text(
"""{
admin off
auto_https off
}
:8080 {
respond /health "proto={http.request.header.X-Forwarded-Proto}"
}
""",
encoding="utf-8",
)
backend_config.chmod(0o644)
spec = default_spec(
profile="self-hosted",
public_url="https://localhost:8443",
ingress_mode="managed",
ingress_image=args.caddy_image,
ingress_https_port=8443,
acme_email="operator@example.test",
)
ingress_config = root / "Caddyfile"
ingress_config.write_text(render_caddy_config(spec), encoding="utf-8")
ingress_config.chmod(0o644)
load_balancer_config = root / "haproxy.cfg"
load_balancer_config.write_text(
render_load_balancer_config(spec),
encoding="utf-8",
)
load_balancer_config.chmod(0o644)
_write_volume_file(
image=args.load_balancer_image,
volume=load_balancer_config_volume,
filename="haproxy.cfg",
content=load_balancer_config.read_text(encoding="utf-8"),
)
_write_volume_file(
image=args.caddy_image,
volume=backend_config_volume,
filename="Caddyfile",
content=backend_config.read_text(encoding="utf-8"),
)
_write_volume_file(
image=args.caddy_image,
volume=ingress_config_volume,
filename="Caddyfile",
content=ingress_config.read_text(encoding="utf-8"),
)
_run(
[
"docker",
"run",
"--rm",
"--read-only",
"--cap-drop",
"ALL",
"--mount",
(
"type=volume,"
f"src={load_balancer_config_volume},"
"dst=/usr/local/etc/haproxy,readonly"
),
args.load_balancer_image,
"haproxy",
"-c",
"-f",
"/usr/local/etc/haproxy/haproxy.cfg",
]
)
_run(
[
"docker",
"run",
"--detach",
"--name",
backend,
"--network",
network,
"--network-alias",
"load-balancer",
"--read-only",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=16m",
"--mount",
(
"type=volume,"
f"src={backend_config_volume},"
"dst=/govoplan-config,readonly"
),
args.caddy_image,
"caddy",
"run",
"--config",
"/govoplan-config/Caddyfile",
]
)
requested_http_port = _available_loopback_port()
requested_https_port = _available_loopback_port(
exclude=frozenset({requested_http_port})
)
ingress_command = [
"docker",
"run",
"--detach",
"--name",
ingress,
"--network",
network,
"--network-alias",
"ingress",
"--read-only",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=16m",
"--security-opt",
"no-new-privileges",
"--cap-drop",
"ALL",
"--cap-add",
"NET_BIND_SERVICE",
"--publish",
f"127.0.0.1:{requested_http_port}:8080/tcp",
"--publish",
f"127.0.0.1:{requested_https_port}:8443/tcp",
"--mount",
(
"type=volume,"
f"src={ingress_config_volume},"
"dst=/govoplan-config,readonly"
),
"--mount",
f"type=volume,src={data_volume},dst=/data",
"--mount",
f"type=volume,src={config_volume},dst=/config",
args.caddy_image,
"caddy",
"run",
"--config",
"/govoplan-config/Caddyfile",
]
_run(ingress_command)
http_port = _published_port(ingress, 8080)
https_port = _published_port(ingress, 8443)
if (http_port, https_port) != (
requested_http_port,
requested_https_port,
):
raise RuntimeError("Docker published unexpected ingress ports")
_probe_ingress(
image=args.probe_image,
network=network,
container=ingress,
)
_run(["docker", "rm", "--force", ingress])
_run(ingress_command)
https_port = _published_port(ingress, 8443)
if https_port != requested_https_port:
raise RuntimeError("Docker changed the ingress TLS binding on restart")
_probe_ingress(
image=args.probe_image,
network=network,
container=ingress,
)
_run(
[
"docker",
"run",
"--rm",
"--mount",
f"type=volume,src={data_volume},dst=/data,readonly",
"--entrypoint",
"sh",
args.caddy_image,
"-c",
'test -n "$(find /data -type f -print -quit)"',
]
)
finally:
for command in cleanup:
_run(command, check=False)
print("Managed ingress container drill passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+656
View File
@@ -0,0 +1,656 @@
#!/usr/bin/env python3
"""Exercise a pinned GovOPlaN runtime image pair on one OCI platform."""
from __future__ import annotations
import argparse
import base64
from datetime import UTC, datetime
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import time
from typing import Callable, Sequence
PLATFORMS = frozenset({"linux/amd64", "linux/arm64"})
DIGEST_IMAGE = re.compile(r"^[^\s@]+@sha256:[0-9a-f]{64}$")
BASE_MODULES = (
"tenancy",
"organizations",
"identity",
"idm",
"access",
"admin",
"dashboard",
"policy",
"audit",
"docs",
"ops",
)
class SmokeError(RuntimeError):
"""A runtime image failed its bounded acceptance drill."""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--api-metadata", type=Path, required=True)
parser.add_argument("--web-metadata", type=Path, required=True)
parser.add_argument("--postgres-metadata", type=Path, required=True)
parser.add_argument("--redis-metadata", type=Path, required=True)
parser.add_argument("--platform", choices=sorted(PLATFORMS), required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--timeout-seconds", type=float, default=600.0)
return parser
def _utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _digest_image(value: object, label: str) -> str:
if not isinstance(value, str) or DIGEST_IMAGE.fullmatch(value) is None:
raise SmokeError(f"{label} must be an exact sha256 image reference")
return value
def platform_image(path: Path, platform: str, label: str) -> str:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SmokeError(f"cannot read {label} OCI metadata") from exc
if not isinstance(payload, dict) or not isinstance(payload.get("platforms"), dict):
raise SmokeError(f"{label} OCI metadata has no platform map")
return _digest_image(payload["platforms"].get(platform), f"{label} {platform}")
def _tail(value: str, *, limit: int = 4000) -> str:
return value[-limit:].strip()
def _run(
arguments: Sequence[str],
*,
check: bool = True,
timeout: float = 600.0,
redactions: Sequence[str] = (),
) -> subprocess.CompletedProcess[str]:
try:
result = subprocess.run(
list(arguments),
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise SmokeError(f"container command could not complete: {type(exc).__name__}") from exc
if check and result.returncode != 0:
detail = _tail(result.stderr or result.stdout or "no diagnostic output")
for secret in redactions:
if secret:
detail = detail.replace(secret, "[redacted]")
raise SmokeError(f"container command failed: {detail}")
return result
def _wait_for(
label: str,
probe: Callable[[], subprocess.CompletedProcess[str]],
*,
timeout: float,
container: str | None = None,
redactions: Sequence[str] = (),
) -> None:
deadline = time.monotonic() + timeout
last = ""
while time.monotonic() < deadline:
if container is not None:
state = _run(
("docker", "inspect", "--format", "{{.State.Running}}", container),
check=False,
timeout=30,
)
if state.returncode != 0 or state.stdout.strip() != "true":
logs = _run(
("docker", "logs", "--tail", "100", container),
check=False,
timeout=30,
)
detail = _tail(logs.stdout + logs.stderr, limit=8000)
for secret in redactions:
if secret:
detail = detail.replace(secret, "[redacted]")
raise SmokeError(
f"{label} container exited before readiness: "
f"{detail or 'no diagnostic output'}"
)
result = probe()
if result.returncode == 0:
return
last = _tail(result.stderr or result.stdout)
time.sleep(2.0)
raise SmokeError(f"{label} did not become ready: {last or 'probe failed'}")
def _environment(
*,
database_password: str,
master_key: str,
platform_slug: str,
) -> dict[str, str]:
database = (
f"postgresql+psycopg://govoplan:{database_password}@postgres:5432/govoplan"
)
return {
"APP_ENV": "dev",
"GOVOPLAN_INSTALL_PROFILE": "evaluation",
"GOVOPLAN_INSTALLATION_ID": f"runtime-smoke-{platform_slug}",
"GOVOPLAN_STATE_PROFILE": "host-shared",
"GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS": "5",
"GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS": "30",
"GOVOPLAN_EXPECTED_API_REPLICAS": "1",
"GOVOPLAN_EXPECTED_WORKER_REPLICAS": "1",
"DATABASE_URL": database,
"GOVOPLAN_DATABASE_URL_PGTOOLS": (
f"postgresql://govoplan:{database_password}@postgres:5432/govoplan"
),
"GOVOPLAN_DB_CONNECTION_LIMIT": "100",
"GOVOPLAN_DB_CONNECTION_RESERVE": "10",
"REDIS_URL": "redis://redis:6379/0",
"CELERY_ENABLED": "true",
"CELERY_QUEUES": "default",
"CELERY_WORKER_CONCURRENCY": "1",
"ENABLED_MODULES": ",".join(BASE_MODULES),
"GOVOPLAN_MIGRATION_TRACK": "release",
"DEV_AUTO_MIGRATE_ENABLED": "false",
"DEV_BOOTSTRAP_ENABLED": "false",
"AUTH_LOGIN_THROTTLE_ENABLED": "true",
"AUTH_COOKIE_SECURE": "false",
"CORS_ORIGINS": "http://localhost",
"GOVOPLAN_TRUSTED_HOSTS": "127.0.0.1,localhost,api",
"FORWARDED_ALLOW_IPS": "127.0.0.1",
"MASTER_KEY_B64": master_key,
"FILE_STORAGE_BACKEND": "local",
"FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files",
"GOVOPLAN_MODULE_LIVE_APPLY_ENABLED": "false",
}
def _env_arguments(values: dict[str, str], *, role: str, node_id: str) -> list[str]:
arguments: list[str] = []
for key, value in sorted(
{**values, "GOVOPLAN_RUNTIME_ROLE": role, "GOVOPLAN_NODE_ID": node_id}.items()
):
arguments.extend(("--env", f"{key}={value}"))
return arguments
def run_smoke(
*,
api_image: str,
web_image: str,
postgres_image: str,
redis_image: str,
platform: str,
timeout: float,
) -> dict[str, object]:
for label, value in (
("API image", api_image),
("Web image", web_image),
("PostgreSQL image", postgres_image),
("Redis image", redis_image),
):
_digest_image(value, label)
if platform not in PLATFORMS:
raise SmokeError(f"unsupported runtime smoke platform: {platform}")
slug = platform.replace("linux/", "").replace("/", "-")
suffix = secrets.token_hex(4)
prefix = f"govoplan-runtime-{slug}-{suffix}"
names = {
"network": f"{prefix}-network",
"volume": f"{prefix}-data",
"postgres": f"{prefix}-postgres",
"redis": f"{prefix}-redis",
"api": f"{prefix}-api",
"web": f"{prefix}-web",
"worker": f"{prefix}-worker",
}
database_password = secrets.token_hex(20)
master_key = base64.urlsafe_b64encode(os.urandom(32)).decode("ascii")
redactions = (database_password, master_key)
environment = _environment(
database_password=database_password,
master_key=master_key,
platform_slug=slug,
)
checks: list[dict[str, object]] = []
started = time.monotonic()
def record(check_id: str, began: float) -> None:
duration = round(time.monotonic() - began, 3)
checks.append(
{
"id": check_id,
"state": "passed",
"duration_seconds": duration,
}
)
print(f"PASS {platform} {check_id} ({duration}s)", flush=True)
common_runtime = [
"--platform",
platform,
"--network",
names["network"],
"--read-only",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--security-opt",
"no-new-privileges:true",
"--cap-drop",
"ALL",
"--mount",
f"type=volume,source={names['volume']},target=/var/lib/govoplan",
]
redis_command = ["redis-server", "--save", "", "--appendonly", "no"]
if platform == "linux/arm64":
# QEMU user-mode execution triggers Redis's host-kernel COW guard even
# though this isolated smoke disables every persistence mechanism.
redis_command.extend(("--ignore-warnings", "ARM64-COW-BUG"))
try:
_run(("docker", "network", "create", names["network"]), timeout=timeout)
_run(("docker", "volume", "create", names["volume"]), timeout=timeout)
began = time.monotonic()
_run(
(
"docker",
"run",
"--detach",
"--platform",
platform,
"--name",
names["postgres"],
"--network",
names["network"],
"--network-alias",
"postgres",
"--env",
"POSTGRES_DB=govoplan",
"--env",
"POSTGRES_USER=govoplan",
"--env",
f"POSTGRES_PASSWORD={database_password}",
"--tmpfs",
"/var/lib/postgresql/data:rw,noexec,nosuid,size=384m",
postgres_image,
),
timeout=timeout,
redactions=redactions,
)
_run(
(
"docker",
"run",
"--detach",
"--platform",
platform,
"--name",
names["redis"],
"--network",
names["network"],
"--network-alias",
"redis",
"--read-only",
"--tmpfs",
"/data:rw,noexec,nosuid,size=64m",
redis_image,
*redis_command,
),
timeout=timeout,
)
_wait_for(
"PostgreSQL",
lambda: _run(
(
"docker",
"exec",
names["postgres"],
"pg_isready",
"--username",
"govoplan",
"--dbname",
"govoplan",
),
check=False,
timeout=30,
),
timeout=timeout,
container=names["postgres"],
redactions=redactions,
)
_wait_for(
"Redis",
lambda: _run(
("docker", "exec", names["redis"], "redis-cli", "ping"),
check=False,
timeout=30,
),
timeout=timeout,
container=names["redis"],
redactions=redactions,
)
record("managed_dependencies_ready", began)
began = time.monotonic()
_run(
(
"docker",
"run",
"--rm",
"--name",
f"{prefix}-migrate",
*common_runtime,
*_env_arguments(
environment,
role="migration",
node_id=f"runtime-smoke-{slug}-migration",
),
api_image,
"python",
"-m",
"govoplan_core.commands.init_db",
"--migration-track",
"release",
),
timeout=timeout,
redactions=redactions,
)
record("release_migrations", began)
began = time.monotonic()
_run(
(
"docker",
"run",
"--rm",
"--name",
f"{prefix}-schema",
*common_runtime,
*_env_arguments(
environment,
role="migration",
node_id=f"runtime-smoke-{slug}-schema",
),
api_image,
"python",
"-c",
(
"import os;"
"from sqlalchemy import create_engine,inspect;"
"engine=create_engine(os.environ['DATABASE_URL']);"
"tables=set(inspect(engine).get_table_names());"
"required={'alembic_version','core_scopes','core_system_settings',"
"'core_runtime_nodes'};"
"missing=required-tables;"
"assert not missing, f'missing release tables: {sorted(missing)}';"
"engine.dispose()"
),
),
timeout=timeout,
redactions=redactions,
)
record("release_schema_contract", began)
began = time.monotonic()
_run(
(
"docker",
"run",
"--detach",
"--name",
names["api"],
"--network-alias",
"api",
"--network-alias",
"load-balancer",
*common_runtime,
*_env_arguments(
environment,
role="api",
node_id=f"runtime-smoke-{slug}-api",
),
api_image,
),
timeout=timeout,
redactions=redactions,
)
_wait_for(
"GovOPlaN API",
lambda: _run(
(
"docker",
"exec",
names["api"],
"python",
"-c",
(
"import urllib.request;"
"r=urllib.request.Request('http://127.0.0.1:8000/health/ready',"
"headers={'Host':'127.0.0.1'});"
"assert urllib.request.urlopen(r,timeout=3).status==200"
),
),
check=False,
timeout=30,
),
timeout=timeout,
container=names["api"],
redactions=redactions,
)
_run(
(
"docker",
"exec",
names["api"],
"python",
"-c",
"import os; assert os.getuid() == 10001",
),
timeout=30,
)
record("api_non_root_readiness", began)
began = time.monotonic()
_run(
(
"docker",
"run",
"--detach",
"--platform",
platform,
"--name",
names["web"],
"--network",
names["network"],
"--network-alias",
"web",
"--read-only",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--security-opt",
"no-new-privileges:true",
"--cap-drop",
"ALL",
web_image,
),
timeout=timeout,
)
_wait_for(
"GovOPlaN WebUI",
lambda: _run(
(
"docker",
"exec",
names["api"],
"python",
"-c",
(
"import urllib.request;"
"assert urllib.request.urlopen('http://web:8080/health',timeout=3).status==200;"
"assert urllib.request.urlopen('http://web:8080/',timeout=3).status==200"
),
),
check=False,
timeout=30,
),
timeout=timeout,
container=names["web"],
redactions=redactions,
)
_run(
("docker", "exec", names["web"], "sh", "-c", "test \"$(id -u)\" = 101"),
timeout=30,
)
record("web_non_root_readiness", began)
began = time.monotonic()
_run(
(
"docker",
"run",
"--detach",
"--name",
names["worker"],
*common_runtime,
*_env_arguments(
environment,
role="worker",
node_id=f"runtime-smoke-{slug}-worker",
),
api_image,
"python",
"-m",
"celery",
"-A",
"govoplan_core.celery_app:celery",
"worker",
"--queues",
"default",
"--pool",
"solo",
"--concurrency",
"1",
"--hostname",
f"runtime-smoke-{slug}@%h",
"--loglevel",
"WARNING",
),
timeout=timeout,
redactions=redactions,
)
_wait_for(
"GovOPlaN worker",
lambda: _run(
(
"docker",
"exec",
names["api"],
"python",
"-c",
(
"from govoplan_core.celery_app import celery;"
"result=celery.send_task('govoplan.ping',queue='default');"
"assert result.get(timeout=10)=='pong'"
),
),
check=False,
timeout=30,
),
timeout=timeout,
container=names["worker"],
redactions=redactions,
)
_run(("docker", "stop", "--time", "20", names["worker"]), timeout=30)
record("worker_delivery_and_shutdown", began)
except SmokeError as exc:
for role in ("api", "web", "worker", "postgres", "redis"):
result = _run(
("docker", "logs", "--tail", "100", names[role]),
check=False,
timeout=30,
)
if result.stdout or result.stderr:
detail = _tail(result.stdout + result.stderr, limit=8000)
for secret in redactions:
detail = detail.replace(secret, "[redacted]")
print(f"--- {role} logs ---\n{detail}")
raise exc
finally:
for role in ("worker", "web", "api", "redis", "postgres"):
_run(
("docker", "rm", "--force", names[role]),
check=False,
timeout=30,
)
_run(("docker", "volume", "rm", "--force", names["volume"]), check=False)
_run(("docker", "network", "rm", names["network"]), check=False)
return {
"schema_version": "1",
"evidence_kind": "govoplan.runtime-image-smoke",
"captured_at": _utc_now(),
"platform": platform,
"images": {
"api": api_image,
"web": web_image,
"postgres": postgres_image,
"redis": redis_image,
},
"result": {"state": "passed"},
"checks": checks,
"duration_seconds": round(time.monotonic() - started, 3),
}
def main() -> int:
args = build_parser().parse_args()
try:
api_image = platform_image(args.api_metadata, args.platform, "API")
web_image = platform_image(args.web_metadata, args.platform, "Web")
postgres_image = platform_image(
args.postgres_metadata,
args.platform,
"PostgreSQL",
)
redis_image = platform_image(args.redis_metadata, args.platform, "Redis")
evidence = run_smoke(
api_image=api_image,
web_image=web_image,
postgres_image=postgres_image,
redis_image=redis_image,
platform=args.platform,
timeout=args.timeout_seconds,
)
except (OSError, SmokeError, ValueError) as exc:
print(f"runtime image smoke failed: {exc}")
return 1
args.output.parent.mkdir(parents=True, exist_ok=True)
temporary = args.output.with_suffix(args.output.suffix + ".tmp")
temporary.write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.chmod(0o644)
temporary.replace(args.output)
print(f"Runtime image smoke evidence written to {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+397
View File
@@ -0,0 +1,397 @@
#!/usr/bin/env python3
"""Exercise GovOPlaN worker delivery guarantees against a real Redis broker."""
from __future__ import annotations
import argparse
from datetime import UTC, datetime
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time
from typing import Any
from urllib.parse import urlsplit, urlunsplit
import uuid
TERMINAL_STATES = {"FAILURE", "REVOKED", "SUCCESS"}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Prove Celery publish/consume, retry, warm shutdown, and worker-loss "
"redelivery against an isolated Redis database."
)
)
parser.add_argument(
"--redis-url",
default=os.environ.get("GOVOPLAN_WORKER_DRILL_REDIS_URL", ""),
)
parser.add_argument("--python", default=sys.executable)
parser.add_argument("--timeout-seconds", type=float, default=120.0)
parser.add_argument(
"--visibility-timeout-seconds",
type=int,
default=30,
)
parser.add_argument("--output", type=Path)
return parser
def _redacted_redis_url(value: str) -> str:
parsed = urlsplit(value)
hostname = parsed.hostname or ""
port = f":{parsed.port}" if parsed.port else ""
return urlunsplit((parsed.scheme, f"{hostname}{port}", parsed.path, "", ""))
def _wait_until(predicate, *, timeout_seconds: float, detail: str):
deadline = time.monotonic() + timeout_seconds
last_value: Any = None
while time.monotonic() < deadline:
last_value = predicate()
if last_value:
return last_value
time.sleep(0.2)
raise TimeoutError(f"Timed out waiting for {detail}; last value: {last_value!r}")
def _worker_command(python: str, hostname: str) -> list[str]:
return [
python,
"-m",
"celery",
"-A",
"govoplan_core.celery_app:celery",
"worker",
"--pool",
"solo",
"--queues",
"default",
"--hostname",
hostname,
"--loglevel",
"WARNING",
"--without-mingle",
]
def _start_worker(
*,
python: str,
hostname: str,
environment: dict[str, str],
log_path: Path,
) -> tuple[subprocess.Popen[bytes], Any]:
log = log_path.open("ab", buffering=0)
process = subprocess.Popen(
_worker_command(python, hostname),
env=environment,
stdin=subprocess.DEVNULL,
stdout=log,
stderr=subprocess.STDOUT,
start_new_session=True,
)
return process, log
def _stop_worker(
process: subprocess.Popen[bytes] | None,
log: Any,
*,
timeout_seconds: float = 30.0,
) -> None:
if process is not None and process.poll() is None:
process.send_signal(signal.SIGTERM)
try:
process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=10)
if log is not None:
log.close()
def _wait_for_worker(app, process: subprocess.Popen[bytes], prefix: str, timeout: float) -> str:
def ping() -> str | None:
if process.poll() is not None:
raise RuntimeError(f"Worker exited before readiness with code {process.returncode}")
replies = app.control.ping(timeout=1.0) or []
for reply in replies:
for hostname, payload in reply.items():
if hostname.startswith(prefix) and payload.get("ok") == "pong":
return hostname
return None
return _wait_until(ping, timeout_seconds=timeout, detail=f"worker {prefix}")
def _wait_for_started(result, *, timeout_seconds: float) -> dict[str, Any]:
def started() -> dict[str, Any] | None:
state = result.state
if state in TERMINAL_STATES and state != "SUCCESS":
raise RuntimeError(f"Probe {result.id} became {state}: {result.result!r}")
info = result.info
if state in {"PROGRESS", "STARTED"} and isinstance(info, dict):
return dict(info)
return None
return _wait_until(
started,
timeout_seconds=timeout_seconds,
detail=f"probe {result.id} to start",
)
def _wait_for_result(result, *, timeout_seconds: float) -> dict[str, Any]:
value = result.get(timeout=timeout_seconds, propagate=True, disable_sync_subtasks=False)
if not isinstance(value, dict):
raise RuntimeError(f"Probe {result.id} returned an invalid result: {value!r}")
return dict(value)
def _publish(probe_task, probe_id: str, *, mode: str, delay_seconds: float):
return probe_task.apply_async(
args=(probe_id,),
kwargs={
"mode": mode,
"delay_seconds": delay_seconds,
"track_delivery": True,
},
queue="default",
)
def _assert_probe(value: dict[str, Any], *, probe_id: str) -> None:
if value.get("probe_id") != probe_id:
raise RuntimeError(f"Probe identity mismatch: {value!r}")
def run_drill(args: argparse.Namespace) -> dict[str, Any]:
redis_url = str(args.redis_url or "").strip()
parsed_redis = urlsplit(redis_url)
if parsed_redis.scheme not in {"redis", "rediss"} or not parsed_redis.hostname:
raise ValueError("--redis-url must be an explicit redis:// or rediss:// URL")
if args.visibility_timeout_seconds < 30:
raise ValueError("--visibility-timeout-seconds must be at least 30")
run_id = uuid.uuid4().hex
started_at = datetime.now(UTC)
with tempfile.TemporaryDirectory(prefix="govoplan-worker-drill-") as directory:
root = Path(directory)
database_url = f"sqlite:///{root / 'runtime.db'}"
environment = dict(os.environ)
environment.update(
{
"REDIS_URL": redis_url,
"CELERY_ENABLED": "true",
"CELERY_QUEUES": "default",
"CELERY_VISIBILITY_TIMEOUT_SECONDS": str(
args.visibility_timeout_seconds
),
"DATABASE_URL": database_url,
"ENABLED_MODULES": "access",
"APP_ENV": "development",
"GOVOPLAN_EXPECTED_WORKER_REPLICAS": "1",
"GOVOPLAN_WORKER_POOL": "acceptance-drill",
}
)
os.environ.update(environment)
from govoplan_core.db.migrations import migrate_database
migrate_database(
database_url=database_url,
enabled_modules=("access",),
)
from govoplan_core.celery_app import celery, worker_acceptance_probe
celery.backend.client.ping()
celery.control.purge()
worker: subprocess.Popen[bytes] | None = None
worker_log: Any = None
evidence: dict[str, Any] = {
"schema_version": "1.0",
"run_id": run_id,
"started_at": started_at.isoformat(),
"redis": _redacted_redis_url(redis_url),
"visibility_timeout_seconds": args.visibility_timeout_seconds,
"checks": [],
}
try:
prefix = f"govoplan-drill-{run_id[:8]}-a@"
worker, worker_log = _start_worker(
python=args.python,
hostname=prefix + "%h",
environment=environment,
log_path=root / "worker-a.log",
)
hostname = _wait_for_worker(
celery,
worker,
prefix,
args.timeout_seconds,
)
evidence["checks"].append(
{"id": "worker_startup", "state": "passed", "worker": hostname}
)
probe_id = f"{run_id}-publish"
result = _publish(
worker_acceptance_probe,
probe_id,
mode="complete",
delay_seconds=0,
)
value = _wait_for_result(result, timeout_seconds=args.timeout_seconds)
_assert_probe(value, probe_id=probe_id)
if value.get("delivery_count") != 1:
raise RuntimeError(f"Publish probe was not delivered exactly once: {value!r}")
evidence["checks"].append(
{
"id": "publish_consume",
"state": "passed",
"task_id": result.id,
"delivery_count": value["delivery_count"],
}
)
probe_id = f"{run_id}-retry"
result = _publish(
worker_acceptance_probe,
probe_id,
mode="retry_once",
delay_seconds=0,
)
value = _wait_for_result(result, timeout_seconds=args.timeout_seconds)
_assert_probe(value, probe_id=probe_id)
if value.get("retries") != 1 or value.get("delivery_count") != 2:
raise RuntimeError(f"Retry probe did not execute twice: {value!r}")
evidence["checks"].append(
{
"id": "application_retry",
"state": "passed",
"task_id": result.id,
"delivery_count": value["delivery_count"],
}
)
probe_id = f"{run_id}-warm"
result = _publish(
worker_acceptance_probe,
probe_id,
mode="complete",
delay_seconds=2,
)
_wait_for_started(result, timeout_seconds=args.timeout_seconds)
worker.send_signal(signal.SIGTERM)
value = _wait_for_result(result, timeout_seconds=args.timeout_seconds)
_assert_probe(value, probe_id=probe_id)
worker.wait(timeout=args.timeout_seconds)
if worker.returncode != 0 or value.get("delivery_count") != 1:
raise RuntimeError(
f"Warm worker shutdown did not finish in-flight work: {value!r}"
)
worker_log.close()
worker = None
worker_log = None
evidence["checks"].append(
{
"id": "graceful_shutdown",
"state": "passed",
"task_id": result.id,
"delivery_count": value["delivery_count"],
}
)
prefix = f"govoplan-drill-{run_id[:8]}-b@"
worker, worker_log = _start_worker(
python=args.python,
hostname=prefix + "%h",
environment=environment,
log_path=root / "worker-b.log",
)
_wait_for_worker(celery, worker, prefix, args.timeout_seconds)
probe_id = f"{run_id}-loss"
result = _publish(
worker_acceptance_probe,
probe_id,
mode="complete",
delay_seconds=min(120.0, args.visibility_timeout_seconds + 20.0),
)
first_started = _wait_for_started(
result,
timeout_seconds=args.timeout_seconds,
)
if first_started.get("delivery_count") != 1:
raise RuntimeError(f"Worker-loss probe did not start once: {first_started!r}")
worker.kill()
worker.wait(timeout=10)
worker_log.close()
worker = None
worker_log = None
prefix = f"govoplan-drill-{run_id[:8]}-c@"
worker, worker_log = _start_worker(
python=args.python,
hostname=prefix + "%h",
environment=environment,
log_path=root / "worker-c.log",
)
_wait_for_worker(celery, worker, prefix, args.timeout_seconds)
value = _wait_for_result(
result,
timeout_seconds=args.timeout_seconds
+ args.visibility_timeout_seconds
+ 30,
)
_assert_probe(value, probe_id=probe_id)
if value.get("delivery_count") != 2:
raise RuntimeError(f"Worker-loss probe was not redelivered: {value!r}")
evidence["checks"].append(
{
"id": "worker_loss_redelivery",
"state": "passed",
"task_id": result.id,
"delivery_count": value["delivery_count"],
"broker_redelivered": bool(value.get("redelivered")),
}
)
except BaseException as exc:
evidence["error"] = f"{type(exc).__name__}: {exc}"
evidence["result"] = {"state": "failed"}
raise
finally:
_stop_worker(worker, worker_log)
celery.control.purge()
evidence["completed_at"] = datetime.now(UTC).isoformat()
evidence["result"] = {"state": "passed"}
return evidence
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
evidence = run_drill(args)
except (OSError, RuntimeError, TimeoutError, ValueError) as exc:
print(f"worker runtime drill failed: {exc}", file=sys.stderr)
return 1
payload = json.dumps(evidence, indent=2, sort_keys=True) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
temporary = args.output.with_suffix(args.output.suffix + ".tmp")
temporary.write_text(payload, encoding="utf-8")
temporary.replace(args.output)
print(f"Worker runtime evidence written to {args.output}")
else:
print(payload, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+40 -8
View File
@@ -6,11 +6,13 @@ from __future__ import annotations
import argparse
from hashlib import sha256
from pathlib import Path
import zipapp
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
ROOT = Path(__file__).resolve().parent
DEFAULT_OUTPUT = ROOT.parent.parent / "runtime" / "deployment" / "govoplan-deploy.pyz"
ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
PYTHON_FILE_MODE = 0o100644
def main(argv: list[str] | None = None) -> int:
@@ -25,13 +27,7 @@ def main(argv: list[str] | None = None) -> int:
temporary = output.with_name(f".{output.name}.tmp")
if temporary.exists():
temporary.unlink()
zipapp.create_archive(
ROOT,
target=temporary,
interpreter="/usr/bin/env python3",
compressed=True,
filter=_include_source,
)
_write_reproducible_zipapp(temporary)
temporary.chmod(0o755)
temporary.replace(output)
digest = sha256(output.read_bytes()).hexdigest()
@@ -39,6 +35,42 @@ def main(argv: list[str] | None = None) -> int:
return 0
def _write_reproducible_zipapp(target: Path) -> None:
sources = tuple(
path
for path in sorted(ROOT.rglob("*"), key=lambda item: item.as_posix())
if path.is_file() and _include_source(path.relative_to(ROOT))
)
if not any(path.relative_to(ROOT).as_posix() == "__main__.py" for path in sources):
raise ValueError("deployment source has no __main__.py")
for path in sources:
if path.is_symlink():
raise ValueError(f"deployment source must not contain symlinks: {path}")
with target.open("wb") as handle:
handle.write(b"#!/usr/bin/env python3\n")
with ZipFile(
handle,
mode="w",
compression=ZIP_DEFLATED,
compresslevel=9,
strict_timestamps=True,
) as archive:
for source in sources:
relative = source.relative_to(ROOT).as_posix()
info = ZipInfo(relative, date_time=ZIP_TIMESTAMP)
info.compress_type = ZIP_DEFLATED
info.create_system = 3
info.external_attr = PYTHON_FILE_MODE << 16
info.flag_bits |= 0x800
archive.writestr(
info,
source.read_bytes(),
compress_type=ZIP_DEFLATED,
compresslevel=9,
)
def _include_source(path: Path) -> bool:
return (
"__pycache__" not in path.parts
@@ -0,0 +1,500 @@
"""Signed, provider-neutral backup and isolated-restore evidence."""
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
import re
from typing import Any, Mapping
from urllib.parse import urlsplit
from .distribution import (
DistributionError,
load_bounded_json,
verify_signed_document,
)
MAX_BACKUP_EVIDENCE_BYTES = 1024 * 1024
MAX_BACKUP_KEYRING_BYTES = 1024 * 1024
DEFAULT_MAX_BACKUP_AGE_SECONDS = 24 * 60 * 60
MAX_COORDINATION_SKEW_SECONDS = 5 * 60
SHA256 = re.compile(r"^[0-9a-f]{64}$")
TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
IMAGE = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$")
REFERENCE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:[^\s]{1,2040}$")
def load_backup_evidence(path: Path) -> dict[str, Any]:
return load_bounded_json(path, maximum_bytes=MAX_BACKUP_EVIDENCE_BYTES)
def load_backup_keyring(path: Path) -> dict[str, Any]:
return load_bounded_json(path, maximum_bytes=MAX_BACKUP_KEYRING_BYTES)
def verify_backup_evidence(
payload: Mapping[str, Any],
keyring: Mapping[str, Any],
*,
installation_id: str,
profile: str,
release: Mapping[str, object],
now: datetime | None = None,
max_age_seconds: int = DEFAULT_MAX_BACKUP_AGE_SECONDS,
openssl: str = "openssl",
) -> dict[str, object]:
current = (now or datetime.now(UTC)).astimezone(UTC)
values = _validate_payload(payload, now=current, max_age_seconds=max_age_seconds)
if payload.get("installation_id") != installation_id:
raise DistributionError("backup evidence belongs to another installation")
subject = _object(payload.get("deployment_subject"), "deployment_subject")
if subject.get("profile") != profile:
raise DistributionError("backup evidence belongs to another deployment profile")
evidence_release = _object(payload.get("release"), "release")
for field in (
"channel",
"version",
"manifest_sha256",
"composition_sha256",
"api_image",
"web_image",
):
if evidence_release.get(field) != release.get(field):
raise DistributionError(
f"backup evidence does not match release field {field!r}"
)
key_id = verify_signed_document(
payload,
keyring,
purpose="govoplan-backup-evidence",
label="backup evidence",
now=current,
openssl=openssl,
)
recovery_point = _object(payload.get("recovery_point"), "recovery_point")
restore = _object(payload.get("restore_drill"), "restore_drill")
return {
"evidence_id": payload["evidence_id"],
"recovery_point_id": recovery_point["id"],
"captured_at": recovery_point["captured_at"],
"expires_at": payload["expires_at"],
"restore_drill_id": restore["drill_id"],
"restore_started_at": restore["started_at"],
"restore_completed_at": restore["completed_at"],
"measured_rpo_seconds": restore["measured_rpo_seconds"],
"measured_rto_seconds": restore["measured_rto_seconds"],
"signature_key_id": key_id,
"component_count": values["component_count"],
}
def _validate_payload(
payload: Mapping[str, Any],
*,
now: datetime,
max_age_seconds: int,
) -> dict[str, int]:
if max_age_seconds < 60 or max_age_seconds > 30 * 24 * 60 * 60:
raise DistributionError("backup maximum age is out of bounds")
_exact_keys(
payload,
{
"schema_version",
"evidence_id",
"installation_id",
"deployment_subject",
"release",
"recovery_point",
"components",
"restore_drill",
"issued_at",
"expires_at",
"revoked",
"signatures",
},
"backup evidence",
)
if payload.get("schema_version") != "1":
raise DistributionError("unsupported backup evidence schema_version")
_token(payload.get("evidence_id"), "evidence_id")
_token(payload.get("installation_id"), "installation_id")
issued = _timestamp(payload.get("issued_at"), "issued_at")
expires = _timestamp(payload.get("expires_at"), "expires_at")
if issued > now or expires <= issued or expires <= now:
raise DistributionError("backup evidence is not currently valid")
if payload.get("revoked") is not False:
raise DistributionError("backup evidence is revoked")
subject = _object(payload.get("deployment_subject"), "deployment_subject")
_exact_keys(subject, {"profile", "topology", "subject_ref"}, "deployment_subject")
if subject.get("profile") not in {"evaluation", "self-hosted"}:
raise DistributionError("deployment_subject.profile is invalid")
_token(subject.get("topology"), "deployment_subject.topology")
_reference(subject.get("subject_ref"), "deployment_subject.subject_ref")
release = _object(payload.get("release"), "release")
_exact_keys(
release,
{
"channel",
"version",
"manifest_sha256",
"composition_sha256",
"api_image",
"web_image",
},
"release",
)
_token(release.get("channel"), "release.channel")
_token(release.get("version"), "release.version")
_sha256(release.get("manifest_sha256"), "release.manifest_sha256")
_sha256(release.get("composition_sha256"), "release.composition_sha256")
_image(release.get("api_image"), "release.api_image")
_image(release.get("web_image"), "release.web_image")
recovery = _object(payload.get("recovery_point"), "recovery_point")
_exact_keys(
recovery,
{"id", "captured_at", "consistency", "rpo_seconds", "write_fence"},
"recovery_point",
)
recovery_id = _token(recovery.get("id"), "recovery_point.id")
captured = _timestamp(recovery.get("captured_at"), "recovery_point.captured_at")
age = (now - captured).total_seconds()
if age < 0 or age > max_age_seconds:
raise DistributionError("backup recovery point is stale or in the future")
if recovery.get("consistency") not in {
"provider-atomic",
"application-quiesced",
"transaction-consistent",
}:
raise DistributionError("recovery_point.consistency is invalid")
declared_rpo = _bounded_integer(
recovery.get("rpo_seconds"),
"recovery_point.rpo_seconds",
maximum=30 * 24 * 60 * 60,
)
fence = _object(recovery.get("write_fence"), "recovery_point.write_fence")
_exact_keys(
fence,
{"mode", "token_sha256", "established_at"},
"recovery_point.write_fence",
)
if fence.get("mode") not in {
"provider-snapshot",
"application-quiesce",
"transaction-boundary",
}:
raise DistributionError("recovery_point.write_fence.mode is invalid")
_sha256(fence.get("token_sha256"), "recovery_point.write_fence.token_sha256")
established = _timestamp(
fence.get("established_at"),
"recovery_point.write_fence.established_at",
)
if abs((captured - established).total_seconds()) > MAX_COORDINATION_SKEW_SECONDS:
raise DistributionError(
"backup write fence is not coordinated with recovery point"
)
components = _object(payload.get("components"), "components")
_exact_keys(
components,
{"database", "objects", "configuration", "key_custody"},
"components",
)
captured_components = [
_database_component(components.get("database")),
_objects_component(components.get("objects")),
_configuration_component(components.get("configuration")),
_key_custody_component(components.get("key_custody")),
]
if any(
abs((component_time - captured).total_seconds()) > MAX_COORDINATION_SKEW_SECONDS
for component_time in captured_components
):
raise DistributionError("backup components do not share one recovery point")
restore = _object(payload.get("restore_drill"), "restore_drill")
_exact_keys(
restore,
{
"drill_id",
"recovery_point_id",
"started_at",
"completed_at",
"isolated_target_ref",
"release_manifest_sha256",
"migration_heads_sha256",
"representative_object_manifest_sha256",
"database_verified",
"objects_verified",
"configuration_verified",
"key_custody_verified",
"semantic_checks",
"measured_rpo_seconds",
"measured_rto_seconds",
"evidence_ref",
},
"restore_drill",
)
_token(restore.get("drill_id"), "restore_drill.drill_id")
if restore.get("recovery_point_id") != recovery_id:
raise DistributionError("restore drill used another recovery point")
started = _timestamp(restore.get("started_at"), "restore_drill.started_at")
completed = _timestamp(restore.get("completed_at"), "restore_drill.completed_at")
if started < captured or completed < started or completed > issued:
raise DistributionError(
"restore drill completion is outside evidence chronology"
)
_reference(restore.get("isolated_target_ref"), "restore_drill.isolated_target_ref")
_reference(restore.get("evidence_ref"), "restore_drill.evidence_ref")
for field in (
"release_manifest_sha256",
"migration_heads_sha256",
"representative_object_manifest_sha256",
):
_sha256(restore.get(field), f"restore_drill.{field}")
if restore.get("release_manifest_sha256") != release.get("manifest_sha256"):
raise DistributionError("restore drill used another immutable release")
for field in (
"database_verified",
"objects_verified",
"configuration_verified",
"key_custody_verified",
):
if restore.get(field) is not True:
raise DistributionError(f"restore_drill.{field} must be true")
semantic = restore.get("semantic_checks")
if not isinstance(semantic, list) or not semantic or len(semantic) > 128:
raise DistributionError("restore_drill.semantic_checks must not be empty")
seen_checks: set[str] = set()
for index, raw in enumerate(semantic):
check = _object(raw, f"restore_drill.semantic_checks[{index}]")
_exact_keys(
check,
{"id", "status", "evidence_ref"},
f"restore_drill.semantic_checks[{index}]",
)
check_id = _token(check.get("id"), f"semantic_checks[{index}].id")
if check_id in seen_checks or check.get("status") != "passed":
raise DistributionError("restore drill semantic checks are invalid")
seen_checks.add(check_id)
_reference(check.get("evidence_ref"), f"semantic_checks[{index}].evidence_ref")
measured_rpo = _bounded_integer(
restore.get("measured_rpo_seconds"),
"restore_drill.measured_rpo_seconds",
maximum=30 * 24 * 60 * 60,
)
measured_rto = _bounded_integer(
restore.get("measured_rto_seconds"),
"restore_drill.measured_rto_seconds",
maximum=30 * 24 * 60 * 60,
)
if abs((completed - started).total_seconds() - measured_rto) > 5:
raise DistributionError("restore drill RTO does not match its timestamps")
if measured_rpo > declared_rpo:
raise DistributionError(
"restore drill exceeds the declared recovery point objective"
)
_validate_signatures(payload.get("signatures"))
return {"component_count": len(captured_components)}
def _database_component(raw: object) -> datetime:
value = _object(raw, "components.database")
_exact_keys(
value,
{
"provider",
"artifact_ref",
"artifact_sha256",
"snapshot_id",
"lsn",
"protected",
"encryption_key_ref",
"captured_at",
},
"components.database",
)
_common_artifact(value, "components.database")
_token(value.get("snapshot_id"), "components.database.snapshot_id")
_bounded_text(value.get("lsn"), "components.database.lsn", maximum=256)
return _timestamp(value.get("captured_at"), "components.database.captured_at")
def _objects_component(raw: object) -> datetime:
value = _object(raw, "components.objects")
_exact_keys(
value,
{
"provider",
"artifact_ref",
"manifest_sha256",
"version_id",
"object_count",
"total_bytes",
"protected",
"encryption_key_ref",
"captured_at",
},
"components.objects",
)
_token(value.get("provider"), "components.objects.provider")
_reference(value.get("artifact_ref"), "components.objects.artifact_ref")
_sha256(value.get("manifest_sha256"), "components.objects.manifest_sha256")
_token(value.get("version_id"), "components.objects.version_id")
_bounded_integer(value.get("object_count"), "components.objects.object_count")
_bounded_integer(value.get("total_bytes"), "components.objects.total_bytes")
_protected_key_reference(value, "components.objects")
return _timestamp(value.get("captured_at"), "components.objects.captured_at")
def _configuration_component(raw: object) -> datetime:
value = _object(raw, "components.configuration")
_exact_keys(
value,
{
"artifact_ref",
"sha256",
"protected",
"encryption_key_ref",
"captured_at",
},
"components.configuration",
)
_reference(value.get("artifact_ref"), "components.configuration.artifact_ref")
_sha256(value.get("sha256"), "components.configuration.sha256")
_protected_key_reference(value, "components.configuration")
return _timestamp(value.get("captured_at"), "components.configuration.captured_at")
def _key_custody_component(raw: object) -> datetime:
value = _object(raw, "components.key_custody")
_exact_keys(
value,
{"provider", "keyset_ref", "keyset_version", "recoverable", "captured_at"},
"components.key_custody",
)
_token(value.get("provider"), "components.key_custody.provider")
_reference(value.get("keyset_ref"), "components.key_custody.keyset_ref")
_token(value.get("keyset_version"), "components.key_custody.keyset_version")
if value.get("recoverable") is not True:
raise DistributionError("components.key_custody.recoverable must be true")
return _timestamp(value.get("captured_at"), "components.key_custody.captured_at")
def _common_artifact(value: Mapping[str, Any], label: str) -> None:
_token(value.get("provider"), f"{label}.provider")
_reference(value.get("artifact_ref"), f"{label}.artifact_ref")
_sha256(value.get("artifact_sha256"), f"{label}.artifact_sha256")
_protected_key_reference(value, label)
def _protected_key_reference(value: Mapping[str, Any], label: str) -> None:
if value.get("protected") is not True:
raise DistributionError(f"{label}.protected must be true")
_reference(value.get("encryption_key_ref"), f"{label}.encryption_key_ref")
def _validate_signatures(raw: object) -> None:
if not isinstance(raw, list) or not raw or len(raw) > 16:
raise DistributionError("backup evidence signatures must not be empty")
seen: set[str] = set()
for index, item in enumerate(raw):
signature = _object(item, f"signatures[{index}]")
_exact_keys(signature, {"key_id", "algorithm", "value"}, f"signatures[{index}]")
key_id = _token(signature.get("key_id"), f"signatures[{index}].key_id")
if key_id in seen or signature.get("algorithm") != "ed25519":
raise DistributionError("backup evidence signatures are invalid")
seen.add(key_id)
encoded = signature.get("value")
if not isinstance(encoded, str) or len(encoded) > 256:
raise DistributionError("backup evidence signature value is invalid")
def _reference(raw: object, label: str) -> str:
value = _bounded_text(raw, label, maximum=2048)
if REFERENCE.fullmatch(value) is None or "BEGIN " in value.upper():
raise DistributionError(f"{label} must be an opaque provider reference")
parsed = urlsplit(value)
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise DistributionError(f"{label} must not contain credentials or query data")
return value
def _object(raw: object, label: str) -> dict[str, Any]:
if not isinstance(raw, dict) or not all(isinstance(key, str) for key in raw):
raise DistributionError(f"{label} must be an object")
return raw
def _exact_keys(value: Mapping[str, Any], keys: set[str], label: str) -> None:
if set(value) != keys:
missing = sorted(keys - set(value))
extra = sorted(set(value) - keys)
detail = []
if missing:
detail.append("missing " + ", ".join(missing))
if extra:
detail.append("unknown " + ", ".join(extra))
raise DistributionError(f"{label} has invalid fields: {'; '.join(detail)}")
def _timestamp(raw: object, label: str) -> datetime:
value = _bounded_text(raw, label, maximum=64)
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise DistributionError(f"{label} must be an RFC3339 timestamp") from exc
if parsed.tzinfo is None:
raise DistributionError(f"{label} must include a timezone")
return parsed.astimezone(UTC)
def _token(raw: object, label: str) -> str:
value = _bounded_text(raw, label, maximum=128)
if TOKEN.fullmatch(value) is None:
raise DistributionError(f"{label} is invalid")
return value
def _sha256(raw: object, label: str) -> str:
value = _bounded_text(raw, label, maximum=64)
if SHA256.fullmatch(value) is None:
raise DistributionError(f"{label} must be a lowercase SHA-256 digest")
return value
def _image(raw: object, label: str) -> str:
value = _bounded_text(raw, label, maximum=300)
if IMAGE.fullmatch(value) is None:
raise DistributionError(f"{label} must be an OCI image pinned by sha256")
return value
def _bounded_text(raw: object, label: str, *, maximum: int) -> str:
if not isinstance(raw, str) or not raw or len(raw) > maximum or "\n" in raw:
raise DistributionError(f"{label} is invalid")
return raw
def _bounded_integer(
raw: object,
label: str,
*,
maximum: int = 2**63 - 1,
) -> int:
if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0 or raw > maximum:
raise DistributionError(f"{label} is out of bounds")
return raw
__all__ = [
"DEFAULT_MAX_BACKUP_AGE_SECONDS",
"MAX_BACKUP_EVIDENCE_BYTES",
"MAX_BACKUP_KEYRING_BYTES",
"load_backup_evidence",
"load_backup_keyring",
"verify_backup_evidence",
]
+230 -2
View File
@@ -22,9 +22,32 @@ ENV_FILENAME = "secrets.env"
COMPOSE_FILENAME = "compose.json"
GARAGE_CONFIG_FILENAME = "garage.toml"
LOAD_BALANCER_CONFIG_FILENAME = "load-balancer.cfg"
CADDY_CONFIG_FILENAME = "Caddyfile"
EXISTING_PROXY_FILENAME = "existing-proxy.json"
PLAN_FILENAME = "plan.json"
RECEIPT_FILENAME = "receipt.json"
MANIFEST_FILENAME = "distribution-manifest.json"
KEYRING_FILENAME = "distribution-keyring.json"
BACKUP_EVIDENCE_FILENAME = "backup-evidence.json"
BACKUP_KEYRING_FILENAME = "backup-keyring.json"
BACKUP_VERIFICATION_FILENAME = "backup-verification.json"
LOCK_FILENAME = ".deployment.lock"
BACKUP_RUNTIME_ENV_KEYS = (
"GOVOPLAN_BACKUP_EVIDENCE_STATE",
"GOVOPLAN_BACKUP_EVIDENCE_ID",
"GOVOPLAN_BACKUP_RECOVERY_POINT_ID",
"GOVOPLAN_BACKUP_RESTORE_DRILL_ID",
"GOVOPLAN_BACKUP_EVIDENCE_SHA256",
"GOVOPLAN_BACKUP_RELEASE_MANIFEST_SHA256",
"GOVOPLAN_BACKUP_CAPTURED_AT",
"GOVOPLAN_BACKUP_EXPIRES_AT",
"GOVOPLAN_BACKUP_RESTORE_STARTED_AT",
"GOVOPLAN_BACKUP_RESTORE_COMPLETED_AT",
"GOVOPLAN_BACKUP_VERIFIED_AT",
"GOVOPLAN_BACKUP_MEASURED_RPO_SECONDS",
"GOVOPLAN_BACKUP_MEASURED_RTO_SECONDS",
"GOVOPLAN_BACKUP_COMPONENT_COUNT",
)
RUNTIME_ENV_KEYS = (
"APP_ENV",
"GOVOPLAN_INSTALL_PROFILE",
@@ -40,6 +63,18 @@ RUNTIME_ENV_KEYS = (
"ENABLED_MODULES",
"CELERY_ENABLED",
"CELERY_QUEUES",
"CELERY_WORKER_CONCURRENCY",
"GOVOPLAN_DB_CONNECTION_LIMIT",
"GOVOPLAN_DB_CONNECTION_RESERVE",
"GOVOPLAN_API_DB_POOL_SIZE",
"GOVOPLAN_API_DB_MAX_OVERFLOW",
"GOVOPLAN_WORKER_DB_POOL_SIZE",
"GOVOPLAN_WORKER_DB_MAX_OVERFLOW",
"GOVOPLAN_SCHEDULER_DB_POOL_SIZE",
"GOVOPLAN_SCHEDULER_DB_MAX_OVERFLOW",
"GOVOPLAN_MIGRATION_DB_POOL_SIZE",
"GOVOPLAN_MIGRATION_DB_MAX_OVERFLOW",
"GOVOPLAN_WORKER_POOLS",
"REDIS_URL",
"CORS_ORIGINS",
"GOVOPLAN_TRUSTED_HOSTS",
@@ -62,6 +97,7 @@ RUNTIME_ENV_KEYS = (
"FILE_STORAGE_S3_BUCKET",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
*BACKUP_RUNTIME_ENV_KEYS,
)
@@ -73,8 +109,15 @@ class BundlePaths:
compose: Path
garage_config: Path
load_balancer_config: Path
caddy_config: Path
existing_proxy: Path
plan: Path
receipt: Path
manifest: Path
keyring: Path
backup_evidence: Path
backup_keyring: Path
backup_verification: Path
lock: Path
@@ -90,8 +133,15 @@ def bundle_paths(root: Path) -> BundlePaths:
compose=resolved / COMPOSE_FILENAME,
garage_config=resolved / GARAGE_CONFIG_FILENAME,
load_balancer_config=resolved / LOAD_BALANCER_CONFIG_FILENAME,
caddy_config=resolved / CADDY_CONFIG_FILENAME,
existing_proxy=resolved / EXISTING_PROXY_FILENAME,
plan=resolved / PLAN_FILENAME,
receipt=resolved / RECEIPT_FILENAME,
manifest=resolved / MANIFEST_FILENAME,
keyring=resolved / KEYRING_FILENAME,
backup_evidence=resolved / BACKUP_EVIDENCE_FILENAME,
backup_keyring=resolved / BACKUP_KEYRING_FILENAME,
backup_verification=resolved / BACKUP_VERIFICATION_FILENAME,
lock=resolved / LOCK_FILENAME,
)
@@ -193,6 +243,46 @@ def reconcile_runtime_environment(
"CELERY_QUEUES": (
"send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default"
),
"CELERY_WORKER_CONCURRENCY": values.get(
"CELERY_WORKER_CONCURRENCY",
"2",
),
"GOVOPLAN_DB_CONNECTION_RESERVE": values.get(
"GOVOPLAN_DB_CONNECTION_RESERVE",
"10",
),
"GOVOPLAN_API_DB_POOL_SIZE": values.get(
"GOVOPLAN_API_DB_POOL_SIZE",
"5",
),
"GOVOPLAN_API_DB_MAX_OVERFLOW": values.get(
"GOVOPLAN_API_DB_MAX_OVERFLOW",
"2",
),
"GOVOPLAN_WORKER_DB_POOL_SIZE": values.get(
"GOVOPLAN_WORKER_DB_POOL_SIZE",
"1",
),
"GOVOPLAN_WORKER_DB_MAX_OVERFLOW": values.get(
"GOVOPLAN_WORKER_DB_MAX_OVERFLOW",
"1",
),
"GOVOPLAN_SCHEDULER_DB_POOL_SIZE": values.get(
"GOVOPLAN_SCHEDULER_DB_POOL_SIZE",
"1",
),
"GOVOPLAN_SCHEDULER_DB_MAX_OVERFLOW": values.get(
"GOVOPLAN_SCHEDULER_DB_MAX_OVERFLOW",
"0",
),
"GOVOPLAN_MIGRATION_DB_POOL_SIZE": values.get(
"GOVOPLAN_MIGRATION_DB_POOL_SIZE",
"2",
),
"GOVOPLAN_MIGRATION_DB_MAX_OVERFLOW": values.get(
"GOVOPLAN_MIGRATION_DB_MAX_OVERFLOW",
"0",
),
"CORS_ORIGINS": spec.public_url,
"GOVOPLAN_TRUSTED_HOSTS": public.hostname or "",
"FORWARDED_ALLOW_IPS": spec.network_subnet,
@@ -212,6 +302,8 @@ def reconcile_runtime_environment(
values["GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE"] = "true"
else:
values["GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE"] = "false"
if postgres.mode == "managed":
values.setdefault("GOVOPLAN_DB_CONNECTION_LIMIT", "100")
storage = spec.components.storage
if storage.mode == "local":
@@ -395,6 +487,10 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
"restart": "unless-stopped",
"volumes": data_mounts,
"networks": ["internal"],
"read_only": True,
"tmpfs": ["/tmp:rw,noexec,nosuid,size=64m"],
"security_opt": ["no-new-privileges:true"],
"cap_drop": ["ALL"],
}
if dependency_conditions:
common_runtime["depends_on"] = dependency_conditions
@@ -410,6 +506,10 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
"restart": "no",
"volumes": data_mounts,
"networks": ["internal"],
"read_only": True,
"tmpfs": ["/tmp:rw,noexec,nosuid,size=64m"],
"security_opt": ["no-new-privileges:true"],
"cap_drop": ["ALL"],
**({"depends_on": dependency_conditions} if dependency_conditions else {}),
}
services["api"] = {
@@ -455,9 +555,13 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
"restart": "unless-stopped",
"scale": spec.replicas.web,
"environment": {"GOVOPLAN_API_UPSTREAM": "http://load-balancer:8000"},
"read_only": True,
"tmpfs": ["/tmp:rw,noexec,nosuid,size=64m"],
"security_opt": ["no-new-privileges:true"],
"cap_drop": ["ALL"],
"networks": ["internal"],
}
services["load-balancer"] = {
load_balancer: dict[str, object] = {
"image": spec.components.load_balancer.image,
"restart": "unless-stopped",
"healthcheck": {
@@ -477,7 +581,6 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
render_load_balancer_config(spec).encode("utf-8")
).hexdigest()
},
"ports": [_published_port(spec.listen.address, spec.listen.port, 8080)],
"read_only": True,
"security_opt": ["no-new-privileges:true"],
"volumes": [
@@ -485,6 +588,54 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
],
"networks": ["internal"],
}
if spec.ingress.mode != "managed":
load_balancer["ports"] = [
_published_port(spec.listen.address, spec.listen.port, 8080)
]
services["load-balancer"] = load_balancer
if spec.ingress.mode == "managed":
services["ingress"] = {
"image": spec.ingress.image,
"restart": "unless-stopped",
"command": [
"caddy",
"run",
"--config",
"/etc/caddy/Caddyfile",
"--adapter",
"caddyfile",
],
"healthcheck": {
"test": [
"CMD",
"caddy",
"validate",
"--config",
"/etc/caddy/Caddyfile",
"--adapter",
"caddyfile",
],
"interval": "30s",
"timeout": "5s",
"retries": 3,
},
"ports": [
_published_port("0.0.0.0", spec.ingress.http_port, 8080),
_published_port("0.0.0.0", spec.ingress.https_port, 8443),
],
"read_only": True,
"tmpfs": ["/tmp:rw,noexec,nosuid,size=64m"],
"security_opt": ["no-new-privileges:true"],
"cap_drop": ["ALL"],
"cap_add": ["NET_BIND_SERVICE"],
"volumes": [
f"./{CADDY_CONFIG_FILENAME}:/etc/caddy/Caddyfile:ro",
"caddy-data:/data",
"caddy-config:/config",
],
"networks": ["internal"],
"depends_on": {"load-balancer": {"condition": "service_healthy"}},
}
if spec.components.redis.mode != "disabled":
services["worker"] = {
**common_runtime,
@@ -546,6 +697,9 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
if spec.components.storage.mode == "garage":
volumes["garage-meta"] = {}
volumes["garage-data"] = {}
if spec.ingress.mode == "managed":
volumes["caddy-data"] = {}
volumes["caddy-config"] = {}
return {
"name": spec.installation_id,
@@ -582,6 +736,31 @@ api_bind_addr = "[::]:3903"
def render_load_balancer_config(spec: InstallationSpec) -> str:
health_host = urlsplit(spec.public_url).hostname or "localhost"
trusted_proxy_cidrs = (
(spec.network_subnet,)
if spec.ingress.mode == "managed"
else spec.ingress.trusted_proxy_cidrs
if spec.ingress.mode == "existing-proxy"
else ()
)
trusted_acl = (
" acl trusted_forward_proxy src " + " ".join(trusted_proxy_cidrs) + "\n"
if trusted_proxy_cidrs
else ""
)
forwarded_rules = (
" http-request set-var(txn.forwarded_proto) req.hdr(X-Forwarded-Proto) if trusted_forward_proxy\n"
" http-request del-header X-Forwarded-Proto\n"
" http-request set-header X-Forwarded-Proto https if trusted_forward_proxy { var(txn.forwarded_proto) -m str https }\n"
" http-request set-header X-Forwarded-Proto http unless { var(txn.forwarded_proto) -m str https }\n"
" http-request del-header X-Forwarded-For unless trusted_forward_proxy\n"
if trusted_proxy_cidrs
else (
" http-request del-header X-Forwarded-Proto\n"
" http-request set-header X-Forwarded-Proto http\n"
" http-request del-header X-Forwarded-For\n"
)
)
return f"""global
log stdout format raw local0
maxconn 4096
@@ -609,6 +788,9 @@ resolvers docker
frontend public_web
bind :8080
{trusted_acl}{forwarded_rules} option forwardfor
http-request del-header X-Forwarded-Host
http-request set-header X-Forwarded-Host %[req.hdr(host)]
default_backend web_replicas
backend web_replicas
@@ -630,6 +812,52 @@ backend api_replicas
"""
def render_caddy_config(spec: InstallationSpec) -> str:
if spec.ingress.mode != "managed":
return "# Managed ingress is not selected.\n"
hostname = urlsplit(spec.public_url).hostname or ""
return f"""{{
admin off
email {spec.ingress.acme_email}
http_port 8080
https_port 8443
}}
{hostname} {{
encode zstd gzip
header {{
Strict-Transport-Security "max-age=31536000; includeSubDomains"
}}
reverse_proxy load-balancer:8080 {{
header_up X-Forwarded-Proto https
}}
}}
"""
def render_existing_proxy_contract(spec: InstallationSpec) -> dict[str, object]:
return {
"schema_version": 1,
"mode": spec.ingress.mode,
"public_url": spec.public_url,
"upstream": (
f"http://{spec.listen.address}:{spec.listen.port}"
if spec.ingress.mode == "existing-proxy"
else None
),
"trusted_proxy_cidrs": list(spec.ingress.trusted_proxy_cidrs),
"required_headers": {
"Host": urlsplit(spec.public_url).hostname or "",
"X-Forwarded-Proto": "https",
"X-Forwarded-For": "client, proxy chain",
},
"health_paths": {
"load_balancer": "/health",
"api_readiness": "/health/ready",
},
}
def service_names(spec: InstallationSpec) -> tuple[str, ...]:
return tuple(render_compose(spec)["services"].keys())
+743 -11
View File
@@ -8,6 +8,7 @@ from dataclasses import replace
from datetime import UTC, datetime
import fcntl
import getpass
import hashlib
import json
import os
from pathlib import Path
@@ -19,7 +20,14 @@ from typing import Iterator, Mapping, Sequence
from urllib.error import URLError
from urllib.request import urlopen
from .backup_evidence import (
DEFAULT_MAX_BACKUP_AGE_SECONDS,
MAX_BACKUP_EVIDENCE_BYTES,
MAX_BACKUP_KEYRING_BYTES,
verify_backup_evidence,
)
from .bundle import (
BACKUP_RUNTIME_ENV_KEYS,
atomic_write,
bundle_paths,
canonical_json,
@@ -29,16 +37,35 @@ from .bundle import (
initial_secrets,
read_env,
reconcile_runtime_environment,
render_caddy_config,
render_compose,
render_existing_proxy_contract,
render_garage_config,
render_load_balancer_config,
service_names,
write_env,
)
from .cluster_evidence import collect_kubernetes_evidence
from .distribution import (
MAX_KEYRING_BYTES,
MAX_MANIFEST_BYTES,
MAX_OFFLINE_INDEX_BYTES,
DistributionError,
canonical_json as canonical_distribution_json,
decode_json_bytes,
fetch_bounded_https,
load_bounded_json,
read_bounded_bytes,
verify_offline_image_index,
verify_manifest,
verify_manifest_binding,
)
from .model import (
ComponentConfig,
DEFAULT_GARAGE_IMAGE,
DEFAULT_INGRESS_IMAGE,
DEFAULT_LOAD_BALANCER_IMAGE,
IngressConfig,
InstallationSpec,
ListenConfig,
ReplicaConfig,
@@ -52,7 +79,12 @@ from .kubernetes import (
render_kubernetes,
write_secret_creation_hint,
)
from .planning import DeploymentPlan, build_plan
from .planning import (
DeploymentPlan,
build_plan,
release_change_requires_backup,
verify_stored_backup_evidence,
)
from .recovery import (
DeploymentOperationJournal,
list_operations,
@@ -126,6 +158,59 @@ def build_parser() -> argparse.ArgumentParser:
_directory_argument(status)
status.add_argument("--json", action="store_true", help="Print JSON.")
verify_release = subparsers.add_parser(
"verify-release",
help="Verify and optionally adopt a signed runtime distribution.",
)
_directory_argument(verify_release)
manifest_source = verify_release.add_mutually_exclusive_group(required=True)
manifest_source.add_argument("--manifest", type=Path)
manifest_source.add_argument("--manifest-url")
verify_release.add_argument(
"--manifest-sha256",
required=True,
help="Independently obtained SHA-256 digest of the signed manifest.",
)
verify_release.add_argument("--trusted-keyring", type=Path, required=True)
verify_release.add_argument(
"--allow-private-release-host",
action="store_true",
help="Allow an explicitly selected private HTTPS release mirror.",
)
verify_release.add_argument(
"--adopt",
action="store_true",
help="Store the verified trust material and select its pinned images.",
)
offline_images = subparsers.add_parser(
"verify-offline-images",
help="Verify prefetched OCI archives against the adopted distribution.",
)
_directory_argument(offline_images)
offline_images.add_argument("--index", type=Path, required=True)
offline_images.add_argument(
"--load",
action="store_true",
help="Load verified archives into Docker using fixed image-load commands.",
)
verify_backup = subparsers.add_parser(
"verify-backup",
help="Verify and optionally adopt signed coordinated backup evidence.",
)
_directory_argument(verify_backup)
evidence_source = verify_backup.add_mutually_exclusive_group(required=True)
evidence_source.add_argument("--evidence", type=Path)
evidence_source.add_argument("--evidence-url")
verify_backup.add_argument("--evidence-sha256", required=True)
verify_backup.add_argument("--trusted-keyring", type=Path, required=True)
verify_backup.add_argument(
"--allow-private-evidence-host",
action="store_true",
)
verify_backup.add_argument("--adopt", action="store_true")
kubernetes = subparsers.add_parser(
"render-kubernetes",
help="Export the stateless multi-host runtime for Kubernetes.",
@@ -141,6 +226,37 @@ def build_parser() -> argparse.ArgumentParser:
help="Output JSON path; defaults to <directory>/kubernetes.json.",
)
verify_kubernetes = subparsers.add_parser(
"verify-kubernetes",
help="Collect sanitized readiness evidence from a live multi-host profile.",
)
_directory_argument(verify_kubernetes)
verify_kubernetes.add_argument("--namespace", default="govoplan")
verify_kubernetes.add_argument(
"--ops-url",
help="Ops status URL; defaults to <public-url>/api/v1/ops/status.",
)
verify_kubernetes.add_argument(
"--api-key-env",
default="GOVOPLAN_OPS_API_KEY",
help="Environment variable containing an API key with Ops read scope.",
)
verify_kubernetes.add_argument(
"--exercise-api-pod-loss",
action="store_true",
help="Delete one ready API pod and prove health-aware replacement.",
)
verify_kubernetes.add_argument(
"--timeout-seconds",
type=float,
default=180.0,
)
verify_kubernetes.add_argument(
"--output",
type=Path,
help="Private evidence output path.",
)
operations = subparsers.add_parser(
"operations",
help="List durable deployment and recovery operations.",
@@ -236,6 +352,26 @@ def _configuration_arguments(
default=default(DEFAULT_LOAD_BALANCER_IMAGE),
help="HAProxy image used by the managed local load balancer.",
)
parser.add_argument(
"--ingress",
choices=("local", "existing-proxy", "managed", "unconfigured"),
default=default(None),
help="Public route boundary; self-hosted requires existing-proxy or managed.",
)
parser.add_argument(
"--ingress-image",
default=default(DEFAULT_INGRESS_IMAGE),
help="Caddy image used by managed ingress.",
)
parser.add_argument(
"--trusted-proxy-cidr",
action="append",
default=None,
help="Exact source CIDR trusted to supply forwarded headers; repeatable.",
)
parser.add_argument("--acme-email", default=default(""))
parser.add_argument("--ingress-http-port", type=int, default=default(80))
parser.add_argument("--ingress-https-port", type=int, default=default(443))
parser.add_argument(
"--api-replicas",
type=int,
@@ -280,13 +416,27 @@ def main(argv: Sequence[str] | None = None) -> int:
return _apply(args)
if args.command == "status":
return _status(args)
if args.command == "verify-release":
return _verify_release(args)
if args.command == "verify-offline-images":
return _verify_offline_images(args)
if args.command == "verify-backup":
return _verify_backup(args)
if args.command == "render-kubernetes":
return _render_kubernetes(args)
if args.command == "verify-kubernetes":
return _verify_kubernetes(args)
if args.command == "operations":
return _operations(args)
if args.command == "recover":
return _recover(args)
except (SpecError, ValueError, OSError, subprocess.SubprocessError) as exc:
except (
DistributionError,
SpecError,
ValueError,
OSError,
subprocess.SubprocessError,
) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
raise RuntimeError(f"unsupported command: {args.command}")
@@ -313,6 +463,12 @@ def _init(args: argparse.Namespace) -> int:
storage_mode=args.storage,
garage_image=args.garage_image,
load_balancer_image=args.load_balancer_image,
ingress_mode=args.ingress,
ingress_image=args.ingress_image,
trusted_proxy_cidrs=tuple(args.trusted_proxy_cidr or ()),
ingress_http_port=args.ingress_http_port,
ingress_https_port=args.ingress_https_port,
acme_email=args.acme_email,
api_replicas=args.api_replicas,
web_replicas=args.web_replicas,
worker_replicas=args.worker_replicas,
@@ -408,15 +564,17 @@ def _render_or_doctor(args: argparse.Namespace) -> int:
def _apply(args: argparse.Namespace) -> int:
paths = bundle_paths(args.directory)
ensure_private_directory(paths.root)
with _deployment_lock(paths.lock):
spec = load_spec(paths.spec)
previous_receipt = _read_json_object(paths.receipt)
backup_required = release_change_requires_backup(spec, previous_receipt)
if args.allow_unverified_images and spec.profile != "evaluation":
raise ValueError(
"--allow-unverified-images is restricted to evaluation installations"
)
ensure_private_directory(paths.root)
with _deployment_lock(paths.lock):
secrets = reconcile_runtime_environment(spec, read_env(paths.env))
_write_bundle(spec, paths, secrets)
secrets = _write_bundle(spec, paths, secrets)
plan = build_plan(spec, paths, include_host_checks=True)
_write_plan(paths.plan, plan)
effective_errors = [
@@ -434,6 +592,7 @@ def _apply(args: argparse.Namespace) -> int:
"components.mail.image.",
"components.storage.image.",
"components.load_balancer.image.",
"ingress.image.",
"release.manifest",
"modules.image_composition",
)
@@ -450,6 +609,36 @@ def _apply(args: argparse.Namespace) -> int:
paths,
plan=plan.to_dict(),
)
try:
if backup_required:
backup_summary = verify_stored_backup_evidence(
spec,
paths,
receipt=previous_receipt,
)
journal.record(
"backup-evidence-verified",
"succeeded",
dict(backup_summary),
)
else:
journal.record(
"backup-evidence-not-required",
"succeeded",
{"release_change": False},
)
except BaseException as exc:
journal.record(
"backup-evidence-rejected",
"blocked",
{
"phase": "preflight",
"exception_type": type(exc).__name__,
"migration_started": False,
},
)
journal.failed(exc)
raise
compose = [
docker,
"compose",
@@ -497,6 +686,29 @@ def _apply(args: argparse.Namespace) -> int:
"succeeded",
{"services": mutable_runtime_services, "timeout_seconds": 120},
)
if backup_required:
try:
backup_summary = verify_stored_backup_evidence(
spec,
paths,
receipt=previous_receipt,
)
except BaseException as exc:
journal.record(
"backup-evidence-rejected",
"blocked",
{
"phase": "migration-boundary",
"exception_type": type(exc).__name__,
"migration_started": False,
},
)
raise
journal.record(
"backup-evidence-reverified",
"succeeded",
dict(backup_summary),
)
journal.migration_started()
_run([*compose, "run", "--rm", "migrate"], cwd=paths.root)
journal.migration_completed()
@@ -504,7 +716,14 @@ def _apply(args: argparse.Namespace) -> int:
_run([*compose, "stop", "web"], cwd=paths.root)
runtime_services = [
name
for name in ("api", "web", "load-balancer", "worker", "scheduler")
for name in (
"api",
"web",
"load-balancer",
"worker",
"scheduler",
"ingress",
)
if name in service_names(spec)
]
_run(
@@ -583,10 +802,344 @@ def _status(args: argparse.Namespace) -> int:
return 0
def _verify_release(args: argparse.Namespace) -> int:
paths = bundle_paths(args.directory)
spec = load_spec(paths.spec)
expected_digest = str(args.manifest_sha256 or "").strip().lower()
if len(expected_digest) != 64 or any(
character not in "0123456789abcdef" for character in expected_digest
):
raise ValueError("--manifest-sha256 must be a lowercase SHA-256 digest")
manifest_url = str(args.manifest_url or "").strip()
if manifest_url:
encoded_manifest = fetch_bounded_https(
manifest_url,
maximum_bytes=MAX_MANIFEST_BYTES,
allow_private_host=args.allow_private_release_host,
)
manifest = decode_json_bytes(
encoded_manifest,
label="distribution manifest",
)
actual_digest = hashlib.sha256(encoded_manifest).hexdigest()
else:
manifest_path = args.manifest.expanduser().resolve()
encoded_manifest = read_bounded_bytes(
manifest_path,
maximum_bytes=MAX_MANIFEST_BYTES,
)
manifest = load_bounded_json(
manifest_path,
maximum_bytes=MAX_MANIFEST_BYTES,
)
actual_digest = hashlib.sha256(encoded_manifest).hexdigest()
if encoded_manifest != canonical_distribution_json(manifest):
raise DistributionError("distribution manifest is not canonical JSON")
if actual_digest != expected_digest:
raise DistributionError("distribution manifest SHA-256 does not match")
keyring_path = args.trusted_keyring.expanduser().resolve()
keyring = load_bounded_json(keyring_path, maximum_bytes=MAX_KEYRING_BYTES)
encoded_keyring = canonical_distribution_json(keyring)
keyring_digest = hashlib.sha256(encoded_keyring).hexdigest()
key_id = verify_manifest(
manifest,
keyring,
expected_channel=spec.release.channel,
)
dependencies = _selected_dependency_images(spec, manifest=manifest)
verify_manifest_binding(
manifest,
channel=str(manifest["channel"]),
version=str(manifest["version"]),
api_image=str(manifest["images"]["api"]["index"]),
web_image=str(manifest["images"]["web"]["index"]),
enabled_modules=spec.enabled_modules,
composition_sha256=str(manifest["composition"]["sha256"]),
dependencies=dependencies,
)
print(
f"Verified GovOPlaN {manifest['version']} ({manifest['channel']}) "
f"with trusted key {key_id}."
)
if not args.adopt:
return 0
images = manifest["images"]
dependency_images = manifest["dependencies"]
release = replace(
spec.release,
channel=str(manifest["channel"]),
version=str(manifest["version"]),
manifest_url=manifest_url,
manifest_sha256=expected_digest,
manifest_keyring_sha256=keyring_digest,
manifest_signature_key_id=key_id,
composition_sha256=str(manifest["composition"]["sha256"]),
api_image=str(images["api"]["index"]),
web_image=str(images["web"]["index"]),
)
components = replace(
spec.components,
postgres=replace(
spec.components.postgres,
image=(
str(dependency_images["postgres"])
if spec.components.postgres.mode == "managed"
else spec.components.postgres.image
),
),
redis=replace(
spec.components.redis,
image=(
str(dependency_images["redis"])
if spec.components.redis.mode == "managed"
else spec.components.redis.image
),
),
mail=replace(
spec.components.mail,
image=(
str(dependency_images["test_mail"])
if spec.components.mail.mode == "test-mail"
else spec.components.mail.image
),
),
storage=replace(
spec.components.storage,
image=(
str(dependency_images["garage"])
if spec.components.storage.mode == "garage"
else spec.components.storage.image
),
),
load_balancer=replace(
spec.components.load_balancer,
image=str(dependency_images["load_balancer"]),
),
)
ingress = replace(
spec.ingress,
image=(
str(dependency_images["managed_ingress"])
if spec.ingress.mode == "managed"
else spec.ingress.image
),
)
adopted = parse_spec(
replace(
spec,
release=release,
components=components,
ingress=ingress,
).to_dict()
)
ensure_private_directory(paths.root)
atomic_write(paths.manifest, encoded_manifest, mode=0o644)
atomic_write(
paths.keyring,
encoded_keyring,
mode=0o644,
)
secrets = reconcile_runtime_environment(adopted, read_env(paths.env))
_write_bundle(adopted, paths, secrets)
print(f"Adopted immutable runtime distribution in {paths.root}.")
return 0
def _verify_offline_images(args: argparse.Namespace) -> int:
paths = bundle_paths(args.directory)
spec = load_spec(paths.spec)
manifest = load_bounded_json(
paths.manifest,
maximum_bytes=MAX_MANIFEST_BYTES,
)
index_path = args.index.expanduser().resolve()
index = load_bounded_json(
index_path,
maximum_bytes=MAX_OFFLINE_INDEX_BYTES,
)
selected_dependencies = _selected_dependency_images(spec, manifest=manifest)
expected = (
str(manifest["images"]["api"]["index"]),
str(manifest["images"]["web"]["index"]),
*tuple(selected_dependencies.values()),
)
archives = verify_offline_image_index(
index,
root=index_path.parent,
expected_references=expected,
)
print(f"Verified {len(archives)} prefetched OCI image archive(s).")
if not args.load:
return 0
docker = shutil.which("docker")
if docker is None:
raise ValueError("Docker CLI is required to load offline images")
for archive in archives:
_run([docker, "image", "load", "--input", str(archive)], cwd=paths.root)
for reference in expected:
_run([docker, "image", "inspect", reference], cwd=paths.root)
print("Loaded and inspected every adopted offline image identity.")
return 0
def _verify_backup(args: argparse.Namespace) -> int:
paths = bundle_paths(args.directory)
ensure_private_directory(paths.root)
with _deployment_lock(paths.lock):
return _verify_backup_locked(args, paths)
def _verify_backup_locked(args: argparse.Namespace, paths) -> int:
spec = load_spec(paths.spec)
expected_digest = str(args.evidence_sha256 or "").strip().lower()
if len(expected_digest) != 64 or any(
character not in "0123456789abcdef" for character in expected_digest
):
raise ValueError("--evidence-sha256 must be a lowercase SHA-256 digest")
if args.evidence_url:
encoded_evidence = fetch_bounded_https(
str(args.evidence_url),
maximum_bytes=MAX_BACKUP_EVIDENCE_BYTES,
allow_private_host=args.allow_private_evidence_host,
)
evidence = decode_json_bytes(encoded_evidence, label="backup evidence")
else:
evidence_path = args.evidence.expanduser().resolve()
encoded_evidence = read_bounded_bytes(
evidence_path,
maximum_bytes=MAX_BACKUP_EVIDENCE_BYTES,
)
evidence = decode_json_bytes(encoded_evidence, label="backup evidence")
if encoded_evidence != canonical_distribution_json(evidence):
raise DistributionError("backup evidence is not canonical JSON")
if hashlib.sha256(encoded_evidence).hexdigest() != expected_digest:
raise DistributionError("backup evidence SHA-256 does not match")
keyring_path = args.trusted_keyring.expanduser().resolve()
keyring = load_bounded_json(
keyring_path,
maximum_bytes=MAX_BACKUP_KEYRING_BYTES,
)
encoded_keyring = canonical_distribution_json(keyring)
receipt = _read_json_object(paths.receipt)
previous_release = receipt.get("release") if receipt else None
release: Mapping[str, object] = (
previous_release
if isinstance(previous_release, Mapping)
else {
"channel": spec.release.channel,
"version": spec.release.version,
"manifest_sha256": spec.release.manifest_sha256,
"composition_sha256": spec.release.composition_sha256,
"api_image": spec.release.api_image,
"web_image": spec.release.web_image,
}
)
summary = verify_backup_evidence(
evidence,
keyring,
installation_id=spec.installation_id,
profile=spec.profile,
release=release,
max_age_seconds=DEFAULT_MAX_BACKUP_AGE_SECONDS,
)
print(
"Verified coordinated recovery point "
f"{summary['recovery_point_id']} with restore drill "
f"{summary['restore_drill_id']} and trusted key "
f"{summary['signature_key_id']}."
)
if not args.adopt:
return 0
verification = {
"schema_version": 1,
"evidence_sha256": expected_digest,
"keyring_sha256": hashlib.sha256(encoded_keyring).hexdigest(),
"signature_key_id": summary["signature_key_id"],
"verified_at": _now(),
"evidence_id": summary["evidence_id"],
"recovery_point_id": summary["recovery_point_id"],
"restore_drill_id": summary["restore_drill_id"],
"release_manifest_sha256": release.get("manifest_sha256"),
"captured_at": summary["captured_at"],
"expires_at": summary["expires_at"],
"restore_started_at": summary["restore_started_at"],
"restore_completed_at": summary["restore_completed_at"],
"measured_rpo_seconds": summary["measured_rpo_seconds"],
"measured_rto_seconds": summary["measured_rto_seconds"],
"component_count": summary["component_count"],
}
atomic_write(paths.backup_evidence, encoded_evidence, mode=0o600)
atomic_write(paths.backup_keyring, encoded_keyring, mode=0o600)
atomic_write(
paths.backup_verification,
canonical_json(verification),
mode=0o600,
)
_write_bundle(
spec,
paths,
reconcile_runtime_environment(spec, read_env(paths.env)),
)
print(f"Adopted signed backup evidence in {paths.root}.")
return 0
def _selected_dependency_images(
spec: InstallationSpec,
*,
manifest: Mapping[str, object],
) -> dict[str, str]:
available = manifest.get("dependencies")
if not isinstance(available, dict):
raise DistributionError("distribution dependencies are invalid")
names = ["load_balancer"]
if spec.components.postgres.mode == "managed":
names.append("postgres")
if spec.components.redis.mode == "managed":
names.append("redis")
if spec.components.mail.mode == "test-mail":
names.append("test_mail")
if spec.components.storage.mode == "garage":
names.append("garage")
if spec.ingress.mode == "managed":
names.append("managed_ingress")
missing = [name for name in names if not isinstance(available.get(name), str)]
if missing:
raise DistributionError(
"distribution is missing selected dependency images: " + ", ".join(missing)
)
return {name: str(available[name]) for name in names}
def _render_kubernetes(args: argparse.Namespace) -> int:
paths = bundle_paths(args.directory)
spec = load_spec(paths.spec)
environment = reconcile_runtime_environment(spec, read_env(paths.env))
environment.update(_backup_runtime_environment(spec, paths))
receipt = _read_json_object(paths.receipt)
backup_required = release_change_requires_backup(spec, receipt)
backup_summary: Mapping[str, object] | None = None
evidence_files = (
paths.backup_evidence,
paths.backup_keyring,
paths.backup_verification,
)
if backup_required:
backup_summary = verify_stored_backup_evidence(
spec,
paths,
receipt=receipt,
)
elif all(path.is_file() for path in evidence_files):
try:
backup_summary = verify_stored_backup_evidence(
spec,
paths,
receipt=receipt,
)
except (DistributionError, OSError):
backup_summary = None
manifest = render_kubernetes(
spec,
environment,
@@ -594,13 +1147,13 @@ def _render_kubernetes(args: argparse.Namespace) -> int:
secret_name=args.secret_name,
tls_secret_name=args.tls_secret_name,
ingress_class_name=args.ingress_class_name,
backup_required=backup_required,
backup_evidence=backup_summary,
)
output = (args.output or (paths.root / "kubernetes.json")).expanduser().resolve()
atomic_write(output, canonical_json(manifest), mode=0o600)
print(f"Wrote stateless Kubernetes runtime manifest to {output}")
print(
"Required Secret keys: " + ", ".join(kubernetes_secret_contract())
)
print("Required Secret keys: " + ", ".join(kubernetes_secret_contract()))
print(
write_secret_creation_hint(
paths.env,
@@ -611,6 +1164,41 @@ def _render_kubernetes(args: argparse.Namespace) -> int:
return 0
def _verify_kubernetes(args: argparse.Namespace) -> int:
paths = bundle_paths(args.directory)
spec = load_spec(paths.spec)
api_key = str(os.environ.get(args.api_key_env) or "").strip()
if not api_key:
raise ValueError(
f"{args.api_key_env} must contain an API key with Ops read scope"
)
ops_url = args.ops_url or (spec.public_url.rstrip("/") + "/api/v1/ops/status")
evidence = collect_kubernetes_evidence(
installation_id=spec.installation_id,
namespace=args.namespace,
ops_url=ops_url,
api_key=api_key,
exercise_api_pod_loss=args.exercise_api_pod_loss,
timeout_seconds=args.timeout_seconds,
)
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
output = (
(args.output or paths.root / "evidence" / f"kubernetes-{timestamp}.json")
.expanduser()
.resolve()
)
ensure_private_directory(output.parent)
atomic_write(output, canonical_json(evidence), mode=0o600)
print(f"Wrote Kubernetes evidence to {output}")
if evidence["result"]["state"] != "passed":
print(
"Failed checks: " + ", ".join(evidence["result"]["failed_checks"]),
file=sys.stderr,
)
return 1
return 0
def _operations(args: argparse.Namespace) -> int:
paths = bundle_paths(args.directory)
payload = list_operations(paths)
@@ -652,6 +1240,7 @@ def _deployment_receipt(
return {
"schema_version": 1,
"installation_id": spec.installation_id,
"profile": spec.profile,
"applied_at": _now(),
"spec_sha256": digest_json(spec.to_dict()),
"compose_sha256": digest_json(render_compose(spec)),
@@ -660,6 +1249,9 @@ def _deployment_receipt(
"channel": spec.release.channel,
"version": spec.release.version,
"manifest_sha256": spec.release.manifest_sha256,
"manifest_keyring_sha256": spec.release.manifest_keyring_sha256,
"manifest_signature_key_id": spec.release.manifest_signature_key_id,
"composition_sha256": spec.release.composition_sha256,
"api_image": spec.release.api_image,
"web_image": spec.release.web_image,
},
@@ -673,6 +1265,11 @@ def _deployment_receipt(
"address": spec.listen.address,
"port": spec.listen.port,
},
"ingress": {
"mode": spec.ingress.mode,
"http_port": spec.ingress.http_port,
"https_port": spec.ingress.https_port,
},
"management": {
"mode": "govoplan-deploy",
"agent": "cli",
@@ -681,6 +1278,16 @@ def _deployment_receipt(
}
def _read_json_object(path: Path) -> dict[str, object]:
if not path.is_file():
return {}
try:
value = load_bounded_json(path, maximum_bytes=64 * 1024)
except (DistributionError, OSError):
return {}
return value
def _updated_spec(
current: InstallationSpec, args: argparse.Namespace
) -> InstallationSpec:
@@ -703,6 +1310,9 @@ def _updated_spec(
if args.manifest_sha256 is not None
else current.release.manifest_sha256
),
manifest_keyring_sha256=current.release.manifest_keyring_sha256,
manifest_signature_key_id=current.release.manifest_signature_key_id,
composition_sha256=current.release.composition_sha256,
api_image=args.api_image or current.release.api_image,
web_image=args.web_image or current.release.web_image,
)
@@ -753,6 +1363,41 @@ def _updated_spec(
)
),
)
ingress_mode = args.ingress or current.ingress.mode
ingress = IngressConfig(
mode=ingress_mode,
image=(args.ingress_image or current.ingress.image or DEFAULT_INGRESS_IMAGE)
if ingress_mode == "managed"
else "",
trusted_proxy_cidrs=tuple(
(
args.trusted_proxy_cidr
if args.trusted_proxy_cidr is not None
else current.ingress.trusted_proxy_cidrs
)
if ingress_mode == "existing-proxy"
else ()
),
http_port=(
args.ingress_http_port
if args.ingress_http_port is not None
else current.ingress.http_port
),
https_port=(
args.ingress_https_port
if args.ingress_https_port is not None
else current.ingress.https_port
),
acme_email=(
(
args.acme_email
if args.acme_email is not None
else current.ingress.acme_email
)
if ingress_mode == "managed"
else ""
),
)
value = replace(
current,
installation_id=args.installation_id or current.installation_id,
@@ -765,6 +1410,7 @@ def _updated_spec(
release=release,
components=components,
replicas=replicas,
ingress=ingress,
enabled_modules=modules,
)
return parse_spec(value.to_dict())
@@ -800,21 +1446,89 @@ def _write_bundle(
spec: InstallationSpec,
paths,
secrets: Mapping[str, str],
) -> None:
) -> dict[str, str]:
ensure_private_directory(paths.root)
runtime_environment = dict(secrets)
runtime_environment.update(_backup_runtime_environment(spec, paths))
atomic_write(paths.spec, canonical_json(spec.to_dict()), mode=0o600)
write_env(paths.env, secrets)
write_env(paths.env, runtime_environment)
atomic_write(paths.compose, canonical_json(render_compose(spec)), mode=0o600)
atomic_write(
paths.load_balancer_config,
render_load_balancer_config(spec).encode("utf-8"),
mode=0o644,
)
atomic_write(
paths.caddy_config,
render_caddy_config(spec).encode("utf-8"),
mode=0o644,
)
atomic_write(
paths.existing_proxy,
canonical_json(render_existing_proxy_contract(spec)),
mode=0o644,
)
atomic_write(
paths.garage_config,
render_garage_config().encode("utf-8"),
mode=0o644,
)
return dict(sorted(runtime_environment.items()))
def _backup_runtime_environment(
spec: InstallationSpec,
paths,
) -> dict[str, str]:
values = {key: "" for key in BACKUP_RUNTIME_ENV_KEYS}
evidence_files = (
paths.backup_evidence,
paths.backup_keyring,
paths.backup_verification,
)
if not any(path.is_file() for path in evidence_files):
values["GOVOPLAN_BACKUP_EVIDENCE_STATE"] = "absent"
return values
if not all(path.is_file() for path in evidence_files):
values["GOVOPLAN_BACKUP_EVIDENCE_STATE"] = "invalid"
return values
verification = _read_json_object(paths.backup_verification)
try:
summary = verify_stored_backup_evidence(
spec,
paths,
receipt=_read_json_object(paths.receipt),
)
except (DistributionError, OSError):
values["GOVOPLAN_BACKUP_EVIDENCE_STATE"] = "invalid"
return values
values.update(
{
"GOVOPLAN_BACKUP_EVIDENCE_STATE": "verified",
"GOVOPLAN_BACKUP_EVIDENCE_ID": str(summary["evidence_id"]),
"GOVOPLAN_BACKUP_RECOVERY_POINT_ID": str(summary["recovery_point_id"]),
"GOVOPLAN_BACKUP_RESTORE_DRILL_ID": str(summary["restore_drill_id"]),
"GOVOPLAN_BACKUP_EVIDENCE_SHA256": str(summary["evidence_sha256"]),
"GOVOPLAN_BACKUP_RELEASE_MANIFEST_SHA256": str(
verification["release_manifest_sha256"]
),
"GOVOPLAN_BACKUP_CAPTURED_AT": str(summary["captured_at"]),
"GOVOPLAN_BACKUP_EXPIRES_AT": str(summary["expires_at"]),
"GOVOPLAN_BACKUP_RESTORE_STARTED_AT": str(summary["restore_started_at"]),
"GOVOPLAN_BACKUP_RESTORE_COMPLETED_AT": str(
summary["restore_completed_at"]
),
"GOVOPLAN_BACKUP_VERIFIED_AT": str(verification["verified_at"]),
"GOVOPLAN_BACKUP_MEASURED_RPO_SECONDS": str(
summary["measured_rpo_seconds"]
),
"GOVOPLAN_BACKUP_MEASURED_RTO_SECONDS": str(
summary["measured_rto_seconds"]
),
"GOVOPLAN_BACKUP_COMPONENT_COUNT": str(summary["component_count"]),
}
)
return values
def _write_plan(path: Path, plan: DeploymentPlan) -> None:
@@ -842,6 +1556,24 @@ def _prompt_configuration(args: argparse.Namespace) -> None:
if args.profile == "self-hosted" and args.public_url.startswith("http://"):
args.public_url = "https://govoplan.example.org"
args.public_url = _prompt("Public URL", args.public_url)
if args.profile == "self-hosted":
args.ingress = _prompt_choice(
"Public ingress",
args.ingress or "existing-proxy",
("existing-proxy", "managed"),
)
if args.ingress == "existing-proxy":
current = (args.trusted_proxy_cidr or ["127.0.0.1/32"])[0]
args.trusted_proxy_cidr = [
_prompt("Trusted reverse-proxy source CIDR", current)
]
else:
args.acme_email = args.acme_email or _prompt(
"ACME account email",
"admin@example.org",
)
else:
args.ingress = args.ingress or "local"
args.postgres = _prompt_choice("PostgreSQL", args.postgres, ("managed", "external"))
if args.postgres == "external" and not args.database_url:
args.database_url = getpass.getpass(
@@ -0,0 +1,380 @@
"""Collect sanitized evidence for the Kubernetes multi-host profile."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from datetime import UTC, datetime
import json
import shutil
import subprocess
import time
from typing import Any
from urllib.request import Request, urlopen
JsonObject = dict[str, Any]
CommandRunner = Callable[[Sequence[str]], JsonObject]
JsonFetcher = Callable[[str, str], JsonObject]
def collect_kubernetes_evidence(
*,
installation_id: str,
namespace: str,
ops_url: str,
api_key: str,
exercise_api_pod_loss: bool = False,
timeout_seconds: float = 180.0,
command_runner: CommandRunner | None = None,
json_fetcher: JsonFetcher | None = None,
) -> JsonObject:
"""Inspect a live cluster and optionally exercise one API pod replacement."""
run_json = command_runner or _kubectl_json
fetch_json = json_fetcher or _fetch_json
nodes = run_json(("get", "nodes", "-o", "json"))
pods = run_json(
(
"-n",
namespace,
"get",
"pods",
"-l",
f"app.kubernetes.io/instance={installation_id}",
"-o",
"json",
)
)
deployments = run_json(
(
"-n",
namespace,
"get",
"deployments",
"-l",
f"app.kubernetes.io/instance={installation_id}",
"-o",
"json",
)
)
ops = fetch_json(ops_url, api_key)
snapshot = _evaluate_snapshot(
installation_id=installation_id,
namespace=namespace,
nodes=nodes,
pods=pods,
deployments=deployments,
ops=ops,
)
replacement: JsonObject | None = None
if exercise_api_pod_loss:
replacement = _exercise_api_pod_loss(
installation_id=installation_id,
namespace=namespace,
ops_url=ops_url,
api_key=api_key,
initial_pods=pods,
timeout_seconds=timeout_seconds,
run_json=run_json,
fetch_json=fetch_json,
)
evidence = {
"schema_version": 1,
"evidence_kind": "govoplan.kubernetes-multi-host",
"collected_at": datetime.now(UTC).isoformat(),
"installation_id": installation_id,
"namespace": namespace,
"snapshot": snapshot,
"api_pod_loss": replacement,
}
failures = [
check["id"] for check in snapshot["checks"] if check["state"] != "passed"
]
if replacement and replacement["state"] != "passed":
failures.append("api_pod_loss")
evidence["result"] = {
"state": "passed" if not failures else "failed",
"failed_checks": failures,
}
return evidence
def _evaluate_snapshot(
*,
installation_id: str,
namespace: str,
nodes: Mapping[str, Any],
pods: Mapping[str, Any],
deployments: Mapping[str, Any],
ops: Mapping[str, Any],
) -> JsonObject:
ready_nodes = [
item
for item in _items(nodes)
if not bool(item.get("spec", {}).get("unschedulable"))
and _condition(item, "Ready") == "True"
]
pod_rows = [_pod_summary(item) for item in _items(pods)]
deployment_rows = [_deployment_summary(item) for item in _items(deployments)]
ready_api = [
item for item in pod_rows if item["component"] == "api" and item["ready"]
]
ready_web = [
item for item in pod_rows if item["component"] == "web" and item["ready"]
]
runtime = ops.get("runtime_cluster")
runtime = runtime if isinstance(runtime, Mapping) else {}
checks = ops.get("checks")
check_rows = checks if isinstance(checks, list) else []
check_by_id = {
str(item.get("id")): item for item in check_rows if isinstance(item, Mapping)
}
ops_readiness = ops.get("readiness")
ops_readiness = ops_readiness if isinstance(ops_readiness, Mapping) else {}
snapshot_checks = [
_check(
"ready_nodes",
len(ready_nodes) >= 2,
f"{len(ready_nodes)} ready schedulable node(s)",
),
_check(
"api_node_spread",
len(ready_api) >= 2 and len({item["node"] for item in ready_api}) >= 2,
f"{len(ready_api)} ready API pod(s) on {len({item['node'] for item in ready_api})} node(s)",
),
_check(
"web_node_spread",
len(ready_web) >= 2 and len({item["node"] for item in ready_web}) >= 2,
f"{len(ready_web)} ready WebUI pod(s) on {len({item['node'] for item in ready_web})} node(s)",
),
_check(
"deployment_availability",
bool(deployment_rows)
and all(item["available"] >= item["desired"] for item in deployment_rows),
f"{len(deployment_rows)} deployment(s) inspected",
),
_check(
"ops_readiness",
bool(ops_readiness.get("ready")),
str(ops_readiness.get("detail") or "Ops readiness response inspected"),
),
_check(
"runtime_composition",
not bool(runtime.get("composition", {}).get("skewed")),
"Active runtime composition matches the loaded module graph",
),
_check(
"runtime_versions",
not bool(runtime.get("software_versions", {}).get("skewed")),
"Active runtime software versions are consistent",
),
_check(
"worker_queue_coverage",
not bool(runtime.get("queues", {}).get("missing")),
"Every configured queue has an active worker",
),
_check(
"database_connection_budget",
str(check_by_id.get("database_capacity", {}).get("state")) == "ok",
str(
check_by_id.get("database_capacity", {}).get("detail")
or "Database capacity check is unavailable"
),
),
]
return {
"installation_id": installation_id,
"namespace": namespace,
"ready_node_names": sorted(_name(item) for item in ready_nodes),
"pods": sorted(pod_rows, key=lambda item: item["name"]),
"deployments": sorted(deployment_rows, key=lambda item: item["name"]),
"runtime": {
"expected": dict(runtime.get("expected") or {}),
"active": dict(runtime.get("active") or {}),
"composition": dict(runtime.get("composition") or {}),
"software_versions": dict(runtime.get("software_versions") or {}),
"queues": dict(runtime.get("queues") or {}),
},
"database_capacity": dict(
check_by_id.get("database_capacity", {}).get("metrics") or {}
),
"checks": snapshot_checks,
}
def _exercise_api_pod_loss(
*,
installation_id: str,
namespace: str,
ops_url: str,
api_key: str,
initial_pods: Mapping[str, Any],
timeout_seconds: float,
run_json: CommandRunner,
fetch_json: JsonFetcher,
) -> JsonObject:
candidates = [
_pod_summary(item)
for item in _items(initial_pods)
if item.get("metadata", {}).get("labels", {}).get("app.kubernetes.io/component")
== "api"
and _condition(item, "Ready") == "True"
]
if len(candidates) < 2:
return {
"state": "failed",
"detail": "At least two ready API pods are required for the loss drill.",
}
victim = sorted(candidates, key=lambda item: item["name"])[0]
initial_uids = {item["uid"] for item in candidates}
desired_ready = len(candidates)
run_json(
(
"-n",
namespace,
"delete",
"pod",
victim["name"],
"--wait=false",
"-o",
"json",
)
)
deadline = time.monotonic() + timeout_seconds
health_failures = 0
replacement: JsonObject | None = None
while time.monotonic() < deadline:
try:
current_ops = fetch_json(ops_url, api_key)
if not bool(current_ops.get("readiness", {}).get("ready")):
health_failures += 1
except (OSError, ValueError):
health_failures += 1
current = run_json(
(
"-n",
namespace,
"get",
"pods",
"-l",
f"app.kubernetes.io/instance={installation_id},app.kubernetes.io/component=api",
"-o",
"json",
)
)
ready = [
_pod_summary(item)
for item in _items(current)
if _condition(item, "Ready") == "True"
]
new_pods = [item for item in ready if item["uid"] not in initial_uids]
if len(ready) >= desired_ready and new_pods:
replacement = sorted(new_pods, key=lambda item: item["name"])[-1]
break
time.sleep(2.0)
passed = replacement is not None and health_failures == 0
return {
"state": "passed" if passed else "failed",
"victim": victim,
"replacement": replacement,
"readiness_failures": health_failures,
"detail": (
"API pod was replaced without an observed readiness outage."
if passed
else "API replacement timed out or readiness failed during replacement."
),
}
def _kubectl_json(arguments: Sequence[str]) -> JsonObject:
kubectl = shutil.which("kubectl")
if kubectl is None:
raise ValueError("kubectl is required for Kubernetes evidence collection")
result = subprocess.run(
(kubectl, *arguments),
check=False,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode:
detail = result.stderr.strip() or result.stdout.strip()
raise ValueError(f"kubectl failed: {detail}")
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ValueError("kubectl did not return JSON") from exc
if not isinstance(payload, dict):
raise ValueError("kubectl returned a non-object JSON payload")
return payload
def _fetch_json(url: str, api_key: str) -> JsonObject:
request = Request(
url,
headers={"Accept": "application/json", "X-API-Key": api_key},
)
with urlopen(request, timeout=15) as response:
payload = json.load(response)
if not isinstance(payload, dict):
raise ValueError("Ops API returned a non-object JSON payload")
return payload
def _items(payload: Mapping[str, Any]) -> list[Mapping[str, Any]]:
items = payload.get("items")
if not isinstance(items, list):
raise ValueError("Kubernetes list response has no items")
return [item for item in items if isinstance(item, Mapping)]
def _condition(item: Mapping[str, Any], condition_type: str) -> str | None:
conditions = item.get("status", {}).get("conditions", [])
for condition in conditions if isinstance(conditions, list) else []:
if isinstance(condition, Mapping) and condition.get("type") == condition_type:
return str(condition.get("status"))
return None
def _name(item: Mapping[str, Any]) -> str:
return str(item.get("metadata", {}).get("name") or "")
def _pod_summary(item: Mapping[str, Any]) -> JsonObject:
metadata = item.get("metadata", {})
labels = metadata.get("labels", {})
return {
"name": _name(item),
"uid": str(metadata.get("uid") or ""),
"component": str(labels.get("app.kubernetes.io/component") or ""),
"worker_pool": labels.get("govoplan.add-ideas.de/worker-pool"),
"node": str(item.get("spec", {}).get("nodeName") or ""),
"ready": _condition(item, "Ready") == "True",
}
def _deployment_summary(item: Mapping[str, Any]) -> JsonObject:
status = item.get("status", {})
return {
"name": _name(item),
"component": str(
item.get("metadata", {})
.get("labels", {})
.get("app.kubernetes.io/component")
or ""
),
"desired": int(item.get("spec", {}).get("replicas") or 0),
"available": int(status.get("availableReplicas") or 0),
"updated": int(status.get("updatedReplicas") or 0),
}
def _check(check_id: str, passed: bool, detail: str) -> JsonObject:
return {
"id": check_id,
"state": "passed" if passed else "failed",
"detail": detail,
}
__all__ = ["collect_kubernetes_evidence"]
@@ -0,0 +1,712 @@
"""Bounded verification for signed GovOPlaN runtime distributions."""
from __future__ import annotations
import base64
from datetime import UTC, datetime
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import re
import socket
import stat
import subprocess
import tempfile
from typing import Any, Mapping
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
MAX_MANIFEST_BYTES = 4 * 1024 * 1024
MAX_KEYRING_BYTES = 1024 * 1024
MAX_OFFLINE_INDEX_BYTES = 4 * 1024 * 1024
MAX_OFFLINE_IMAGE_BYTES = 16 * 1024 * 1024 * 1024
SHA256 = re.compile(r"^[0-9a-f]{64}$")
DIGEST_IMAGE = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$")
TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,127}$")
MODULE_ID = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
KEY_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
PLATFORMS = ("linux/amd64", "linux/arm64")
MANIFEST_FILENAME = "distribution-manifest.json"
KEYRING_FILENAME = "distribution-keyring.json"
class DistributionError(ValueError):
"""Distribution evidence is absent, malformed, or untrusted."""
def canonical_signed_payload(payload: Mapping[str, Any]) -> bytes:
unsigned = dict(payload)
unsigned.pop("signatures", None)
return json.dumps(
unsigned,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
def canonical_json(payload: Mapping[str, Any]) -> bytes:
return (
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
).encode("utf-8")
def load_bounded_json(path: Path, *, maximum_bytes: int) -> dict[str, Any]:
encoded = read_bounded_bytes(path, maximum_bytes=maximum_bytes)
try:
value = json.loads(encoded)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise DistributionError(f"trusted JSON file is malformed: {path}") from exc
if not isinstance(value, dict):
raise DistributionError(f"trusted JSON root must be an object: {path}")
return value
def read_bounded_bytes(path: Path, *, maximum_bytes: int) -> bytes:
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise DistributionError(f"cannot open trusted JSON file: {path}") from exc
try:
opened = os.fstat(descriptor)
if not stat.S_ISREG(opened.st_mode) or opened.st_size > maximum_bytes:
raise DistributionError(
f"trusted JSON file is invalid or too large: {path}"
)
chunks: list[bytes] = []
total = 0
while True:
chunk = os.read(descriptor, min(64 * 1024, maximum_bytes + 1 - total))
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
if total > maximum_bytes:
raise DistributionError(f"trusted JSON file is too large: {path}")
final = os.fstat(descriptor)
if (opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns) != (
final.st_dev,
final.st_ino,
final.st_size,
final.st_mtime_ns,
):
raise DistributionError(f"trusted JSON file changed while read: {path}")
finally:
os.close(descriptor)
return b"".join(chunks)
def fetch_bounded_https(
url: str,
*,
maximum_bytes: int,
timeout_seconds: float = 15.0,
allow_private_host: bool = False,
) -> bytes:
parsed = urlsplit(url)
if parsed.scheme != "https" or not parsed.hostname:
raise DistributionError("distribution downloads require an absolute HTTPS URL")
if parsed.username or parsed.password or parsed.fragment:
raise DistributionError(
"distribution URL must not contain credentials or a fragment"
)
if not allow_private_host:
_require_public_host(parsed.hostname)
request = Request(url, headers={"Accept": "application/json"})
try:
with urlopen(request, timeout=timeout_seconds) as response: # noqa: S310
final = urlsplit(response.geturl())
if final.scheme != "https":
raise DistributionError("distribution redirect left HTTPS")
declared = response.headers.get("Content-Length")
if declared and int(declared) > maximum_bytes:
raise DistributionError("distribution download exceeds its size limit")
value = response.read(maximum_bytes + 1)
except DistributionError:
raise
except (OSError, ValueError) as exc:
raise DistributionError(f"distribution download failed: {exc}") from exc
if len(value) > maximum_bytes:
raise DistributionError("distribution download exceeds its size limit")
return value
def decode_json_bytes(value: bytes, *, label: str) -> dict[str, Any]:
try:
payload = json.loads(value)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise DistributionError(f"{label} is not valid JSON") from exc
if not isinstance(payload, dict):
raise DistributionError(f"{label} root must be an object")
return payload
def validate_manifest(
payload: Mapping[str, Any],
*,
expected_channel: str | None = None,
now: datetime | None = None,
) -> None:
_exact_keys(
payload,
required={
"schema_version",
"channel",
"sequence",
"version",
"issued_at",
"expires_at",
"revoked",
"deployer",
"images",
"dependencies",
"composition",
"signatures",
},
label="distribution manifest",
)
if payload.get("schema_version") != "1":
raise DistributionError("unsupported distribution manifest schema_version")
channel = _token(payload.get("channel"), "channel", maximum=32, pattern=MODULE_ID)
if expected_channel is not None and channel != expected_channel:
raise DistributionError(
f"distribution channel is {channel!r}, expected {expected_channel!r}"
)
if (
isinstance(payload.get("sequence"), bool)
or not isinstance(payload.get("sequence"), int)
or int(payload["sequence"]) < 1
):
raise DistributionError("distribution sequence must be a positive integer")
_token(payload.get("version"), "version", maximum=128, pattern=TOKEN)
issued = _datetime(payload.get("issued_at"), "issued_at")
expires = _datetime(payload.get("expires_at"), "expires_at")
current = (now or datetime.now(UTC)).astimezone(UTC)
if expires <= issued:
raise DistributionError("distribution expiry must be after issuance")
if issued > current:
raise DistributionError("distribution is not valid yet")
if expires <= current:
raise DistributionError("distribution manifest has expired")
if payload.get("revoked") is not False:
raise DistributionError("distribution manifest is revoked")
deployer = _object(payload.get("deployer"), "deployer")
_exact_keys(deployer, required={"url", "sha256"}, label="deployer")
_https_url(deployer.get("url"), "deployer.url")
_sha256(deployer.get("sha256"), "deployer.sha256")
images = _object(payload.get("images"), "images")
if set(images) != {"api", "web"}:
raise DistributionError("images must contain exactly api and web")
for name in ("api", "web"):
_validate_image(_object(images[name], f"images.{name}"), f"images.{name}")
dependencies = _object(payload.get("dependencies"), "dependencies")
if not dependencies:
raise DistributionError("dependencies must not be empty")
for name, reference in dependencies.items():
if MODULE_ID.fullmatch(str(name)) is None:
raise DistributionError(f"invalid dependency name: {name!r}")
_digest_image(reference, f"dependencies.{name}")
composition = _object(payload.get("composition"), "composition")
_exact_keys(
composition,
required={"sha256", "module_ids", "packages"},
label="composition",
)
_sha256(composition.get("sha256"), "composition.sha256")
module_ids = _string_array(composition.get("module_ids"), "module_ids")
if any(MODULE_ID.fullmatch(item) is None for item in module_ids):
raise DistributionError("composition.module_ids contains an invalid id")
packages = composition.get("packages")
if not isinstance(packages, list) or not packages:
raise DistributionError("composition.packages must be a non-empty array")
seen_packages: set[str] = set()
for index, item in enumerate(packages):
package = _object(item, f"composition.packages[{index}]")
_exact_keys(
package,
required={"name", "version", "wheel_sha256"},
label=f"composition.packages[{index}]",
)
name = _token(
package.get("name"),
f"composition.packages[{index}].name",
maximum=128,
pattern=re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$"),
)
if name in seen_packages:
raise DistributionError("composition contains duplicate packages")
seen_packages.add(name)
_token(
package.get("version"),
f"composition.packages[{index}].version",
maximum=128,
pattern=TOKEN,
)
_sha256(
package.get("wheel_sha256"),
f"composition.packages[{index}].wheel_sha256",
)
signatures = payload.get("signatures")
if not isinstance(signatures, list) or not signatures:
raise DistributionError("distribution manifest has no signatures")
seen_signatures: set[str] = set()
for index, item in enumerate(signatures):
signature = _object(item, f"signatures[{index}]")
_exact_keys(
signature,
required={"key_id", "algorithm", "value"},
label=f"signatures[{index}]",
)
key_id = _token(
signature.get("key_id"),
f"signatures[{index}].key_id",
maximum=128,
pattern=KEY_ID,
)
if key_id in seen_signatures:
raise DistributionError("distribution contains duplicate signatures")
seen_signatures.add(key_id)
if signature.get("algorithm") != "ed25519":
raise DistributionError("distribution signature algorithm must be ed25519")
_signature_bytes(signature.get("value"), f"signatures[{index}].value")
def verify_manifest(
payload: Mapping[str, Any],
keyring: Mapping[str, Any],
*,
expected_channel: str | None = None,
now: datetime | None = None,
openssl: str = "openssl",
) -> str:
current = (now or datetime.now(UTC)).astimezone(UTC)
validate_manifest(payload, expected_channel=expected_channel, now=current)
return verify_signed_document(
payload,
keyring,
purpose="govoplan-runtime-distribution",
label="distribution",
now=current,
openssl=openssl,
)
def verify_signed_document(
payload: Mapping[str, Any],
keyring: Mapping[str, Any],
*,
purpose: str,
label: str,
now: datetime,
openssl: str = "openssl",
) -> str:
keys = _trusted_keys(keyring, now=now, purpose=purpose, label=label)
signed = canonical_signed_payload(payload)
failures: list[str] = []
signatures = payload.get("signatures")
if not isinstance(signatures, list) or not signatures:
raise DistributionError(f"{label} has no signatures")
for index, raw in enumerate(signatures):
item = _object(raw, f"{label}.signatures[{index}]")
_exact_keys(
item,
required={"key_id", "algorithm", "value"},
label=f"{label}.signatures[{index}]",
)
key_id = str(item["key_id"])
if KEY_ID.fullmatch(key_id) is None or item.get("algorithm") != "ed25519":
raise DistributionError(f"{label} signature is invalid")
public_key = keys.get(key_id)
if public_key is None:
continue
signature = _signature_bytes(item["value"], "signature.value")
try:
_openssl_verify(
signed,
signature,
public_key,
openssl=openssl,
)
except DistributionError as exc:
failures.append(f"{key_id}: {exc}")
continue
return key_id
detail = (
"; ".join(failures) if failures else "no signature used an active trusted key"
)
raise DistributionError(f"{label} signature verification failed: {detail}")
def verify_manifest_binding(
payload: Mapping[str, Any],
*,
channel: str,
version: str,
api_image: str,
web_image: str,
enabled_modules: tuple[str, ...],
composition_sha256: str,
dependencies: Mapping[str, str],
) -> None:
if payload.get("channel") != channel or payload.get("version") != version:
raise DistributionError(
"stored manifest does not match release channel/version"
)
images = _object(payload.get("images"), "images")
if _object(images.get("api"), "images.api").get("index") != api_image:
raise DistributionError("stored manifest does not match API image")
if _object(images.get("web"), "images.web").get("index") != web_image:
raise DistributionError("stored manifest does not match Web image")
composition = _object(payload.get("composition"), "composition")
if composition.get("sha256") != composition_sha256:
raise DistributionError("stored manifest composition digest does not match")
available_modules = set(_string_array(composition.get("module_ids"), "module_ids"))
missing = sorted(set(enabled_modules) - available_modules)
if missing:
raise DistributionError(
"enabled modules are absent from runtime composition: " + ", ".join(missing)
)
manifest_dependencies = _object(payload.get("dependencies"), "dependencies")
for name, reference in dependencies.items():
if manifest_dependencies.get(name) != reference:
raise DistributionError(
f"stored manifest does not match dependency image {name!r}"
)
def verify_offline_image_index(
index: Mapping[str, Any],
*,
root: Path,
expected_references: tuple[str, ...],
) -> tuple[Path, ...]:
_exact_keys(index, required={"schema_version", "images"}, label="offline index")
if index.get("schema_version") != "1":
raise DistributionError("unsupported offline image index schema")
images = index.get("images")
if not isinstance(images, list):
raise DistributionError("offline image index images must be an array")
references: dict[str, Path] = {}
for item in images:
value = _object(item, "offline image")
_exact_keys(
value,
required={"reference", "archive", "sha256"},
label="offline image",
)
reference = _digest_image(value.get("reference"), "offline image reference")
archive_value = value.get("archive")
if not isinstance(archive_value, str) or not archive_value:
raise DistributionError("offline image archive must be a relative path")
archive_relative = Path(archive_value)
if archive_relative.is_absolute() or ".." in archive_relative.parts:
raise DistributionError("offline image archive must stay inside its bundle")
archive = root / archive_relative
if reference in references:
raise DistributionError("offline image index contains duplicate references")
if _sha256_regular_file(
archive, maximum_bytes=MAX_OFFLINE_IMAGE_BYTES
) != _sha256(value.get("sha256"), "offline image sha256"):
raise DistributionError(f"offline image archive digest mismatch: {archive}")
references[reference] = archive
missing = sorted(set(expected_references) - set(references))
if missing:
raise DistributionError(
"offline image bundle is incomplete: " + ", ".join(missing)
)
return tuple(references[item] for item in expected_references)
def file_sha256(path: Path, *, maximum_bytes: int = MAX_MANIFEST_BYTES) -> str:
return _sha256_regular_file(path, maximum_bytes=maximum_bytes)
def _validate_image(value: Mapping[str, Any], label: str) -> None:
_exact_keys(
value,
required={"index", "platforms", "sbom", "provenance"},
label=label,
)
_digest_image(value.get("index"), f"{label}.index")
platforms = _object(value.get("platforms"), f"{label}.platforms")
if set(platforms) != set(PLATFORMS):
raise DistributionError(f"{label}.platforms must cover amd64 and arm64")
for platform, reference in platforms.items():
_digest_image(reference, f"{label}.platforms.{platform}")
_validate_artifact(_object(value.get("sbom"), f"{label}.sbom"), f"{label}.sbom")
_validate_artifact(
_object(value.get("provenance"), f"{label}.provenance"),
f"{label}.provenance",
)
def _validate_artifact(value: Mapping[str, Any], label: str) -> None:
_exact_keys(value, required={"url", "sha256"}, label=label)
_https_url(value.get("url"), f"{label}.url")
_sha256(value.get("sha256"), f"{label}.sha256")
def _trusted_keys(
keyring: Mapping[str, Any],
*,
now: datetime,
purpose: str,
label: str,
) -> dict[str, str]:
_exact_keys(
keyring,
required={"schema_version", "purpose", "keys"},
label=f"{label} keyring",
)
if keyring.get("schema_version") != "1":
raise DistributionError(f"unsupported {label} keyring schema_version")
if keyring.get("purpose") != purpose:
raise DistributionError(f"{label} keyring has the wrong purpose")
values = keyring.get("keys")
if not isinstance(values, list) or not values:
raise DistributionError(f"{label} keyring contains no keys")
trusted: dict[str, str] = {}
for index, item in enumerate(values):
key = _object(item, f"keyring.keys[{index}]")
_exact_keys(
key,
required={
"key_id",
"algorithm",
"status",
"public_key_pem",
"not_before",
"expires_at",
},
label=f"keyring.keys[{index}]",
)
key_id = _token(
key.get("key_id"),
f"keyring.keys[{index}].key_id",
maximum=128,
pattern=KEY_ID,
)
if key_id in trusted:
raise DistributionError(f"{label} keyring contains duplicate key ids")
if key.get("algorithm") != "ed25519":
raise DistributionError(f"{label} key must use ed25519")
if key.get("status") not in {"active", "retired", "revoked"}:
raise DistributionError(f"{label} key has an invalid status")
not_before = _datetime(key.get("not_before"), "key.not_before")
expires = _datetime(key.get("expires_at"), "key.expires_at")
public_key = key.get("public_key_pem")
if (
not isinstance(public_key, str)
or len(public_key.encode("utf-8")) > 8192
or "BEGIN PUBLIC KEY" not in public_key
):
raise DistributionError(f"{label} key has an invalid public key")
if key.get("status") == "active" and not_before <= now < expires:
trusted[key_id] = public_key
if not trusted:
raise DistributionError(f"{label} keyring has no currently active keys")
return trusted
def _openssl_verify(
payload: bytes,
signature: bytes,
public_key: str,
*,
openssl: str,
) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-distribution-verify-") as value:
root = Path(value)
payload_path = root / "payload.json"
signature_path = root / "signature.bin"
key_path = root / "public.pem"
payload_path.write_bytes(payload)
signature_path.write_bytes(signature)
key_path.write_text(public_key, encoding="utf-8")
try:
completed = subprocess.run(
[
openssl,
"pkeyutl",
"-verify",
"-pubin",
"-inkey",
str(key_path),
"-rawin",
"-in",
str(payload_path),
"-sigfile",
str(signature_path),
],
check=False,
capture_output=True,
text=True,
timeout=10,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise DistributionError("OpenSSL Ed25519 verifier is unavailable") from exc
if completed.returncode != 0:
raise DistributionError("Ed25519 signature is invalid")
def _signature_bytes(value: object, label: str) -> bytes:
if not isinstance(value, str) or len(value) > 256:
raise DistributionError(f"{label} is invalid")
try:
decoded = base64.b64decode(value, validate=True)
except (ValueError, base64.binascii.Error) as exc:
raise DistributionError(f"{label} is not valid base64") from exc
if len(decoded) != 64:
raise DistributionError(f"{label} is not an Ed25519 signature")
return decoded
def _require_public_host(hostname: str) -> None:
try:
addresses = {
value[4][0]
for value in socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM)
}
except OSError as exc:
raise DistributionError(
f"distribution host cannot be resolved: {hostname}"
) from exc
if not addresses:
raise DistributionError("distribution host resolved to no addresses")
for value in addresses:
address = ipaddress.ip_address(value)
if not address.is_global:
raise DistributionError(
"distribution host resolves to a non-public address"
)
def _sha256_regular_file(path: Path, *, maximum_bytes: int) -> str:
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise DistributionError(f"cannot open immutable artifact: {path}") from exc
digest = hashlib.sha256()
try:
opened = os.fstat(descriptor)
if not stat.S_ISREG(opened.st_mode) or opened.st_size > maximum_bytes:
raise DistributionError(
f"immutable artifact is invalid or too large: {path}"
)
while True:
chunk = os.read(descriptor, 1024 * 1024)
if not chunk:
break
digest.update(chunk)
final = os.fstat(descriptor)
if (opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns) != (
final.st_dev,
final.st_ino,
final.st_size,
final.st_mtime_ns,
):
raise DistributionError(f"immutable artifact changed while read: {path}")
finally:
os.close(descriptor)
return digest.hexdigest()
def _object(value: object, label: str) -> dict[str, Any]:
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
raise DistributionError(f"{label} must be an object")
return value
def _exact_keys(
value: Mapping[str, Any],
*,
required: set[str],
label: str,
) -> None:
missing = sorted(required - set(value))
extra = sorted(set(value) - required)
if missing or extra:
detail = []
if missing:
detail.append("missing " + ", ".join(missing))
if extra:
detail.append("unknown " + ", ".join(extra))
raise DistributionError(f"{label} has invalid fields: {'; '.join(detail)}")
def _token(
value: object,
label: str,
*,
maximum: int,
pattern: re.Pattern[str],
) -> str:
if (
not isinstance(value, str)
or len(value) > maximum
or pattern.fullmatch(value) is None
):
raise DistributionError(f"{label} is invalid")
return value
def _datetime(value: object, label: str) -> datetime:
if not isinstance(value, str) or len(value) > 64:
raise DistributionError(f"{label} must be an RFC3339 timestamp")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise DistributionError(f"{label} must be an RFC3339 timestamp") from exc
if parsed.tzinfo is None:
raise DistributionError(f"{label} must include a timezone")
return parsed.astimezone(UTC)
def _sha256(value: object, label: str) -> str:
if not isinstance(value, str) or SHA256.fullmatch(value) is None:
raise DistributionError(f"{label} must be a lowercase SHA-256 digest")
return value
def _digest_image(value: object, label: str) -> str:
if (
not isinstance(value, str)
or len(value) > 300
or DIGEST_IMAGE.fullmatch(value) is None
):
raise DistributionError(f"{label} must be an OCI image pinned by sha256")
return value
def _https_url(value: object, label: str) -> str:
if not isinstance(value, str) or len(value) > 2048:
raise DistributionError(f"{label} must be an HTTPS URL")
parsed = urlsplit(value)
if (
parsed.scheme != "https"
or not parsed.netloc
or parsed.username
or parsed.password
):
raise DistributionError(f"{label} must be an HTTPS URL without credentials")
return value
def _string_array(value: object, label: str) -> tuple[str, ...]:
if (
not isinstance(value, list)
or len(value) > 1024
or any(not isinstance(item, str) for item in value)
or len(set(value)) != len(value)
):
raise DistributionError(f"{label} must be an array of unique strings")
return tuple(value)
+371 -8
View File
@@ -2,12 +2,15 @@
from __future__ import annotations
from dataclasses import dataclass
from hashlib import sha256
import json
from pathlib import Path
import re
from typing import Any, Mapping
from urllib.parse import urlsplit
from .bundle import BACKUP_RUNTIME_ENV_KEYS
from .model import InstallationSpec, image_is_digest_pinned
@@ -26,6 +29,17 @@ _CONFIG_KEYS = (
"ENABLED_MODULES",
"CELERY_ENABLED",
"CELERY_QUEUES",
"GOVOPLAN_DB_CONNECTION_LIMIT",
"GOVOPLAN_DB_CONNECTION_RESERVE",
"GOVOPLAN_API_DB_POOL_SIZE",
"GOVOPLAN_API_DB_MAX_OVERFLOW",
"GOVOPLAN_WORKER_DB_POOL_SIZE",
"GOVOPLAN_WORKER_DB_MAX_OVERFLOW",
"GOVOPLAN_SCHEDULER_DB_POOL_SIZE",
"GOVOPLAN_SCHEDULER_DB_MAX_OVERFLOW",
"GOVOPLAN_MIGRATION_DB_POOL_SIZE",
"GOVOPLAN_MIGRATION_DB_MAX_OVERFLOW",
"GOVOPLAN_WORKER_POOLS",
"CORS_ORIGINS",
"GOVOPLAN_TRUSTED_HOSTS",
"FORWARDED_ALLOW_IPS",
@@ -42,7 +56,17 @@ _CONFIG_KEYS = (
"FILE_STORAGE_S3_BUCKET",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
*BACKUP_RUNTIME_ENV_KEYS,
)
_QUEUE_NAME = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
@dataclass(frozen=True, slots=True)
class _WorkerPool:
name: str
queues: tuple[str, ...]
replicas: int
concurrency: int
def render_kubernetes(
@@ -53,10 +77,14 @@ def render_kubernetes(
secret_name: str = "govoplan-runtime",
tls_secret_name: str = "govoplan-tls",
ingress_class_name: str | None = None,
backup_required: bool = True,
backup_evidence: Mapping[str, object] | None = None,
) -> dict[str, Any]:
"""Render runtime roles only; shared state services stay externally managed."""
_validate_cluster_profile(spec, environment, namespace, secret_name)
worker_pools = _worker_pools(spec, environment)
database_capacity = _database_capacity(spec, environment, worker_pools)
name = _resource_name(spec.installation_id)
public_host = urlsplit(spec.public_url).hostname or "localhost"
labels = {"app.kubernetes.io/name": "govoplan", "app.kubernetes.io/instance": name}
@@ -75,6 +103,8 @@ def render_kubernetes(
"GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS": "60",
"GOVOPLAN_EXPECTED_API_REPLICAS": str(spec.replicas.api),
"GOVOPLAN_EXPECTED_WORKER_REPLICAS": str(spec.replicas.worker),
"GOVOPLAN_DB_CONNECTION_PEAK": str(database_capacity["peak"]),
"GOVOPLAN_DB_CONNECTION_AVAILABLE": str(database_capacity["available"]),
"FILE_STORAGE_BACKEND": "s3",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED": "false",
"FILE_STORAGE_S3_ENDPOINT_TRUSTED": "true",
@@ -130,6 +160,7 @@ def render_kubernetes(
readiness_path="/health/ready",
liveness_path="/health",
probe_host=public_host,
extra_environment=_role_database_environment(environment, "API"),
),
_service(
name=f"{name}-api",
@@ -168,16 +199,27 @@ def render_kubernetes(
config_name=config_name,
secret_name=secret_name,
service_account=service_account,
database_environment=_role_database_environment(
environment,
"MIGRATION",
),
backup_required=backup_required,
backup_evidence=backup_evidence,
),
]
if spec.replicas.worker:
for pool in worker_pools:
worker_name = (
f"{name}-worker"
if len(worker_pools) == 1 and pool.name == "default"
else _name_with_suffix(name, f"worker-{pool.name}")
)
items.append(
_deployment(
name=f"{name}-worker",
name=worker_name,
namespace=namespace,
labels=labels,
role="worker",
replicas=spec.replicas.worker,
replicas=pool.replicas,
image=spec.release.api_image,
command=(
"python",
@@ -187,15 +229,39 @@ def render_kubernetes(
"govoplan_core.celery_app:celery",
"worker",
"--queues",
str(environment["CELERY_QUEUES"]),
",".join(pool.queues),
"--concurrency",
str(pool.concurrency),
"--loglevel",
"INFO",
),
config_name=config_name,
secret_name=secret_name,
service_account=service_account,
extra_environment={
**_role_database_environment(environment, "WORKER"),
"CELERY_QUEUES": ",".join(pool.queues),
"CELERY_WORKER_CONCURRENCY": str(pool.concurrency),
"GOVOPLAN_WORKER_POOL": pool.name,
},
selector_labels={
"govoplan.add-ideas.de/worker-pool": pool.name,
},
)
)
if pool.replicas > 1:
items.append(
_pod_disruption_budget(
worker_name,
namespace,
labels,
"worker",
selector_labels={
"govoplan.add-ideas.de/worker-pool": pool.name,
},
)
)
if worker_pools:
items.append(
_deployment(
name=f"{name}-scheduler",
@@ -225,6 +291,10 @@ def render_kubernetes(
config_name=config_name,
secret_name=secret_name,
service_account=service_account,
extra_environment=_role_database_environment(
environment,
"SCHEDULER",
),
)
)
if spec.replicas.api > 1:
@@ -248,6 +318,15 @@ def render_kubernetes(
"annotations": {
"govoplan.add-ideas.de/profile": "stateless-shared-state",
"govoplan.add-ideas.de/secret-contract": ",".join(_SECRET_KEYS),
"govoplan.add-ideas.de/database-connection-peak": str(
database_capacity["peak"]
),
"govoplan.add-ideas.de/database-connection-available": str(
database_capacity["available"]
),
"govoplan.add-ideas.de/worker-pools": ",".join(
pool.name for pool in worker_pools
),
}
},
"items": items,
@@ -290,7 +369,13 @@ def _validate_cluster_profile(
+ ", ".join(unpinned_images)
)
missing = [
key for key in (*_SECRET_KEYS, "CELERY_QUEUES") if not environment.get(key)
key
for key in (
*_SECRET_KEYS,
"CELERY_QUEUES",
"GOVOPLAN_DB_CONNECTION_LIMIT",
)
if not environment.get(key)
]
if missing:
raise ValueError(
@@ -298,6 +383,245 @@ def _validate_cluster_profile(
)
def _worker_pools(
spec: InstallationSpec,
environment: Mapping[str, str],
) -> tuple[_WorkerPool, ...]:
if spec.replicas.worker == 0:
if str(environment.get("GOVOPLAN_WORKER_POOLS") or "").strip():
raise ValueError("Worker pools require at least one worker replica")
return ()
configured_queues = tuple(
item.strip()
for item in str(environment.get("CELERY_QUEUES") or "").split(",")
if item.strip()
)
if not configured_queues or any(
_QUEUE_NAME.fullmatch(item) is None for item in configured_queues
):
raise ValueError("CELERY_QUEUES must contain canonical queue names")
raw_pools = str(environment.get("GOVOPLAN_WORKER_POOLS") or "").strip()
if not raw_pools:
return (
_WorkerPool(
name="default",
queues=configured_queues,
replicas=spec.replicas.worker,
concurrency=_bounded_environment_integer(
environment,
"CELERY_WORKER_CONCURRENCY",
default=2,
minimum=1,
maximum=64,
),
),
)
try:
payload = json.loads(raw_pools)
except json.JSONDecodeError as exc:
raise ValueError("GOVOPLAN_WORKER_POOLS must be valid JSON") from exc
if not isinstance(payload, list) or not payload:
raise ValueError("GOVOPLAN_WORKER_POOLS must be a non-empty JSON list")
pools: list[_WorkerPool] = []
seen_names: set[str] = set()
seen_queues: set[str] = set()
for index, item in enumerate(payload):
if not isinstance(item, dict):
raise ValueError(f"Worker pool {index} must be an object")
unknown = set(item) - {"name", "queues", "replicas", "concurrency"}
if unknown:
raise ValueError(
f"Worker pool {index} has unsupported keys: "
+ ", ".join(sorted(unknown))
)
pool_name = item.get("name")
queues = item.get("queues")
replicas = item.get("replicas")
concurrency = item.get("concurrency")
if not isinstance(pool_name, str) or _DNS_LABEL.fullmatch(pool_name) is None:
raise ValueError(f"Worker pool {index} has an invalid name")
if pool_name in seen_names:
raise ValueError(f"Worker pool name is duplicated: {pool_name}")
if (
not isinstance(queues, list)
or not queues
or any(
not isinstance(queue, str) or _QUEUE_NAME.fullmatch(queue) is None
for queue in queues
)
):
raise ValueError(f"Worker pool {pool_name} has invalid queues")
duplicate_queues = seen_queues.intersection(queues)
if duplicate_queues:
raise ValueError(
"Worker queues must have one owning pool: "
+ ", ".join(sorted(duplicate_queues))
)
if not isinstance(replicas, int) or not 1 <= replicas <= 128:
raise ValueError(
f"Worker pool {pool_name} replicas must be between 1 and 128"
)
if not isinstance(concurrency, int) or not 1 <= concurrency <= 64:
raise ValueError(
f"Worker pool {pool_name} concurrency must be between 1 and 64"
)
pools.append(
_WorkerPool(
name=pool_name,
queues=tuple(queues),
replicas=replicas,
concurrency=concurrency,
)
)
seen_names.add(pool_name)
seen_queues.update(queues)
if sum(pool.replicas for pool in pools) != spec.replicas.worker:
raise ValueError("Worker pool replicas must equal installation replicas.worker")
configured_queue_set = set(configured_queues)
if seen_queues != configured_queue_set:
missing = configured_queue_set - seen_queues
unknown = seen_queues - configured_queue_set
detail = []
if missing:
detail.append("missing " + ", ".join(sorted(missing)))
if unknown:
detail.append("unknown " + ", ".join(sorted(unknown)))
raise ValueError(
"Worker pools must cover CELERY_QUEUES exactly: " + "; ".join(detail)
)
return tuple(sorted(pools, key=lambda pool: pool.name))
def _database_capacity(
spec: InstallationSpec,
environment: Mapping[str, str],
worker_pools: tuple[_WorkerPool, ...],
) -> dict[str, int]:
limit = _bounded_environment_integer(
environment,
"GOVOPLAN_DB_CONNECTION_LIMIT",
minimum=10,
maximum=1_000_000,
)
reserve = _bounded_environment_integer(
environment,
"GOVOPLAN_DB_CONNECTION_RESERVE",
default=10,
minimum=1,
maximum=999_999,
)
if reserve >= limit:
raise ValueError("Database connection reserve must be below the limit")
api_ceiling = _role_database_ceiling(environment, "API", 5, 2)
worker_ceiling = _role_database_ceiling(environment, "WORKER", 1, 1)
scheduler_ceiling = _role_database_ceiling(environment, "SCHEDULER", 1, 0)
migration_ceiling = _role_database_ceiling(environment, "MIGRATION", 2, 0)
worker_processes = sum(
pool.replicas * (pool.concurrency + 1) for pool in worker_pools
)
steady = (
spec.replicas.api * api_ceiling
+ worker_processes * worker_ceiling
+ (scheduler_ceiling if worker_pools else 0)
)
rollout_surge = api_ceiling + migration_ceiling
if worker_pools:
rollout_surge += scheduler_ceiling
rollout_surge += sum(
(pool.concurrency + 1) * worker_ceiling for pool in worker_pools
)
peak = steady + rollout_surge
available = limit - reserve
if peak > available:
raise ValueError(
"Kubernetes database connection peak exceeds the configured budget: "
f"peak={peak}, available={available}, limit={limit}, reserve={reserve}"
)
return {
"limit": limit,
"reserve": reserve,
"available": available,
"steady": steady,
"peak": peak,
}
def _role_database_ceiling(
environment: Mapping[str, str],
role: str,
default_pool_size: int,
default_overflow: int,
) -> int:
return _bounded_environment_integer(
environment,
f"GOVOPLAN_{role}_DB_POOL_SIZE",
default=default_pool_size,
minimum=1,
maximum=100,
) + _bounded_environment_integer(
environment,
f"GOVOPLAN_{role}_DB_MAX_OVERFLOW",
default=default_overflow,
minimum=0,
maximum=200,
)
def _role_database_environment(
environment: Mapping[str, str],
role: str,
) -> dict[str, str]:
defaults = {
"API": (5, 2),
"WORKER": (1, 1),
"SCHEDULER": (1, 0),
"MIGRATION": (2, 0),
}
pool_size, overflow = defaults[role]
return {
"GOVOPLAN_DB_POOL_SIZE": str(
_bounded_environment_integer(
environment,
f"GOVOPLAN_{role}_DB_POOL_SIZE",
default=pool_size,
minimum=1,
maximum=100,
)
),
"GOVOPLAN_DB_MAX_OVERFLOW": str(
_bounded_environment_integer(
environment,
f"GOVOPLAN_{role}_DB_MAX_OVERFLOW",
default=overflow,
minimum=0,
maximum=200,
)
),
}
def _bounded_environment_integer(
environment: Mapping[str, str],
name: str,
*,
default: int | None = None,
minimum: int,
maximum: int,
) -> int:
raw = environment.get(name)
if raw is None or not str(raw).strip():
if default is None:
raise ValueError(f"{name} is required")
return default
try:
value = int(str(raw))
except ValueError as exc:
raise ValueError(f"{name} must be an integer") from exc
if not minimum <= value <= maximum:
raise ValueError(f"{name} must be between {minimum} and {maximum}")
return value
def _deployment(
*,
name: str,
@@ -315,8 +639,13 @@ def _deployment(
liveness_path: str | None = None,
probe_host: str | None = None,
extra_environment: Mapping[str, str] | None = None,
selector_labels: Mapping[str, str] | None = None,
) -> dict[str, Any]:
role_labels = {**labels, "app.kubernetes.io/component": role}
role_labels = {
**labels,
"app.kubernetes.io/component": role,
**dict(selector_labels or {}),
}
environment: list[dict[str, Any]] = [
{"name": "TMPDIR", "value": "/tmp"},
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": role},
@@ -400,6 +729,8 @@ def _deployment(
"env": [
{"name": "TMPDIR", "value": "/tmp"},
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": "migration-wait"},
{"name": "GOVOPLAN_DB_POOL_SIZE", "value": "1"},
{"name": "GOVOPLAN_DB_MAX_OVERFLOW", "value": "0"},
*_secret_environment(secret_name),
],
"securityContext": {
@@ -439,9 +770,29 @@ def _migration_job(
config_name: str,
secret_name: str,
service_account: str,
database_environment: Mapping[str, str],
backup_required: bool,
backup_evidence: Mapping[str, object] | None,
) -> dict[str, Any]:
job_labels = {**labels, "app.kubernetes.io/component": "migration"}
job_name = _name_with_suffix(name, f"migrate-{release_key}")
backup_annotations = {
"govoplan.add-ideas.de/backup-required": str(backup_required).lower(),
}
if backup_evidence is not None:
backup_annotations.update(
{
"govoplan.add-ideas.de/backup-evidence-sha256": str(
backup_evidence["evidence_sha256"]
),
"govoplan.add-ideas.de/recovery-point": str(
backup_evidence["recovery_point_id"]
),
"govoplan.add-ideas.de/restore-drill": str(
backup_evidence["restore_drill_id"]
),
}
)
return {
"apiVersion": "batch/v1",
"kind": "Job",
@@ -451,7 +802,7 @@ def _migration_job(
"labels": job_labels,
"annotations": {
"govoplan.add-ideas.de/recovery-mode": "forward-recovery",
"govoplan.add-ideas.de/backup-required": "true",
**backup_annotations,
"argocd.argoproj.io/sync-wave": "-1",
},
},
@@ -481,6 +832,12 @@ def _migration_job(
"env": [
{"name": "TMPDIR", "value": "/tmp"},
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": "migration"},
*(
{"name": key, "value": value}
for key, value in sorted(
database_environment.items()
)
),
{
"name": "GOVOPLAN_NODE_ID",
"valueFrom": {
@@ -541,8 +898,14 @@ def _pod_disruption_budget(
namespace: str,
labels: dict[str, str],
role: str,
*,
selector_labels: Mapping[str, str] | None = None,
) -> dict[str, Any]:
role_labels = {**labels, "app.kubernetes.io/component": role}
role_labels = {
**labels,
"app.kubernetes.io/component": role,
**dict(selector_labels or {}),
}
return {
"apiVersion": "policy/v1",
"kind": "PodDisruptionBudget",
+175
View File
@@ -14,6 +14,7 @@ from urllib.parse import urlsplit
SCHEMA_VERSION = 1
DEFAULT_GARAGE_IMAGE = "dxflrs/garage:v2.3.0"
DEFAULT_LOAD_BALANCER_IMAGE = "haproxy:3.2.21-alpine"
DEFAULT_INGRESS_IMAGE = "caddy:2.10.2-alpine"
INSTALLATION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]{1,47}$")
ENV_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$")
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
@@ -41,6 +42,7 @@ FULL_MODULES = (
*BASE_MODULES,
"addresses",
"dist_lists",
"templates",
"files",
"mail",
"campaigns",
@@ -76,6 +78,9 @@ class ReleaseConfig:
version: str
manifest_url: str
manifest_sha256: str
manifest_keyring_sha256: str
manifest_signature_key_id: str
composition_sha256: str
api_image: str
web_image: str
@@ -109,6 +114,16 @@ class ReplicaConfig:
worker: int
@dataclass(frozen=True, slots=True)
class IngressConfig:
mode: str
image: str
trusted_proxy_cidrs: tuple[str, ...]
http_port: int
https_port: int
acme_email: str
@dataclass(frozen=True, slots=True)
class InstallationSpec:
schema_version: int
@@ -120,11 +135,13 @@ class InstallationSpec:
release: ReleaseConfig
components: ComponentConfig
replicas: ReplicaConfig
ingress: IngressConfig
enabled_modules: tuple[str, ...]
def to_dict(self) -> dict[str, Any]:
value = asdict(self)
value["enabled_modules"] = list(self.enabled_modules)
value["ingress"]["trusted_proxy_cidrs"] = list(self.ingress.trusted_proxy_cidrs)
return value
@@ -141,6 +158,12 @@ def default_spec(
storage_mode: str = "local",
garage_image: str = DEFAULT_GARAGE_IMAGE,
load_balancer_image: str = DEFAULT_LOAD_BALANCER_IMAGE,
ingress_mode: str | None = None,
ingress_image: str = DEFAULT_INGRESS_IMAGE,
trusted_proxy_cidrs: tuple[str, ...] = (),
ingress_http_port: int = 80,
ingress_https_port: int = 443,
acme_email: str = "",
api_replicas: int = 1,
web_replicas: int = 1,
worker_replicas: int | None = None,
@@ -177,6 +200,9 @@ def default_spec(
"version": version,
"manifest_url": manifest_url,
"manifest_sha256": manifest_sha256,
"manifest_keyring_sha256": "",
"manifest_signature_key_id": "",
"composition_sha256": "",
"api_image": api_image,
"web_image": web_image,
},
@@ -211,6 +237,15 @@ def default_spec(
"web": web_replicas,
"worker": effective_worker_replicas,
},
"ingress": {
"mode": ingress_mode
or ("unconfigured" if profile == "self-hosted" else "local"),
"image": ingress_image if ingress_mode == "managed" else "",
"trusted_proxy_cidrs": list(trusted_proxy_cidrs),
"http_port": ingress_http_port,
"https_port": ingress_https_port,
"acme_email": acme_email,
},
"enabled_modules": list(modules),
}
return parse_spec(raw)
@@ -240,6 +275,7 @@ def parse_spec(raw: object) -> InstallationSpec:
"release",
"components",
"replicas",
"ingress",
"enabled_modules",
},
"installation",
@@ -274,6 +310,17 @@ def parse_spec(raw: object) -> InstallationSpec:
release = _release(root.get("release"))
components = _components(root.get("components"), profile=profile)
replicas = _replicas(root.get("replicas"), components=components)
ingress = _ingress(root.get("ingress"), profile=profile)
if ingress.mode == "managed":
try:
ipaddress.ip_address(public_parts.hostname or "")
except ValueError:
pass
else:
raise SpecError("managed ingress requires a DNS hostname in public_url")
public_port = public_parts.port or 443
if public_port != ingress.https_port:
raise SpecError("managed ingress HTTPS port must match the public_url port")
enabled_raw = root.get("enabled_modules")
if not isinstance(enabled_raw, list):
@@ -300,6 +347,7 @@ def parse_spec(raw: object) -> InstallationSpec:
release=release,
components=components,
replicas=replicas,
ingress=ingress,
enabled_modules=tuple(enabled_modules),
)
@@ -313,6 +361,9 @@ def _release(raw: object) -> ReleaseConfig:
"version",
"manifest_url",
"manifest_sha256",
"manifest_keyring_sha256",
"manifest_signature_key_id",
"composition_sha256",
"api_image",
"web_image",
},
@@ -334,6 +385,23 @@ def _release(raw: object) -> ReleaseConfig:
raise SpecError(
"release.manifest_sha256 must be a lowercase SHA-256 hex digest"
)
manifest_keyring_sha256 = _optional_string(value, "manifest_keyring_sha256").lower()
if manifest_keyring_sha256 and not SHA256_PATTERN.fullmatch(
manifest_keyring_sha256
):
raise SpecError(
"release.manifest_keyring_sha256 must be a lowercase SHA-256 hex digest"
)
manifest_signature_key_id = _optional_string(value, "manifest_signature_key_id")
if manifest_signature_key_id and not re.fullmatch(
r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", manifest_signature_key_id
):
raise SpecError("release.manifest_signature_key_id is invalid")
composition_sha256 = _optional_string(value, "composition_sha256").lower()
if composition_sha256 and not SHA256_PATTERN.fullmatch(composition_sha256):
raise SpecError(
"release.composition_sha256 must be a lowercase SHA-256 hex digest"
)
api_image = _image(_string(value, "api_image"), "release.api_image")
web_image = _image(_string(value, "web_image"), "release.web_image")
return ReleaseConfig(
@@ -341,6 +409,9 @@ def _release(raw: object) -> ReleaseConfig:
version=version,
manifest_url=manifest_url,
manifest_sha256=manifest_sha256,
manifest_keyring_sha256=manifest_keyring_sha256,
manifest_signature_key_id=manifest_signature_key_id,
composition_sha256=composition_sha256,
api_image=api_image,
web_image=web_image,
)
@@ -449,6 +520,84 @@ def _replicas(raw: object, *, components: ComponentConfig) -> ReplicaConfig:
return replicas
def _ingress(raw: object, *, profile: str) -> IngressConfig:
if raw is None:
return IngressConfig(
mode="unconfigured" if profile == "self-hosted" else "local",
image="",
trusted_proxy_cidrs=(),
http_port=80,
https_port=443,
acme_email="",
)
value = _mapping(raw, "ingress")
_only_keys(
value,
{
"mode",
"image",
"trusted_proxy_cidrs",
"http_port",
"https_port",
"acme_email",
},
"ingress",
)
mode = _choice(
value,
"mode",
{"local", "existing-proxy", "managed", "unconfigured"},
)
image = _optional_string(value, "image")
raw_cidrs = value.get("trusted_proxy_cidrs", [])
if not isinstance(raw_cidrs, list) or len(raw_cidrs) > 16:
raise SpecError(
"ingress.trusted_proxy_cidrs must be an array of at most 16 networks"
)
cidrs: list[str] = []
for item in raw_cidrs:
if not isinstance(item, str):
raise SpecError("ingress.trusted_proxy_cidrs must contain strings")
cidrs.append(_trusted_proxy_network(item))
if len(set(cidrs)) != len(cidrs):
raise SpecError("ingress.trusted_proxy_cidrs contains duplicates")
http_port = _port(_integer(value, "http_port"), "ingress.http_port")
https_port = _port(_integer(value, "https_port"), "ingress.https_port")
if http_port == https_port:
raise SpecError("ingress HTTP and HTTPS ports must differ")
acme_email = _optional_string(value, "acme_email")
if acme_email and (
len(acme_email) > 254 or re.fullmatch(r"[^@\s]+@[^@\s]+", acme_email) is None
):
raise SpecError("ingress.acme_email must be a valid email address")
if profile == "self-hosted" and mode == "local":
raise SpecError(
"self-hosted installations require existing-proxy or managed ingress"
)
if profile == "evaluation" and mode == "unconfigured":
raise SpecError("evaluation installations cannot use unconfigured ingress")
if mode == "managed":
image = _image(image or DEFAULT_INGRESS_IMAGE, "ingress.image")
if not acme_email:
raise SpecError("managed ingress requires ingress.acme_email")
elif image:
raise SpecError("ingress.image is only valid for managed ingress")
if mode == "existing-proxy" and not cidrs:
raise SpecError("existing-proxy ingress requires a trusted proxy CIDR")
if mode != "existing-proxy" and cidrs:
raise SpecError("trusted proxy CIDRs are only valid for existing-proxy ingress")
if mode != "managed" and acme_email:
raise SpecError("ingress.acme_email is only valid for managed ingress")
return IngressConfig(
mode=mode,
image=image,
trusted_proxy_cidrs=tuple(cidrs),
http_port=http_port,
https_port=https_port,
acme_email=acme_email,
)
def _service(
raw: object,
label: str,
@@ -580,6 +729,32 @@ def _network(value: str, label: str) -> str:
return str(network)
def _trusted_proxy_network(value: str) -> str:
try:
network = ipaddress.ip_network(value.strip(), strict=True)
except ValueError as exc:
raise SpecError(
"ingress.trusted_proxy_cidrs must contain canonical IP networks"
) from exc
if network.is_unspecified or network.is_multicast:
raise SpecError("trusted proxy CIDR cannot be unspecified or multicast")
if network.version == 4:
if network.is_global and network.prefixlen != 32:
raise SpecError("a public trusted proxy must be an exact IPv4 address")
if not network.is_global and network.prefixlen < 24:
raise SpecError(
"a private trusted IPv4 proxy network must be /24 or narrower"
)
else:
if network.is_global and network.prefixlen != 128:
raise SpecError("a public trusted proxy must be an exact IPv6 address")
if not network.is_global and network.prefixlen < 64:
raise SpecError(
"a private trusted IPv6 proxy network must be /64 or narrower"
)
return str(network)
def _port(value: int, label: str) -> int:
if value < 1 or value > 65535:
raise SpecError(f"{label} must be between 1 and 65535")
+654 -46
View File
@@ -3,25 +3,50 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
import hashlib
import json
import os
from pathlib import Path
import platform
import shutil
import socket
import ssl
import stat
import subprocess
import time
from typing import Callable, Mapping, Sequence
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
from .backup_evidence import (
MAX_BACKUP_EVIDENCE_BYTES,
MAX_BACKUP_KEYRING_BYTES,
verify_backup_evidence,
)
from .bundle import (
BundlePaths,
canonical_json,
digest_json,
environment_fingerprint,
read_env,
render_caddy_config,
render_compose,
render_existing_proxy_contract,
service_names,
)
from .distribution import (
MAX_KEYRING_BYTES,
MAX_MANIFEST_BYTES,
DistributionError,
canonical_json as canonical_distribution_json,
decode_json_bytes,
file_sha256,
load_bounded_json,
read_bounded_bytes,
verify_manifest,
verify_manifest_binding,
)
from .model import (
InstallationSpec,
image_is_digest_pinned,
@@ -169,6 +194,7 @@ def build_plan(
def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ...]:
checks: list[Check] = []
checks.extend(_ingress_configuration_checks(spec, paths))
images = {
"release.api_image": spec.release.api_image,
"release.web_image": spec.release.web_image,
@@ -182,6 +208,8 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ..
if spec.components.storage.mode == "garage":
images["components.storage.image"] = spec.components.storage.image
images["components.load_balancer.image"] = spec.components.load_balancer.image
if spec.ingress.mode == "managed":
images["ingress.image"] = spec.ingress.image
for label, image in images.items():
if image_is_unpublished(image):
@@ -212,39 +240,9 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ..
)
)
if spec.release.manifest_url and spec.release.manifest_sha256:
checks.append(
Check(
"release.manifest",
"ok",
"A distribution manifest URL and expected digest are recorded.",
)
)
else:
checks.append(
Check(
"release.manifest",
"error" if spec.profile == "self-hosted" else "warning",
"No verified distribution manifest is recorded.",
"Use a published signed distribution manifest for self-hosted apply.",
)
)
if spec.profile == "self-hosted":
checks.append(
Check(
"release.signature_verification",
"error",
"Signed distribution-manifest verification is not implemented in the deployer yet.",
"Use the published verifier/bootstrap slice before a production apply.",
)
)
checks.append(
Check(
"modules.image_composition",
"error" if spec.enabled_modules else "warning",
"The selected module set is not yet verified against image package contents.",
"Use the signed distribution composition evidence before production apply.",
)
checks.extend(_distribution_checks(spec, paths))
checks.extend(
_backup_evidence_checks(spec, paths, receipt=_read_receipt(paths.receipt))
)
values = read_env(paths.env)
@@ -364,6 +362,401 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ..
return tuple(checks)
def release_change_requires_backup(
spec: InstallationSpec,
receipt: Mapping[str, object],
) -> bool:
if spec.profile != "self-hosted" or not receipt:
return False
previous = receipt.get("release")
if not isinstance(previous, Mapping):
return True
desired = {
"channel": spec.release.channel,
"version": spec.release.version,
"manifest_sha256": spec.release.manifest_sha256,
"composition_sha256": spec.release.composition_sha256,
"api_image": spec.release.api_image,
"web_image": spec.release.web_image,
}
return any(previous.get(key) != value for key, value in desired.items())
def verify_stored_backup_evidence(
spec: InstallationSpec,
paths: BundlePaths,
*,
receipt: Mapping[str, object],
) -> dict[str, object]:
verification = load_bounded_json(
paths.backup_verification,
maximum_bytes=64 * 1024,
)
expected_fields = {
"schema_version",
"evidence_sha256",
"keyring_sha256",
"signature_key_id",
"verified_at",
"evidence_id",
"recovery_point_id",
"restore_drill_id",
"release_manifest_sha256",
"captured_at",
"expires_at",
"restore_started_at",
"restore_completed_at",
"measured_rpo_seconds",
"measured_rto_seconds",
"component_count",
}
if set(verification) != expected_fields or verification.get("schema_version") != 1:
raise DistributionError("backup verification receipt is malformed")
encoded_evidence = read_bounded_bytes(
paths.backup_evidence,
maximum_bytes=MAX_BACKUP_EVIDENCE_BYTES,
)
encoded_keyring = read_bounded_bytes(
paths.backup_keyring,
maximum_bytes=MAX_BACKUP_KEYRING_BYTES,
)
evidence = decode_json_bytes(encoded_evidence, label="backup evidence")
keyring = decode_json_bytes(encoded_keyring, label="backup keyring")
if encoded_evidence != canonical_distribution_json(evidence):
raise DistributionError("stored backup evidence is not canonical JSON")
if encoded_keyring != canonical_distribution_json(keyring):
raise DistributionError("stored backup keyring is not canonical JSON")
evidence_digest = hashlib.sha256(encoded_evidence).hexdigest()
keyring_digest = hashlib.sha256(encoded_keyring).hexdigest()
if evidence_digest != verification.get("evidence_sha256"):
raise DistributionError("stored backup evidence digest has changed")
if keyring_digest != verification.get("keyring_sha256"):
raise DistributionError("stored backup keyring digest has changed")
previous_release = receipt.get("release") if receipt else None
expected_release: Mapping[str, object] = (
previous_release
if isinstance(previous_release, Mapping)
else {
"channel": spec.release.channel,
"version": spec.release.version,
"manifest_sha256": spec.release.manifest_sha256,
"composition_sha256": spec.release.composition_sha256,
"api_image": spec.release.api_image,
"web_image": spec.release.web_image,
}
)
summary = verify_backup_evidence(
evidence,
keyring,
installation_id=spec.installation_id,
profile=spec.profile,
release=expected_release,
)
expected_summary = {
"signature_key_id": verification.get("signature_key_id"),
"evidence_id": verification.get("evidence_id"),
"recovery_point_id": verification.get("recovery_point_id"),
"restore_drill_id": verification.get("restore_drill_id"),
"captured_at": verification.get("captured_at"),
"expires_at": verification.get("expires_at"),
"restore_started_at": verification.get("restore_started_at"),
"restore_completed_at": verification.get("restore_completed_at"),
"measured_rpo_seconds": verification.get("measured_rpo_seconds"),
"measured_rto_seconds": verification.get("measured_rto_seconds"),
"component_count": verification.get("component_count"),
}
for field, expected in expected_summary.items():
if summary.get(field) != expected:
raise DistributionError(
f"backup verification receipt does not match {field!r}"
)
if expected_release.get("manifest_sha256") != verification.get(
"release_manifest_sha256"
):
raise DistributionError("backup verification receipt has another release")
return {
**summary,
"evidence_sha256": evidence_digest,
"keyring_sha256": keyring_digest,
}
def _backup_evidence_checks(
spec: InstallationSpec,
paths: BundlePaths,
*,
receipt: Mapping[str, object],
) -> tuple[Check, ...]:
required = release_change_requires_backup(spec, receipt)
available = all(
path.is_file()
for path in (
paths.backup_evidence,
paths.backup_keyring,
paths.backup_verification,
)
)
if not available:
return (
Check(
"backup.migration_gate",
"error"
if required
else "warning"
if spec.profile == "self-hosted"
else "ok",
(
"A release-changing migration has no verified coordinated backup evidence."
if required
else "No current coordinated backup evidence is adopted."
),
(
"Run verify-backup --adopt after an isolated restore drill."
if spec.profile == "self-hosted"
else ""
),
),
)
try:
summary = verify_stored_backup_evidence(
spec,
paths,
receipt=receipt,
)
except (DistributionError, OSError) as exc:
return (
Check(
"backup.migration_gate",
"error" if required else "warning",
f"Coordinated backup evidence is invalid: {exc}",
"Adopt fresh signed evidence for the currently applied release.",
),
)
return (
Check(
"backup.migration_gate",
"ok",
(
"Release migration is backed by recovery point "
f"{summary['recovery_point_id']} and restore drill "
f"{summary['restore_drill_id']}."
),
),
)
def _ingress_configuration_checks(
spec: InstallationSpec,
paths: BundlePaths,
) -> tuple[Check, ...]:
if spec.ingress.mode == "unconfigured":
return (
Check(
"ingress.configuration",
"error" if spec.profile == "self-hosted" else "warning",
"No supported public ingress boundary is configured.",
"Select managed ingress or an existing reverse proxy before apply.",
),
)
checks = [
Check(
"ingress.configuration",
"ok",
f"Ingress mode {spec.ingress.mode!r} has a bounded configuration.",
)
]
if spec.ingress.mode == "managed":
checks.append(
_artifact_check(
"ingress.managed_config",
paths.caddy_config,
render_caddy_config(spec).encode("utf-8"),
"Managed ingress configuration",
)
)
ingress = render_compose(spec)["services"].get("ingress", {})
volumes = ingress.get("volumes", []) if isinstance(ingress, dict) else []
persistent = "caddy-data:/data" in volumes and "caddy-config:/config" in volumes
checks.append(
Check(
"ingress.certificate_state",
"ok" if persistent else "error",
(
"Managed certificate and renewal state uses persistent private volumes."
if persistent
else "Managed certificate state is not persistent."
),
"Restore the caddy-data and caddy-config volume bindings."
if not persistent
else "",
)
)
elif spec.ingress.mode == "existing-proxy":
checks.append(
_artifact_check(
"ingress.existing_proxy_contract",
paths.existing_proxy,
canonical_json(render_existing_proxy_contract(spec)),
"Existing reverse-proxy contract",
)
)
return tuple(checks)
def _artifact_check(
check_id: str,
path: Path,
expected: bytes,
label: str,
) -> Check:
try:
actual = path.read_bytes()
except OSError as exc:
return Check(
check_id,
"error",
f"{label} is unavailable: {exc}",
"Re-render the installation bundle.",
)
matches = actual == expected
return Check(
check_id,
"ok" if matches else "error",
f"{label} {'matches' if matches else 'does not match'} the installation specification.",
"Re-render the installation bundle before apply." if not matches else "",
)
def _distribution_checks(
spec: InstallationSpec,
paths: BundlePaths,
) -> tuple[Check, ...]:
blocking_level = "error" if spec.profile == "self-hosted" else "warning"
if not (
spec.release.manifest_sha256
and spec.release.manifest_keyring_sha256
and spec.release.manifest_signature_key_id
and spec.release.composition_sha256
and paths.manifest.exists()
and paths.keyring.exists()
):
return (
Check(
"release.manifest",
blocking_level,
"No locally verified runtime distribution is recorded.",
"Run govoplan-deploy verify-release --adopt with an independently trusted keyring.",
),
Check(
"release.signature_verification",
blocking_level,
"Runtime distribution signature evidence is unavailable.",
"Install and verify the signed distribution before apply.",
),
Check(
"modules.image_composition",
blocking_level,
"Enabled modules are not bound to image composition evidence.",
"Adopt a distribution whose composition contains every enabled module.",
),
)
try:
manifest_digest = file_sha256(
paths.manifest,
maximum_bytes=MAX_MANIFEST_BYTES,
)
if manifest_digest != spec.release.manifest_sha256:
raise DistributionError(
"stored manifest digest does not match installation"
)
keyring_digest = file_sha256(
paths.keyring,
maximum_bytes=MAX_KEYRING_BYTES,
)
if keyring_digest != spec.release.manifest_keyring_sha256:
raise DistributionError("stored keyring digest does not match installation")
manifest = load_bounded_json(
paths.manifest,
maximum_bytes=MAX_MANIFEST_BYTES,
)
keyring = load_bounded_json(
paths.keyring,
maximum_bytes=MAX_KEYRING_BYTES,
)
key_id = verify_manifest(
manifest,
keyring,
expected_channel=spec.release.channel,
)
if key_id != spec.release.manifest_signature_key_id:
raise DistributionError(
"verified signature key does not match installation"
)
verify_manifest_binding(
manifest,
channel=spec.release.channel,
version=spec.release.version,
api_image=spec.release.api_image,
web_image=spec.release.web_image,
enabled_modules=spec.enabled_modules,
composition_sha256=spec.release.composition_sha256,
dependencies=_selected_dependency_images(spec),
)
except (DistributionError, OSError) as exc:
return (
Check(
"release.manifest",
blocking_level,
f"Runtime distribution verification failed: {exc}",
"Re-adopt an unexpired, non-revoked manifest from a trusted release key.",
),
Check(
"release.signature_verification",
blocking_level,
"Runtime distribution signature is not trusted.",
"Correct the manifest/keyring binding before apply.",
),
Check(
"modules.image_composition",
blocking_level,
"Runtime image composition is not trusted.",
"Correct the signed composition binding before apply.",
),
)
return (
Check(
"release.manifest",
"ok",
"Stored runtime distribution matches its independently pinned digest.",
),
Check(
"release.signature_verification",
"ok",
f"Runtime distribution is signed by trusted key {key_id}.",
),
Check(
"modules.image_composition",
"ok",
"Every enabled module is present in signed image composition evidence.",
),
)
def _selected_dependency_images(spec: InstallationSpec) -> dict[str, str]:
values = {"load_balancer": spec.components.load_balancer.image}
if spec.components.postgres.mode == "managed":
values["postgres"] = spec.components.postgres.image
if spec.components.redis.mode == "managed":
values["redis"] = spec.components.redis.image
if spec.components.mail.mode == "test-mail":
values["test_mail"] = spec.components.mail.image
if spec.components.storage.mode == "garage":
values["garage"] = spec.components.storage.image
if spec.ingress.mode == "managed":
values["managed_ingress"] = spec.ingress.image
return values
def host_checks(
spec: InstallationSpec,
paths: BundlePaths,
@@ -371,6 +764,7 @@ def host_checks(
command_runner: CommandRunner | None = None,
) -> tuple[Check, ...]:
checks: list[Check] = []
runner = command_runner or _run_command
machine = platform.machine().lower()
supported = machine in {"x86_64", "amd64", "aarch64", "arm64"}
checks.append(
@@ -443,7 +837,6 @@ def host_checks(
)
)
else:
runner = command_runner or _run_command
result = runner((docker, "compose", "version", "--short"), paths.root)
checks.append(
Check(
@@ -508,30 +901,245 @@ def host_checks(
)
receipt = _read_receipt(paths.receipt)
checks.extend(
_ingress_host_checks(
spec,
paths,
receipt=receipt,
docker=docker,
command_runner=runner,
)
)
return tuple(checks)
def _ingress_host_checks(
spec: InstallationSpec,
paths: BundlePaths,
*,
receipt: Mapping[str, object],
docker: str | None,
command_runner: CommandRunner,
) -> tuple[Check, ...]:
checks: list[Check] = []
applied = bool(receipt)
if spec.ingress.mode in {"managed", "existing-proxy"}:
public = urlsplit(spec.public_url)
host = public.hostname or ""
port = public.port or 443
checks.append(_dns_resolution_check(host, port))
if spec.ingress.mode == "managed" and not applied:
checks.append(
Check(
"ingress.tls",
"warning",
"TLS issuance will be verified after managed ingress starts.",
"Ensure public DNS resolves to this host and ports 80/443 are reachable.",
)
)
checks.append(
Check(
"ingress.public_route",
"warning",
"Public-route health will be verified after managed ingress starts.",
"Permit inbound HTTP and HTTPS through the host firewall and upstream NAT.",
)
)
else:
checks.append(_tls_validity_check(host, port))
checks.append(
_public_route_check(
spec.public_url,
require_ready=applied,
)
)
previous_ingress = receipt.get("ingress")
desired_ingress = {
"mode": spec.ingress.mode,
"http_port": spec.ingress.http_port,
"https_port": spec.ingress.https_port,
}
previous_listen = receipt.get("listen")
desired_listen = {
"address": spec.listen.address,
"port": spec.listen.port,
}
if not receipt or previous_listen != desired_listen:
available = _port_available(spec.listen.address, spec.listen.port)
if spec.ingress.mode == "managed":
if not applied or previous_ingress != desired_ingress:
for label, port in (
("http", spec.ingress.http_port),
("https", spec.ingress.https_port),
):
checks.append(
Check(
_available_port_check(
f"host.ingress_{label}_port",
"0.0.0.0",
port,
)
)
elif not applied or previous_listen != desired_listen:
checks.append(
_available_port_check(
"host.listen_port",
"ok" if available else "error",
(
f"Listen endpoint {spec.listen.address}:{spec.listen.port} is available."
if available
else f"Listen endpoint {spec.listen.address}:{spec.listen.port} is already in use."
),
"Choose another listen port or stop the conflicting service."
if not available
else "",
spec.listen.address,
spec.listen.port,
)
)
if applied:
checks.append(
_local_upstream_check(
spec,
paths,
docker=docker,
command_runner=command_runner,
)
)
return tuple(checks)
def _available_port_check(check_id: str, address: str, port: int) -> Check:
available = _port_available(address, port)
return Check(
check_id,
"ok" if available else "error",
(
f"Listen endpoint {address}:{port} is available."
if available
else f"Listen endpoint {address}:{port} is already in use."
),
"Choose another port or stop the conflicting service." if not available else "",
)
def _dns_resolution_check(host: str, port: int) -> Check:
try:
results = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
addresses = sorted({str(item[4][0]) for item in results})
except OSError as exc:
return Check(
"ingress.dns",
"error",
f"Public hostname {host!r} does not resolve: {exc}",
"Publish public A/AAAA records before apply.",
)
return Check(
"ingress.dns",
"ok",
f"Public hostname {host!r} resolves to {', '.join(addresses[:8])}.",
)
def _tls_validity_check(host: str, port: int) -> Check:
try:
context = ssl.create_default_context()
with socket.create_connection((host, port), timeout=3.0) as connection:
with context.wrap_socket(connection, server_hostname=host) as secured:
certificate = secured.getpeercert()
expires = str(certificate.get("notAfter") or "")
remaining_seconds = ssl.cert_time_to_seconds(expires) - time.time()
except (OSError, ValueError, ssl.SSLError) as exc:
return Check(
"ingress.tls",
"error",
f"Public TLS validation failed for {host}:{port}: {exc}",
"Correct certificate issuance, trust chain, hostname, and public routing.",
)
remaining_days = int(remaining_seconds // 86400)
level = "ok" if remaining_days >= 21 else "warning"
return Check(
"ingress.tls",
level,
f"Public TLS certificate is valid for approximately {remaining_days} more day(s).",
"Verify automated certificate renewal immediately."
if level == "warning"
else "",
)
def _public_route_check(public_url: str, *, require_ready: bool) -> Check:
target = public_url.rstrip("/") + "/health/ready"
status: int | None = None
try:
request = Request(target, headers={"User-Agent": "govoplan-deploy/doctor"})
with urlopen(request, timeout=4.0) as response:
status = response.status
except HTTPError as exc:
status = exc.code
except (OSError, URLError, ValueError) as exc:
return Check(
"ingress.public_route",
"error",
f"Public route is unreachable: {exc}",
"Check external DNS, firewall/NAT, reverse-proxy routing, and TLS.",
)
acceptable = status == 200 if require_ready else status not in {400, 404, 421}
return Check(
"ingress.public_route",
"ok" if acceptable else "error",
f"Public readiness route returned HTTP {status}.",
(
"Route the configured hostname and /health/ready path to the generated upstream."
if not acceptable
else ""
),
)
def _local_upstream_check(
spec: InstallationSpec,
paths: BundlePaths,
*,
docker: str | None,
command_runner: CommandRunner,
) -> Check:
if docker is None:
return Check(
"ingress.local_upstream",
"error",
"Local upstream health cannot be checked without Docker.",
"Restore Docker access and rerun doctor.",
)
argv = (
docker,
"compose",
"--env-file",
str(paths.env),
"--project-name",
spec.installation_id,
"--file",
str(paths.compose),
"exec",
"--no-TTY",
"load-balancer",
"wget",
"-qO-",
"http://127.0.0.1:8080/health",
)
try:
result = command_runner(argv, paths.root)
except (OSError, subprocess.SubprocessError) as exc:
return Check(
"ingress.local_upstream",
"error",
f"Local upstream health probe failed: {exc}",
"Inspect the load-balancer and WebUI service health.",
)
return Check(
"ingress.local_upstream",
"ok" if result.returncode == 0 else "error",
(
"The generated local upstream is healthy."
if result.returncode == 0
else "The generated local upstream health probe failed."
),
"Inspect load-balancer and WebUI health before exposing the route."
if result.returncode != 0
else "",
)
def _read_receipt(path: Path) -> Mapping[str, object]:
if not path.exists():
return {}
@@ -25,6 +25,13 @@ _BUNDLE_FILES = (
"compose.json",
"garage.toml",
"load-balancer.cfg",
"Caddyfile",
"existing-proxy.json",
"distribution-manifest.json",
"distribution-keyring.json",
"backup-evidence.json",
"backup-keyring.json",
"backup-verification.json",
"receipt.json",
)
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Sign and validate provider-produced GovOPlaN backup evidence."""
from __future__ import annotations
import argparse
import base64
import hashlib
from pathlib import Path
import re
import stat
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from govoplan_deploy.backup_evidence import (
MAX_BACKUP_EVIDENCE_BYTES,
load_backup_keyring,
verify_backup_evidence,
)
from govoplan_deploy.bundle import atomic_write
from govoplan_deploy.distribution import (
canonical_json,
canonical_signed_payload,
load_bounded_json,
)
KEY_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Sign a provider-produced backup/restore evidence document and "
"validate it against an independently managed public keyring."
)
)
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--trusted-keyring", type=Path, required=True)
parser.add_argument(
"--signing-key",
action="append",
required=True,
metavar="KEY_ID=PRIVATE_PEM",
help="Ed25519 signer; may be repeated during key rotation.",
)
parser.add_argument(
"--replace-signatures",
action="store_true",
help="Replace existing signatures instead of rejecting the input.",
)
args = parser.parse_args()
source = args.input.expanduser().resolve()
payload = load_bounded_json(source, maximum_bytes=MAX_BACKUP_EVIDENCE_BYTES)
existing = payload.get("signatures")
if existing not in (None, []) and not args.replace_signatures:
raise SystemExit("input already contains signatures; use --replace-signatures")
signers = [_load_signer(value) for value in args.signing_key]
if len({key_id for key_id, _ in signers}) != len(signers):
raise SystemExit("duplicate signing key id")
payload["signatures"] = []
signed = canonical_signed_payload(payload)
payload["signatures"] = [
{
"key_id": key_id,
"algorithm": "ed25519",
"value": base64.b64encode(private_key.sign(signed)).decode("ascii"),
}
for key_id, private_key in signers
]
keyring = load_backup_keyring(args.trusted_keyring.expanduser().resolve())
release = payload.get("release")
if not isinstance(release, dict):
raise SystemExit("input release must be an object")
verify_backup_evidence(
payload,
keyring,
installation_id=str(payload.get("installation_id") or ""),
profile=str(
_object(payload.get("deployment_subject"), "deployment_subject").get(
"profile"
)
or ""
),
release=release,
)
encoded = canonical_json(payload)
output = args.output.expanduser().resolve()
atomic_write(output, encoded, mode=0o600)
print(f"Wrote {output}")
print(f"SHA256 {hashlib.sha256(encoded).hexdigest()}")
return 0
def _load_signer(value: str) -> tuple[str, Ed25519PrivateKey]:
key_id, separator, raw_path = value.partition("=")
if not separator or KEY_ID.fullmatch(key_id) is None or not raw_path:
raise SystemExit("--signing-key must use KEY_ID=/path/to/private.pem")
path = Path(raw_path).expanduser().resolve()
mode = stat.S_IMODE(path.stat().st_mode)
if mode & 0o077:
raise SystemExit(
f"private signing key must not be group/world accessible: {path}"
)
private_key = serialization.load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(private_key, Ed25519PrivateKey):
raise SystemExit(f"signing key is not Ed25519: {path}")
return key_id, private_key
def _object(value: object, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise SystemExit(f"input {label} must be an object")
return value
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -13,7 +13,13 @@ const metaRoot = path.resolve(metaRootArgument);
const repositoryCatalog = JSON.parse(
fs.readFileSync(path.join(metaRoot, "repositories.json"), "utf8")
);
const workspaceRoot = path.resolve(repositoryCatalog.default_parent);
const siblingWorkspaceRoot = path.dirname(metaRoot);
const configuredWorkspaceRoot = path.resolve(repositoryCatalog.default_parent);
const workspaceRoot = fs.existsSync(
path.join(siblingWorkspaceRoot, "govoplan-core", "webui")
)
? siblingWorkspaceRoot
: configuredWorkspaceRoot;
const typescriptPath = path.join(
workspaceRoot,
"govoplan-core",
+213 -26
View File
@@ -19,6 +19,18 @@ from typing import Any
META_ROOT = Path(__file__).resolve().parents[2]
HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put"}
PATH_PARAMETER = re.compile(r"\$\{[^{}]*\}|\{[^{}]+\}")
ENDPOINT_SURFACE_CATEGORIES = {
"ui_reachable",
"intentionally_headless",
"public_integration",
"worker_internal",
"compatibility",
"missing_ui",
"removable",
}
DEFAULT_ENDPOINT_DECLARATIONS = (
META_ROOT / "tools" / "inventory" / "endpoint-surface-declarations.json"
)
def main() -> int:
@@ -31,21 +43,29 @@ def main() -> int:
parser.add_argument(
"--strict",
action="store_true",
help="Fail when a used translation key is absent from a generated locale catalog.",
help="Fail on missing translations or incomplete endpoint-surface declarations.",
)
parser.add_argument(
"--endpoint-declarations",
type=Path,
default=DEFAULT_ENDPOINT_DECLARATIONS,
help="Versioned endpoint-surface declaration registry.",
)
args = parser.parse_args()
catalog = json.loads(
(META_ROOT / "repositories.json").read_text(encoding="utf-8")
)
workspace_root = Path(catalog["default_parent"]).resolve()
catalog = json.loads((META_ROOT / "repositories.json").read_text(encoding="utf-8"))
workspace_root = _resolve_workspace_root(catalog)
webui = _extract_webui()
backend_endpoints = _extract_backend_endpoints(catalog, workspace_root)
manifests = _extract_manifests(catalog, workspace_root)
endpoint_declarations = _load_endpoint_declarations(
args.endpoint_declarations.resolve()
)
inventory = _assemble_inventory(
webui=webui,
backend_endpoints=backend_endpoints,
manifests=manifests,
endpoint_declarations=endpoint_declarations,
)
output_dir = args.output_dir.resolve()
@@ -60,15 +80,50 @@ def main() -> int:
print(f"Platform inventory JSON: {json_path}")
print(f"Platform inventory summary: {markdown_path}")
if args.strict and inventory["translation_health"]["missing_catalog_entries"]:
if args.strict:
failures: list[str] = []
if inventory["translation_health"]["missing_catalog_entries"]:
failures.append("used translation keys are missing from generated catalogs")
if inventory["api"]["unclassified_endpoints"]:
failures.append(
f"{len(inventory['api']['unclassified_endpoints'])} backend "
"endpoints have no WebUI evidence or surface declaration"
)
if inventory["api"]["stale_endpoint_declarations"]:
failures.append(
f"{len(inventory['api']['stale_endpoint_declarations'])} "
"endpoint declarations do not match a backend endpoint"
)
if failures:
print(
"Used translation keys are missing from generated catalogs.",
"Strict platform inventory failed: " + "; ".join(failures) + ".",
file=sys.stderr,
)
return 1
return 0
def _resolve_workspace_root(catalog: dict[str, Any]) -> Path:
sibling_root = META_ROOT.parent.resolve()
configured_root = Path(str(catalog["default_parent"])).expanduser().resolve()
repositories = catalog.get("repositories")
if not isinstance(repositories, list):
raise ValueError("repository catalog has no repositories array")
def source_count(root: Path) -> int:
return sum(
1
for item in repositories
if isinstance(item, dict)
and isinstance(item.get("path"), str)
and (root / item["path"] / "src").is_dir()
)
sibling_count = source_count(sibling_root)
configured_count = source_count(configured_root)
return sibling_root if sibling_count >= configured_count else configured_root
def _extract_webui() -> dict[str, Any]:
helper = META_ROOT / "tools" / "inventory" / "extract-webui-structure.mjs"
completed = subprocess.run(
@@ -167,7 +222,9 @@ def _endpoint_from_decorator(
route = _static_string(decorator.args[0])
if route is None:
return None
owner = decorator.func.value.id if isinstance(decorator.func.value, ast.Name) else ""
owner = (
decorator.func.value.id if isinstance(decorator.func.value, ast.Name) else ""
)
prefix = prefixes.get(owner, "")
return {
"method": method.upper(),
@@ -247,6 +304,7 @@ def _assemble_inventory(
webui: dict[str, Any],
backend_endpoints: list[dict[str, Any]],
manifests: list[dict[str, Any]],
endpoint_declarations: dict[tuple[str, str, str], dict[str, Any]],
) -> dict[str, Any]:
frontend_refs = webui["frontendApiReferences"]
frontend_paths = {
@@ -254,25 +312,61 @@ def _assemble_inventory(
for reference in frontend_refs
if canonical_api_path(reference["path"])
}
endpoint_keys = {endpoint_key(endpoint) for endpoint in backend_endpoints}
classified_endpoints: list[dict[str, Any]] = []
for endpoint in backend_endpoints:
key = endpoint_key(endpoint)
canonical_path = key[2]
static_webui_reference = canonical_path in frontend_paths
declaration = endpoint_declarations.get(key)
surface = (
{
"category": "ui_reachable",
"rationale": "A canonical API path reference exists in WebUI source.",
"source": "static_webui_scan",
}
if static_webui_reference
else (
{**declaration, "source": "declaration"}
if declaration is not None
else None
)
)
classified_endpoints.append(
{
**endpoint,
"canonical_path": canonical_path,
"static_webui_reference": static_webui_reference,
"surface": surface,
}
)
unreferenced = [
endpoint
for endpoint in backend_endpoints
if canonical_api_path(endpoint["path"]) not in frontend_paths
for endpoint in classified_endpoints
if not endpoint["static_webui_reference"]
]
unclassified = [
endpoint for endpoint in unreferenced if endpoint["surface"] is None
]
stale_declarations = [
declaration
for key, declaration in endpoint_declarations.items()
if key not in endpoint_keys
]
classification_counts = Counter(
endpoint["surface"]["category"]
for endpoint in classified_endpoints
if endpoint["surface"] is not None
)
usages = {item["key"] for item in webui["translationUsages"]}
catalogs = webui["translationCatalog"]
catalog_keys = {
locale: set(entries)
for locale, entries in catalogs.items()
}
catalog_keys = {locale: set(entries) for locale, entries in catalogs.items()}
expected_locales = sorted(catalog_keys)
missing_catalog_entries = [
{
"key": key,
"missing_locales": [
locale
for locale in expected_locales
if key not in catalog_keys[locale]
locale for locale in expected_locales if key not in catalog_keys[locale]
],
}
for key in sorted(usages)
@@ -311,9 +405,12 @@ def _assemble_inventory(
"missing_catalog_entries": missing_catalog_entries,
},
"api": {
"backend_endpoints": backend_endpoints,
"backend_endpoints": classified_endpoints,
"frontend_references": frontend_refs,
"unreferenced_by_static_webui_scan": unreferenced,
"unclassified_endpoints": unclassified,
"stale_endpoint_declarations": stale_declarations,
"classification_counts": dict(sorted(classification_counts.items())),
},
"summary": {
"modules": len(manifests),
@@ -326,6 +423,8 @@ def _assemble_inventory(
"backend_endpoints": len(backend_endpoints),
"frontend_api_references": len(frontend_refs),
"backend_endpoints_without_static_webui_reference": len(unreferenced),
"unclassified_backend_endpoints": len(unclassified),
"stale_endpoint_declarations": len(stale_declarations),
},
}
@@ -340,6 +439,7 @@ def _render_markdown(inventory: dict[str, Any]) -> str:
item["repository"]
for item in inventory["api"]["unreferenced_by_static_webui_scan"]
)
classification_counts = inventory["api"]["classification_counts"]
lines = [
"# GovOPlaN Platform Interface Inventory",
"",
@@ -360,6 +460,8 @@ def _render_markdown(inventory: dict[str, Any]) -> str:
"- Backend endpoints without a static WebUI reference: "
f"{summary['backend_endpoints_without_static_webui_reference']}"
),
f"- Unclassified backend endpoints: {summary['unclassified_backend_endpoints']}",
f"- Stale endpoint declarations: {summary['stale_endpoint_declarations']}",
f"- Used translation keys missing from a locale catalog: {len(missing)}",
"",
"## Help Review Candidates",
@@ -388,6 +490,19 @@ def _render_markdown(inventory: dict[str, Any]) -> str:
f"| `{repository}` | {count} |"
for repository, count in sorted(endpoint_by_repository.items())
)
lines.extend(
[
"",
"## Endpoint Surface Classifications",
"",
"| Classification | Endpoints |",
"| --- | ---: |",
]
)
lines.extend(
f"| `{category}` | {count} |"
for category, count in sorted(classification_counts.items())
)
lines.extend(
[
"",
@@ -408,11 +523,89 @@ def canonical_api_path(value: str) -> str:
if "/api/" in path:
path = path[path.index("/api/") :]
path = re.sub(r"^/api/v\d+", "", path)
path = re.sub(r"(?<=[^/])\$\{[^{}]*\}$", "", path)
path = PATH_PARAMETER.sub("{}", path)
path = re.sub(r"/+", "/", path)
return path.rstrip("/") or "/"
def endpoint_key(endpoint: dict[str, Any]) -> tuple[str, str, str]:
return (
str(endpoint["repository"]),
str(endpoint["method"]).upper(),
canonical_api_path(str(endpoint["path"])),
)
def _load_endpoint_declarations(
path: Path,
) -> dict[tuple[str, str, str], dict[str, Any]]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ValueError(
f"Endpoint declaration registry does not exist: {path}"
) from exc
except json.JSONDecodeError as exc:
raise ValueError(
f"Endpoint declaration registry is invalid JSON: {exc}"
) from exc
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
raise ValueError("Endpoint declaration registry must use schema_version 1.")
entries = payload.get("endpoints")
if not isinstance(entries, list):
raise ValueError(
"Endpoint declaration registry must contain an endpoints list."
)
declarations: dict[tuple[str, str, str], dict[str, Any]] = {}
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
raise ValueError(f"Endpoint declaration {index} must be an object.")
repository = entry.get("repository")
method = entry.get("method")
raw_path = entry.get("path")
category = entry.get("category")
rationale = entry.get("rationale")
if not isinstance(repository, str) or not repository.strip():
raise ValueError(f"Endpoint declaration {index} has no repository.")
if not isinstance(method, str) or method.lower() not in HTTP_METHODS:
raise ValueError(f"Endpoint declaration {index} has an invalid method.")
if not isinstance(raw_path, str) or not raw_path.startswith("/"):
raise ValueError(f"Endpoint declaration {index} has an invalid path.")
canonical_path = canonical_api_path(raw_path)
if raw_path != canonical_path:
raise ValueError(
f"Endpoint declaration {index} path must be canonical: {canonical_path}"
)
if category not in ENDPOINT_SURFACE_CATEGORIES:
raise ValueError(f"Endpoint declaration {index} has an invalid category.")
if not isinstance(rationale, str) or not rationale.strip():
raise ValueError(f"Endpoint declaration {index} has no rationale.")
tracking_issue = entry.get("tracking_issue")
if category == "missing_ui" and (
not isinstance(tracking_issue, str) or not tracking_issue.strip()
):
raise ValueError(
f"Endpoint declaration {index} requires a tracking_issue for missing_ui."
)
key = (repository, method.upper(), canonical_path)
if key in declarations:
raise ValueError(f"Duplicate endpoint declaration: {key!r}.")
declarations[key] = {
"repository": repository,
"method": method.upper(),
"path": canonical_path,
"category": category,
"rationale": rationale.strip(),
**(
{"tracking_issue": tracking_issue.strip()}
if isinstance(tracking_issue, str) and tracking_issue.strip()
else {}
),
}
return declarations
def _join_route(prefix: str, route: str) -> str:
return f"/{prefix.strip('/')}/{route.strip('/')}".replace("//", "/")
@@ -444,17 +637,11 @@ def _call_name(node: ast.AST) -> str:
def _plain_value(value: Any) -> Any:
if is_dataclass(value):
return {
key: _plain_value(item)
for key, item in asdict(value).items()
}
return {key: _plain_value(item) for key, item in asdict(value).items()}
if isinstance(value, tuple):
return [_plain_value(item) for item in value]
if isinstance(value, dict):
return {
str(key): _plain_value(item)
for key, item in value.items()
}
return {str(key): _plain_value(item) for key, item in value.items()}
return value
+1 -1
View File
@@ -51,7 +51,7 @@ set +a
export APP_ENV="${APP_ENV:-staging}"
export GOVOPLAN_INSTALL_PROFILE="${GOVOPLAN_INSTALL_PROFILE:-production-like}"
export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow_engine,workflow,views,search,risk_compliance,notifications,docs,ops}"
export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,idm,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,dist_lists,templates,workflow_engine,workflow,views,search,risk_compliance,notifications,docs,ops}"
export DATABASE_URL="${DATABASE_URL:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL:-postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
export GOVOPLAN_DATABASE_URL_PGTOOLS="${GOVOPLAN_DATABASE_URL_PGTOOLS:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS:-postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
export REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}"
+1 -1
View File
@@ -66,7 +66,7 @@ export DATABASE_URL="${DATABASE_URL:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL:-po
export GOVOPLAN_DATABASE_URL_PGTOOLS="${GOVOPLAN_DATABASE_URL_PGTOOLS:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS:-postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
export REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}"
export CELERY_ENABLED="${CELERY_ENABLED:-true}"
export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow_engine,workflow,views,search,risk_compliance,notifications,docs,ops}"
export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,idm,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,dist_lists,templates,workflow_engine,workflow,views,search,risk_compliance,notifications,docs,ops}"
export FILE_STORAGE_BACKEND="${FILE_STORAGE_BACKEND:-local}"
export FILE_STORAGE_LOCAL_ROOT="${FILE_STORAGE_LOCAL_ROOT:-$META_ROOT/runtime/production-like/files}"
export DEV_AUTO_MIGRATE_ENABLED="${DEV_AUTO_MIGRATE_ENABLED:-false}"
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Create runtime SBOM, provenance, and an unsigned distribution descriptor."""
from __future__ import annotations
import argparse
from datetime import UTC, datetime, timedelta
import hashlib
import json
from pathlib import Path
import re
import sys
from typing import Any
from urllib.parse import urlsplit
import uuid
SHA256 = re.compile(r"^[0-9a-f]{64}$")
DIGEST_IMAGE = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--composition", type=Path, required=True)
parser.add_argument("--api-metadata", type=Path, required=True)
parser.add_argument("--web-metadata", type=Path, required=True)
parser.add_argument("--deployer", type=Path, required=True)
parser.add_argument("--deployer-url", required=True)
parser.add_argument("--artifact-base-url", required=True)
parser.add_argument("--source-commit", required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--channel", default="stable")
parser.add_argument("--sequence", type=int, required=True)
parser.add_argument("--expires-days", type=int, default=90)
parser.add_argument(
"--dependency",
action="append",
default=[],
metavar="NAME=IMAGE@SHA256",
)
parser.add_argument("--output-directory", type=Path, required=True)
parser.add_argument("--descriptor", type=Path, required=True)
return parser
def finalize(args: argparse.Namespace) -> dict[str, Any]:
composition = _json_object(args.composition)
api = _image_metadata(_json_object(args.api_metadata), "api")
web = _image_metadata(_json_object(args.web_metadata), "web")
dependencies = dict(_dependency(value) for value in args.dependency)
if not dependencies:
raise ValueError("at least one --dependency is required")
_https_url(args.deployer_url, "deployer URL")
artifact_base = _https_url(args.artifact_base_url, "artifact base URL").rstrip("/")
if args.sequence < 1 or not 1 <= args.expires_days <= 365:
raise ValueError("sequence and expiry window are out of bounds")
output = args.output_directory.expanduser().resolve()
output.mkdir(parents=True, exist_ok=True)
api_sbom = _api_sbom(composition, version=args.version)
web_sbom = _web_sbom(composition, version=args.version)
api_provenance = _provenance(
subject=api["index"],
source_commit=args.source_commit,
composition=composition,
)
web_provenance = _provenance(
subject=web["index"],
source_commit=args.source_commit,
composition=composition,
)
artifact_values = {
"api-sbom.cdx.json": api_sbom,
"web-sbom.cdx.json": web_sbom,
"api-provenance.json": api_provenance,
"web-provenance.json": web_provenance,
}
artifacts: dict[str, dict[str, str]] = {}
for filename, value in artifact_values.items():
path = output / filename
encoded = _canonical_json(value)
path.write_bytes(encoded)
artifacts[filename] = {
"url": f"{artifact_base}/{filename}",
"sha256": hashlib.sha256(encoded).hexdigest(),
}
composition_encoded = _canonical_json(composition)
packages = _manifest_packages(composition)
issued = datetime.now(UTC).replace(microsecond=0)
descriptor: dict[str, Any] = {
"schema_version": "1",
"channel": args.channel,
"sequence": args.sequence,
"version": args.version,
"issued_at": issued.isoformat(),
"expires_at": (issued + timedelta(days=args.expires_days)).isoformat(),
"revoked": False,
"deployer": {
"url": args.deployer_url,
"sha256": _sha256_file(args.deployer),
},
"images": {
"api": {
**api,
"sbom": artifacts["api-sbom.cdx.json"],
"provenance": artifacts["api-provenance.json"],
},
"web": {
**web,
"sbom": artifacts["web-sbom.cdx.json"],
"provenance": artifacts["web-provenance.json"],
},
},
"dependencies": dict(sorted(dependencies.items())),
"composition": {
"sha256": hashlib.sha256(composition_encoded).hexdigest(),
"module_ids": list(composition["python"]["module_ids"]),
"packages": packages,
},
}
args.descriptor.parent.mkdir(parents=True, exist_ok=True)
args.descriptor.write_bytes(_canonical_json(descriptor))
return descriptor
def _api_sbom(composition: dict[str, Any], *, version: str) -> dict[str, Any]:
components = []
for package in composition["python"]["packages"]:
components.append(
{
"type": "library",
"name": package["package"],
"version": package["version"],
"hashes": [{"alg": "SHA-256", "content": package["sha256"]}],
"purl": f"pkg:pypi/{package['package']}@{package['version']}",
}
)
return {
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, 'govoplan-api:' + version)}",
"version": 1,
"metadata": {"component": {"type": "application", "name": "govoplan-api", "version": version}},
"components": components,
}
def _web_sbom(composition: dict[str, Any], *, version: str) -> dict[str, Any]:
return {
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, 'govoplan-web:' + version)}",
"version": 1,
"metadata": {"component": {"type": "application", "name": "govoplan-web", "version": version}},
"components": [
{
"type": "file",
"name": "govoplan-web-dist",
"version": version,
"hashes": [
{
"alg": "SHA-256",
"content": composition["web"]["sha256"],
}
],
}
],
}
def _provenance(
*,
subject: str,
source_commit: str,
composition: dict[str, Any],
) -> dict[str, Any]:
digest = subject.rsplit("@sha256:", 1)[1]
return {
"_type": "https://in-toto.io/Statement/v1",
"subject": [{"name": subject.split("@", 1)[0], "digest": {"sha256": digest}}],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://govoplan.add-ideas.de/build/runtime-oci/v1",
"externalParameters": {
"source_commit": source_commit,
"network_free_image_assembly": True,
},
"resolvedDependencies": [
{
"uri": "govoplan:runtime-composition",
"digest": {
"sha256": hashlib.sha256(_canonical_json(composition)).hexdigest()
},
}
],
},
"runDetails": {
"builder": {"id": "https://git.add-ideas.de/GovOPlaN/govoplan/actions"},
"metadata": {"invocationId": source_commit},
},
},
}
def _manifest_packages(composition: dict[str, Any]) -> list[dict[str, str]]:
values = []
for package in composition["python"]["packages"]:
values.append(
{
"name": str(package["package"]),
"version": str(package["version"]),
"wheel_sha256": str(package["sha256"]),
}
)
return sorted(values, key=lambda item: item["name"])
def _image_metadata(value: dict[str, Any], label: str) -> dict[str, Any]:
if set(value) != {"index", "platforms"}:
raise ValueError(f"{label} image metadata has invalid fields")
if not isinstance(value["index"], str) or DIGEST_IMAGE.fullmatch(value["index"]) is None:
raise ValueError(f"{label} index is not digest-pinned")
platforms = value["platforms"]
if not isinstance(platforms, dict) or set(platforms) != {"linux/amd64", "linux/arm64"}:
raise ValueError(f"{label} image does not cover amd64 and arm64")
if any(not isinstance(item, str) or DIGEST_IMAGE.fullmatch(item) is None for item in platforms.values()):
raise ValueError(f"{label} platform image is not digest-pinned")
return {"index": value["index"], "platforms": dict(sorted(platforms.items()))}
def _dependency(value: str) -> tuple[str, str]:
if "=" not in value:
raise ValueError("--dependency must use NAME=IMAGE@SHA256")
name, reference = value.split("=", 1)
if re.fullmatch(r"[a-z][a-z0-9_]{1,63}", name) is None:
raise ValueError(f"invalid dependency name: {name!r}")
if DIGEST_IMAGE.fullmatch(reference) is None:
raise ValueError(f"dependency {name!r} is not digest-pinned")
return name, reference
def _json_object(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"JSON root must be an object: {path}")
return value
def _https_url(value: str, label: str) -> str:
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
raise ValueError(f"{label} must be an HTTPS URL without credentials")
return value
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _canonical_json(value: object) -> bytes:
return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
def main() -> int:
args = build_parser().parse_args()
try:
finalize(args)
except (KeyError, OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"Runtime release evidence written below {args.output_directory}")
print(f"Unsigned descriptor written to {args.descriptor}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Validate and sign a GovOPlaN OCI runtime distribution manifest."""
from __future__ import annotations
import argparse
import base64
import json
from pathlib import Path
import sys
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
META_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(META_ROOT / "tools" / "deployment"))
from govoplan_deploy.distribution import ( # noqa: E402
DistributionError,
canonical_json,
canonical_signed_payload,
validate_manifest,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--descriptor", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument(
"--signing-key",
action="append",
default=[],
metavar="KEY_ID=PRIVATE_PEM",
required=True,
)
return parser
def sign_manifest(
descriptor: dict[str, object],
signing_keys: tuple[tuple[str, Path], ...],
) -> dict[str, object]:
payload = dict(descriptor)
payload["signatures"] = [
_signature(payload, key_id=key_id, path=path)
for key_id, path in signing_keys
]
validate_manifest(payload)
return payload
def _signature(
payload: dict[str, object],
*,
key_id: str,
path: Path,
) -> dict[str, str]:
try:
key = serialization.load_pem_private_key(path.read_bytes(), password=None)
except (OSError, ValueError, TypeError) as exc:
raise DistributionError(f"cannot load signing key {key_id!r}") from exc
if not isinstance(key, Ed25519PrivateKey):
raise DistributionError(f"signing key {key_id!r} is not Ed25519")
return {
"key_id": key_id,
"algorithm": "ed25519",
"value": base64.b64encode(key.sign(canonical_signed_payload(payload))).decode(
"ascii"
),
}
def _parse_signing_key(value: str) -> tuple[str, Path]:
if "=" not in value:
raise DistributionError("--signing-key must use KEY_ID=PRIVATE_PEM")
key_id, raw_path = value.split("=", 1)
if not key_id or not raw_path:
raise DistributionError("--signing-key must use KEY_ID=PRIVATE_PEM")
return key_id, Path(raw_path).expanduser().resolve()
def main() -> int:
args = build_parser().parse_args()
try:
descriptor = json.loads(args.descriptor.read_text(encoding="utf-8"))
if not isinstance(descriptor, dict):
raise DistributionError("descriptor root must be an object")
if "signatures" in descriptor:
raise DistributionError("descriptor must not contain signatures")
payload = sign_manifest(
descriptor,
tuple(_parse_signing_key(value) for value in args.signing_key),
)
encoded = canonical_json(payload)
args.output.parent.mkdir(parents=True, exist_ok=True)
temporary = args.output.with_suffix(args.output.suffix + ".tmp")
temporary.write_bytes(encoded)
temporary.chmod(0o644)
temporary.replace(args.output)
except (DistributionError, OSError, ValueError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"Runtime distribution manifest written to {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+26 -62
View File
@@ -484,31 +484,35 @@ def publish_catalog_candidate(
)
)
result_fields = {
"candidate_root": candidate_root,
"candidate_catalog": candidate_catalog,
"candidate_keyring": candidate_keyring,
"resolved_web_root": resolved_web_root,
"target_catalog": target_catalog,
"target_keyring": target_keyring,
"channel": channel,
"branch": effective_branch,
"tag_name": effective_tag_name,
"remote": remote,
"validation_valid": validation_valid,
"validation_error": validation_error,
"validation_warnings": validation_warnings,
"candidate_catalog_hash": candidate_catalog_hash,
"candidate_keyring_hash": candidate_keyring_hash,
"target_catalog_hash_before": target_catalog_hash_before,
"target_keyring_hash_before": target_keyring_hash_before,
"catalog_changed": catalog_changed,
"keyring_changed": keyring_changed,
}
if blockers:
return result(
status="blocked",
applied=False,
candidate_root=candidate_root,
candidate_catalog=candidate_catalog,
candidate_keyring=candidate_keyring,
resolved_web_root=resolved_web_root,
target_catalog=target_catalog,
target_keyring=target_keyring,
channel=channel,
branch=effective_branch,
tag_name=effective_tag_name,
remote=remote,
validation_valid=validation_valid,
validation_error=validation_error,
validation_warnings=validation_warnings,
candidate_catalog_hash=candidate_catalog_hash,
candidate_keyring_hash=candidate_keyring_hash,
target_catalog_hash_before=target_catalog_hash_before,
target_keyring_hash_before=target_keyring_hash_before,
catalog_changed=catalog_changed,
keyring_changed=keyring_changed,
steps=tuple(steps),
notes=tuple([*notes, *blockers]),
**result_fields,
)
if not apply:
@@ -518,27 +522,9 @@ def publish_catalog_candidate(
return result(
status="ready",
applied=False,
candidate_root=candidate_root,
candidate_catalog=candidate_catalog,
candidate_keyring=candidate_keyring,
resolved_web_root=resolved_web_root,
target_catalog=target_catalog,
target_keyring=target_keyring,
channel=channel,
branch=effective_branch,
tag_name=effective_tag_name,
remote=remote,
validation_valid=validation_valid,
validation_error=validation_error,
validation_warnings=validation_warnings,
candidate_catalog_hash=candidate_catalog_hash,
candidate_keyring_hash=candidate_keyring_hash,
target_catalog_hash_before=target_catalog_hash_before,
target_keyring_hash_before=target_keyring_hash_before,
catalog_changed=catalog_changed,
keyring_changed=keyring_changed,
steps=tuple(steps),
notes=tuple(notes),
**result_fields,
)
directory_payloads = module_directory_payloads(
@@ -615,11 +601,7 @@ def publish_catalog_candidate(
publication_commit,
)
_seal_git_metadata_file(
resolved_web_root
/ ".git"
/ "refs"
/ "tags"
/ effective_tag_name,
resolved_web_root / ".git" / "refs" / "tags" / effective_tag_name,
label="website publication tag reference",
)
publication_tag_object = git_text(
@@ -704,30 +686,12 @@ def publish_catalog_candidate(
return result(
status="published" if push else "applied",
applied=True,
candidate_root=candidate_root,
candidate_catalog=candidate_catalog,
candidate_keyring=candidate_keyring,
resolved_web_root=resolved_web_root,
target_catalog=target_catalog,
target_keyring=target_keyring,
channel=channel,
branch=effective_branch,
tag_name=effective_tag_name,
remote=remote,
validation_valid=validation_valid,
validation_error=validation_error,
validation_warnings=validation_warnings,
candidate_catalog_hash=candidate_catalog_hash,
candidate_keyring_hash=candidate_keyring_hash,
target_catalog_hash_before=target_catalog_hash_before,
target_keyring_hash_before=target_keyring_hash_before,
catalog_changed=catalog_changed,
keyring_changed=keyring_changed,
publication_commit_sha=publication_commit,
publication_tag_object_sha=publication_tag_object,
publication_tag_commit_sha=publication_tag_commit,
steps=tuple(completed_steps),
notes=tuple(notes),
**result_fields,
)
@@ -72,5 +72,9 @@ while IFS=$'\t' read -r package_name spec; do
done < "$GOVOPLAN_DEPS"
if [[ "${#module_paths[@]}" -gt 0 ]]; then
retry npm install --prefer-online --no-save --install-links "${module_paths[@]}"
# Module repositories historically declared their local Vite/TypeScript
# toolchain as peers. The release host owns that build toolchain; resolving
# tagged source packages must not let an older, unused peer range block the
# verified composition.
retry npm install --prefer-online --no-save --install-links --legacy-peer-deps "${module_paths[@]}"
fi
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""Assemble a deterministic, network-free GovOPlaN OCI build context."""
from __future__ import annotations
import argparse
import configparser
from email.parser import BytesParser
from email.policy import compat32
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import zipfile
NORMALIZED_PACKAGE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,127}$")
MAX_WHEELS = 512
MAX_WHEEL_BYTES = 512 * 1024 * 1024
MAX_WEB_FILES = 100_000
MAX_WEB_BYTES = 2 * 1024 * 1024 * 1024
class ContextError(ValueError):
"""The release inputs cannot form an immutable runtime context."""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--wheelhouse", type=Path, required=True)
parser.add_argument("--web-dist", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--required-module", action="append", default=[])
parser.add_argument(
"--source-date-epoch",
type=int,
default=int(os.environ.get("SOURCE_DATE_EPOCH", "0") or 0),
)
return parser
def prepare_context(
*,
wheelhouse: Path,
web_dist: Path,
output: Path,
required_modules: tuple[str, ...] = (),
source_date_epoch: int = 0,
) -> dict[str, object]:
source_wheels = _regular_files(wheelhouse, suffix=".whl", maximum=MAX_WHEELS)
if not source_wheels:
raise ContextError("wheelhouse contains no wheel artifacts")
if output.exists() and any(output.iterdir()):
raise ContextError("output directory must be absent or empty")
output.mkdir(parents=True, exist_ok=True)
target_wheels = output / "wheelhouse"
target_web = output / "web-dist"
target_wheels.mkdir(mode=0o755)
packages: list[dict[str, object]] = []
govoplan_wheel_rows: list[dict[str, object]] = []
roots: list[tuple[str, str]] = []
module_ids: set[str] = set()
seen_packages: set[str] = set()
wheel_rows: list[dict[str, object]] = []
for source in source_wheels:
if source.stat().st_size > MAX_WHEEL_BYTES:
raise ContextError(f"wheel exceeds size limit: {source.name}")
identity = inspect_wheel(source)
package_name = str(identity["package"])
if package_name in seen_packages:
raise ContextError(f"duplicate wheel distribution: {package_name}")
seen_packages.add(package_name)
target = target_wheels / source.name
_copy_regular(source, target, source_date_epoch=source_date_epoch)
row = {
"filename": source.name,
"sha256": _sha256_file(target),
"size": target.stat().st_size,
}
wheel_rows.append(row)
if package_name.startswith("govoplan-"):
package_modules = tuple(str(item) for item in identity["module_ids"])
module_ids.update(package_modules)
package = {
**row,
"package": package_name,
"version": identity["version"],
"module_ids": list(package_modules),
}
packages.append(package)
govoplan_wheel_rows.append(row)
root = (
f"{package_name}[server]"
if package_name == "govoplan-core"
else package_name
)
roots.append((root, str(identity["version"])))
if not any(package["package"] == "govoplan-core" for package in packages):
raise ContextError("wheelhouse does not contain govoplan-core")
missing_modules = sorted(set(required_modules) - module_ids)
if missing_modules:
raise ContextError(
"runtime composition is missing required modules: "
+ ", ".join(missing_modules)
)
requirements = "".join(
f"{package}=={version}\n" for package, version in sorted(roots)
)
_write_regular(
output / "requirements-runtime.txt",
requirements.encode("utf-8"),
source_date_epoch=source_date_epoch,
)
web_rows = _copy_web_tree(
web_dist,
target_web,
source_date_epoch=source_date_epoch,
)
composition: dict[str, object] = {
"schema_version": "1",
"python": {
"packages": sorted(packages, key=lambda item: str(item["package"])),
"module_ids": sorted(module_ids),
"wheelhouse_sha256": _rows_digest(govoplan_wheel_rows),
"wheel_count": len(govoplan_wheel_rows),
},
"web": {
"sha256": _rows_digest(web_rows),
"file_count": len(web_rows),
},
}
encoded = (json.dumps(composition, indent=2, sort_keys=True) + "\n").encode(
"utf-8"
)
_write_regular(
output / "composition.json",
encoded,
source_date_epoch=source_date_epoch,
)
_write_regular(
target_web / ".well-known" / "govoplan-composition.json",
encoded,
source_date_epoch=source_date_epoch,
)
_copy_regular(
Path(__file__).resolve().parent / "runtime" / "nginx.conf",
output / "nginx.conf",
source_date_epoch=source_date_epoch,
)
return composition
def inspect_wheel(path: Path) -> dict[str, object]:
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise ContextError(f"wheel cannot be opened safely: {path.name}") from exc
try:
opened = os.fstat(descriptor)
if not stat.S_ISREG(opened.st_mode):
raise ContextError(f"wheel is not a regular file: {path.name}")
with os.fdopen(os.dup(descriptor), "rb") as handle:
with zipfile.ZipFile(handle) as archive:
metadata = [
member
for member in archive.infolist()
if PurePosixPath(member.filename).name == "METADATA"
and PurePosixPath(member.filename).parent.name.endswith(
".dist-info"
)
]
if len(metadata) != 1:
raise ContextError(
f"wheel must contain one METADATA file: {path.name}"
)
parsed = BytesParser(policy=compat32).parsebytes(
archive.read(metadata[0])
)
package = _normalize_package(str(parsed.get("Name") or ""))
version = str(parsed.get("Version") or "").strip()
if VERSION.fullmatch(version) is None:
raise ContextError(f"wheel has invalid version: {path.name}")
entry_points_name = (
PurePosixPath(metadata[0].filename).parent / "entry_points.txt"
).as_posix()
module_ids: tuple[str, ...] = ()
if entry_points_name in archive.namelist():
module_ids = _module_entry_points(
archive.read(entry_points_name).decode("utf-8")
)
final = os.fstat(descriptor)
if (opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns) != (
final.st_dev,
final.st_ino,
final.st_size,
final.st_mtime_ns,
):
raise ContextError(f"wheel changed while inspected: {path.name}")
except (OSError, RuntimeError, zipfile.BadZipFile) as exc:
if isinstance(exc, ContextError):
raise
raise ContextError(f"wheel is not a readable archive: {path.name}") from exc
finally:
os.close(descriptor)
return {"package": package, "version": version, "module_ids": module_ids}
def _module_entry_points(value: str) -> tuple[str, ...]:
parser = configparser.ConfigParser(interpolation=None, strict=True)
try:
parser.read_string(value)
except configparser.Error as exc:
raise ContextError("wheel entry_points.txt is malformed") from exc
if not parser.has_section("govoplan.modules"):
return ()
values = tuple(sorted(parser.options("govoplan.modules")))
for item in values:
if re.fullmatch(r"[a-z][a-z0-9_]{1,63}", item) is None:
raise ContextError(f"wheel has invalid module entry point: {item!r}")
return values
def _normalize_package(value: str) -> str:
normalized = re.sub(r"[-_.]+", "-", value.strip().lower())
if NORMALIZED_PACKAGE.fullmatch(normalized) is None:
raise ContextError("wheel has invalid package name")
return normalized
def _regular_files(root: Path, *, suffix: str, maximum: int) -> list[Path]:
if root.is_symlink() or not root.is_dir():
raise ContextError(f"input directory is not a real directory: {root}")
values = sorted(path for path in root.iterdir() if path.name.endswith(suffix))
if len(values) > maximum:
raise ContextError(f"input directory exceeds {maximum} files")
for path in values:
if path.is_symlink() or not path.is_file():
raise ContextError(f"input artifact is not a regular file: {path.name}")
return values
def _copy_web_tree(
source: Path,
target: Path,
*,
source_date_epoch: int,
) -> list[dict[str, object]]:
if source.is_symlink() or not source.is_dir():
raise ContextError("WebUI dist must be a real directory")
rows: list[dict[str, object]] = []
total = 0
for path in sorted(source.rglob("*")):
relative = path.relative_to(source)
if path.is_symlink():
raise ContextError(f"WebUI dist contains a symlink: {relative}")
if path.is_dir():
continue
if not path.is_file():
raise ContextError(f"WebUI dist contains a special file: {relative}")
if len(rows) >= MAX_WEB_FILES:
raise ContextError("WebUI dist exceeds its file-count limit")
total += path.stat().st_size
if total > MAX_WEB_BYTES:
raise ContextError("WebUI dist exceeds its total-size limit")
destination = target / relative
_copy_regular(path, destination, source_date_epoch=source_date_epoch)
rows.append(
{
"path": relative.as_posix(),
"sha256": _sha256_file(destination),
"size": destination.stat().st_size,
}
)
if not rows:
raise ContextError("WebUI dist contains no files")
return rows
def _copy_regular(source: Path, target: Path, *, source_date_epoch: int) -> None:
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
with source.open("rb") as source_handle, target.open("xb") as target_handle:
shutil.copyfileobj(source_handle, target_handle)
target_handle.flush()
os.fsync(target_handle.fileno())
target.chmod(0o644)
os.utime(target, (source_date_epoch, source_date_epoch))
def _write_regular(path: Path, value: bytes, *, source_date_epoch: int) -> None:
path.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
path.write_bytes(value)
path.chmod(0o644)
os.utime(path, (source_date_epoch, source_date_epoch))
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _rows_digest(rows: list[dict[str, object]]) -> str:
encoded = json.dumps(rows, separators=(",", ":"), sort_keys=True).encode(
"utf-8"
)
return hashlib.sha256(encoded).hexdigest()
def main() -> int:
args = build_parser().parse_args()
try:
composition = prepare_context(
wheelhouse=args.wheelhouse.expanduser().resolve(),
web_dist=args.web_dist.expanduser().resolve(),
output=args.output.expanduser().resolve(),
required_modules=tuple(args.required_module),
source_date_epoch=args.source_date_epoch,
)
except (ContextError, OSError) as exc:
print(f"error: {exc}", file=os.sys.stderr)
return 1
print(json.dumps(composition, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Publish immutable GovOPlaN runtime evidence as Gitea release assets."""
from __future__ import annotations
import argparse
import hashlib
import json
import mimetypes
import os
from pathlib import Path
import re
import secrets
import sys
from typing import Any
from urllib.error import HTTPError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen
MAX_ASSET_BYTES = 256 * 1024 * 1024
COMMIT_SHA = re.compile(r"^[0-9a-f]{40}$")
class PublishError(RuntimeError):
"""A release asset cannot be published immutably."""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="https://git.add-ideas.de")
parser.add_argument("--owner", default="GovOPlaN")
parser.add_argument("--repo", default="govoplan")
parser.add_argument("--tag", required=True)
parser.add_argument("--target-commit", required=True)
parser.add_argument("--title", required=True)
parser.add_argument("--body", default="Signed GovOPlaN runtime distribution.")
parser.add_argument("--asset", type=Path, action="append", default=[], required=True)
parser.add_argument("--token-env", default="GITEA_RELEASE_TOKEN")
return parser
class GiteaReleasePublisher:
def __init__(self, *, base_url: str, owner: str, repo: str, token: str) -> None:
if not base_url.startswith("https://"):
raise PublishError("Gitea release publication requires HTTPS")
if not token:
raise PublishError("Gitea release token is empty")
self.base_url = base_url.rstrip("/")
self.owner = owner
self.repo = repo
self.token = token
def release(
self,
*,
tag: str,
target_commit: str,
title: str,
body: str,
) -> dict[str, Any]:
if COMMIT_SHA.fullmatch(target_commit) is None:
raise PublishError("release target must be an exact lowercase commit SHA")
resolved_target = self._resolve_commit(target_commit)
if resolved_target != target_commit:
raise PublishError("release target did not resolve to the requested commit")
existing_tag = self._resolve_commit(tag, allow_missing=True)
if existing_tag is not None and existing_tag != target_commit:
raise PublishError(
f"release tag {tag!r} already points to another commit"
)
path = self._repo_path(f"/releases/tags/{quote(tag, safe='')}")
try:
release = self._json("GET", path)
except HTTPError as exc:
if exc.code != 404:
raise
release = self._json(
"POST",
self._repo_path("/releases"),
payload={
"tag_name": tag,
"target_commitish": target_commit,
"name": title,
"body": body,
"draft": False,
"prerelease": False,
},
expected=201,
)
if self._resolve_commit(tag) != target_commit:
raise PublishError(
f"release tag {tag!r} does not resolve to the requested commit"
)
return release
def upload_assets(self, release: dict[str, Any], assets: tuple[Path, ...]) -> None:
release_id = release.get("id")
if isinstance(release_id, bool) or not isinstance(release_id, int):
raise PublishError("Gitea release response has no numeric id")
existing = self._json(
"GET",
self._repo_path(f"/releases/{release_id}/assets"),
)
if not isinstance(existing, list):
raise PublishError("Gitea release assets response is invalid")
existing_by_name = {
str(item.get("name")): item for item in existing if isinstance(item, dict)
}
for asset in assets:
path = asset.expanduser().resolve()
if path.is_symlink() or not path.is_file():
raise PublishError(f"release asset is not a regular file: {path}")
size = path.stat().st_size
if size > MAX_ASSET_BYTES:
raise PublishError(f"release asset exceeds size limit: {path.name}")
prior = existing_by_name.get(path.name)
if prior is not None:
self._require_same_existing_asset(prior, path)
continue
self._upload(release_id, path)
def _require_same_existing_asset(self, prior: dict[str, Any], path: Path) -> None:
url = prior.get("browser_download_url")
size = prior.get("size")
if not isinstance(url, str) or not url.startswith("https://") or size != path.stat().st_size:
raise PublishError(f"release asset already exists with another identity: {path.name}")
request = Request(url, headers=self._headers())
digest = hashlib.sha256()
total = 0
with urlopen(request, timeout=30) as response: # noqa: S310
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > MAX_ASSET_BYTES:
raise PublishError("existing release asset exceeds size limit")
digest.update(chunk)
if digest.hexdigest() != _sha256_file(path):
raise PublishError(f"release asset already exists with another digest: {path.name}")
def _upload(self, release_id: int, path: Path) -> None:
boundary = "govoplan-" + secrets.token_hex(16)
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
prefix = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
f"Content-Type: {content_type}\r\n\r\n"
).encode("utf-8")
suffix = f"\r\n--{boundary}--\r\n".encode("ascii")
data = prefix + path.read_bytes() + suffix
query = urlencode({"name": path.name})
request = Request(
self._repo_path(f"/releases/{release_id}/assets") + "?" + query,
data=data,
method="POST",
headers={
**self._headers(),
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
)
try:
with urlopen(request, timeout=120) as response: # noqa: S310
if response.status != 201:
raise PublishError(
f"Gitea asset upload returned HTTP {response.status}"
)
except HTTPError as exc:
raise PublishError(f"Gitea asset upload failed with HTTP {exc.code}") from exc
def _json(
self,
method: str,
url: str,
*,
payload: dict[str, Any] | None = None,
expected: int = 200,
) -> Any:
data = None
headers = self._headers()
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(url, data=data, method=method, headers=headers)
with urlopen(request, timeout=30) as response: # noqa: S310
if response.status != expected:
raise PublishError(f"Gitea API returned HTTP {response.status}")
return json.load(response)
def _headers(self) -> dict[str, str]:
return {"Authorization": f"token {self.token}", "Accept": "application/json"}
def _resolve_commit(self, ref: str, *, allow_missing: bool = False) -> str | None:
path = self._repo_path(f"/git/commits/{quote(ref, safe='')}")
try:
commit = self._json("GET", path)
except HTTPError as exc:
if allow_missing and exc.code == 404:
return None
raise
if not isinstance(commit, dict):
raise PublishError(f"Gitea returned an invalid commit for {ref!r}")
sha = commit.get("sha")
if not isinstance(sha, str) or COMMIT_SHA.fullmatch(sha) is None:
raise PublishError(f"Gitea returned an invalid commit SHA for {ref!r}")
return sha
def _repo_path(self, suffix: str) -> str:
return (
f"{self.base_url}/api/v1/repos/{quote(self.owner, safe='')}/"
f"{quote(self.repo, safe='')}{suffix}"
)
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def main() -> int:
args = build_parser().parse_args()
try:
publisher = GiteaReleasePublisher(
base_url=args.base_url,
owner=args.owner,
repo=args.repo,
token=os.environ.get(args.token_env, ""),
)
release = publisher.release(
tag=args.tag,
target_commit=args.target_commit,
title=args.title,
body=args.body,
)
publisher.upload_assets(release, tuple(args.asset))
except (HTTPError, OSError, PublishError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"Published {len(args.asset)} immutable asset(s) to {args.owner}/{args.repo} {args.tag}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Resolve amd64/arm64 child digests from an OCI image index."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import sys
DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
def resolve_platforms(
payload: object,
*,
repository: str,
index_digest: str,
) -> dict[str, object]:
if not repository or "@" in repository or any(value.isspace() for value in repository):
raise ValueError("repository must be an unpinned OCI repository name")
last_slash = repository.rfind("/")
last_colon = repository.rfind(":")
if last_colon > last_slash:
repository = repository[:last_colon]
if not repository:
raise ValueError("repository must be an unpinned OCI repository name")
if DIGEST.fullmatch(index_digest) is None:
raise ValueError("index digest must be sha256:<hex>")
if not isinstance(payload, dict) or not isinstance(payload.get("manifests"), list):
raise ValueError("OCI index must contain manifests")
platforms: dict[str, str] = {}
for item in payload["manifests"]:
if not isinstance(item, dict) or not isinstance(item.get("platform"), dict):
continue
platform = item["platform"]
key = f"{platform.get('os')}/{platform.get('architecture')}"
if key not in {"linux/amd64", "linux/arm64"}:
continue
digest = item.get("digest")
if not isinstance(digest, str) or DIGEST.fullmatch(digest) is None:
raise ValueError(f"OCI index has an invalid {key} digest")
if key in platforms:
raise ValueError(f"OCI index has duplicate {key} manifests")
platforms[key] = f"{repository}@{digest}"
if set(platforms) != {"linux/amd64", "linux/arm64"}:
raise ValueError("OCI index must contain linux/amd64 and linux/arm64")
return {
"index": f"{repository}@{index_digest}",
"platforms": dict(sorted(platforms.items())),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", required=True)
parser.add_argument("--index-digest", required=True)
parser.add_argument("--index", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
try:
payload = json.loads(args.index.read_text(encoding="utf-8"))
result = resolve_platforms(
payload,
repository=args.repository,
index_digest=args.index_digest,
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(result, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+37
View File
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1.7
ARG PYTHON_IMAGE
FROM ${PYTHON_IMAGE}
ARG GOVOPLAN_RELEASE_VERSION
ARG GOVOPLAN_COMPOSITION_SHA256
LABEL org.opencontainers.image.title="GovOPlaN API runtime" \
org.opencontainers.image.version="${GOVOPLAN_RELEASE_VERSION}" \
org.govoplan.composition.sha256="${GOVOPLAN_COMPOSITION_SHA256}"
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/opt/govoplan/runtime \
PATH=/opt/govoplan/runtime/bin:${PATH} \
GOVOPLAN_CORE_SOURCE_ROOT=/opt/govoplan/runtime/govoplan_core_runtime \
HOME=/var/lib/govoplan
COPY wheelhouse/ /opt/govoplan/wheels/
COPY requirements-runtime.txt composition.json /opt/govoplan/
RUN python -m pip install --disable-pip-version-check --no-cache-dir \
--no-index --find-links=/opt/govoplan/wheels \
--target=/opt/govoplan/runtime \
--requirement=/opt/govoplan/requirements-runtime.txt \
&& rm -rf /opt/govoplan/wheels \
&& groupadd --gid 10001 govoplan \
&& useradd --uid 10001 --gid 10001 --home-dir /var/lib/govoplan \
--create-home --shell /usr/sbin/nologin govoplan \
&& mkdir -p /var/lib/govoplan /tmp/govoplan \
&& chown -R 10001:10001 /var/lib/govoplan /tmp/govoplan \
&& chmod -R a-w /opt/govoplan
USER 10001:10001
WORKDIR /var/lib/govoplan
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=12 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"]
CMD ["python", "-m", "uvicorn", "govoplan_core.server.app:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
+21
View File
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1.7
ARG NGINX_IMAGE
FROM ${NGINX_IMAGE}
ARG GOVOPLAN_RELEASE_VERSION
ARG GOVOPLAN_COMPOSITION_SHA256
LABEL org.opencontainers.image.title="GovOPlaN WebUI runtime" \
org.opencontainers.image.version="${GOVOPLAN_RELEASE_VERSION}" \
org.govoplan.composition.sha256="${GOVOPLAN_COMPOSITION_SHA256}"
USER 0
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/*
COPY web-dist/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/nginx.conf
RUN chown -R 101:101 /usr/share/nginx/html \
&& chmod -R a-w /usr/share/nginx/html /etc/nginx/nginx.conf
USER 101:101
EXPOSE 8080
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
+50
View File
@@ -0,0 +1,50 @@
pid /tmp/nginx.pid;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
error_log /dev/stderr warn;
sendfile on;
server_tokens off;
client_body_temp_path /tmp/client_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
proxy_temp_path /tmp/proxy_temp;
scgi_temp_path /tmp/scgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
map $http_x_forwarded_proto $govoplan_forwarded_proto {
default $scheme;
http http;
https https;
}
server {
listen 8080;
root /usr/share/nginx/html;
location = /health {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location /api/ {
proxy_pass http://load-balancer:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $govoplan_forwarded_proto;
proxy_set_header X-Forwarded-For $http_x_forwarded_for;
}
location / {
try_files $uri $uri/ /index.html;
}
}
}
+63 -7
View File
@@ -16,15 +16,20 @@ REGISTERED_TRANSPORT = "registered"
PUBLIC_HTTPS_TRANSPORT = "public-https"
GITEA_SSH_PREFIX = "git@git.add-ideas.de:"
GITEA_HTTPS_PREFIX = "https://git.add-ideas.de/"
GITEA_REPOSITORY_PATH = re.compile(
r"(?:GovOPlaN|add-ideas)/[a-z0-9][a-z0-9-]*[.]git"
)
GITEA_CHECKOUT_AUTH_KEY = "http.https://git.add-ideas.de/.extraheader"
GITEA_REPOSITORY_PATH = re.compile(r"(?:GovOPlaN|add-ideas)/[a-z0-9][a-z0-9-]*[.]git")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Clone GovOPlaN repositories listed in repositories.json.")
parser.add_argument("--check", action="store_true", help="Only report missing repositories.")
parser.add_argument("--parent", type=Path, help="Override checkout parent directory.")
parser = argparse.ArgumentParser(
description="Clone GovOPlaN repositories listed in repositories.json."
)
parser.add_argument(
"--check", action="store_true", help="Only report missing repositories."
)
parser.add_argument(
"--parent", type=Path, help="Override checkout parent directory."
)
parser.add_argument(
"--repo",
action="append",
@@ -48,6 +53,14 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"git.add-ideas.de SSH remotes to anonymous HTTPS."
),
)
parser.add_argument(
"--reuse-checkout-auth",
action="store_true",
help=(
"Reuse the checkout repository's short-lived Gitea HTTP auth "
"header for child clones without printing or persisting it."
),
)
return parser.parse_args(argv)
@@ -82,6 +95,45 @@ def clone_remote(remote: str, *, transport: str) -> str:
return GITEA_HTTPS_PREFIX + repository_path
def _add_checkout_auth(environment: dict[str, str], *, root: Path) -> None:
result = subprocess.run(
[
"git",
"-C",
str(root),
"config",
"--local",
"--get",
GITEA_CHECKOUT_AUTH_KEY,
],
check=False,
capture_output=True,
text=True,
)
auth_header = result.stdout.strip()
if result.returncode != 0 or not auth_header:
raise ValueError(
"checkout authentication is unavailable; ensure actions/checkout "
"persists the GITEA_TOKEN credentials"
)
if re.fullmatch(
r"authorization: basic [A-Za-z0-9+/=]+",
auth_header,
flags=re.IGNORECASE,
) is None:
raise ValueError("checkout authentication has an unsupported format")
try:
config_count = int(environment.get("GIT_CONFIG_COUNT", "0"))
except ValueError as exc:
raise ValueError("GIT_CONFIG_COUNT must be an integer") from exc
if config_count < 0:
raise ValueError("GIT_CONFIG_COUNT cannot be negative")
environment[f"GIT_CONFIG_KEY_{config_count}"] = GITEA_CHECKOUT_AUTH_KEY
environment[f"GIT_CONFIG_VALUE_{config_count}"] = auth_header
environment["GIT_CONFIG_COUNT"] = str(config_count + 1)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
manifest = json.loads((ROOT / "repositories.json").read_text(encoding="utf-8"))
@@ -95,7 +147,9 @@ def main(argv: list[str] | None = None) -> int:
excluded = set(args.exclude_repo)
unknown = (requested | excluded) - set(names)
if unknown:
raise ValueError(f"unknown registered repositories: {', '.join(sorted(unknown))}")
raise ValueError(
f"unknown registered repositories: {', '.join(sorted(unknown))}"
)
overlap = requested & excluded
if overlap:
raise ValueError(
@@ -119,6 +173,8 @@ def main(argv: list[str] | None = None) -> int:
"GIT_TERMINAL_PROMPT": "0",
}
)
if args.reuse_checkout_auth and missing and not args.check:
_add_checkout_auth(environment, root=ROOT)
for entry, repo, remote in missing:
print(f"missing: {entry['name']} -> {repo}")
if not args.check:
+9 -2
View File
@@ -149,6 +149,7 @@ def main() -> int:
requirements=requirements,
python=python,
local_requirements=local_requirements,
repair_requirements=environment.stale_requirements,
force=args.force,
)
@@ -250,6 +251,7 @@ def build_install_plan(
python: str,
local_requirements: tuple[RequirementEntry, ...],
force: bool,
repair_requirements: tuple[RequirementEntry, ...] = (),
) -> InstallPlan:
full_command = (python, "-m", "pip", "install", "-r", str(requirements))
if force:
@@ -273,7 +275,12 @@ def build_install_plan(
removed_paths = set(previous_inputs) - set(current_inputs)
stale_requirements = requirements_key in changed_paths or requirements_key in removed_paths
installs: list[tuple[str, ...]] = []
# Metadata changes and installation drift can happen together. Keep both
# sets in one resolver transaction so a selective metadata sync also
# repairs local distributions omitted from the current environment.
installs: list[tuple[str, ...]] = [
requirement.install_args for requirement in repair_requirements
]
warnings: list[str] = []
if stale_requirements:
@@ -322,7 +329,7 @@ def build_install_plan(
)
return InstallPlan(
"Selective Python environment sync",
f"installing {len(deduped_installs)} stale local requirement(s) in one resolver transaction.",
f"installing {len(deduped_installs)} stale or missing local requirement(s) in one resolver transaction.",
(command,),
tuple(warnings),
)