Compare commits
67
Commits
ed31409034
...
v0.1.14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f039dd39c | ||
|
|
d107d94fec | ||
|
|
6163c5992f | ||
|
|
2f28f22fd1 | ||
|
|
909862afdb | ||
|
|
eb04804d36 | ||
|
|
017aa7a702 | ||
|
|
af27b9fbdf | ||
|
|
cb45251c59 | ||
|
|
3b3d5b3386 | ||
|
|
313249b8fc | ||
|
|
282c90c54b | ||
|
|
25424187a8 | ||
|
|
ff8ee991c3 | ||
|
|
a5a0731d20 | ||
|
|
a0f161041d | ||
|
|
cb85999a14 | ||
|
|
cbfe8b03a7 | ||
|
|
768e9a51c9 | ||
|
|
087561ee12 | ||
|
|
11c1aa1815 | ||
|
|
478ecb5d0e | ||
|
|
32689d027a | ||
|
|
b7cc2d2df4 | ||
|
|
b70869e747 | ||
|
|
0c84afb158 | ||
|
|
145aa58c11 | ||
|
|
a3566c9311 | ||
|
|
3219460064 | ||
|
|
4b2a15adb5 | ||
|
|
acc5ffc247 | ||
|
|
f4f9836a09 | ||
|
|
758fa1bba7 | ||
|
|
0acc8cfc31 | ||
|
|
344bcaf1bc | ||
|
|
794622e4ed | ||
|
|
7653e9851f | ||
|
|
6f896d9c04 | ||
|
|
8f5ac52b58 | ||
|
|
2bc9ad7f00 | ||
|
|
fa1a4bacfb | ||
|
|
0c0669768d | ||
|
|
7b6ceeb185 | ||
|
|
ff12f676a1 | ||
|
|
eb9ab9ef1c | ||
|
|
935c1fe162 | ||
|
|
c7d1cd0e8f | ||
|
|
ac80d7e4e3 | ||
|
|
5e449b0983 | ||
|
|
d4bf07b446 | ||
|
|
adc4db9fdf | ||
|
|
484f2af3ac | ||
|
|
abf9564cee | ||
|
|
5bef966119 | ||
|
|
5e80b39bbd | ||
|
|
9370f501a0 | ||
|
|
e8f7e2c194 | ||
|
|
cbbe08d912 | ||
|
|
2c515f73c2 | ||
|
|
b40f1428fd | ||
|
|
43380eb068 | ||
|
|
29acb55b7c | ||
|
|
4f08b52333 | ||
|
|
d3713bf2ee | ||
|
|
be4410ef1a | ||
|
|
29d07fe375 | ||
|
|
d9003bf63a |
+1
-1
@@ -15,7 +15,7 @@ GOVOPLAN_DB_MAX_OVERFLOW=10
|
|||||||
GOVOPLAN_DB_POOL_TIMEOUT_SECONDS=30
|
GOVOPLAN_DB_POOL_TIMEOUT_SECONDS=30
|
||||||
GOVOPLAN_DB_POOL_RECYCLE_SECONDS=1800
|
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
|
CELERY_ENABLED=true
|
||||||
REDIS_URL=redis://127.0.0.1:6379/0
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
name: Dependency Audit
|
name: Dependency Audit
|
||||||
|
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
@@ -21,13 +23,13 @@ jobs:
|
|||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
- name: Use anonymous HTTPS for public GovOPlaN repositories
|
- name: Use HTTPS for GovOPlaN repositories
|
||||||
run: |
|
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 "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"
|
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
|
- name: Bootstrap GovOPlaN repositories
|
||||||
working-directory: govoplan
|
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
|
- name: Install backend dev audit dependencies
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
name: Module Matrix
|
name: Module Matrix
|
||||||
|
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -19,6 +21,13 @@ jobs:
|
|||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 20
|
--health-retries 20
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
with:
|
with:
|
||||||
@@ -29,13 +38,13 @@ jobs:
|
|||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
- name: Use anonymous HTTPS for public GovOPlaN repositories
|
- name: Use HTTPS for GovOPlaN repositories
|
||||||
run: |
|
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 "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"
|
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
|
- name: Bootstrap GovOPlaN repositories
|
||||||
working-directory: govoplan
|
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
|
- name: Install backend release dependencies
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: |
|
run: |
|
||||||
@@ -64,6 +73,13 @@ jobs:
|
|||||||
-s ../govoplan-search/tests \
|
-s ../govoplan-search/tests \
|
||||||
-p test_postgres_search.py \
|
-p test_postgres_search.py \
|
||||||
-v
|
-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
|
- name: Run module matrix and contract tests
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: GOVOPLAN_CORE_ROOT="$PWD/../govoplan-core" PYTHON="$PWD/.venv/bin/python" bash tools/checks/check-module-matrix.sh
|
run: GOVOPLAN_CORE_ROOT="$PWD/../govoplan-core" PYTHON="$PWD/.venv/bin/python" bash tools/checks/check-module-matrix.sh
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
name: Release Integration
|
name: Release Integration
|
||||||
|
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -16,13 +18,13 @@ jobs:
|
|||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
- name: Use anonymous HTTPS for public GovOPlaN repositories
|
- name: Use HTTPS for GovOPlaN repositories
|
||||||
run: |
|
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 "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"
|
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
|
- name: Bootstrap GovOPlaN repositories
|
||||||
working-directory: govoplan
|
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
|
- name: Install backend release integration dependencies
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
name: Security Audit
|
name: Security Audit
|
||||||
|
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -30,7 +32,7 @@ jobs:
|
|||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Bootstrap GovOPlaN repositories
|
- name: Bootstrap GovOPlaN repositories
|
||||||
working-directory: govoplan
|
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
|
- name: Run whole-system security audit
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: tools/checks/security-audit/run.sh --mode "$SECURITY_AUDIT_MODE" --scope "$SECURITY_AUDIT_SCOPE" --reports-dir audit-reports
|
run: tools/checks/security-audit/run.sh --mode "$SECURITY_AUDIT_MODE" --scope "$SECURITY_AUDIT_SCOPE" --reports-dir audit-reports
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.venv/
|
.venv/
|
||||||
runtime/
|
runtime/
|
||||||
|
!tools/release/runtime/
|
||||||
|
tools/release/runtime/*
|
||||||
|
!tools/release/runtime/Dockerfile.api
|
||||||
|
!tools/release/runtime/Dockerfile.web
|
||||||
|
!tools/release/runtime/nginx.conf
|
||||||
__pycache__/
|
__pycache__/
|
||||||
audit-reports/
|
audit-reports/
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ Clone missing repositories listed in `repositories.json`:
|
|||||||
./tools/repo/bootstrap-repositories.py
|
./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:
|
Update generated repository type notes in all READMEs:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -91,8 +91,15 @@ The private installation directory contains:
|
|||||||
| `compose.json` | Deterministic generated Compose definition |
|
| `compose.json` | Deterministic generated Compose definition |
|
||||||
| `garage.toml` | Non-secret managed Garage server configuration |
|
| `garage.toml` | Non-secret managed Garage server configuration |
|
||||||
| `load-balancer.cfg` | Non-secret HAProxy WebUI/API discovery 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 |
|
| `plan.json` | Latest desired-state diff and readiness findings |
|
||||||
| `receipt.json` | Last successfully applied immutable identities |
|
| `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 |
|
| `applied-state/` | Checksum-verified snapshot of the last healthy deployment bundle |
|
||||||
| `operations/<id>/` | Private hash-chained deployment progress and recovery evidence |
|
| `operations/<id>/` | Private hash-chained deployment progress and recovery evidence |
|
||||||
| `kubernetes.json` | Optional stateless multi-host Kubernetes export |
|
| `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
|
only to exercise local orchestration before release artifacts exist; it is
|
||||||
rejected for `self-hosted`.
|
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
|
## Current Production Gates
|
||||||
|
|
||||||
The tool deliberately reports blockers instead of pretending the source tree is
|
The tool deliberately reports blockers instead of pretending the source tree is
|
||||||
a production distribution:
|
a production distribution:
|
||||||
|
|
||||||
1. **OCI release artifacts.** The release pipeline does not yet publish pinned
|
1. **First publication.** The protected workflow and fail-closed artifact
|
||||||
multi-architecture API and WebUI images.
|
contracts are implemented, but a release operator must configure the Gitea
|
||||||
2. **Signed distribution manifest.** A channel manifest must bind exact image
|
registry/release tokens and runtime Ed25519 key, publish the first pinned
|
||||||
digests, Compose compatibility, SBOM/provenance references, and revocation
|
release, and retain its amd64/arm64 readiness evidence.
|
||||||
state. Recording a URL and checksum is not signature verification.
|
|
||||||
3. **First administrator.** Production needs a one-time, restricted enrollment
|
3. **First administrator.** Production needs a one-time, restricted enrollment
|
||||||
identity. The development bootstrap must not be enabled in production.
|
identity. The development bootstrap must not be enabled in production.
|
||||||
4. **Image/module composition.** The selected module set must be proven present
|
4. **Image/module composition.** The deployer now enforces the signed
|
||||||
in the exact image or installed from verified offline artifacts before it is
|
composition. A selected module not shipped by that release cannot be
|
||||||
enabled.
|
enabled.
|
||||||
5. **Deployment agent.** Web updates need a separate privileged reconciler with
|
5. **Deployment agent.** Web updates need a separate privileged reconciler with
|
||||||
a typed command allowlist. The API and browser must never receive the Docker
|
a typed command allowlist. The API and browser must never receive the Docker
|
||||||
socket or arbitrary shell access.
|
socket or arbitrary shell access.
|
||||||
6. **Ingress and certificates.** The managed HAProxy service provides HTTP
|
6. **Ingress reachability evidence.** Managed Caddy ingress and the
|
||||||
load balancing inside the deployment boundary; it does not issue or renew
|
existing-proxy contract are implemented. A production claim still requires
|
||||||
certificates. A self-hosted profile still needs an explicit choice
|
running `doctor` from the target host after public DNS/firewall changes and
|
||||||
between an existing reverse proxy and a supported managed ingress, including
|
retaining the first successful container drill and public TLS/readiness
|
||||||
trusted-proxy boundaries, TLS certificate issuance, renewal, and health
|
evidence.
|
||||||
probing through the public route.
|
|
||||||
|
|
||||||
`apply --allow-unverified-images` is therefore restricted to the evaluation
|
`apply --allow-unverified-images` is therefore restricted to the evaluation
|
||||||
profile. It explicitly acknowledges both mutable image identities and
|
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
|
### 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
|
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
|
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
|
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
|
fencing-token lease. Multiple API replicas are rejected when Redis is disabled
|
||||||
because distributed throttling and queued work cannot then be shared correctly.
|
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
|
This is same-host scaling. Docker Compose uses a bridge network and does not
|
||||||
place containers on another machine. See
|
place containers on another machine. See
|
||||||
[Scaling And Multi-Host Deployment](SCALING_AND_MULTI_HOST_DEPLOYMENT.md) for
|
[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
|
backup is restored. See
|
||||||
[Recovery And Rollback Guarantees](RECOVERY_AND_ROLLBACK_GUARANTEES.md).
|
[Recovery And Rollback Guarantees](RECOVERY_AND_ROLLBACK_GUARANTEES.md).
|
||||||
|
|
||||||
Production updates still need an operator-provided database backup/restore
|
Production updates still need operator/provider-created coordinated backup and
|
||||||
gate, database compatibility declaration, image signature verification, and
|
restore evidence, a database compatibility declaration, and a
|
||||||
deployment-specific drain policy. The deployment journal proves its own
|
deployment-specific drain policy. The deployer now verifies and enforces the
|
||||||
actions; it does not manufacture backup evidence.
|
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
|
## 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
|
containers, API readiness/liveness probes, rolling Deployments, Services, Pod
|
||||||
disruption budgets, a tokenless ServiceAccount, and one fenced scheduler. Apply
|
disruption budgets, a tokenless ServiceAccount, and one fenced scheduler. Apply
|
||||||
the named Secret through the cluster's secret manager and review ingress proxy
|
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).
|
[Scaling And Multi-Host Deployment](SCALING_AND_MULTI_HOST_DEPLOYMENT.md).
|
||||||
|
|
||||||
## Recovery Commands
|
## Recovery Commands
|
||||||
@@ -353,22 +491,37 @@ of the reviewed update recipe instead of a non-functional update button.
|
|||||||
|
|
||||||
## Distribution Workflow
|
## 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
|
```sh
|
||||||
curl --proto '=https' --tlsv1.2 --fail --location \
|
curl --proto '=https' --tlsv1.2 --fail --location \
|
||||||
https://govoplan.add-ideas.de/install/v1/bootstrap.pyz \
|
https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/vX.Y.Z/govoplan-deploy.pyz \
|
||||||
--output govoplan-bootstrap.pyz
|
--output govoplan-deploy.pyz
|
||||||
python3 govoplan-bootstrap.pyz init
|
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
|
The zipapp has no GovOPlaN package dependency. It accepts a bounded HTTPS
|
||||||
verification command before execution. The zipapp then downloads only a signed
|
manifest or a prefetched file, requires an independently supplied SHA-256
|
||||||
distribution manifest, verifies it against an embedded or explicitly installed
|
digest and explicit trusted keyring, and executes OpenSSL with a fixed argument
|
||||||
keyring, and renders the same installation contract implemented here.
|
vector for Ed25519 verification. It never evaluates downloaded shell text or
|
||||||
|
accepts an arbitrary command string.
|
||||||
The source-tree script is the test harness for that future zipapp. It is not yet
|
|
||||||
the internet bootstrap artifact.
|
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -378,7 +531,9 @@ Run the focused tests:
|
|||||||
./.venv/bin/python -m unittest -v tests.test_deployment_installer
|
./.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
|
requirements, managed Garage bootstrap, S3 policy, replica validation, HAProxy
|
||||||
discovery configuration, Compose service selection, secret non-disclosure,
|
discovery configuration, Compose service selection, secret non-disclosure,
|
||||||
service-specific environment isolation, private file modes, external endpoint
|
service-specific environment isolation, private file modes, external endpoint
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ machine-readable field, label, translation, route, API-reference, and module
|
|||||||
manifest evidence. This hand-maintained document remains the reviewed product
|
manifest evidence. This hand-maintained document remains the reviewed product
|
||||||
interpretation and rollout ledger; generated evidence does not replace it.
|
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:
|
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 |
|
| 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 |
|
| `/` 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 |
|
| `/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 |
|
| 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
|
## Direct Module Route Contributions
|
||||||
|
|
||||||
The access guard column reports only the route-level declaration in
|
The access column summarizes only the route-level declaration in `module.ts`.
|
||||||
`module.ts`. Inner APIs and controls may impose additional checks.
|
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 |
|
| Routes | Owner | Route-level access | Primary archetype | Migration issue |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `/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) |
|
| `/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` | `govoplan-addresses` `AddressBookPage` | `addresses:contact:read` | Browse and manage contacts, address books, and lists | Directory/list-detail | Contributed; unreviewed; P2 after Campaign |
|
| `/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` |
|
||||||
| `/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 |
|
| `/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` |
|
||||||
| `/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) |
|
| `/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/: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 |
|
| `/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` | `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 |
|
| `/operator` | Campaign | Campaign read plus queue/control scope | Compatibility redirect to `/campaigns/queue` | Campaign #74; retire under the compatibility policy |
|
||||||
| `/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 |
|
| `/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` |
|
||||||
| `/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 |
|
| `/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` | `govoplan-dashboard` `DashboardPage` | No route-specific scope | Assemble module-provided actionable widgets | Dashboard | Contributed; unreviewed; P2 |
|
| `/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` |
|
||||||
| `/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 |
|
| `/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` |
|
||||||
| `/files` | `govoplan-files` `FilesPage` | `files:file:read` | Browse folders/files and perform managed-file work | Directory/explorer | Contributed; metadata gap; unreviewed; P2 after Campaign |
|
| `/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` |
|
||||||
| `/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 |
|
| `/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` |
|
||||||
| `/mail` | `govoplan-mail` `MailboxPage` | `mail:mailbox:read` | Browse mailboxes and messages | Directory/list-detail | Contributed; metadata gap; unreviewed; P2 after Campaign |
|
| `/docs` | Docs | Documentation or settings read | Documentation/reference | [Docs #15](https://git.add-ideas.de/GovOPlaN/govoplan-docs/issues/15) |
|
||||||
| `/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 |
|
| `/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` |
|
||||||
| `/ops` | `govoplan-ops` `OpsPage` | Ops read or system/tenant settings read scopes | Inspect runtime health and readiness | Monitoring | Contributed; unreviewed; P2 |
|
| `/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` |
|
||||||
| `/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 |
|
| `/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` |
|
||||||
| `/scheduling` | `govoplan-scheduling` `SchedulingPage` | `scheduling:schedule:read` | Plan and decide scheduling requests and availability | List-detail/guided decision | Contributed; metadata gap; unreviewed; P2 |
|
| `/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
|
## 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
|
is recorded here as an evidence gap; this inventory does not infer whether each
|
||||||
gap is intentional.
|
gap is intentional.
|
||||||
|
|
||||||
| Module | `module.ts` routes | Backend manifest frontend routes | Backend nav alignment | Result |
|
The generated comparison is aligned for all authenticated canonical routes.
|
||||||
| --- | --- | --- | --- | --- |
|
Two deliberate exceptions remain visible:
|
||||||
| 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 |
|
|
||||||
|
|
||||||
Before a release claims a complete configured-system route inventory, add a
|
- Campaign contributes `/operator` as a compatibility redirect for saved View
|
||||||
contract check or explicit exceptions so executable routes and manifest
|
projections; its canonical and manifest-declared destination is
|
||||||
metadata cannot silently diverge.
|
`/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
|
## 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 |
|
| 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` | 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 | In progress under Core #225; surface-level evidence still needed |
|
| `/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-audit` `admin.sections` | System audit; tenant audit | Evidence/provenance and reporting | Unreviewed |
|
| `/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-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-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-organizations` `admin.sections` | Tenant organization settings | Configuration/list-detail | 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 | Pattern migration, contextual help, blocker explanations and focused evidence complete in Files #42 (`d8ae506`) |
|
||||||
| `/admin` | `govoplan-policy` `admin.sections` | System, tenant, group, and user retention | Effective value, source/provenance, consequential configuration | Unreviewed; Core #225 phase 4 |
|
| `/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` 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 |
|
| `/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` |
|
||||||
| `/settings` | Core host | Profile; interface; workspace; local connection | Personal configuration with adaptive forms and immediate feedback | Unreviewed |
|
| `/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` | Files and Mail named capabilities | User-scoped file connections and mail profiles/policy | Optional integration regions disappear cleanly when capability absent | Unreviewed |
|
| `/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 |
|
| `/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 |
|
| `/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 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`,
|
Other named capability exports (`files.connectors`, `organizations.functionPicker`,
|
||||||
and mail profile validation) are contracts consumed inside the composed surfaces
|
and mail profile validation) are contracts consumed inside the composed surfaces
|
||||||
above; they are not independent routes.
|
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 Pilot Surface Map
|
||||||
|
|
||||||
Campaign is detailed first because it exercises almost every archetype. The
|
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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 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
|
results`. Campaign #63 owns the intervention and status vocabulary; Workflow is
|
||||||
not required to define or implement it.
|
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
|
The generated manifest snapshot reports no WebUI package for:
|
||||||
`webui/src/module.ts` at this snapshot:
|
|
||||||
|
|
||||||
`govoplan-approvals`, `govoplan-assets`, `govoplan-booking`,
|
`govoplan-assets`, `govoplan-booking`, `govoplan-certificates`,
|
||||||
`govoplan-certificates`, `govoplan-committee`, `govoplan-consultation`,
|
`govoplan-connectors`, `govoplan-consultation`, `govoplan-contracts`,
|
||||||
`govoplan-contracts`, `govoplan-dist-lists`, `govoplan-evaluation`,
|
`govoplan-decisions`, `govoplan-encryption`, `govoplan-evaluation`,
|
||||||
`govoplan-facilities`, `govoplan-forms-runtime`, `govoplan-grants`,
|
`govoplan-facilities`, `govoplan-grants`, `govoplan-helpdesk`,
|
||||||
`govoplan-helpdesk`, `govoplan-identity`, `govoplan-inspections`,
|
`govoplan-identity`, `govoplan-identity-trust`, `govoplan-inspections`,
|
||||||
`govoplan-tickets`, `govoplan-learning`, `govoplan-permits`,
|
`govoplan-learning`, `govoplan-mandates`, `govoplan-parties`,
|
||||||
`govoplan-poll`, `govoplan-procurement`, `govoplan-records`,
|
`govoplan-permits`, `govoplan-poll`, `govoplan-procurement`,
|
||||||
`govoplan-resources`, `govoplan-rest`, `govoplan-risk-compliance`,
|
`govoplan-records`, `govoplan-resources`, `govoplan-rest`,
|
||||||
`govoplan-soap`, `govoplan-tenancy`, and `govoplan-transparency`.
|
`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
|
Tenancy does provide composed administration surfaces despite having no direct
|
||||||
maturity or decide that it needs a WebUI. Connector-only, capability-only, or
|
route. This section is only negative package evidence; connector-only,
|
||||||
backend-only modules may remain intentionally headless.
|
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
|
## 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 |
|
| 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 |
|
| 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 |
|
| 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`) |
|
| 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`) |
|
| 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`) |
|
| 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`) |
|
| 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`) |
|
| 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 |
|
| 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 |
|
| 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 | 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 |
|
| 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 | 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 |
|
| 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 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 |
|
| 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 |
|
| 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
|
Workflow remains outside this rollout matrix because it has its own runtime and
|
||||||
|
|||||||
@@ -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
|
This is a platform contract, not an assertion that every existing module
|
||||||
operation has adopted it. Module operations with external or multi-resource
|
operation has adopted it. Module operations with external or multi-resource
|
||||||
effects must be migrated to the ledger before claiming these guarantees.
|
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
|
## 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
|
3. restore a separately verified, coordinated database/object/key backup and
|
||||||
then deploy the matching release.
|
then deploy the matching release.
|
||||||
|
|
||||||
The deployment tool does not create or validate that database backup. A
|
The deployment tool does not create that backup. It does verify an externally
|
||||||
`backup-required` annotation on the Kubernetes migration Job is an operator
|
produced, signed evidence contract covering PostgreSQL, objects, protected
|
||||||
gate, not backup evidence. Production automation must provide a backup hook or
|
configuration, and key custody at one recovery point plus an isolated restore
|
||||||
external backup controller whose artifact, timestamp, scope, encryption key,
|
drill. A self-hosted release change cannot reach the migration command or be
|
||||||
and restore test can be referenced from the recovery record.
|
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
|
## Scaled Nodes
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -190,19 +190,45 @@ access, node visibility, drain controls, migration serialization, and scheduler
|
|||||||
fencing. It does not by itself provide:
|
fencing. It does not by itself provide:
|
||||||
|
|
||||||
- a highly available PostgreSQL, Redis, or object-store deployment;
|
- 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;
|
- autoscaling policy;
|
||||||
- central logs, metrics, traces, or alert routing;
|
- 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;
|
- automatic reconciliation of every possible module side effect;
|
||||||
- a service-level availability guarantee.
|
- 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
|
availability, drill replica loss, rolling replacement, session continuity, job
|
||||||
redelivery, scheduler failover, migration exclusion, object-store outage, and a
|
redelivery, scheduler failover, migration exclusion, object-store outage, and a
|
||||||
coordinated database/object/key restore. Recovery rules and evidence are
|
coordinated database/object/key restore. Recovery rules and evidence are
|
||||||
defined in [Recovery And Rollback Guarantees](RECOVERY_AND_ROLLBACK_GUARANTEES.md).
|
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
|
## Live Multi-Host Evidence
|
||||||
|
|
||||||
After deploying a pinned release on at least two Kubernetes nodes, create an API
|
After deploying a pinned release on at least two Kubernetes nodes, create an API
|
||||||
|
|||||||
@@ -141,6 +141,12 @@ The canonical backlog item is
|
|||||||
|
|
||||||
Implementation status as of the current source tree:
|
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
|
- Slice 6 has a working application-tier foundation: state profiles, shared
|
||||||
object storage, runtime node registration/heartbeats/drain, fenced scheduler,
|
object storage, runtime node registration/heartbeats/drain, fenced scheduler,
|
||||||
migration serialization, exact-head startup waiting, Ops visibility, and a
|
migration serialization, exact-head startup waiting, Ops visibility, and a
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,18 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^$|^[0-9a-f]{64}$"
|
"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": {
|
"api_image": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
"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": {
|
"enabled_modules": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"uniqueItems": true,
|
"uniqueItems": true,
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
-e ../govoplan-dashboard
|
-e ../govoplan-dashboard
|
||||||
-e ../govoplan-addresses
|
-e ../govoplan-addresses
|
||||||
-e ../govoplan-dist-lists
|
-e ../govoplan-dist-lists
|
||||||
|
-e ../govoplan-templates
|
||||||
-e ../govoplan-files
|
-e ../govoplan-files
|
||||||
-e ../govoplan-forms
|
-e ../govoplan-forms
|
||||||
-e ../govoplan-forms-runtime
|
-e ../govoplan-forms-runtime
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -25,7 +25,9 @@ from govoplan_deploy.bundle import ( # noqa: E402
|
|||||||
initial_secrets,
|
initial_secrets,
|
||||||
read_env,
|
read_env,
|
||||||
reconcile_runtime_environment,
|
reconcile_runtime_environment,
|
||||||
|
render_caddy_config,
|
||||||
render_compose,
|
render_compose,
|
||||||
|
render_existing_proxy_contract,
|
||||||
render_load_balancer_config,
|
render_load_balancer_config,
|
||||||
write_env,
|
write_env,
|
||||||
)
|
)
|
||||||
@@ -316,7 +318,16 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
rendered = json.dumps(manifest, sort_keys=True)
|
||||||
kinds = [item["kind"] for item in manifest["items"]]
|
kinds = [item["kind"] for item in manifest["items"]]
|
||||||
deployments = {
|
deployments = {
|
||||||
@@ -349,6 +360,18 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
"forward-recovery",
|
"forward-recovery",
|
||||||
migration["metadata"]["annotations"]["govoplan.add-ideas.de/recovery-mode"],
|
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")
|
config = next(item for item in manifest["items"] if item["kind"] == "ConfigMap")
|
||||||
self.assertEqual("shared", config["data"]["GOVOPLAN_STATE_PROFILE"])
|
self.assertEqual("shared", config["data"]["GOVOPLAN_STATE_PROFILE"])
|
||||||
self.assertEqual("3", config["data"]["GOVOPLAN_EXPECTED_API_REPLICAS"])
|
self.assertEqual("3", config["data"]["GOVOPLAN_EXPECTED_API_REPLICAS"])
|
||||||
@@ -509,6 +532,65 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
self.assertEqual(1, compose["services"]["api"]["scale"])
|
self.assertEqual(1, compose["services"]["api"]["scale"])
|
||||||
self.assertEqual(1, compose["services"]["web"]["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(
|
def test_disabled_redis_removes_workers_and_sets_single_process_acknowledgement(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -713,6 +795,7 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
def test_legacy_spec_defaults_new_topology_fields(self) -> None:
|
def test_legacy_spec_defaults_new_topology_fields(self) -> None:
|
||||||
raw = default_spec().to_dict()
|
raw = default_spec().to_dict()
|
||||||
raw.pop("replicas")
|
raw.pop("replicas")
|
||||||
|
raw.pop("ingress")
|
||||||
raw["components"].pop("load_balancer")
|
raw["components"].pop("load_balancer")
|
||||||
raw["components"]["storage"].pop("image")
|
raw["components"]["storage"].pop("image")
|
||||||
|
|
||||||
@@ -722,6 +805,7 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
self.assertEqual(1, parsed.replicas.web)
|
self.assertEqual(1, parsed.replicas.web)
|
||||||
self.assertEqual(1, parsed.replicas.worker)
|
self.assertEqual(1, parsed.replicas.worker)
|
||||||
self.assertEqual("managed", parsed.components.load_balancer.mode)
|
self.assertEqual("managed", parsed.components.load_balancer.mode)
|
||||||
|
self.assertEqual("local", parsed.ingress.mode)
|
||||||
|
|
||||||
def test_compose_contains_no_secret_values(self) -> None:
|
def test_compose_contains_no_secret_values(self) -> None:
|
||||||
spec = default_spec()
|
spec = default_spec()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -21,6 +21,28 @@ SPEC.loader.exec_module(inventory)
|
|||||||
|
|
||||||
|
|
||||||
class PlatformInterfaceInventoryTests(unittest.TestCase):
|
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:
|
def test_canonical_api_path_normalizes_versions_and_parameters(self) -> None:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
inventory.canonical_api_path(
|
inventory.canonical_api_path(
|
||||||
|
|||||||
@@ -113,6 +113,60 @@ class PythonEnvironmentSyncTests(unittest.TestCase):
|
|||||||
self.assertEqual(plan.mode, "Selective Python environment repair")
|
self.assertEqual(plan.mode, "Selective Python environment repair")
|
||||||
self.assertEqual(plan.commands[0][-2:], ("-e", str(project_root)))
|
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:
|
def test_declared_module_entry_points_are_part_of_environment_validation(self) -> None:
|
||||||
sync = load_sync_module()
|
sync = load_sync_module()
|
||||||
with tempfile.TemporaryDirectory(prefix="govoplan-python-sync-") as directory:
|
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
|
||||||
|
)
|
||||||
@@ -340,6 +340,7 @@ def add_scoped_workflow_manifest(repo: Path) -> None:
|
|||||||
(backend / "__init__.py").write_text("", encoding="utf-8")
|
(backend / "__init__.py").write_text("", encoding="utf-8")
|
||||||
(backend / "manifest.py").write_text(
|
(backend / "manifest.py").write_text(
|
||||||
"""from govoplan_core.core.modules import DocumentationCondition, DocumentationTopic, ModuleManifest, PermissionDefinition
|
"""from govoplan_core.core.modules import DocumentationCondition, DocumentationTopic, ModuleManifest, PermissionDefinition
|
||||||
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
|
||||||
|
|
||||||
def get_manifest():
|
def get_manifest():
|
||||||
@@ -368,6 +369,20 @@ def get_manifest():
|
|||||||
conditions=(DocumentationCondition(required_scopes=("access:item:read",)),),
|
conditions=(DocumentationCondition(required_scopes=("access:item:read",)),),
|
||||||
metadata={"kind": "workflow"},
|
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 = repo / "src" / "govoplan_access" / "backend" / "manifest.py"
|
||||||
manifest.write_text(
|
manifest.write_text(
|
||||||
"""from govoplan_core.core.modules import DocumentationTopic, ModuleManifest
|
"""from govoplan_core.core.modules import DocumentationTopic, ModuleManifest
|
||||||
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
|
||||||
|
|
||||||
def get_manifest():
|
def get_manifest():
|
||||||
@@ -402,6 +418,20 @@ def get_manifest():
|
|||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
metadata={"kind": "workflow"},
|
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.",),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -94,8 +94,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
|
|||||||
"name": "govoplan-core",
|
"name": "govoplan-core",
|
||||||
"path": "govoplan-core",
|
"path": "govoplan-core",
|
||||||
"remote": (
|
"remote": (
|
||||||
"git@git.add-ideas.de:"
|
"git@git.add-ideas.de:GovOPlaN/govoplan-core.git"
|
||||||
"GovOPlaN/govoplan-core.git"
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -134,6 +133,66 @@ class RepositoryBootstrapTests(unittest.TestCase):
|
|||||||
self.assertEqual("/bin/false", environment["GIT_ASKPASS"])
|
self.assertEqual("/bin/false", environment["GIT_ASKPASS"])
|
||||||
self.assertEqual("0", environment["GIT_TERMINAL_PROMPT"])
|
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:
|
def test_main_validates_every_remote_before_cloning(self) -> None:
|
||||||
bootstrap = load_bootstrap_module()
|
bootstrap = load_bootstrap_module()
|
||||||
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
||||||
@@ -149,8 +208,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
|
|||||||
"name": "govoplan-core",
|
"name": "govoplan-core",
|
||||||
"path": "govoplan-core",
|
"path": "govoplan-core",
|
||||||
"remote": (
|
"remote": (
|
||||||
"git@git.add-ideas.de:"
|
"git@git.add-ideas.de:GovOPlaN/govoplan-core.git"
|
||||||
"GovOPlaN/govoplan-core.git"
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -197,9 +255,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
|
|||||||
"git@git.add-ideas.de:"
|
"git@git.add-ideas.de:"
|
||||||
"add-ideas/addideas-govoplan-website.git"
|
"add-ideas/addideas-govoplan-website.git"
|
||||||
),
|
),
|
||||||
"bootstrap_transport": (
|
"bootstrap_transport": (bootstrap.REGISTERED_TRANSPORT),
|
||||||
bootstrap.REGISTERED_TRANSPORT
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -240,10 +296,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
|
|||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"path": name,
|
"path": name,
|
||||||
"remote": (
|
"remote": (f"git@git.add-ideas.de:GovOPlaN/{name}.git"),
|
||||||
"git@git.add-ideas.de:GovOPlaN/"
|
|
||||||
f"{name}.git"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
for name in ("govoplan-core", "govoplan-poll")
|
for name in ("govoplan-core", "govoplan-poll")
|
||||||
],
|
],
|
||||||
@@ -291,8 +344,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
|
|||||||
"name": "govoplan-core",
|
"name": "govoplan-core",
|
||||||
"path": "govoplan-core",
|
"path": "govoplan-core",
|
||||||
"remote": (
|
"remote": (
|
||||||
"git@git.add-ideas.de:"
|
"git@git.add-ideas.de:GovOPlaN/govoplan-core.git"
|
||||||
"GovOPlaN/govoplan-core.git"
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -333,8 +385,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
|
|||||||
"name": "govoplan-core",
|
"name": "govoplan-core",
|
||||||
"path": "govoplan-core",
|
"path": "govoplan-core",
|
||||||
"remote": (
|
"remote": (
|
||||||
"git@git.add-ideas.de:"
|
"git@git.add-ideas.de:GovOPlaN/govoplan-core.git"
|
||||||
"GovOPlaN/govoplan-core.git"
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -359,7 +410,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
|
|||||||
self.assertEqual(1, status)
|
self.assertEqual(1, status)
|
||||||
runner.assert_not_called()
|
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,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
manifest = json.loads(
|
manifest = json.loads(
|
||||||
@@ -372,9 +423,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
self.assertTrue(registered_only)
|
self.assertTrue(registered_only)
|
||||||
|
|
||||||
for workflow in sorted(
|
for workflow in sorted((META_ROOT / ".gitea" / "workflows").glob("*.yml")):
|
||||||
(META_ROOT / ".gitea" / "workflows").glob("*.yml")
|
|
||||||
):
|
|
||||||
contents = workflow.read_text(encoding="utf-8")
|
contents = workflow.read_text(encoding="utf-8")
|
||||||
if (
|
if (
|
||||||
"bootstrap-repositories.py" not in contents
|
"bootstrap-repositories.py" not in contents
|
||||||
@@ -382,6 +431,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
with self.subTest(workflow=workflow.name):
|
with self.subTest(workflow=workflow.name):
|
||||||
|
self.assertIn("--reuse-checkout-auth", contents)
|
||||||
for repository in registered_only:
|
for repository in registered_only:
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
f"--exclude-repo {repository}",
|
f"--exclude-repo {repository}",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -25,6 +25,12 @@ from govoplan_core.core.datasources import (
|
|||||||
datasource_publication,
|
datasource_publication,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import ModuleContext
|
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 (
|
from govoplan_core.core.tabular_sources import (
|
||||||
TabularSnapshotInput,
|
TabularSnapshotInput,
|
||||||
tabular_snapshot_writer,
|
tabular_snapshot_writer,
|
||||||
@@ -73,6 +79,9 @@ def main() -> int:
|
|||||||
Base.metadata.create_all(
|
Base.metadata.create_all(
|
||||||
engine,
|
engine,
|
||||||
tables=[
|
tables=[
|
||||||
|
DistributedLease.__table__,
|
||||||
|
RecoveryOperation.__table__,
|
||||||
|
RecoveryCheckpoint.__table__,
|
||||||
ConnectorTabularSource.__table__,
|
ConnectorTabularSource.__table__,
|
||||||
DatasourceRecord.__table__,
|
DatasourceRecord.__table__,
|
||||||
DatasourcePayloadRecord.__table__,
|
DatasourcePayloadRecord.__table__,
|
||||||
@@ -86,153 +95,168 @@ def main() -> int:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
session_factory = sessionmaker(bind=engine)
|
session_factory = sessionmaker(bind=engine)
|
||||||
with session_factory() as session:
|
bind_process_runtime_identity(_runtime_identity())
|
||||||
principal = _principal()
|
try:
|
||||||
writer = tabular_snapshot_writer(registry)
|
with session_factory() as session:
|
||||||
lifecycle = datasource_lifecycle(registry)
|
principal = _principal()
|
||||||
catalogue = datasource_catalogue(registry)
|
writer = tabular_snapshot_writer(registry)
|
||||||
publisher = datasource_publication(registry)
|
lifecycle = datasource_lifecycle(registry)
|
||||||
runner = dataflow_run_lifecycle(registry)
|
catalogue = datasource_catalogue(registry)
|
||||||
if (
|
publisher = datasource_publication(registry)
|
||||||
writer is None
|
runner = dataflow_run_lifecycle(registry)
|
||||||
or lifecycle is None
|
if (
|
||||||
or catalogue is None
|
writer is None
|
||||||
or publisher is None
|
or lifecycle is None
|
||||||
or runner is None
|
or catalogue is None
|
||||||
):
|
or publisher is None
|
||||||
raise RuntimeError("Datasource composition capabilities are incomplete.")
|
or runner is None
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Datasource composition capabilities are incomplete."
|
||||||
|
)
|
||||||
|
|
||||||
origin = writer.create_snapshot(
|
origin = writer.create_snapshot(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
snapshot=TabularSnapshotInput(
|
snapshot=TabularSnapshotInput(
|
||||||
name="Monthly cases",
|
name="Monthly cases",
|
||||||
source_name="connector_monthly_cases",
|
source_name="connector_monthly_cases",
|
||||||
rows=(
|
rows=(
|
||||||
{"id": 1, "amount": 5},
|
{"id": 1, "amount": 5},
|
||||||
{"id": 2, "amount": 15},
|
{"id": 2, "amount": 15},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
)
|
datasource = lifecycle.register_origin(
|
||||||
datasource = lifecycle.register_origin(
|
session,
|
||||||
session,
|
principal,
|
||||||
principal,
|
origin_ref=origin.ref,
|
||||||
origin_ref=origin.ref,
|
name="Monthly cases cache",
|
||||||
name="Monthly cases cache",
|
source_name="monthly_cases",
|
||||||
source_name="monthly_cases",
|
mode="cached",
|
||||||
mode="cached",
|
)
|
||||||
)
|
result = preview_pipeline(
|
||||||
result = preview_pipeline(
|
session,
|
||||||
session,
|
tenant_id="tenant-1",
|
||||||
tenant_id="tenant-1",
|
actor_id="account-1",
|
||||||
actor_id="account-1",
|
payload=PipelinePreviewRequest(
|
||||||
payload=PipelinePreviewRequest(
|
graph=_graph(
|
||||||
graph=_graph(
|
datasource_ref=datasource.ref,
|
||||||
datasource_ref=datasource.ref,
|
fingerprint=datasource.fingerprint,
|
||||||
fingerprint=datasource.fingerprint,
|
),
|
||||||
|
row_limit=100,
|
||||||
),
|
),
|
||||||
row_limit=100,
|
principal=principal,
|
||||||
),
|
registry=registry,
|
||||||
principal=principal,
|
)
|
||||||
registry=registry,
|
expected_rows = [
|
||||||
)
|
{"id": 1, "amount": 5},
|
||||||
expected_rows = [
|
{"id": 2, "amount": 15},
|
||||||
{"id": 1, "amount": 5},
|
]
|
||||||
{"id": 2, "amount": 15},
|
if result.status != "succeeded":
|
||||||
]
|
raise RuntimeError(
|
||||||
if result.status != "succeeded":
|
f"Dataflow preview failed: {result.diagnostics}"
|
||||||
raise RuntimeError(f"Dataflow preview failed: {result.diagnostics}")
|
)
|
||||||
if result.rows != expected_rows:
|
if result.rows != expected_rows:
|
||||||
raise RuntimeError(f"Unexpected Dataflow rows: {result.rows!r}")
|
raise RuntimeError(f"Unexpected Dataflow rows: {result.rows!r}")
|
||||||
if result.source_fingerprints[0]["source_ref"] != datasource.ref:
|
if result.source_fingerprints[0]["source_ref"] != datasource.ref:
|
||||||
raise RuntimeError("Dataflow lineage did not retain the datasource reference.")
|
raise RuntimeError(
|
||||||
pipeline = create_pipeline(
|
"Dataflow lineage did not retain the datasource reference."
|
||||||
session,
|
)
|
||||||
tenant_id="tenant-1",
|
pipeline = create_pipeline(
|
||||||
actor_id="account-1",
|
session,
|
||||||
payload=PipelineCreateRequest(
|
tenant_id="tenant-1",
|
||||||
name="Monthly case output",
|
actor_id="account-1",
|
||||||
status="active",
|
payload=PipelineCreateRequest(
|
||||||
graph=_graph(
|
name="Monthly case output",
|
||||||
datasource_ref=datasource.ref,
|
status="active",
|
||||||
fingerprint=datasource.fingerprint,
|
graph=_graph(
|
||||||
|
datasource_ref=datasource.ref,
|
||||||
|
fingerprint=datasource.fingerprint,
|
||||||
|
),
|
||||||
|
editor_mode="graph",
|
||||||
),
|
),
|
||||||
editor_mode="graph",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
run_request = DataflowRunRequest(
|
|
||||||
pipeline_ref=f"pipeline:{pipeline.id}",
|
|
||||||
revision=1,
|
|
||||||
idempotency_key="composition-run-1",
|
|
||||||
publication=DataflowPublicationTarget(
|
|
||||||
name="Monthly case result",
|
|
||||||
source_name="monthly_case_result",
|
|
||||||
freeze=True,
|
|
||||||
frozen_label="Composition evidence",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
published = runner.start_run(
|
|
||||||
session,
|
|
||||||
principal,
|
|
||||||
request=run_request,
|
|
||||||
)
|
|
||||||
replayed = runner.start_run(
|
|
||||||
session,
|
|
||||||
principal,
|
|
||||||
request=run_request,
|
|
||||||
)
|
|
||||||
if published.status != "queued":
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Dataflow run was not queued: {published.status}"
|
|
||||||
)
|
)
|
||||||
worker = SqlDataflowRunWorker(
|
run_request = DataflowRunRequest(
|
||||||
registry=_AutomationRegistry(registry, principal)
|
pipeline_ref=f"pipeline:{pipeline.id}",
|
||||||
)
|
revision=1,
|
||||||
worker_result = worker.dispatch_pending(
|
idempotency_key="composition-run-1",
|
||||||
session,
|
publication=DataflowPublicationTarget(
|
||||||
worker_id="composition-worker",
|
name="Monthly case result",
|
||||||
)
|
source_name="monthly_case_result",
|
||||||
if worker_result["succeeded"] != 1:
|
freeze=True,
|
||||||
raise RuntimeError(
|
frozen_label="Composition evidence",
|
||||||
f"Dataflow worker failed: {worker_result!r}"
|
),
|
||||||
)
|
)
|
||||||
completed = runner.get_run(
|
published = runner.start_run(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
run_ref=published.ref,
|
request=run_request,
|
||||||
)
|
|
||||||
if completed is None:
|
|
||||||
raise RuntimeError("Dataflow run evidence disappeared.")
|
|
||||||
published = completed
|
|
||||||
if published.status != "succeeded":
|
|
||||||
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.")
|
|
||||||
if (
|
|
||||||
not published.output_datasource_ref
|
|
||||||
or not published.output_materialization_ref
|
|
||||||
):
|
|
||||||
raise RuntimeError("Dataflow publication did not retain output references.")
|
|
||||||
output = catalogue.read_datasource(
|
|
||||||
session,
|
|
||||||
principal,
|
|
||||||
request=DatasourceReadRequest(
|
|
||||||
datasource_ref=published.output_datasource_ref,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if list(output.rows) != expected_rows:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Unexpected published Dataflow rows: {list(output.rows)!r}"
|
|
||||||
)
|
)
|
||||||
if (
|
replayed = runner.start_run(
|
||||||
output.materialization is None
|
session,
|
||||||
or output.materialization.ref != published.output_materialization_ref
|
principal,
|
||||||
or output.materialization.frozen_at is None
|
request=run_request,
|
||||||
):
|
|
||||||
raise RuntimeError(
|
|
||||||
"Published Datasource materialization is not pinned and frozen."
|
|
||||||
)
|
)
|
||||||
engine.dispose()
|
if published.status != "queued":
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Dataflow run was not queued: {published.status}"
|
||||||
|
)
|
||||||
|
worker = SqlDataflowRunWorker(
|
||||||
|
registry=_AutomationRegistry(registry, principal)
|
||||||
|
)
|
||||||
|
worker_result = worker.dispatch_pending(
|
||||||
|
session,
|
||||||
|
worker_id="composition-worker",
|
||||||
|
)
|
||||||
|
if worker_result["succeeded"] != 1:
|
||||||
|
raise RuntimeError(f"Dataflow worker failed: {worker_result!r}")
|
||||||
|
completed = runner.get_run(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
run_ref=published.ref,
|
||||||
|
)
|
||||||
|
if completed is None:
|
||||||
|
raise RuntimeError("Dataflow run evidence disappeared.")
|
||||||
|
published = completed
|
||||||
|
if published.status != "succeeded":
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not published.output_datasource_ref
|
||||||
|
or not published.output_materialization_ref
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Dataflow publication did not retain output references."
|
||||||
|
)
|
||||||
|
output = catalogue.read_datasource(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=DatasourceReadRequest(
|
||||||
|
datasource_ref=published.output_datasource_ref,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if list(output.rows) != expected_rows:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Unexpected published Dataflow rows: {list(output.rows)!r}"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
output.materialization is None
|
||||||
|
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(
|
print(
|
||||||
"Connector -> Datasources -> pinned Dataflow publication composition passed."
|
"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:
|
class _AutomationRegistry:
|
||||||
def __init__(self, registry, principal: ApiPrincipal) -> None:
|
def __init__(self, registry, principal: ApiPrincipal) -> None:
|
||||||
self.registry = registry
|
self.registry = registry
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -6,11 +6,13 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import zipapp
|
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
DEFAULT_OUTPUT = ROOT.parent.parent / "runtime" / "deployment" / "govoplan-deploy.pyz"
|
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:
|
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")
|
temporary = output.with_name(f".{output.name}.tmp")
|
||||||
if temporary.exists():
|
if temporary.exists():
|
||||||
temporary.unlink()
|
temporary.unlink()
|
||||||
zipapp.create_archive(
|
_write_reproducible_zipapp(temporary)
|
||||||
ROOT,
|
|
||||||
target=temporary,
|
|
||||||
interpreter="/usr/bin/env python3",
|
|
||||||
compressed=True,
|
|
||||||
filter=_include_source,
|
|
||||||
)
|
|
||||||
temporary.chmod(0o755)
|
temporary.chmod(0o755)
|
||||||
temporary.replace(output)
|
temporary.replace(output)
|
||||||
digest = sha256(output.read_bytes()).hexdigest()
|
digest = sha256(output.read_bytes()).hexdigest()
|
||||||
@@ -39,6 +35,42 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return 0
|
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:
|
def _include_source(path: Path) -> bool:
|
||||||
return (
|
return (
|
||||||
"__pycache__" not in path.parts
|
"__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",
|
||||||
|
]
|
||||||
@@ -22,9 +22,32 @@ ENV_FILENAME = "secrets.env"
|
|||||||
COMPOSE_FILENAME = "compose.json"
|
COMPOSE_FILENAME = "compose.json"
|
||||||
GARAGE_CONFIG_FILENAME = "garage.toml"
|
GARAGE_CONFIG_FILENAME = "garage.toml"
|
||||||
LOAD_BALANCER_CONFIG_FILENAME = "load-balancer.cfg"
|
LOAD_BALANCER_CONFIG_FILENAME = "load-balancer.cfg"
|
||||||
|
CADDY_CONFIG_FILENAME = "Caddyfile"
|
||||||
|
EXISTING_PROXY_FILENAME = "existing-proxy.json"
|
||||||
PLAN_FILENAME = "plan.json"
|
PLAN_FILENAME = "plan.json"
|
||||||
RECEIPT_FILENAME = "receipt.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"
|
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 = (
|
RUNTIME_ENV_KEYS = (
|
||||||
"APP_ENV",
|
"APP_ENV",
|
||||||
"GOVOPLAN_INSTALL_PROFILE",
|
"GOVOPLAN_INSTALL_PROFILE",
|
||||||
@@ -74,6 +97,7 @@ RUNTIME_ENV_KEYS = (
|
|||||||
"FILE_STORAGE_S3_BUCKET",
|
"FILE_STORAGE_S3_BUCKET",
|
||||||
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
|
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
|
||||||
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
||||||
|
*BACKUP_RUNTIME_ENV_KEYS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -85,8 +109,15 @@ class BundlePaths:
|
|||||||
compose: Path
|
compose: Path
|
||||||
garage_config: Path
|
garage_config: Path
|
||||||
load_balancer_config: Path
|
load_balancer_config: Path
|
||||||
|
caddy_config: Path
|
||||||
|
existing_proxy: Path
|
||||||
plan: Path
|
plan: Path
|
||||||
receipt: Path
|
receipt: Path
|
||||||
|
manifest: Path
|
||||||
|
keyring: Path
|
||||||
|
backup_evidence: Path
|
||||||
|
backup_keyring: Path
|
||||||
|
backup_verification: Path
|
||||||
lock: Path
|
lock: Path
|
||||||
|
|
||||||
|
|
||||||
@@ -102,8 +133,15 @@ def bundle_paths(root: Path) -> BundlePaths:
|
|||||||
compose=resolved / COMPOSE_FILENAME,
|
compose=resolved / COMPOSE_FILENAME,
|
||||||
garage_config=resolved / GARAGE_CONFIG_FILENAME,
|
garage_config=resolved / GARAGE_CONFIG_FILENAME,
|
||||||
load_balancer_config=resolved / LOAD_BALANCER_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,
|
plan=resolved / PLAN_FILENAME,
|
||||||
receipt=resolved / RECEIPT_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,
|
lock=resolved / LOCK_FILENAME,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -449,6 +487,10 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
|||||||
"restart": "unless-stopped",
|
"restart": "unless-stopped",
|
||||||
"volumes": data_mounts,
|
"volumes": data_mounts,
|
||||||
"networks": ["internal"],
|
"networks": ["internal"],
|
||||||
|
"read_only": True,
|
||||||
|
"tmpfs": ["/tmp:rw,noexec,nosuid,size=64m"],
|
||||||
|
"security_opt": ["no-new-privileges:true"],
|
||||||
|
"cap_drop": ["ALL"],
|
||||||
}
|
}
|
||||||
if dependency_conditions:
|
if dependency_conditions:
|
||||||
common_runtime["depends_on"] = dependency_conditions
|
common_runtime["depends_on"] = dependency_conditions
|
||||||
@@ -464,6 +506,10 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
|||||||
"restart": "no",
|
"restart": "no",
|
||||||
"volumes": data_mounts,
|
"volumes": data_mounts,
|
||||||
"networks": ["internal"],
|
"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 {}),
|
**({"depends_on": dependency_conditions} if dependency_conditions else {}),
|
||||||
}
|
}
|
||||||
services["api"] = {
|
services["api"] = {
|
||||||
@@ -509,9 +555,13 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
|||||||
"restart": "unless-stopped",
|
"restart": "unless-stopped",
|
||||||
"scale": spec.replicas.web,
|
"scale": spec.replicas.web,
|
||||||
"environment": {"GOVOPLAN_API_UPSTREAM": "http://load-balancer:8000"},
|
"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"],
|
"networks": ["internal"],
|
||||||
}
|
}
|
||||||
services["load-balancer"] = {
|
load_balancer: dict[str, object] = {
|
||||||
"image": spec.components.load_balancer.image,
|
"image": spec.components.load_balancer.image,
|
||||||
"restart": "unless-stopped",
|
"restart": "unless-stopped",
|
||||||
"healthcheck": {
|
"healthcheck": {
|
||||||
@@ -531,7 +581,6 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
|||||||
render_load_balancer_config(spec).encode("utf-8")
|
render_load_balancer_config(spec).encode("utf-8")
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
},
|
},
|
||||||
"ports": [_published_port(spec.listen.address, spec.listen.port, 8080)],
|
|
||||||
"read_only": True,
|
"read_only": True,
|
||||||
"security_opt": ["no-new-privileges:true"],
|
"security_opt": ["no-new-privileges:true"],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
@@ -539,6 +588,54 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
|||||||
],
|
],
|
||||||
"networks": ["internal"],
|
"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":
|
if spec.components.redis.mode != "disabled":
|
||||||
services["worker"] = {
|
services["worker"] = {
|
||||||
**common_runtime,
|
**common_runtime,
|
||||||
@@ -600,6 +697,9 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
|||||||
if spec.components.storage.mode == "garage":
|
if spec.components.storage.mode == "garage":
|
||||||
volumes["garage-meta"] = {}
|
volumes["garage-meta"] = {}
|
||||||
volumes["garage-data"] = {}
|
volumes["garage-data"] = {}
|
||||||
|
if spec.ingress.mode == "managed":
|
||||||
|
volumes["caddy-data"] = {}
|
||||||
|
volumes["caddy-config"] = {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"name": spec.installation_id,
|
"name": spec.installation_id,
|
||||||
@@ -636,6 +736,31 @@ api_bind_addr = "[::]:3903"
|
|||||||
|
|
||||||
def render_load_balancer_config(spec: InstallationSpec) -> str:
|
def render_load_balancer_config(spec: InstallationSpec) -> str:
|
||||||
health_host = urlsplit(spec.public_url).hostname or "localhost"
|
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
|
return f"""global
|
||||||
log stdout format raw local0
|
log stdout format raw local0
|
||||||
maxconn 4096
|
maxconn 4096
|
||||||
@@ -663,6 +788,9 @@ resolvers docker
|
|||||||
|
|
||||||
frontend public_web
|
frontend public_web
|
||||||
bind :8080
|
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
|
default_backend web_replicas
|
||||||
|
|
||||||
backend web_replicas
|
backend web_replicas
|
||||||
@@ -684,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, ...]:
|
def service_names(spec: InstallationSpec) -> tuple[str, ...]:
|
||||||
return tuple(render_compose(spec)["services"].keys())
|
return tuple(render_compose(spec)["services"].keys())
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from dataclasses import replace
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
import fcntl
|
import fcntl
|
||||||
import getpass
|
import getpass
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -19,7 +20,14 @@ from typing import Iterator, Mapping, Sequence
|
|||||||
from urllib.error import URLError
|
from urllib.error import URLError
|
||||||
from urllib.request import urlopen
|
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 (
|
from .bundle import (
|
||||||
|
BACKUP_RUNTIME_ENV_KEYS,
|
||||||
atomic_write,
|
atomic_write,
|
||||||
bundle_paths,
|
bundle_paths,
|
||||||
canonical_json,
|
canonical_json,
|
||||||
@@ -29,17 +37,35 @@ from .bundle import (
|
|||||||
initial_secrets,
|
initial_secrets,
|
||||||
read_env,
|
read_env,
|
||||||
reconcile_runtime_environment,
|
reconcile_runtime_environment,
|
||||||
|
render_caddy_config,
|
||||||
render_compose,
|
render_compose,
|
||||||
|
render_existing_proxy_contract,
|
||||||
render_garage_config,
|
render_garage_config,
|
||||||
render_load_balancer_config,
|
render_load_balancer_config,
|
||||||
service_names,
|
service_names,
|
||||||
write_env,
|
write_env,
|
||||||
)
|
)
|
||||||
from .cluster_evidence import collect_kubernetes_evidence
|
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 (
|
from .model import (
|
||||||
ComponentConfig,
|
ComponentConfig,
|
||||||
DEFAULT_GARAGE_IMAGE,
|
DEFAULT_GARAGE_IMAGE,
|
||||||
|
DEFAULT_INGRESS_IMAGE,
|
||||||
DEFAULT_LOAD_BALANCER_IMAGE,
|
DEFAULT_LOAD_BALANCER_IMAGE,
|
||||||
|
IngressConfig,
|
||||||
InstallationSpec,
|
InstallationSpec,
|
||||||
ListenConfig,
|
ListenConfig,
|
||||||
ReplicaConfig,
|
ReplicaConfig,
|
||||||
@@ -53,7 +79,12 @@ from .kubernetes import (
|
|||||||
render_kubernetes,
|
render_kubernetes,
|
||||||
write_secret_creation_hint,
|
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 (
|
from .recovery import (
|
||||||
DeploymentOperationJournal,
|
DeploymentOperationJournal,
|
||||||
list_operations,
|
list_operations,
|
||||||
@@ -127,6 +158,59 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
_directory_argument(status)
|
_directory_argument(status)
|
||||||
status.add_argument("--json", action="store_true", help="Print JSON.")
|
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(
|
kubernetes = subparsers.add_parser(
|
||||||
"render-kubernetes",
|
"render-kubernetes",
|
||||||
help="Export the stateless multi-host runtime for Kubernetes.",
|
help="Export the stateless multi-host runtime for Kubernetes.",
|
||||||
@@ -268,6 +352,26 @@ def _configuration_arguments(
|
|||||||
default=default(DEFAULT_LOAD_BALANCER_IMAGE),
|
default=default(DEFAULT_LOAD_BALANCER_IMAGE),
|
||||||
help="HAProxy image used by the managed local load balancer.",
|
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(
|
parser.add_argument(
|
||||||
"--api-replicas",
|
"--api-replicas",
|
||||||
type=int,
|
type=int,
|
||||||
@@ -312,6 +416,12 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
return _apply(args)
|
return _apply(args)
|
||||||
if args.command == "status":
|
if args.command == "status":
|
||||||
return _status(args)
|
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":
|
if args.command == "render-kubernetes":
|
||||||
return _render_kubernetes(args)
|
return _render_kubernetes(args)
|
||||||
if args.command == "verify-kubernetes":
|
if args.command == "verify-kubernetes":
|
||||||
@@ -320,7 +430,13 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
return _operations(args)
|
return _operations(args)
|
||||||
if args.command == "recover":
|
if args.command == "recover":
|
||||||
return _recover(args)
|
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)
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
raise RuntimeError(f"unsupported command: {args.command}")
|
raise RuntimeError(f"unsupported command: {args.command}")
|
||||||
@@ -347,6 +463,12 @@ def _init(args: argparse.Namespace) -> int:
|
|||||||
storage_mode=args.storage,
|
storage_mode=args.storage,
|
||||||
garage_image=args.garage_image,
|
garage_image=args.garage_image,
|
||||||
load_balancer_image=args.load_balancer_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,
|
api_replicas=args.api_replicas,
|
||||||
web_replicas=args.web_replicas,
|
web_replicas=args.web_replicas,
|
||||||
worker_replicas=args.worker_replicas,
|
worker_replicas=args.worker_replicas,
|
||||||
@@ -442,15 +564,17 @@ def _render_or_doctor(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
def _apply(args: argparse.Namespace) -> int:
|
def _apply(args: argparse.Namespace) -> int:
|
||||||
paths = bundle_paths(args.directory)
|
paths = bundle_paths(args.directory)
|
||||||
spec = load_spec(paths.spec)
|
|
||||||
if args.allow_unverified_images and spec.profile != "evaluation":
|
|
||||||
raise ValueError(
|
|
||||||
"--allow-unverified-images is restricted to evaluation installations"
|
|
||||||
)
|
|
||||||
ensure_private_directory(paths.root)
|
ensure_private_directory(paths.root)
|
||||||
with _deployment_lock(paths.lock):
|
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"
|
||||||
|
)
|
||||||
secrets = reconcile_runtime_environment(spec, read_env(paths.env))
|
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)
|
plan = build_plan(spec, paths, include_host_checks=True)
|
||||||
_write_plan(paths.plan, plan)
|
_write_plan(paths.plan, plan)
|
||||||
effective_errors = [
|
effective_errors = [
|
||||||
@@ -468,6 +592,7 @@ def _apply(args: argparse.Namespace) -> int:
|
|||||||
"components.mail.image.",
|
"components.mail.image.",
|
||||||
"components.storage.image.",
|
"components.storage.image.",
|
||||||
"components.load_balancer.image.",
|
"components.load_balancer.image.",
|
||||||
|
"ingress.image.",
|
||||||
"release.manifest",
|
"release.manifest",
|
||||||
"modules.image_composition",
|
"modules.image_composition",
|
||||||
)
|
)
|
||||||
@@ -484,6 +609,36 @@ def _apply(args: argparse.Namespace) -> int:
|
|||||||
paths,
|
paths,
|
||||||
plan=plan.to_dict(),
|
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 = [
|
compose = [
|
||||||
docker,
|
docker,
|
||||||
"compose",
|
"compose",
|
||||||
@@ -531,6 +686,29 @@ def _apply(args: argparse.Namespace) -> int:
|
|||||||
"succeeded",
|
"succeeded",
|
||||||
{"services": mutable_runtime_services, "timeout_seconds": 120},
|
{"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()
|
journal.migration_started()
|
||||||
_run([*compose, "run", "--rm", "migrate"], cwd=paths.root)
|
_run([*compose, "run", "--rm", "migrate"], cwd=paths.root)
|
||||||
journal.migration_completed()
|
journal.migration_completed()
|
||||||
@@ -538,7 +716,14 @@ def _apply(args: argparse.Namespace) -> int:
|
|||||||
_run([*compose, "stop", "web"], cwd=paths.root)
|
_run([*compose, "stop", "web"], cwd=paths.root)
|
||||||
runtime_services = [
|
runtime_services = [
|
||||||
name
|
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)
|
if name in service_names(spec)
|
||||||
]
|
]
|
||||||
_run(
|
_run(
|
||||||
@@ -617,10 +802,344 @@ def _status(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
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:
|
def _render_kubernetes(args: argparse.Namespace) -> int:
|
||||||
paths = bundle_paths(args.directory)
|
paths = bundle_paths(args.directory)
|
||||||
spec = load_spec(paths.spec)
|
spec = load_spec(paths.spec)
|
||||||
environment = reconcile_runtime_environment(spec, read_env(paths.env))
|
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(
|
manifest = render_kubernetes(
|
||||||
spec,
|
spec,
|
||||||
environment,
|
environment,
|
||||||
@@ -628,6 +1147,8 @@ def _render_kubernetes(args: argparse.Namespace) -> int:
|
|||||||
secret_name=args.secret_name,
|
secret_name=args.secret_name,
|
||||||
tls_secret_name=args.tls_secret_name,
|
tls_secret_name=args.tls_secret_name,
|
||||||
ingress_class_name=args.ingress_class_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()
|
output = (args.output or (paths.root / "kubernetes.json")).expanduser().resolve()
|
||||||
atomic_write(output, canonical_json(manifest), mode=0o600)
|
atomic_write(output, canonical_json(manifest), mode=0o600)
|
||||||
@@ -719,6 +1240,7 @@ def _deployment_receipt(
|
|||||||
return {
|
return {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"installation_id": spec.installation_id,
|
"installation_id": spec.installation_id,
|
||||||
|
"profile": spec.profile,
|
||||||
"applied_at": _now(),
|
"applied_at": _now(),
|
||||||
"spec_sha256": digest_json(spec.to_dict()),
|
"spec_sha256": digest_json(spec.to_dict()),
|
||||||
"compose_sha256": digest_json(render_compose(spec)),
|
"compose_sha256": digest_json(render_compose(spec)),
|
||||||
@@ -727,6 +1249,9 @@ def _deployment_receipt(
|
|||||||
"channel": spec.release.channel,
|
"channel": spec.release.channel,
|
||||||
"version": spec.release.version,
|
"version": spec.release.version,
|
||||||
"manifest_sha256": spec.release.manifest_sha256,
|
"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,
|
"api_image": spec.release.api_image,
|
||||||
"web_image": spec.release.web_image,
|
"web_image": spec.release.web_image,
|
||||||
},
|
},
|
||||||
@@ -740,6 +1265,11 @@ def _deployment_receipt(
|
|||||||
"address": spec.listen.address,
|
"address": spec.listen.address,
|
||||||
"port": spec.listen.port,
|
"port": spec.listen.port,
|
||||||
},
|
},
|
||||||
|
"ingress": {
|
||||||
|
"mode": spec.ingress.mode,
|
||||||
|
"http_port": spec.ingress.http_port,
|
||||||
|
"https_port": spec.ingress.https_port,
|
||||||
|
},
|
||||||
"management": {
|
"management": {
|
||||||
"mode": "govoplan-deploy",
|
"mode": "govoplan-deploy",
|
||||||
"agent": "cli",
|
"agent": "cli",
|
||||||
@@ -748,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(
|
def _updated_spec(
|
||||||
current: InstallationSpec, args: argparse.Namespace
|
current: InstallationSpec, args: argparse.Namespace
|
||||||
) -> InstallationSpec:
|
) -> InstallationSpec:
|
||||||
@@ -770,6 +1310,9 @@ def _updated_spec(
|
|||||||
if args.manifest_sha256 is not None
|
if args.manifest_sha256 is not None
|
||||||
else current.release.manifest_sha256
|
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,
|
api_image=args.api_image or current.release.api_image,
|
||||||
web_image=args.web_image or current.release.web_image,
|
web_image=args.web_image or current.release.web_image,
|
||||||
)
|
)
|
||||||
@@ -820,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(
|
value = replace(
|
||||||
current,
|
current,
|
||||||
installation_id=args.installation_id or current.installation_id,
|
installation_id=args.installation_id or current.installation_id,
|
||||||
@@ -832,6 +1410,7 @@ def _updated_spec(
|
|||||||
release=release,
|
release=release,
|
||||||
components=components,
|
components=components,
|
||||||
replicas=replicas,
|
replicas=replicas,
|
||||||
|
ingress=ingress,
|
||||||
enabled_modules=modules,
|
enabled_modules=modules,
|
||||||
)
|
)
|
||||||
return parse_spec(value.to_dict())
|
return parse_spec(value.to_dict())
|
||||||
@@ -867,21 +1446,89 @@ def _write_bundle(
|
|||||||
spec: InstallationSpec,
|
spec: InstallationSpec,
|
||||||
paths,
|
paths,
|
||||||
secrets: Mapping[str, str],
|
secrets: Mapping[str, str],
|
||||||
) -> None:
|
) -> dict[str, str]:
|
||||||
ensure_private_directory(paths.root)
|
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)
|
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.compose, canonical_json(render_compose(spec)), mode=0o600)
|
||||||
atomic_write(
|
atomic_write(
|
||||||
paths.load_balancer_config,
|
paths.load_balancer_config,
|
||||||
render_load_balancer_config(spec).encode("utf-8"),
|
render_load_balancer_config(spec).encode("utf-8"),
|
||||||
mode=0o644,
|
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(
|
atomic_write(
|
||||||
paths.garage_config,
|
paths.garage_config,
|
||||||
render_garage_config().encode("utf-8"),
|
render_garage_config().encode("utf-8"),
|
||||||
mode=0o644,
|
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:
|
def _write_plan(path: Path, plan: DeploymentPlan) -> None:
|
||||||
@@ -909,6 +1556,24 @@ def _prompt_configuration(args: argparse.Namespace) -> None:
|
|||||||
if args.profile == "self-hosted" and args.public_url.startswith("http://"):
|
if args.profile == "self-hosted" and args.public_url.startswith("http://"):
|
||||||
args.public_url = "https://govoplan.example.org"
|
args.public_url = "https://govoplan.example.org"
|
||||||
args.public_url = _prompt("Public URL", args.public_url)
|
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"))
|
args.postgres = _prompt_choice("PostgreSQL", args.postgres, ("managed", "external"))
|
||||||
if args.postgres == "external" and not args.database_url:
|
if args.postgres == "external" and not args.database_url:
|
||||||
args.database_url = getpass.getpass(
|
args.database_url = getpass.getpass(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -10,6 +10,7 @@ import re
|
|||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from .bundle import BACKUP_RUNTIME_ENV_KEYS
|
||||||
from .model import InstallationSpec, image_is_digest_pinned
|
from .model import InstallationSpec, image_is_digest_pinned
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ _CONFIG_KEYS = (
|
|||||||
"FILE_STORAGE_S3_BUCKET",
|
"FILE_STORAGE_S3_BUCKET",
|
||||||
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
|
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
|
||||||
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
||||||
|
*BACKUP_RUNTIME_ENV_KEYS,
|
||||||
)
|
)
|
||||||
_QUEUE_NAME = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
|
_QUEUE_NAME = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
|
||||||
|
|
||||||
@@ -75,6 +77,8 @@ def render_kubernetes(
|
|||||||
secret_name: str = "govoplan-runtime",
|
secret_name: str = "govoplan-runtime",
|
||||||
tls_secret_name: str = "govoplan-tls",
|
tls_secret_name: str = "govoplan-tls",
|
||||||
ingress_class_name: str | None = None,
|
ingress_class_name: str | None = None,
|
||||||
|
backup_required: bool = True,
|
||||||
|
backup_evidence: Mapping[str, object] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Render runtime roles only; shared state services stay externally managed."""
|
"""Render runtime roles only; shared state services stay externally managed."""
|
||||||
|
|
||||||
@@ -199,6 +203,8 @@ def render_kubernetes(
|
|||||||
environment,
|
environment,
|
||||||
"MIGRATION",
|
"MIGRATION",
|
||||||
),
|
),
|
||||||
|
backup_required=backup_required,
|
||||||
|
backup_evidence=backup_evidence,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
for pool in worker_pools:
|
for pool in worker_pools:
|
||||||
@@ -765,9 +771,28 @@ def _migration_job(
|
|||||||
secret_name: str,
|
secret_name: str,
|
||||||
service_account: str,
|
service_account: str,
|
||||||
database_environment: Mapping[str, str],
|
database_environment: Mapping[str, str],
|
||||||
|
backup_required: bool,
|
||||||
|
backup_evidence: Mapping[str, object] | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
job_labels = {**labels, "app.kubernetes.io/component": "migration"}
|
job_labels = {**labels, "app.kubernetes.io/component": "migration"}
|
||||||
job_name = _name_with_suffix(name, f"migrate-{release_key}")
|
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 {
|
return {
|
||||||
"apiVersion": "batch/v1",
|
"apiVersion": "batch/v1",
|
||||||
"kind": "Job",
|
"kind": "Job",
|
||||||
@@ -777,7 +802,7 @@ def _migration_job(
|
|||||||
"labels": job_labels,
|
"labels": job_labels,
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"govoplan.add-ideas.de/recovery-mode": "forward-recovery",
|
"govoplan.add-ideas.de/recovery-mode": "forward-recovery",
|
||||||
"govoplan.add-ideas.de/backup-required": "true",
|
**backup_annotations,
|
||||||
"argocd.argoproj.io/sync-wave": "-1",
|
"argocd.argoproj.io/sync-wave": "-1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from urllib.parse import urlsplit
|
|||||||
SCHEMA_VERSION = 1
|
SCHEMA_VERSION = 1
|
||||||
DEFAULT_GARAGE_IMAGE = "dxflrs/garage:v2.3.0"
|
DEFAULT_GARAGE_IMAGE = "dxflrs/garage:v2.3.0"
|
||||||
DEFAULT_LOAD_BALANCER_IMAGE = "haproxy:3.2.21-alpine"
|
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}$")
|
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}$")
|
ENV_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$")
|
||||||
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||||
@@ -41,6 +42,7 @@ FULL_MODULES = (
|
|||||||
*BASE_MODULES,
|
*BASE_MODULES,
|
||||||
"addresses",
|
"addresses",
|
||||||
"dist_lists",
|
"dist_lists",
|
||||||
|
"templates",
|
||||||
"files",
|
"files",
|
||||||
"mail",
|
"mail",
|
||||||
"campaigns",
|
"campaigns",
|
||||||
@@ -76,6 +78,9 @@ class ReleaseConfig:
|
|||||||
version: str
|
version: str
|
||||||
manifest_url: str
|
manifest_url: str
|
||||||
manifest_sha256: str
|
manifest_sha256: str
|
||||||
|
manifest_keyring_sha256: str
|
||||||
|
manifest_signature_key_id: str
|
||||||
|
composition_sha256: str
|
||||||
api_image: str
|
api_image: str
|
||||||
web_image: str
|
web_image: str
|
||||||
|
|
||||||
@@ -109,6 +114,16 @@ class ReplicaConfig:
|
|||||||
worker: int
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class InstallationSpec:
|
class InstallationSpec:
|
||||||
schema_version: int
|
schema_version: int
|
||||||
@@ -120,11 +135,13 @@ class InstallationSpec:
|
|||||||
release: ReleaseConfig
|
release: ReleaseConfig
|
||||||
components: ComponentConfig
|
components: ComponentConfig
|
||||||
replicas: ReplicaConfig
|
replicas: ReplicaConfig
|
||||||
|
ingress: IngressConfig
|
||||||
enabled_modules: tuple[str, ...]
|
enabled_modules: tuple[str, ...]
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
value = asdict(self)
|
value = asdict(self)
|
||||||
value["enabled_modules"] = list(self.enabled_modules)
|
value["enabled_modules"] = list(self.enabled_modules)
|
||||||
|
value["ingress"]["trusted_proxy_cidrs"] = list(self.ingress.trusted_proxy_cidrs)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -141,6 +158,12 @@ def default_spec(
|
|||||||
storage_mode: str = "local",
|
storage_mode: str = "local",
|
||||||
garage_image: str = DEFAULT_GARAGE_IMAGE,
|
garage_image: str = DEFAULT_GARAGE_IMAGE,
|
||||||
load_balancer_image: str = DEFAULT_LOAD_BALANCER_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,
|
api_replicas: int = 1,
|
||||||
web_replicas: int = 1,
|
web_replicas: int = 1,
|
||||||
worker_replicas: int | None = None,
|
worker_replicas: int | None = None,
|
||||||
@@ -177,6 +200,9 @@ def default_spec(
|
|||||||
"version": version,
|
"version": version,
|
||||||
"manifest_url": manifest_url,
|
"manifest_url": manifest_url,
|
||||||
"manifest_sha256": manifest_sha256,
|
"manifest_sha256": manifest_sha256,
|
||||||
|
"manifest_keyring_sha256": "",
|
||||||
|
"manifest_signature_key_id": "",
|
||||||
|
"composition_sha256": "",
|
||||||
"api_image": api_image,
|
"api_image": api_image,
|
||||||
"web_image": web_image,
|
"web_image": web_image,
|
||||||
},
|
},
|
||||||
@@ -211,6 +237,15 @@ def default_spec(
|
|||||||
"web": web_replicas,
|
"web": web_replicas,
|
||||||
"worker": effective_worker_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),
|
"enabled_modules": list(modules),
|
||||||
}
|
}
|
||||||
return parse_spec(raw)
|
return parse_spec(raw)
|
||||||
@@ -240,6 +275,7 @@ def parse_spec(raw: object) -> InstallationSpec:
|
|||||||
"release",
|
"release",
|
||||||
"components",
|
"components",
|
||||||
"replicas",
|
"replicas",
|
||||||
|
"ingress",
|
||||||
"enabled_modules",
|
"enabled_modules",
|
||||||
},
|
},
|
||||||
"installation",
|
"installation",
|
||||||
@@ -274,6 +310,17 @@ def parse_spec(raw: object) -> InstallationSpec:
|
|||||||
release = _release(root.get("release"))
|
release = _release(root.get("release"))
|
||||||
components = _components(root.get("components"), profile=profile)
|
components = _components(root.get("components"), profile=profile)
|
||||||
replicas = _replicas(root.get("replicas"), components=components)
|
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")
|
enabled_raw = root.get("enabled_modules")
|
||||||
if not isinstance(enabled_raw, list):
|
if not isinstance(enabled_raw, list):
|
||||||
@@ -300,6 +347,7 @@ def parse_spec(raw: object) -> InstallationSpec:
|
|||||||
release=release,
|
release=release,
|
||||||
components=components,
|
components=components,
|
||||||
replicas=replicas,
|
replicas=replicas,
|
||||||
|
ingress=ingress,
|
||||||
enabled_modules=tuple(enabled_modules),
|
enabled_modules=tuple(enabled_modules),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -313,6 +361,9 @@ def _release(raw: object) -> ReleaseConfig:
|
|||||||
"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",
|
||||||
},
|
},
|
||||||
@@ -334,6 +385,23 @@ def _release(raw: object) -> ReleaseConfig:
|
|||||||
raise SpecError(
|
raise SpecError(
|
||||||
"release.manifest_sha256 must be a lowercase SHA-256 hex digest"
|
"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")
|
api_image = _image(_string(value, "api_image"), "release.api_image")
|
||||||
web_image = _image(_string(value, "web_image"), "release.web_image")
|
web_image = _image(_string(value, "web_image"), "release.web_image")
|
||||||
return ReleaseConfig(
|
return ReleaseConfig(
|
||||||
@@ -341,6 +409,9 @@ def _release(raw: object) -> ReleaseConfig:
|
|||||||
version=version,
|
version=version,
|
||||||
manifest_url=manifest_url,
|
manifest_url=manifest_url,
|
||||||
manifest_sha256=manifest_sha256,
|
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,
|
api_image=api_image,
|
||||||
web_image=web_image,
|
web_image=web_image,
|
||||||
)
|
)
|
||||||
@@ -449,6 +520,84 @@ def _replicas(raw: object, *, components: ComponentConfig) -> ReplicaConfig:
|
|||||||
return replicas
|
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(
|
def _service(
|
||||||
raw: object,
|
raw: object,
|
||||||
label: str,
|
label: str,
|
||||||
@@ -580,6 +729,32 @@ def _network(value: str, label: str) -> str:
|
|||||||
return str(network)
|
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:
|
def _port(value: int, label: str) -> int:
|
||||||
if value < 1 or value > 65535:
|
if value < 1 or value > 65535:
|
||||||
raise SpecError(f"{label} must be between 1 and 65535")
|
raise SpecError(f"{label} must be between 1 and 65535")
|
||||||
|
|||||||
@@ -3,25 +3,50 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from typing import Callable, Mapping, Sequence
|
from typing import Callable, Mapping, Sequence
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.parse import urlsplit
|
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 (
|
from .bundle import (
|
||||||
BundlePaths,
|
BundlePaths,
|
||||||
|
canonical_json,
|
||||||
digest_json,
|
digest_json,
|
||||||
environment_fingerprint,
|
environment_fingerprint,
|
||||||
read_env,
|
read_env,
|
||||||
|
render_caddy_config,
|
||||||
render_compose,
|
render_compose,
|
||||||
|
render_existing_proxy_contract,
|
||||||
service_names,
|
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 (
|
from .model import (
|
||||||
InstallationSpec,
|
InstallationSpec,
|
||||||
image_is_digest_pinned,
|
image_is_digest_pinned,
|
||||||
@@ -169,6 +194,7 @@ def build_plan(
|
|||||||
|
|
||||||
def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ...]:
|
def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ...]:
|
||||||
checks: list[Check] = []
|
checks: list[Check] = []
|
||||||
|
checks.extend(_ingress_configuration_checks(spec, paths))
|
||||||
images = {
|
images = {
|
||||||
"release.api_image": spec.release.api_image,
|
"release.api_image": spec.release.api_image,
|
||||||
"release.web_image": spec.release.web_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":
|
if spec.components.storage.mode == "garage":
|
||||||
images["components.storage.image"] = spec.components.storage.image
|
images["components.storage.image"] = spec.components.storage.image
|
||||||
images["components.load_balancer.image"] = spec.components.load_balancer.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():
|
for label, image in images.items():
|
||||||
if image_is_unpublished(image):
|
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.extend(_distribution_checks(spec, paths))
|
||||||
checks.append(
|
checks.extend(
|
||||||
Check(
|
_backup_evidence_checks(spec, paths, receipt=_read_receipt(paths.receipt))
|
||||||
"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.",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
values = read_env(paths.env)
|
values = read_env(paths.env)
|
||||||
@@ -364,6 +362,401 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ..
|
|||||||
return tuple(checks)
|
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(
|
def host_checks(
|
||||||
spec: InstallationSpec,
|
spec: InstallationSpec,
|
||||||
paths: BundlePaths,
|
paths: BundlePaths,
|
||||||
@@ -371,6 +764,7 @@ def host_checks(
|
|||||||
command_runner: CommandRunner | None = None,
|
command_runner: CommandRunner | None = None,
|
||||||
) -> tuple[Check, ...]:
|
) -> tuple[Check, ...]:
|
||||||
checks: list[Check] = []
|
checks: list[Check] = []
|
||||||
|
runner = command_runner or _run_command
|
||||||
machine = platform.machine().lower()
|
machine = platform.machine().lower()
|
||||||
supported = machine in {"x86_64", "amd64", "aarch64", "arm64"}
|
supported = machine in {"x86_64", "amd64", "aarch64", "arm64"}
|
||||||
checks.append(
|
checks.append(
|
||||||
@@ -443,7 +837,6 @@ def host_checks(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
runner = command_runner or _run_command
|
|
||||||
result = runner((docker, "compose", "version", "--short"), paths.root)
|
result = runner((docker, "compose", "version", "--short"), paths.root)
|
||||||
checks.append(
|
checks.append(
|
||||||
Check(
|
Check(
|
||||||
@@ -508,30 +901,245 @@ def host_checks(
|
|||||||
)
|
)
|
||||||
|
|
||||||
receipt = _read_receipt(paths.receipt)
|
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")
|
previous_listen = receipt.get("listen")
|
||||||
desired_listen = {
|
desired_listen = {
|
||||||
"address": spec.listen.address,
|
"address": spec.listen.address,
|
||||||
"port": spec.listen.port,
|
"port": spec.listen.port,
|
||||||
}
|
}
|
||||||
if not receipt or previous_listen != desired_listen:
|
if spec.ingress.mode == "managed":
|
||||||
available = _port_available(spec.listen.address, spec.listen.port)
|
if not applied or previous_ingress != desired_ingress:
|
||||||
|
for label, port in (
|
||||||
|
("http", spec.ingress.http_port),
|
||||||
|
("https", spec.ingress.https_port),
|
||||||
|
):
|
||||||
|
checks.append(
|
||||||
|
_available_port_check(
|
||||||
|
f"host.ingress_{label}_port",
|
||||||
|
"0.0.0.0",
|
||||||
|
port,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif not applied or previous_listen != desired_listen:
|
||||||
checks.append(
|
checks.append(
|
||||||
Check(
|
_available_port_check(
|
||||||
"host.listen_port",
|
"host.listen_port",
|
||||||
"ok" if available else "error",
|
spec.listen.address,
|
||||||
(
|
spec.listen.port,
|
||||||
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."
|
|
||||||
),
|
if applied:
|
||||||
"Choose another listen port or stop the conflicting service."
|
checks.append(
|
||||||
if not available
|
_local_upstream_check(
|
||||||
else "",
|
spec,
|
||||||
|
paths,
|
||||||
|
docker=docker,
|
||||||
|
command_runner=command_runner,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return tuple(checks)
|
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]:
|
def _read_receipt(path: Path) -> Mapping[str, object]:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ _BUNDLE_FILES = (
|
|||||||
"compose.json",
|
"compose.json",
|
||||||
"garage.toml",
|
"garage.toml",
|
||||||
"load-balancer.cfg",
|
"load-balancer.cfg",
|
||||||
|
"Caddyfile",
|
||||||
|
"existing-proxy.json",
|
||||||
|
"distribution-manifest.json",
|
||||||
|
"distribution-keyring.json",
|
||||||
|
"backup-evidence.json",
|
||||||
|
"backup-keyring.json",
|
||||||
|
"backup-verification.json",
|
||||||
"receipt.json",
|
"receipt.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -305,12 +305,11 @@
|
|||||||
"repository": "govoplan-calendar"
|
"repository": "govoplan-calendar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/calendar/caldav/outbox",
|
"path": "/calendar/caldav/outbox",
|
||||||
"rationale": "CalDAV outbox diagnostics and recovery UI is tracked separately.",
|
"rationale": "The synchronized-calendar settings dialog exposes bounded outbound diagnostics and recovery actions.",
|
||||||
"repository": "govoplan-calendar",
|
"repository": "govoplan-calendar"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-calendar/issues/21"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "worker_internal",
|
"category": "worker_internal",
|
||||||
@@ -320,28 +319,25 @@
|
|||||||
"repository": "govoplan-calendar"
|
"repository": "govoplan-calendar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/calendar/caldav/outbox/{}/discard",
|
"path": "/calendar/caldav/outbox/{}/discard",
|
||||||
"rationale": "CalDAV outbox diagnostics and recovery UI is tracked separately.",
|
"rationale": "The outbound-change dialog exposes guarded discard with destructive confirmation.",
|
||||||
"repository": "govoplan-calendar",
|
"repository": "govoplan-calendar"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-calendar/issues/21"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/calendar/caldav/outbox/{}/reconcile",
|
"path": "/calendar/caldav/outbox/{}/reconcile",
|
||||||
"rationale": "CalDAV outbox diagnostics and recovery UI is tracked separately.",
|
"rationale": "The outbound-change dialog exposes reconciliation when backend action state permits it.",
|
||||||
"repository": "govoplan-calendar",
|
"repository": "govoplan-calendar"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-calendar/issues/21"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/calendar/caldav/outbox/{}/retry",
|
"path": "/calendar/caldav/outbox/{}/retry",
|
||||||
"rationale": "CalDAV outbox diagnostics and recovery UI is tracked separately.",
|
"rationale": "The outbound-change dialog exposes retry for failed latest-generation writes.",
|
||||||
"repository": "govoplan-calendar",
|
"repository": "govoplan-calendar"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-calendar/issues/21"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "worker_internal",
|
"category": "worker_internal",
|
||||||
@@ -1585,6 +1581,134 @@
|
|||||||
"path": "/workflow/definitions/{}/triggers",
|
"path": "/workflow/definitions/{}/triggers",
|
||||||
"rationale": "This capability-first module intentionally exposes a headless API for other modules and integrations.",
|
"rationale": "This capability-first module intentionally exposes a headless API for other modules and integrations.",
|
||||||
"repository": "govoplan-workflow-engine"
|
"repository": "govoplan-workflow-engine"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "ui_reachable",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/addresses/contact-merges/{}/split",
|
||||||
|
"rationale": "The Addresses WebUI constructs the selected merge id and recovery action dynamically.",
|
||||||
|
"repository": "govoplan-addresses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "ui_reachable",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/addresses/contact-merges/{}/undo",
|
||||||
|
"rationale": "The Addresses WebUI constructs the selected merge id and recovery action dynamically.",
|
||||||
|
"repository": "govoplan-addresses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "intentionally_headless",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/addresses/contact-point-snapshots",
|
||||||
|
"rationale": "Campaign and other modules consume the governed contact-point snapshot capability without a direct Addresses screen.",
|
||||||
|
"repository": "govoplan-addresses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "intentionally_headless",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/addresses/contact-point-snapshots/{}",
|
||||||
|
"rationale": "Campaign and other modules consume the governed contact-point snapshot capability without a direct Addresses screen.",
|
||||||
|
"repository": "govoplan-addresses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "intentionally_headless",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/addresses/contact-point-sources/preview",
|
||||||
|
"rationale": "This capability endpoint previews a module-supplied contact source and is consumed by integrations rather than a direct screen.",
|
||||||
|
"repository": "govoplan-addresses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "intentionally_headless",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/addresses/contact-points/resolve",
|
||||||
|
"rationale": "This governed recipient-resolution endpoint is consumed by Campaign and other modules.",
|
||||||
|
"repository": "govoplan-addresses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "intentionally_headless",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/addresses/contacts/{}/redirect",
|
||||||
|
"rationale": "Stored references and module capabilities resolve merged-contact redirects without a direct user action.",
|
||||||
|
"repository": "govoplan-addresses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "missing_ui",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/addresses/imports/{}",
|
||||||
|
"rationale": "The import flow can create, apply, and roll back a run, but the WebUI does not yet resume a saved run by id after navigation or reload.",
|
||||||
|
"repository": "govoplan-addresses",
|
||||||
|
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/22"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "ui_reachable",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/campaigns/{}/versions/{}/print-output/download",
|
||||||
|
"rationale": "The Campaign WebUI validates and follows the build artifact's server-provided download path.",
|
||||||
|
"repository": "govoplan-campaign"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "missing_ui",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/relationships",
|
||||||
|
"rationale": "IDM documents typed relationships but its current administration page exposes function assignments only.",
|
||||||
|
"repository": "govoplan-idm",
|
||||||
|
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-idm/issues/11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "missing_ui",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/relationships",
|
||||||
|
"rationale": "IDM documents typed relationships but its current administration page exposes function assignments only.",
|
||||||
|
"repository": "govoplan-idm",
|
||||||
|
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-idm/issues/11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "missing_ui",
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/relationships/{}",
|
||||||
|
"rationale": "IDM documents typed relationships but its current administration page exposes function assignments only.",
|
||||||
|
"repository": "govoplan-idm",
|
||||||
|
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-idm/issues/11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "missing_ui",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/relationships/{}/revoke",
|
||||||
|
"rationale": "IDM documents typed relationships but its current administration page exposes function assignments only.",
|
||||||
|
"repository": "govoplan-idm",
|
||||||
|
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-idm/issues/11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "missing_ui",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/typed-groups",
|
||||||
|
"rationale": "IDM documents typed groups but its current administration page exposes function assignments only.",
|
||||||
|
"repository": "govoplan-idm",
|
||||||
|
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-idm/issues/11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "missing_ui",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/typed-groups",
|
||||||
|
"rationale": "IDM documents typed groups but its current administration page exposes function assignments only.",
|
||||||
|
"repository": "govoplan-idm",
|
||||||
|
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-idm/issues/11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "missing_ui",
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/typed-groups/{}",
|
||||||
|
"rationale": "IDM documents typed groups but its current administration page exposes function assignments only.",
|
||||||
|
"repository": "govoplan-idm",
|
||||||
|
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-idm/issues/11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "missing_ui",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/typed-groups/{}/memberships",
|
||||||
|
"rationale": "IDM lacks the typed-group membership explanation surface for this existing API.",
|
||||||
|
"repository": "govoplan-idm",
|
||||||
|
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-idm/issues/11"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"schema_version": 1
|
"schema_version": 1
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ const metaRoot = path.resolve(metaRootArgument);
|
|||||||
const repositoryCatalog = JSON.parse(
|
const repositoryCatalog = JSON.parse(
|
||||||
fs.readFileSync(path.join(metaRoot, "repositories.json"), "utf8")
|
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(
|
const typescriptPath = path.join(
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
"govoplan-core",
|
"govoplan-core",
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ def main() -> int:
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
catalog = json.loads((META_ROOT / "repositories.json").read_text(encoding="utf-8"))
|
catalog = json.loads((META_ROOT / "repositories.json").read_text(encoding="utf-8"))
|
||||||
workspace_root = Path(catalog["default_parent"]).resolve()
|
workspace_root = _resolve_workspace_root(catalog)
|
||||||
webui = _extract_webui()
|
webui = _extract_webui()
|
||||||
backend_endpoints = _extract_backend_endpoints(catalog, workspace_root)
|
backend_endpoints = _extract_backend_endpoints(catalog, workspace_root)
|
||||||
manifests = _extract_manifests(catalog, workspace_root)
|
manifests = _extract_manifests(catalog, workspace_root)
|
||||||
@@ -103,6 +103,27 @@ def main() -> int:
|
|||||||
return 0
|
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]:
|
def _extract_webui() -> dict[str, Any]:
|
||||||
helper = META_ROOT / "tools" / "inventory" / "extract-webui-structure.mjs"
|
helper = META_ROOT / "tools" / "inventory" / "extract-webui-structure.mjs"
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ set +a
|
|||||||
|
|
||||||
export APP_ENV="${APP_ENV:-staging}"
|
export APP_ENV="${APP_ENV:-staging}"
|
||||||
export GOVOPLAN_INSTALL_PROFILE="${GOVOPLAN_INSTALL_PROFILE:-production-like}"
|
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 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 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 REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}"
|
||||||
|
|||||||
@@ -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 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 REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}"
|
||||||
export CELERY_ENABLED="${CELERY_ENABLED:-true}"
|
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_BACKEND="${FILE_STORAGE_BACKEND:-local}"
|
||||||
export FILE_STORAGE_LOCAL_ROOT="${FILE_STORAGE_LOCAL_ROOT:-$META_ROOT/runtime/production-like/files}"
|
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}"
|
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())
|
||||||
@@ -72,5 +72,9 @@ while IFS=$'\t' read -r package_name spec; do
|
|||||||
done < "$GOVOPLAN_DEPS"
|
done < "$GOVOPLAN_DEPS"
|
||||||
|
|
||||||
if [[ "${#module_paths[@]}" -gt 0 ]]; then
|
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
|
fi
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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"]
|
||||||
@@ -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;"]
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,15 +16,20 @@ REGISTERED_TRANSPORT = "registered"
|
|||||||
PUBLIC_HTTPS_TRANSPORT = "public-https"
|
PUBLIC_HTTPS_TRANSPORT = "public-https"
|
||||||
GITEA_SSH_PREFIX = "git@git.add-ideas.de:"
|
GITEA_SSH_PREFIX = "git@git.add-ideas.de:"
|
||||||
GITEA_HTTPS_PREFIX = "https://git.add-ideas.de/"
|
GITEA_HTTPS_PREFIX = "https://git.add-ideas.de/"
|
||||||
GITEA_REPOSITORY_PATH = re.compile(
|
GITEA_CHECKOUT_AUTH_KEY = "http.https://git.add-ideas.de/.extraheader"
|
||||||
r"(?:GovOPlaN|add-ideas)/[a-z0-9][a-z0-9-]*[.]git"
|
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:
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description="Clone GovOPlaN repositories listed in repositories.json.")
|
parser = argparse.ArgumentParser(
|
||||||
parser.add_argument("--check", action="store_true", help="Only report missing repositories.")
|
description="Clone GovOPlaN repositories listed in repositories.json."
|
||||||
parser.add_argument("--parent", type=Path, help="Override checkout parent directory.")
|
)
|
||||||
|
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(
|
parser.add_argument(
|
||||||
"--repo",
|
"--repo",
|
||||||
action="append",
|
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."
|
"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)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -82,6 +95,45 @@ def clone_remote(remote: str, *, transport: str) -> str:
|
|||||||
return GITEA_HTTPS_PREFIX + repository_path
|
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:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
args = parse_args(argv)
|
args = parse_args(argv)
|
||||||
manifest = json.loads((ROOT / "repositories.json").read_text(encoding="utf-8"))
|
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)
|
excluded = set(args.exclude_repo)
|
||||||
unknown = (requested | excluded) - set(names)
|
unknown = (requested | excluded) - set(names)
|
||||||
if unknown:
|
if unknown:
|
||||||
raise ValueError(f"unknown registered repositories: {', '.join(sorted(unknown))}")
|
raise ValueError(
|
||||||
|
f"unknown registered repositories: {', '.join(sorted(unknown))}"
|
||||||
|
)
|
||||||
overlap = requested & excluded
|
overlap = requested & excluded
|
||||||
if overlap:
|
if overlap:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -119,6 +173,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
"GIT_TERMINAL_PROMPT": "0",
|
"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:
|
for entry, repo, remote in missing:
|
||||||
print(f"missing: {entry['name']} -> {repo}")
|
print(f"missing: {entry['name']} -> {repo}")
|
||||||
if not args.check:
|
if not args.check:
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ def main() -> int:
|
|||||||
requirements=requirements,
|
requirements=requirements,
|
||||||
python=python,
|
python=python,
|
||||||
local_requirements=local_requirements,
|
local_requirements=local_requirements,
|
||||||
|
repair_requirements=environment.stale_requirements,
|
||||||
force=args.force,
|
force=args.force,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -250,6 +251,7 @@ def build_install_plan(
|
|||||||
python: str,
|
python: str,
|
||||||
local_requirements: tuple[RequirementEntry, ...],
|
local_requirements: tuple[RequirementEntry, ...],
|
||||||
force: bool,
|
force: bool,
|
||||||
|
repair_requirements: tuple[RequirementEntry, ...] = (),
|
||||||
) -> InstallPlan:
|
) -> InstallPlan:
|
||||||
full_command = (python, "-m", "pip", "install", "-r", str(requirements))
|
full_command = (python, "-m", "pip", "install", "-r", str(requirements))
|
||||||
if force:
|
if force:
|
||||||
@@ -273,7 +275,12 @@ def build_install_plan(
|
|||||||
removed_paths = set(previous_inputs) - set(current_inputs)
|
removed_paths = set(previous_inputs) - set(current_inputs)
|
||||||
stale_requirements = requirements_key in changed_paths or requirements_key in removed_paths
|
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] = []
|
warnings: list[str] = []
|
||||||
|
|
||||||
if stale_requirements:
|
if stale_requirements:
|
||||||
@@ -322,7 +329,7 @@ def build_install_plan(
|
|||||||
)
|
)
|
||||||
return InstallPlan(
|
return InstallPlan(
|
||||||
"Selective Python environment sync",
|
"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,),
|
(command,),
|
||||||
tuple(warnings),
|
tuple(warnings),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user