Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf2f02891f | ||
|
|
7ec0bbb826 | ||
|
|
3ca068f76a | ||
|
|
61463a24cb | ||
|
|
8262215fcd | ||
|
|
8aba74e01e | ||
|
|
a24c94435e | ||
|
|
774793976c | ||
|
|
492449a4e2 | ||
|
|
8e890b37ed | ||
|
|
077735bc24 | ||
|
|
d9522d3cc4 | ||
|
|
bad0ea37a7 | ||
|
|
9ffd46fe22 | ||
|
|
be51a9c347 | ||
|
|
e36a6573bf | ||
|
|
629bfec1f1 | ||
|
|
9e956eec6f | ||
|
|
9bb2c808a6 | ||
|
|
f7590a7b8b | ||
|
|
1a68565ba0 | ||
|
|
d9f67b5c26 | ||
|
|
1cfdaec250 | ||
|
|
62501d399a | ||
|
|
ce5528e3b8 | ||
|
|
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: |
|
||||||
@@ -46,9 +55,9 @@ jobs:
|
|||||||
- name: Install WebUI release dependencies with test scripts
|
- name: Install WebUI release dependencies with test scripts
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: bash tools/release/install-webui-release-dependencies.sh ../govoplan-core/webui
|
run: bash tools/release/install-webui-release-dependencies.sh ../govoplan-core/webui
|
||||||
- name: Validate platform endpoint surface declarations
|
- name: Validate platform interface and endpoint declarations
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: .venv/bin/python tools/inventory/platform-interface-inventory.py --strict
|
run: .venv/bin/python tools/inventory/platform-interface-inventory.py --strict-declarations --strict-endpoints
|
||||||
- name: Validate Search against PostgreSQL
|
- name: Validate Search against PostgreSQL
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
env:
|
env:
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
name: Developer Meta-package Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Existing protected release version without leading v
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-package:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||||
|
REQUESTED_VERSION: ${{ inputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Validate protected release tag and package version
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
requested_version = os.environ.get("REQUESTED_VERSION", "").strip()
|
||||||
|
tag = f"v{requested_version}" if requested_version else os.environ["TRIGGER_TAG"]
|
||||||
|
if not tag.startswith("v") or not tag[1:]:
|
||||||
|
raise SystemExit("release tag is missing")
|
||||||
|
project_text = subprocess.check_output(
|
||||||
|
["git", "show", f"{tag}:packages/govoplan-meta/pyproject.toml"],
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
project = tomllib.loads(project_text)["project"]
|
||||||
|
if tag != f"v{project['version']}":
|
||||||
|
raise SystemExit("meta-package version does not match the release tag")
|
||||||
|
tag_commit = subprocess.check_output(
|
||||||
|
["git", "rev-parse", f"refs/tags/{tag}^{{commit}}"], text=True
|
||||||
|
).strip()
|
||||||
|
if subprocess.run(
|
||||||
|
["git", "merge-base", "--is-ancestor", tag_commit, "origin/main"]
|
||||||
|
).returncode:
|
||||||
|
raise SystemExit("release tag is not contained in main")
|
||||||
|
if not requested_version:
|
||||||
|
head_commit = subprocess.check_output(
|
||||||
|
["git", "rev-parse", "HEAD"], text=True
|
||||||
|
).strip()
|
||||||
|
if head_commit != tag_commit:
|
||||||
|
raise SystemExit("tag-triggered checkout does not match the release tag")
|
||||||
|
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||||
|
env_file.write(f"RELEASE_TAG={tag}\n")
|
||||||
|
subprocess.run(["git", "checkout", "--detach", tag_commit], check=True)
|
||||||
|
PY
|
||||||
|
- name: Build developer package
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||||
|
python -m build --wheel --outdir dist packages/govoplan-meta
|
||||||
|
python -m twine check dist/*.whl
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
wheels = tuple(Path("dist").glob("*.whl"))
|
||||||
|
if len(wheels) != 1:
|
||||||
|
raise SystemExit("meta release must contain exactly one wheel")
|
||||||
|
wheel = wheels[0]
|
||||||
|
evidence = {
|
||||||
|
"schema_version": "1",
|
||||||
|
"repository": os.environ["GITEA_REPOSITORY"],
|
||||||
|
"tag": os.environ["RELEASE_TAG"],
|
||||||
|
"commit": subprocess.check_output(
|
||||||
|
["git", "rev-parse", "HEAD"], text=True
|
||||||
|
).strip(),
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"filename": wheel.name,
|
||||||
|
"sha256": hashlib.sha256(wheel.read_bytes()).hexdigest(),
|
||||||
|
"size": wheel.stat().st_size,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
Path("dist/package-artifacts.json").write_text(
|
||||||
|
json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
- name: Retain package hash evidence
|
||||||
|
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||||
|
with:
|
||||||
|
name: developer-meta-package
|
||||||
|
path: dist/package-artifacts.json
|
||||||
|
- name: Check immutable registry state
|
||||||
|
env:
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
project = tomllib.loads(
|
||||||
|
Path("packages/govoplan-meta/pyproject.toml").read_text(encoding="utf-8")
|
||||||
|
)["project"]
|
||||||
|
wheels = tuple(Path("dist").glob("*.whl"))
|
||||||
|
if len(wheels) != 1:
|
||||||
|
raise SystemExit("meta release must contain exactly one wheel")
|
||||||
|
wheel = wheels[0]
|
||||||
|
digest = hashlib.sha256(wheel.read_bytes()).hexdigest()
|
||||||
|
package_url = "/".join(
|
||||||
|
(
|
||||||
|
"https://git.add-ideas.de/api/v1/packages/GovOPlaN",
|
||||||
|
"pypi",
|
||||||
|
quote(str(project["name"]), safe=""),
|
||||||
|
quote(str(project["version"]), safe=""),
|
||||||
|
"files",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
request = Request(
|
||||||
|
package_url,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": f"token {os.environ['PACKAGE_TOKEN']}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
publish = True
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
files = json.load(response)
|
||||||
|
except HTTPError as exc:
|
||||||
|
if exc.code != 404:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if not isinstance(files, list) or len(files) != 1:
|
||||||
|
raise SystemExit("immutable meta-package has an unexpected file set")
|
||||||
|
if files[0].get("sha256") != digest:
|
||||||
|
raise SystemExit(
|
||||||
|
"immutable meta-package already exists with a different SHA-256"
|
||||||
|
)
|
||||||
|
publish = False
|
||||||
|
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||||
|
env_file.write(f"PUBLISH_PYPI={int(publish)}\n")
|
||||||
|
PY
|
||||||
|
- name: Publish developer package
|
||||||
|
env:
|
||||||
|
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_USERNAME"
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||||
|
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||||
|
python -m twine upload --non-interactive \
|
||||||
|
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||||
|
dist/*.whl
|
||||||
|
else
|
||||||
|
echo "Exact developer meta-package is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
name: Release Integration
|
name: Release Integration
|
||||||
|
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -16,13 +18,19 @@ 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: Validate package publication contracts
|
||||||
|
working-directory: govoplan
|
||||||
|
run: |
|
||||||
|
python tools/repo/sync-module-package-workflows.py --check
|
||||||
|
python tools/release/generate-developer-meta-package.py --check
|
||||||
|
python -m unittest tests.test_module_package_workflows tests.test_package_registry_release
|
||||||
- name: Install backend release integration dependencies
|
- name: Install backend release integration dependencies
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
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: Resolve immutable release source
|
||||||
|
working-directory: govoplan
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
git fetch --force --no-tags origin "refs/tags/v$VERSION:refs/tags/v$VERSION"
|
||||||
|
mkdir -p runtime-output
|
||||||
|
git rev-parse "v$VERSION^{commit}" > runtime-output/release-source-commit
|
||||||
|
grep -Eq '^[0-9a-f]{40}$' runtime-output/release-source-commit
|
||||||
|
- 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
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
GOVOPLAN_PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||||
|
GOVOPLAN_PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
python -m venv .runtime-build
|
||||||
|
.runtime-build/bin/python -m pip install --upgrade pip cryptography
|
||||||
|
.runtime-build/bin/python tools/release/generate-release-package-set.py \
|
||||||
|
--version "$VERSION" \
|
||||||
|
--output runtime-output/release-packages.json
|
||||||
|
.runtime-build/bin/python tools/release/resolve-package-artifacts.py \
|
||||||
|
--package-set runtime-output/release-packages.json \
|
||||||
|
--wheelhouse runtime-output/local-wheels \
|
||||||
|
--webui-packages runtime-output/webui-packages \
|
||||||
|
--lock-output runtime-output/package-artifacts.lock.json \
|
||||||
|
--requirements-output runtime-output/requirements-release.packages.txt \
|
||||||
|
--python .runtime-build/bin/python
|
||||||
|
PYTHON="$PWD/.runtime-build/bin/python" \
|
||||||
|
GOVOPLAN_WEBUI_PACKAGE_LOCK="$PWD/runtime-output/package-artifacts.lock.json" \
|
||||||
|
GOVOPLAN_WEBUI_PACKAGE_DIR="$PWD/runtime-output/webui-packages" \
|
||||||
|
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 }}
|
||||||
|
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: |
|
||||||
|
SOURCE_COMMIT="$(cat runtime-output/release-source-commit)"
|
||||||
|
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" \
|
||||||
|
--package-lock runtime-output/package-artifacts.lock.json \
|
||||||
|
--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 }}
|
||||||
|
GITEA_RELEASE_TOKEN: ${{ secrets.GOVOPLAN_RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
SOURCE_COMMIT="$(cat runtime-output/release-source-commit)"
|
||||||
|
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/release-packages.json \
|
||||||
|
--asset runtime-output/package-artifacts.lock.json \
|
||||||
|
--asset runtime-output/requirements-release.packages.txt \
|
||||||
|
--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,7 +4,16 @@
|
|||||||
.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
|
||||||
|
!tools/release/runtime/web-entrypoint.sh
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
audit-reports/
|
audit-reports/
|
||||||
coverage/
|
coverage/
|
||||||
htmlcov/
|
htmlcov/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -107,6 +113,18 @@ Generate the CycloneDX dependency inventory from a resolved release environment:
|
|||||||
./.venv/bin/python tools/release/generate-release-sbom.py --python ./.venv/bin/python
|
./.venv/bin/python tools/release/generate-release-sbom.py --python ./.venv/bin/python
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Synchronize module package workflows and inspect the registry release contract:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/repo/sync-module-package-workflows.py --check
|
||||||
|
./.venv/bin/python tools/release/generate-release-package-set.py \
|
||||||
|
--output /tmp/govoplan-release-packages.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Package publication, exact artifact locking, and the optional `govoplan`
|
||||||
|
developer meta-package are documented in
|
||||||
|
[Package Registry Releases](docs/PACKAGE_REGISTRY_RELEASES.md).
|
||||||
|
|
||||||
For reproducible release artifacts, set `SOURCE_DATE_EPOCH` to the release
|
For reproducible release artifacts, set `SOURCE_DATE_EPOCH` to the release
|
||||||
commit timestamp (or pass an explicit timezone-qualified `--timestamp`):
|
commit timestamp (or pass an explicit timezone-qualified `--timestamp`):
|
||||||
|
|
||||||
@@ -155,6 +173,9 @@ The current executable slice and remaining production gates are documented in
|
|||||||
[Installation and Deployment Architecture](docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md).
|
[Installation and Deployment Architecture](docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md).
|
||||||
Same-host replica balancing and the multi-host promotion boundary are documented
|
Same-host replica balancing and the multi-host promotion boundary are documented
|
||||||
in [Scaling and Multi-Host Deployment](docs/SCALING_AND_MULTI_HOST_DEPLOYMENT.md).
|
in [Scaling and Multi-Host Deployment](docs/SCALING_AND_MULTI_HOST_DEPLOYMENT.md).
|
||||||
|
Create, update, pause, resume, verify and remove a local or multi-hypervisor K3s
|
||||||
|
VM target with the guarded lifecycle documented in
|
||||||
|
[Kubernetes VM Test Lab](docs/KUBERNETES_TEST_LAB.md).
|
||||||
The recovery state machine, migration rollback boundary, and required restore
|
The recovery state machine, migration rollback boundary, and required restore
|
||||||
drills are documented in
|
drills are documented in
|
||||||
[Recovery and Rollback Guarantees](docs/RECOVERY_AND_ROLLBACK_GUARANTEES.md).
|
[Recovery and Rollback Guarantees](docs/RECOVERY_AND_ROLLBACK_GUARANTEES.md).
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Assisted and Non-Digital Channels
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
GovOPlaN must support people who cannot or do not use a self-service portal.
|
||||||
|
Telephone, paper, in-person service, authorized representation, mobile staff,
|
||||||
|
interpreters, and temporary offline work are not exceptional side systems.
|
||||||
|
They are governed channels into the same service, case, workflow, record, and
|
||||||
|
decision.
|
||||||
|
|
||||||
|
The goal is equivalent institutional treatment, not forced channel identity.
|
||||||
|
The system preserves which channel was used and which evidence is available
|
||||||
|
without giving digitally confident users stronger substantive rights.
|
||||||
|
|
||||||
|
The first end-to-end journey is tracked in
|
||||||
|
[GovOPlaN #42](https://git.add-ideas.de/GovOPlaN/govoplan/issues/42).
|
||||||
|
|
||||||
|
## Actor Model
|
||||||
|
|
||||||
|
Every assisted interaction distinguishes:
|
||||||
|
|
||||||
|
- the affected person or organization;
|
||||||
|
- the real staff member or external helper entering information;
|
||||||
|
- the represented party and representation basis;
|
||||||
|
- an interpreter, witness, guardian, or support person where relevant;
|
||||||
|
- the responsible institutional function;
|
||||||
|
- the channel and location;
|
||||||
|
- the person who reviewed or confirmed the captured information.
|
||||||
|
|
||||||
|
"Entered by" is not "declared by". "Declared by" is not "verified by".
|
||||||
|
Authentication assurance, representation authority, and evidence quality are
|
||||||
|
separate fields.
|
||||||
|
|
||||||
|
## Channel-Neutral Intake Contract
|
||||||
|
|
||||||
|
All channels create the same versioned service/form submission contract with
|
||||||
|
additional provenance:
|
||||||
|
|
||||||
|
- service, form, schema, language, and accessibility version;
|
||||||
|
- valid and recorded time;
|
||||||
|
- channel (`portal`, `counter`, `telephone`, `paper`, `email`, `mobile`,
|
||||||
|
`representative`, `offline_import`, or configured extension);
|
||||||
|
- affected and represented parties;
|
||||||
|
- capture actor and responsible function;
|
||||||
|
- consent, notice, purpose, legal basis, and information source;
|
||||||
|
- field-level source and confidence where staff transcribed or inferred data;
|
||||||
|
- attachments, scans, originals, signatures, recordings, and attestations as
|
||||||
|
governed evidence references;
|
||||||
|
- read-back/confirmation result and correction path;
|
||||||
|
- receipt and chosen return channels;
|
||||||
|
- duplicate/matching assessment and any manual resolution.
|
||||||
|
|
||||||
|
Forms Runtime owns the submission lifecycle. Parties owns procedural capacity
|
||||||
|
and representation. Identity/Addresses own subject and contact references.
|
||||||
|
Cases owns the matter. Records owns filing and retention. Audit preserves the
|
||||||
|
action/effect evidence.
|
||||||
|
|
||||||
|
## Assisted Session
|
||||||
|
|
||||||
|
An assisted session is a resumable work item, not a privileged bypass. It:
|
||||||
|
|
||||||
|
1. selects service, language, channel, affected party, and represented capacity;
|
||||||
|
2. shows the staff member only fields and evidence relevant to the service;
|
||||||
|
3. explains why sensitive data is requested and what evidence quality is
|
||||||
|
required;
|
||||||
|
4. records source per value when information comes from speech, paper, an
|
||||||
|
existing register, or staff observation;
|
||||||
|
5. validates and previews consequences before submission;
|
||||||
|
6. supports read-back, correction, confirmation, and a second-person check
|
||||||
|
where policy requires it;
|
||||||
|
7. generates an accessible receipt through the requested channel;
|
||||||
|
8. creates follow-up tasks when original documents, signatures, translation,
|
||||||
|
or verification remain outstanding.
|
||||||
|
|
||||||
|
The helper's normal account and represented function remain in the audit
|
||||||
|
chain. Assistance never grants access to unrelated records about the person.
|
||||||
|
|
||||||
|
## Paper And Scanning
|
||||||
|
|
||||||
|
- Register receipt before scanning so custody and deadlines do not depend on
|
||||||
|
successful OCR.
|
||||||
|
- Store the original scan or external archive reference with digest, pages,
|
||||||
|
capture device/provider, time, operator, and quality assessment.
|
||||||
|
- Treat OCR and extracted fields as derived data with confidence and source
|
||||||
|
coordinates. A person confirms consequential values.
|
||||||
|
- Support separation, ordering, missing-page, duplicate, malware, and
|
||||||
|
readability review.
|
||||||
|
- File the resulting document and submission into the appropriate eAkte;
|
||||||
|
retain or return the physical original according to policy.
|
||||||
|
- Produce cover sheets, barcodes, and return instructions through Templates,
|
||||||
|
not a separate print domain.
|
||||||
|
|
||||||
|
## Telephone And In-Person Handling
|
||||||
|
|
||||||
|
- Show a scripted but adaptable interview from the same Form definition.
|
||||||
|
- Record how identity and representation were checked; do not equate caller ID
|
||||||
|
with identity proof.
|
||||||
|
- Require explicit confirmation of consequential declarations and capture the
|
||||||
|
method (read-back, signed summary, one-time code, witness, later letter).
|
||||||
|
- Record call audio only when a lawful, declared profile permits it; an
|
||||||
|
interaction note is the default.
|
||||||
|
- Make interrupted sessions resumable without exposing prior answers to an
|
||||||
|
unauthorized caller or visitor.
|
||||||
|
|
||||||
|
## Offline And Mobile Work
|
||||||
|
|
||||||
|
Offline packages are encrypted, device-bound, time-limited, purpose-limited,
|
||||||
|
and contain only the required forms/reference data. Synchronization uses
|
||||||
|
idempotent intents and exposes conflicts rather than last-write-wins. Device
|
||||||
|
loss, expiry, revocation, duplicate submission, clock drift, and outcome
|
||||||
|
unknown have explicit recovery paths.
|
||||||
|
|
||||||
|
## Outbound Non-Digital Delivery
|
||||||
|
|
||||||
|
Campaign and Postbox model one delivery intent with channel choices and policy:
|
||||||
|
|
||||||
|
- portal/postbox delivery;
|
||||||
|
- email;
|
||||||
|
- print and postal fulfillment through a managed provider or local handoff;
|
||||||
|
- in-person collection;
|
||||||
|
- telephone notification followed by durable confirmation;
|
||||||
|
- accessible or language-specific variants.
|
||||||
|
|
||||||
|
Distribution preferences are purpose- and service-specific, effective-dated,
|
||||||
|
and may be overridden only by a documented legal or urgent-delivery rule. A
|
||||||
|
fallback occurs only before a channel has accepted the effect unless policy
|
||||||
|
explicitly authorizes duplicate delivery. Receipts distinguish creation,
|
||||||
|
provider acceptance, dispatch, delivery, return, and acknowledgement.
|
||||||
|
|
||||||
|
## Accessibility And Equality
|
||||||
|
|
||||||
|
- The person can request language, easy-language, large-print, screen-reader,
|
||||||
|
sign-language, relay, interpreter, or representative support without those
|
||||||
|
preferences becoming a general-purpose profile visible everywhere.
|
||||||
|
- Staff interfaces support keyboard-only capture, clear focus, error summary,
|
||||||
|
read-back, and printable/offline alternatives.
|
||||||
|
- Channel choice and need for assistance must not be used as an adverse risk
|
||||||
|
signal.
|
||||||
|
- Reports compare completion, wait, correction, abandonment, and outcome by
|
||||||
|
channel only under a declared equality/service-quality purpose and with
|
||||||
|
privacy thresholds.
|
||||||
|
|
||||||
|
## Security And Abuse Controls
|
||||||
|
|
||||||
|
- purpose-aware field access and session timeout;
|
||||||
|
- current authority checks for every read and effect;
|
||||||
|
- dual control for high-risk identity, payment, address, or representation
|
||||||
|
changes;
|
||||||
|
- immutable source/attestation evidence and correction history;
|
||||||
|
- rate and anomaly controls that do not silently reject a person;
|
||||||
|
- explicit safe handling of domestic-abuse, protected-address, witness, or
|
||||||
|
sealed-record cases;
|
||||||
|
- no secret answers or full documents in ordinary operational logs.
|
||||||
|
|
||||||
|
## First Reference Journey
|
||||||
|
|
||||||
|
Implement the permit-to-payment/service-to-decision journey through three
|
||||||
|
equivalent starts:
|
||||||
|
|
||||||
|
1. self-service portal submission;
|
||||||
|
2. staff-assisted counter/telephone submission;
|
||||||
|
3. paper receipt, scan, extraction, confirmation, and filing.
|
||||||
|
|
||||||
|
All three must create the same Case and Workflow contract, preserve different
|
||||||
|
provenance, support correction, produce a receipt, file an eAkte, reach the same
|
||||||
|
decision rules, and prove accessibility, privacy, recovery, and channel
|
||||||
|
fallback in browser and operator tests.
|
||||||
@@ -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.
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# GovOPlaN Capability and IT-Infrastructure Fit Assessment
|
# GovOPlaN Capability and IT-Infrastructure Fit Assessment
|
||||||
|
|
||||||
|
> **Pinned historical evidence:** This document assesses the exact 2026-07-22
|
||||||
|
> Campaign composition below. It is intentionally not updated to describe later
|
||||||
|
> main-branch work. Use [Strategy Status](STRATEGY_STATUS.md) for the current
|
||||||
|
> cross-product reconciliation and create a new dated fit assessment for a new
|
||||||
|
> target composition.
|
||||||
|
|
||||||
## Assessment record
|
## Assessment record
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
@@ -19,8 +25,8 @@
|
|||||||
Datasources, Dataflow, Search, encryption contracts, and other later main-branch
|
Datasources, Dataflow, Search, encryption contracts, and other later main-branch
|
||||||
work must not be inferred into this evidence record. The current product
|
work must not be inferred into this evidence record. The current product
|
||||||
direction and implemented-state reconciliation are documented separately in
|
direction and implemented-state reconciliation are documented separately in
|
||||||
the
|
the [Institutional Governance Target Architecture](INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md)
|
||||||
[Institutional Governance Target Architecture](INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
and [Strategy Status](STRATEGY_STATUS.md).
|
||||||
|
|
||||||
This is a fit assessment, not a production approval or security certification.
|
This is a fit assessment, not a production approval or security certification.
|
||||||
It deliberately does not infer implementation from a repository, issue, or
|
It deliberately does not infer implementation from a repository, issue, or
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ Read it together with:
|
|||||||
|
|
||||||
- the [institutional governance target architecture](INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md)
|
- the [institutional governance target architecture](INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md)
|
||||||
- the [selected reference-journey program](REFERENCE_JOURNEY_PROGRAM.md)
|
- the [selected reference-journey program](REFERENCE_JOURNEY_PROGRAM.md)
|
||||||
- the [current capability and infrastructure fit assessment](CAPABILITY_AND_INFRASTRUCTURE_FIT.md)
|
- the [current strategy status](STRATEGY_STATUS.md)
|
||||||
|
- the [pinned Campaign capability and infrastructure fit assessment](CAPABILITY_AND_INFRASTRUCTURE_FIT.md)
|
||||||
- the [interface pattern language](INTERFACE_PATTERN_LANGUAGE.md)
|
- the [interface pattern language](INTERFACE_PATTERN_LANGUAGE.md)
|
||||||
- the [interface surface inventory](INTERFACE_SURFACE_INVENTORY.md)
|
- the [interface surface inventory](INTERFACE_SURFACE_INVENTORY.md)
|
||||||
- the [module contract and install model](MODULE_CONTRACTS_AND_INSTALLS.md)
|
- the [module contract and install model](MODULE_CONTRACTS_AND_INSTALLS.md)
|
||||||
@@ -41,8 +42,8 @@ Read it together with:
|
|||||||
- Use [Near-term portfolio order](#near-term-portfolio-order) for the bridge to
|
- Use [Near-term portfolio order](#near-term-portfolio-order) for the bridge to
|
||||||
implementation and [Product decisions](#product-decisions-to-make-progressively)
|
implementation and [Product decisions](#product-decisions-to-make-progressively)
|
||||||
for choices that can remain deferred.
|
for choices that can remain deferred.
|
||||||
- Use the [dated snapshot appendix](#snapshot-appendix-2026-07-20) only to
|
- Use the [dated strategic review](STRATEGIC_REVIEW_2026-08-05.md) to understand
|
||||||
understand which live backlog and release facts informed this revision.
|
why the current convergence and reference-journey order was chosen.
|
||||||
|
|
||||||
### Planning ownership
|
### Planning ownership
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ Read it together with:
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| What product should GovOPlaN become, for whom, in which configurations, and through which outcome horizons? | This meta roadmap |
|
| What product should GovOPlaN become, for whom, in which configurations, and through which outcome horizons? | This meta roadmap |
|
||||||
| Which module owns a capability, which technical wave should deliver it, and what implementation gates apply? | The Core master roadmap and owning-module concepts |
|
| Which module owns a capability, which technical wave should deliver it, and what implementation gates apply? | The Core master roadmap and owning-module concepts |
|
||||||
| What is actively planned, blocked, implemented, or closed now? | Gitea issues |
|
| What is actively planned, blocked, implemented, or closed now? | Gitea issues and the dated reconciliation in `STRATEGY_STATUS.md` |
|
||||||
| What can a named composition credibly claim in a target environment? | A dated capability/infrastructure fit assessment |
|
| What can a named composition credibly claim in a target environment? | A dated capability/infrastructure fit assessment |
|
||||||
|
|
||||||
The horizons and near-term order below express product outcomes and portfolio
|
The horizons and near-term order below express product outcomes and portfolio
|
||||||
@@ -1351,71 +1352,10 @@ language, what service it configured, who can act, which systems participate,
|
|||||||
what happens when they fail, how a decision can be reviewed, and where the
|
what happens when they fail, how a decision can be reviewed, and where the
|
||||||
evidence remains—and the product can prove that explanation at runtime.
|
evidence remains—and the product can prove that explanation at runtime.
|
||||||
|
|
||||||
## Snapshot appendix: 2026-07-20
|
## Dated Context
|
||||||
|
|
||||||
This appendix records volatile facts that informed this revision. It is not a
|
The volatile release and backlog appendix that originally accompanied this
|
||||||
second source of truth and should be refreshed or removed when a later roadmap
|
roadmap has been removed so the durable direction cannot become a competing
|
||||||
review uses a new release/backlog snapshot.
|
status source. The [Strategic Review 2026-08-05](STRATEGIC_REVIEW_2026-08-05.md)
|
||||||
|
retains the dated assessment and reasoning. Current reconciliation belongs in
|
||||||
### Composition and release snapshot
|
[Strategy Status](STRATEGY_STATUS.md), and live work state belongs in Gitea.
|
||||||
|
|
||||||
The cross-repository contract scan found 43 module manifest contracts, 29
|
|
||||||
provided interface names, 16 requirements, and no contract error across 65
|
|
||||||
scanned repositories. That is meaningful composition evidence, but the release
|
|
||||||
metadata trailed the integrated code: Core, Policy, Poll, and Scheduling
|
|
||||||
declared `0.1.9` while the whole-product release requirements remained on
|
|
||||||
module tag `v0.1.8`; the root self-hosted `.env.example` and release smoke
|
|
||||||
composition did not yet exercise all installed release modules. Other
|
|
||||||
development compositions already included some of those modules. This was a
|
|
||||||
release/composition gap, not evidence that the underlying slices did not exist.
|
|
||||||
|
|
||||||
### Backlog snapshot
|
|
||||||
|
|
||||||
The Gitea audit found 206 open issues across 36 of 66 catalogued repositories
|
|
||||||
and 362 closed issues. Campaign had 51 open issues and Core 44; together they
|
|
||||||
held 46% of current work. This reflected substantial completed kernel,
|
|
||||||
security, and platform work and a deliberate concentration on the first usable
|
|
||||||
vertical, but also risked crowding out production evidence and the shared
|
|
||||||
process spine.
|
|
||||||
|
|
||||||
The issue workflow needed a reconciliation pass before another delivery
|
|
||||||
program could be inferred from labels: 119 open issues remained in triage, 116
|
|
||||||
had no milestone, and several recently pushed Calendar, Scheduling, Poll,
|
|
||||||
Campaign, and Files slices still described themselves as local or awaiting
|
|
||||||
integration. Conversely, 30 repositories had no open issue; for many
|
|
||||||
later-wave modules this meant no implementation program had been opened, not
|
|
||||||
that the capability was complete.
|
|
||||||
|
|
||||||
[Poll #2](https://git.add-ideas.de/GovOPlaN/govoplan-poll/issues/2) was a clear
|
|
||||||
tracker-drift example: its configurable transition engine, agreed transition
|
|
||||||
matrix/history, idempotent keyed retries, re-decision audit, archive/unarchive,
|
|
||||||
and preservation behavior were implemented and pushed while the issue still
|
|
||||||
reported `needs-info`.
|
|
||||||
|
|
||||||
Issue anchors that informed the bridge from the baseline into this roadmap:
|
|
||||||
|
|
||||||
- [Meta #10](https://git.add-ideas.de/GovOPlaN/govoplan/issues/10) for the
|
|
||||||
capability/infrastructure assessment and its target proof;
|
|
||||||
- [Meta #11](https://git.add-ideas.de/GovOPlaN/govoplan/issues/11) for the
|
|
||||||
universal interface and focused-view direction;
|
|
||||||
- [Core #225](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/225) for
|
|
||||||
guided, safe configuration;
|
|
||||||
- [Core #29](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/29) for the
|
|
||||||
backup/restore production gate;
|
|
||||||
- [Core #263](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/263) and
|
|
||||||
[Campaign #63](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/63),
|
|
||||||
[#62](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/62),
|
|
||||||
[#65](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/65), and
|
|
||||||
[#69](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/69) for the
|
|
||||||
reference interface/delivery vocabulary and behavior;
|
|
||||||
- [Poll #1](https://git.add-ideas.de/GovOPlaN/govoplan-poll/issues/1) for the
|
|
||||||
database-enforced respondent invariant exposed by Scheduling;
|
|
||||||
- [Connectors #6](https://git.add-ideas.de/GovOPlaN/govoplan-connectors/issues/6)
|
|
||||||
for the governed connector configuration/simulation foundation;
|
|
||||||
- [Meta #9](https://git.add-ideas.de/GovOPlaN/govoplan/issues/9) for the first
|
|
||||||
permit-to-payment reference process; and
|
|
||||||
- [Meta #12](https://git.add-ideas.de/GovOPlaN/govoplan/issues/12) for the
|
|
||||||
deliberately deferred, consumer-independent export-control story.
|
|
||||||
|
|
||||||
Live Gitea issue state remains canonical. These dated facts explain the roadmap
|
|
||||||
sequence only.
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Federated GovOPlaN Architecture
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Federation lets autonomous GovOPlaN installations exchange data,
|
||||||
|
configuration, work, messages, records, and evidence without sharing a database
|
||||||
|
or surrendering local policy. It is institution-to-institution cooperation,
|
||||||
|
not multi-tenancy across an untrusted network.
|
||||||
|
|
||||||
|
The first implementation should prove a bounded exchange between two
|
||||||
|
installations. A new federation module is not justified until the shared
|
||||||
|
protocol has at least two independent consumers. Core owns neutral envelopes
|
||||||
|
and trust contracts; Connectors owns transport providers; domain modules own
|
||||||
|
the objects and effects they exchange.
|
||||||
|
|
||||||
|
Implementation is tracked in
|
||||||
|
[GovOPlaN #41](https://git.add-ideas.de/GovOPlaN/govoplan/issues/41).
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
1. Every installation remains authoritative for its tenants, identities,
|
||||||
|
policies, keys, records, and local mappings.
|
||||||
|
2. A remote identity or permission never becomes a local authorization claim.
|
||||||
|
3. Every exchange declares purpose, legal/organizational basis, classification,
|
||||||
|
minimization, retention expectation, and permitted onward use.
|
||||||
|
4. Every object reference identifies origin instance, owner tenant, object type,
|
||||||
|
object ID, exact revision, and source-authority mode.
|
||||||
|
5. Payloads and receipts are signed; sensitive transports use mutually
|
||||||
|
authenticated encrypted channels.
|
||||||
|
6. Acceptance, rejection, outcome unknown, retry, revocation, correction, and
|
||||||
|
reconciliation are durable states.
|
||||||
|
7. Local policy may reject or narrow a remote request. It cannot silently claim
|
||||||
|
to have accepted an effect that did not occur.
|
||||||
|
8. Federation works asynchronously and can exchange signed offline bundles
|
||||||
|
where continuous connectivity is unavailable.
|
||||||
|
|
||||||
|
## Trust Domains
|
||||||
|
|
||||||
|
An instance publishes a signed, versioned federation descriptor containing:
|
||||||
|
|
||||||
|
- stable instance and operator identity;
|
||||||
|
- supported protocol and schema versions;
|
||||||
|
- signing and transport key identifiers with rotation history;
|
||||||
|
- accepted object and exchange profiles;
|
||||||
|
- endpoint locations and size/rate limits;
|
||||||
|
- support, incident, revocation, and data-protection contacts;
|
||||||
|
- evidence and conformance references.
|
||||||
|
|
||||||
|
Pairing is a two-sided administrative workflow. Each side verifies the other,
|
||||||
|
maps the remote institution to a local trusted-party record, selects permitted
|
||||||
|
profiles and purposes, sets policy ceilings, and records approvals. Trust is
|
||||||
|
directional and profile-specific; trusting signed Postbox delivery does not
|
||||||
|
automatically permit case transfer or configuration import.
|
||||||
|
|
||||||
|
## Exchange Envelope
|
||||||
|
|
||||||
|
Every request, response, receipt, correction, and revocation uses one neutral
|
||||||
|
envelope with:
|
||||||
|
|
||||||
|
- message ID, correlation ID, causation ID, creation and expiry;
|
||||||
|
- origin and destination instance/institution/tenant references;
|
||||||
|
- real actor and represented institutional capacity where disclosure is
|
||||||
|
permitted;
|
||||||
|
- exchange profile and semantic schema version;
|
||||||
|
- exact domain object references and content digests;
|
||||||
|
- purpose, legal basis, classification, data categories, retention expectation,
|
||||||
|
onward-transfer constraint, and subject notice status;
|
||||||
|
- requested action and idempotency key;
|
||||||
|
- encryption recipients and signature chain;
|
||||||
|
- attachment/object manifests rather than unbounded embedded blobs;
|
||||||
|
- previous-envelope references for correction, replacement, or revocation.
|
||||||
|
|
||||||
|
The envelope is evidence, not a universal domain object. Each owner validates
|
||||||
|
and imports or links its own payload.
|
||||||
|
|
||||||
|
## Exchange Profiles
|
||||||
|
|
||||||
|
| Profile | First owners | Behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Postbox delivery | Postbox, Campaign, Notifications | Address or derive a remote function-bound postbox, obtain acceptance receipt, and track acknowledgement where permitted |
|
||||||
|
| Case handoff | Cases, Parties, Services, Workflow Engine | Offer exact context and evidence; destination accepts into a new local case and returns the mapping |
|
||||||
|
| Record transfer | Records, Files, DMS, Audit | Transfer or offer a signed record package with file-plan, metadata, content digests, holds, and disposition constraints |
|
||||||
|
| Decision/evidence reference | Decisions, Committee, Audit | Publish a protected exact outcome or verifiable reference without transferring unrelated case content |
|
||||||
|
| Data product publication | Datasources, Dataflow, Reporting | Publish immutable governed materializations with schema, quality, freshness, lineage, and use constraints |
|
||||||
|
| Configuration package | Core, Policy, Views, Workflow, Forms, Templates | Exchange signed definitions; destination assesses compatibility, maps values, derives locally, and never imports secrets |
|
||||||
|
| Search discovery | Search and domain providers | Return permission-filtered metadata or a handoff link; never expose raw remote indexes as local authority |
|
||||||
|
|
||||||
|
## State Machine
|
||||||
|
|
||||||
|
```text
|
||||||
|
draft -> authorized -> queued -> transmitted -> received
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
outcome_unknown rejected
|
||||||
|
|
|
||||||
|
received -> validating -> accepted -> applied -> acknowledged
|
||||||
|
| | |
|
||||||
|
v v v
|
||||||
|
rejected accepted_ reconciled
|
||||||
|
pending
|
||||||
|
```
|
||||||
|
|
||||||
|
Acceptance means the destination durably owns the received intent. It does not
|
||||||
|
mean the requested domain effect completed. Receipts distinguish transport,
|
||||||
|
validation, acceptance, application, and human acknowledgement.
|
||||||
|
|
||||||
|
## Conflict And Autonomy
|
||||||
|
|
||||||
|
- Incoming native objects become local references, mirrors, or newly owned
|
||||||
|
objects according to the profile. They do not overwrite local authority by
|
||||||
|
ID coincidence.
|
||||||
|
- Local mappings are effective-dated and auditable.
|
||||||
|
- Corrections create a linked revision. They do not erase what the destination
|
||||||
|
previously observed.
|
||||||
|
- Revocation is a request and evidence event; the destination applies its own
|
||||||
|
legal and retention rules.
|
||||||
|
- Configuration imports use assessment and derivation. A remote package cannot
|
||||||
|
weaken local policy or install code implicitly.
|
||||||
|
- A disconnected partner remains a visible pending/failed state; work can be
|
||||||
|
rerouted through an approved alternative channel.
|
||||||
|
|
||||||
|
## Security And Privacy
|
||||||
|
|
||||||
|
- Use mTLS for paired online transports and signed envelopes for end-to-end
|
||||||
|
origin evidence.
|
||||||
|
- Encrypt payload objects for the destination, with key rotation and outcome-
|
||||||
|
unknown recovery; transport encryption alone is insufficient for queued
|
||||||
|
bundles.
|
||||||
|
- Do not put bearer credentials, local permission scopes, or reusable secrets
|
||||||
|
in an exchange.
|
||||||
|
- Rate-limit and size-bound discovery and transfer; quarantine unknown schemas
|
||||||
|
and active content.
|
||||||
|
- Evaluate current local authorization at every effect even when the envelope
|
||||||
|
describes historical authority.
|
||||||
|
- Log metadata separately from protected content so operators can reconcile
|
||||||
|
without broad content access.
|
||||||
|
- Subject access, correction, restriction, legal hold, and deletion requests
|
||||||
|
become federated workflows with local decisions and receipts, not remote
|
||||||
|
direct database operations.
|
||||||
|
|
||||||
|
## First Reference Proof
|
||||||
|
|
||||||
|
1. Pair two disposable installations with independent tenants, keys, and
|
||||||
|
policies.
|
||||||
|
2. Exchange signed descriptors and approve only the Postbox delivery profile.
|
||||||
|
3. Deliver one Campaign message to a remote function-bound Postbox.
|
||||||
|
4. Prove replay safety, rejection, timeout/outcome unknown, retry,
|
||||||
|
acknowledgement, correction, key rotation, and revoked trust.
|
||||||
|
5. Export the complete evidence bundle and restore both sides from backup.
|
||||||
|
6. Add configuration-package exchange only after the delivery proof passes.
|
||||||
|
|
||||||
|
The result is a provider-neutral federation contract. A future dedicated
|
||||||
|
module becomes appropriate only when pairing, trust administration, exchange
|
||||||
|
queues, and evidence have a lifecycle independent of Connectors and the first
|
||||||
|
domain owner.
|
||||||
@@ -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,125 @@ 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 first immutable production-distribution baseline is published as
|
||||||
a production distribution:
|
[`v0.1.14`](https://git.add-ideas.de/GovOPlaN/govoplan/releases/tag/v0.1.14)
|
||||||
|
from source commit `1f039dd39c1ce2672f4978c8abc6dff862ef1445`. Runtime
|
||||||
|
Distribution [run #459](https://git.add-ideas.de/GovOPlaN/govoplan/actions/runs/459)
|
||||||
|
proved migrations, schema compatibility, non-root API/Web readiness, and worker
|
||||||
|
delivery/shutdown on both `linux/amd64` and `linux/arm64`. Its signed manifest
|
||||||
|
has SHA-256
|
||||||
|
`d703267e01855dee63200cb20921c91c3f95fbff550c8ca76e9a35cba3f69109`
|
||||||
|
and pins these runtime indexes:
|
||||||
|
|
||||||
1. **OCI release artifacts.** The release pipeline does not yet publish pinned
|
- API: `git.add-ideas.de/govoplan/runtime-api@sha256:197ed01790986f2bc927eaa5d8348fa118702e5d2dc05feb851fc2643c23764a`
|
||||||
multi-architecture API and WebUI images.
|
- WebUI: `git.add-ideas.de/govoplan/runtime-web@sha256:e936cca124f1fad29a067834cf17627d4c236410fdc3fa129e0ccb26b8193812`
|
||||||
2. **Signed distribution manifest.** A channel manifest must bind exact image
|
|
||||||
digests, Compose compatibility, SBOM/provenance references, and revocation
|
The signed bootstrap has SHA-256
|
||||||
state. Recording a URL and checksum is not signature verification.
|
`1ff946fba82b0895d153b23352d06e30fe18388450dfd37fed6fb9912310efc5`
|
||||||
3. **First administrator.** Production needs a one-time, restricted enrollment
|
and key id `runtime-distribution-2026-01`. The managed-ingress boundary passed
|
||||||
|
the same publication run and the independently dispatchable Runtime Ingress
|
||||||
|
Drill [run #458](https://git.add-ideas.de/GovOPlaN/govoplan/actions/runs/458).
|
||||||
|
Every later release must renew this evidence; the following target-specific
|
||||||
|
gates remain:
|
||||||
|
|
||||||
|
1. **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
|
2. **Image/module composition.** The deployer 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
|
3. **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
|
4. **Target 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 public TLS/readiness evidence for that deployment.
|
||||||
trusted-proxy boundaries, TLS certificate issuance, renewal, and health
|
|
||||||
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 +327,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 +353,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 +432,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 +456,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 +504,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 +544,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
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Institutional Digital Twin
|
||||||
|
|
||||||
|
## Definition
|
||||||
|
|
||||||
|
The institutional digital twin is a governed, time-aware projection of how an
|
||||||
|
institution is constituted and operates. It connects structure, authority,
|
||||||
|
services, work, information, technology, obligations, controls, evidence, and
|
||||||
|
outcomes without becoming a second source of truth.
|
||||||
|
|
||||||
|
The twin is not one editable graph database and not an employee-surveillance
|
||||||
|
system. Domain modules and external systems keep ownership. The twin stores or
|
||||||
|
materializes exact references, declared relationships, provenance, confidence,
|
||||||
|
and projection versions. Changes flow through owner actions.
|
||||||
|
|
||||||
|
Implementation is tracked in
|
||||||
|
[GovOPlaN #43](https://git.add-ideas.de/GovOPlaN/govoplan/issues/43).
|
||||||
|
|
||||||
|
## Questions It Should Answer
|
||||||
|
|
||||||
|
- Which unit and function is responsible for a service, decision, record,
|
||||||
|
system, dataset, control, or risk at a given valid and recorded time?
|
||||||
|
- Which mandates and policies permit or constrain an action?
|
||||||
|
- Which processes, providers, staff capacities, data sources, and records are
|
||||||
|
required to deliver a service?
|
||||||
|
- What is affected if a system, provider, organizational unit, role, package,
|
||||||
|
or legal rule changes?
|
||||||
|
- Where are responsibilities missing, conflicting, expired, or concentrated?
|
||||||
|
- Which controls are evidenced, stale, failed, or dependent on an unverified
|
||||||
|
assertion?
|
||||||
|
- How do actual process traces differ from defined workflows?
|
||||||
|
- Which public outcomes can be explained from protected internal evidence?
|
||||||
|
|
||||||
|
## Projection Planes
|
||||||
|
|
||||||
|
| Plane | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| Current | Valid now, reconstructed from owner projections and current provider state |
|
||||||
|
| Historical | Valid at and recorded by selected instants, with present-day security enforced |
|
||||||
|
| Planned | Approved or proposed future structures, services, policies, projects, and package changes |
|
||||||
|
| Observed | Events, process traces, service measures, incidents, effects, and evidence actually recorded |
|
||||||
|
| Scenario | Non-authoritative simulation of a proposed change and its estimated consequences |
|
||||||
|
|
||||||
|
The UI must label these planes unambiguously. Scenario output never becomes an
|
||||||
|
institutional fact until an authorized owner action accepts it.
|
||||||
|
|
||||||
|
## Canonical Graph
|
||||||
|
|
||||||
|
Nodes are stable institutional references, including institution, tenant,
|
||||||
|
unit, function, assignment, mandate, jurisdiction, service, case, party, task,
|
||||||
|
workflow, approval, decision, record, file, message, appointment, dataset,
|
||||||
|
report, provider, system, control, risk, project, asset, and configuration
|
||||||
|
package.
|
||||||
|
|
||||||
|
Edges have:
|
||||||
|
|
||||||
|
- owner and source authority;
|
||||||
|
- relationship type and direction;
|
||||||
|
- valid-from/valid-to and recorded/superseded times;
|
||||||
|
- exact source revision and evidence digest;
|
||||||
|
- institution/tenant boundary;
|
||||||
|
- purpose and visibility classification;
|
||||||
|
- confidence and derivation method for inferred relationships;
|
||||||
|
- correction and replacement references.
|
||||||
|
|
||||||
|
Inferred edges are never displayed as owner assertions. They remain
|
||||||
|
explainable analytical products with source lineage.
|
||||||
|
|
||||||
|
## Ownership And Implementation
|
||||||
|
|
||||||
|
- Core owns neutral institutional references, temporal context, provider
|
||||||
|
registration, and graph projection contracts.
|
||||||
|
- Domain modules publish bounded nodes and edges through provider interfaces.
|
||||||
|
- Search indexes discoverable identities and links.
|
||||||
|
- Reporting materializes governed analytical projections.
|
||||||
|
- Dataflow computes derived relationships, quality checks, and scenarios.
|
||||||
|
- Policy evaluates visibility, purpose, retention, and allowed scenario/action
|
||||||
|
transitions.
|
||||||
|
- Audit supplies observed events and evidence references.
|
||||||
|
- Projects supplies planned change and benefit relationships.
|
||||||
|
- Views renders role- and task-focused twin perspectives.
|
||||||
|
- Workflow Engine coordinates accepted changes but does not edit owner tables.
|
||||||
|
|
||||||
|
No new digital-twin module is required for the first slice. A dedicated owner
|
||||||
|
is justified later if persisted scenario models, graph revisions, and
|
||||||
|
cross-domain projection lifecycle become independent product objects.
|
||||||
|
|
||||||
|
## Beyond The Current Platform
|
||||||
|
|
||||||
|
### Continuous assurance
|
||||||
|
|
||||||
|
Controls become versioned assertions with evidence requirements, evaluation
|
||||||
|
frequency, responsible function, exception workflow, and freshness. Dataflow
|
||||||
|
and provider checks evaluate them continuously; Policy decides whether a stale
|
||||||
|
or failed control advises, requires review, or blocks an effect.
|
||||||
|
|
||||||
|
### Process mining and conformance
|
||||||
|
|
||||||
|
Governed event histories can derive actual paths, wait times, rework, and
|
||||||
|
exceptions. Comparison to Workflow definitions should improve procedures, not
|
||||||
|
rank individuals. Access to personal or small-cohort detail is purpose-limited
|
||||||
|
and separately governed.
|
||||||
|
|
||||||
|
### Change-impact simulation
|
||||||
|
|
||||||
|
A proposed organizational, provider, policy, or package change can be assessed
|
||||||
|
against dependencies, mandates, open work, records, controls, capacity, and
|
||||||
|
recovery plans before activation. Results identify uncertainty rather than
|
||||||
|
inventing precision.
|
||||||
|
|
||||||
|
### Federated institutional models
|
||||||
|
|
||||||
|
Installations can exchange signed public or partner-specific subsets of their
|
||||||
|
service, mandate, provider, and evidence graph. Every side maps the references
|
||||||
|
locally and retains autonomy. Federation does not create one supranational
|
||||||
|
master graph.
|
||||||
|
|
||||||
|
### Accountable assistance
|
||||||
|
|
||||||
|
Assistance may summarize context, identify missing evidence, draft a decision
|
||||||
|
or workflow, propose mappings, and explain policy. Every output records model,
|
||||||
|
inputs, constraints, uncertainty, human review, and accepted edits. Assistance
|
||||||
|
does not become the acting authority.
|
||||||
|
|
||||||
|
### Public evidence chains
|
||||||
|
|
||||||
|
Transparency packages can publish a minimized chain from rule and aggregate
|
||||||
|
facts to decision and observed outcome, with digests proving relation to
|
||||||
|
protected evidence. Public verification does not require disclosure of the
|
||||||
|
underlying personal data.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Do not infer competence, misconduct, intent, or personal performance from
|
||||||
|
graph proximity or incomplete events.
|
||||||
|
- Do not centralize protected content merely to make graph queries easier.
|
||||||
|
- Do not use historical authorization to expose data now prohibited.
|
||||||
|
- Do not let a scenario engine write domain state directly.
|
||||||
|
- Do not hide source authority, freshness, uncertainty, or missing evidence.
|
||||||
|
- Do not retain analytical detail longer than the declared purpose requires.
|
||||||
|
|
||||||
|
## Delivery Slices
|
||||||
|
|
||||||
|
1. Publish exact institutional reference/edge providers for the service-to-
|
||||||
|
decision and monthly-data journeys.
|
||||||
|
2. Build a current/historical dependency explorer with source and access
|
||||||
|
explanations.
|
||||||
|
3. Add planned Project/package changes and bounded impact reports.
|
||||||
|
4. Add control evidence/freshness and process conformance for one journey.
|
||||||
|
5. Prove a minimized federated projection and a public evidence package.
|
||||||
@@ -9,13 +9,17 @@ concepts prepared outside the repositories:
|
|||||||
- `software_big_picture.md`
|
- `software_big_picture.md`
|
||||||
|
|
||||||
The source concepts describe GovOPlaN as an operational governance platform for
|
The source concepts describe GovOPlaN as an operational governance platform for
|
||||||
public institutions. This document merges that direction with the implemented
|
public institutions. This document is the canonical repository version of that
|
||||||
platform state as of 2026-08-01. It is the canonical repository version of the
|
durable architectural direction. Its implementation table records the accepted
|
||||||
direction. Gitea issues remain the source of truth for delivery state.
|
2026-08-01 baseline; it is not a rolling status report. Current reconciliation
|
||||||
|
lives in [Strategy Status](STRATEGY_STATUS.md), and Gitea issues remain the
|
||||||
|
source of truth for delivery state.
|
||||||
|
|
||||||
Read this together with:
|
Read this together with:
|
||||||
|
|
||||||
- [Connected Governance Platform Roadmap](CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
- [Connected Governance Platform Roadmap](CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||||
|
- [Platform Core Ideas](PLATFORM_CORE_IDEAS.md)
|
||||||
|
- [Strategy Status](STRATEGY_STATUS.md)
|
||||||
- [Reference Journey Program](REFERENCE_JOURNEY_PROGRAM.md)
|
- [Reference Journey Program](REFERENCE_JOURNEY_PROGRAM.md)
|
||||||
- [Module Contracts and Install Boundaries](MODULE_CONTRACTS_AND_INSTALLS.md)
|
- [Module Contracts and Install Boundaries](MODULE_CONTRACTS_AND_INSTALLS.md)
|
||||||
- [Datasource and Definition Graph Architecture](DATASOURCE_AND_DEFINITION_GRAPH_ARCHITECTURE.md)
|
- [Datasource and Definition Graph Architecture](DATASOURCE_AND_DEFINITION_GRAPH_ARCHITECTURE.md)
|
||||||
@@ -70,7 +74,12 @@ compositions described here are now implemented. Subsequent work is
|
|||||||
**product depth and stronger maturity evidence**, not another runtime rewrite
|
**product depth and stronger maturity evidence**, not another runtime rewrite
|
||||||
or an unimplemented architecture boundary.
|
or an unimplemented architecture boundary.
|
||||||
|
|
||||||
## Implementation status (2026-08-01)
|
## Accepted implementation baseline (2026-08-01)
|
||||||
|
|
||||||
|
This section is retained as the dated baseline against which the architecture
|
||||||
|
decision was accepted. Later implementation must be reconciled in
|
||||||
|
`STRATEGY_STATUS.md` rather than editing individual rows here into a competing
|
||||||
|
status report.
|
||||||
|
|
||||||
The architecture contract is implemented as a bounded, executable vertical
|
The architecture contract is implemented as a bounded, executable vertical
|
||||||
slice. The portfolio declarations and provider governance gates apply to the
|
slice. The portfolio declarations and provider governance gates apply to the
|
||||||
@@ -79,7 +88,7 @@ were proven now have independent persistent owners:
|
|||||||
|
|
||||||
| Area | Implemented state | Remaining rollout |
|
| Area | Implemented state | Remaining rollout |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Module portfolio metadata | Core validates versioned architecture layer/kind, maturity evidence, known limits, ownership boundaries, authority modes, reference packages, target-tested providers, and migration/upgrade/recovery/security/operations documentation. | Complete for all 62 source manifests. Focused and release checks enforce `--require-architecture`; a new module cannot enter the workspace without truthful declaration and repository-local evidence. |
|
| Module portfolio metadata | Core validates versioned architecture layer/kind, maturity evidence, known limits, ownership boundaries, authority modes, reference packages, target-tested providers, and migration/upgrade/recovery/security/operations documentation. | Complete for the source manifests in the 2026-08-01 snapshot. Focused and release checks enforce `--require-architecture`; current portfolio counts belong in `STRATEGY_STATUS.md`. |
|
||||||
| External providers | Core validates provider objects/field groups, operations, integration maturity, source authority, bounded reads, freshness/health, idempotency, conflicts, outcome-unknown handling, evidence, correction, reconciliation, outage, classification, purpose, retention, and secret handling. Addresses/CardDAV, Files remote storage, Mail SMTP/IMAP, Calendar CalDAV/ICS/Graph/EWS, and Connectors tabular/sanctions providers declare the contract and tenant-bounded secret-free runtime state. | Registry validation rejects any declared external provider without a sanitized state provider. Future adapters must cross the same gate before activation. |
|
| External providers | Core validates provider objects/field groups, operations, integration maturity, source authority, bounded reads, freshness/health, idempotency, conflicts, outcome-unknown handling, evidence, correction, reconciliation, outage, classification, purpose, retention, and secret handling. Addresses/CardDAV, Files remote storage, Mail SMTP/IMAP, Calendar CalDAV/ICS/Graph/EWS, and Connectors tabular/sanctions providers declare the contract and tenant-bounded secret-free runtime state. | Registry validation rejects any declared external provider without a sanitized state provider. Future adapters must cross the same gate before activation. |
|
||||||
| Institutional context | Core provides versioned temporal, actor/representation, institution/unit/function/task/mandate/jurisdiction/service/case/party/work-item/workflow/approval/decision/record, legal-basis, evidence, information-governance, external-source, presentation, and geographic references. Events, automation actions, audit records, and the transactional Audit outbox preserve the envelope. | Owning modules must progressively require the relevant subset for consequential operations. |
|
| Institutional context | Core provides versioned temporal, actor/representation, institution/unit/function/task/mandate/jurisdiction/service/case/party/work-item/workflow/approval/decision/record, legal-basis, evidence, information-governance, external-source, presentation, and geographic references. Events, automation actions, audit records, and the transactional Audit outbox preserve the envelope. | Owning modules must progressively require the relevant subset for consequential operations. |
|
||||||
| Semantic provider contracts | Provider-neutral DTOs and protocols cover Mandate resolution, versioned Service definitions, procedure Parties/representation, and formal Decisions. `govoplan-mandates`, `govoplan-services`, `govoplan-parties`, and `govoplan-decisions` now persist immutable revisions behind those contracts with tenant isolation, bounded reads, replay safety, OCC, migrations, uninstall guards, permissions, APIs, capability documentation, and recovery documentation. | The owners are deliberately headless. Procedure-specific UI remains with consuming modules. |
|
| Semantic provider contracts | Provider-neutral DTOs and protocols cover Mandate resolution, versioned Service definitions, procedure Parties/representation, and formal Decisions. `govoplan-mandates`, `govoplan-services`, `govoplan-parties`, and `govoplan-decisions` now persist immutable revisions behind those contracts with tenant isolation, bounded reads, replay safety, OCC, migrations, uninstall guards, permissions, APIs, capability documentation, and recovery documentation. | The owners are deliberately headless. Procedure-specific UI remains with consuming modules. |
|
||||||
@@ -391,7 +400,9 @@ submodule, configuration fragment, package, or profile.
|
|||||||
|
|
||||||
- This reconciliation is canonical in the meta repository and mirrored to the
|
- This reconciliation is canonical in the meta repository and mirrored to the
|
||||||
Gitea wiki.
|
Gitea wiki.
|
||||||
- All 62 source manifests carry validated evidence-based architecture metadata.
|
- All source manifests in the accepted 2026-08-01 baseline carried validated
|
||||||
|
evidence-based architecture metadata; current counts belong in
|
||||||
|
`STRATEGY_STATUS.md`.
|
||||||
- External-reference, action/effect, operational-health, ownership, policy,
|
- External-reference, action/effect, operational-health, ownership, policy,
|
||||||
audit, and documentation primitives compose into one enforced provider
|
audit, and documentation primitives compose into one enforced provider
|
||||||
declaration and sanitized runtime-state contract.
|
declaration and sanitized runtime-state contract.
|
||||||
@@ -446,7 +457,7 @@ submodule, configuration fragment, package, or profile.
|
|||||||
recovery, accessibility, privacy, security, and operator evidence. This is a
|
recovery, accessibility, privacy, security, and operator evidence. This is a
|
||||||
maturity gate, not missing architecture implementation.
|
maturity gate, not missing architecture implementation.
|
||||||
|
|
||||||
## What remains after the executable architecture slice
|
## What remains within the accepted 2026-08-01 architecture slice
|
||||||
|
|
||||||
The remaining work is not another Core or cross-module architecture rewrite.
|
The remaining work is not another Core or cross-module architecture rewrite.
|
||||||
It falls into two explicitly different categories, neither of which can be
|
It falls into two explicitly different categories, neither of which can be
|
||||||
@@ -487,12 +498,38 @@ persistence, migrations, recovery/disable semantics, documentation and focused
|
|||||||
tests. Their remaining tickets concern concrete providers, deeper adapters and
|
tests. Their remaining tickets concern concrete providers, deeper adapters and
|
||||||
target evidence, not an unresolved institutional architecture boundary.
|
target evidence, not an unresolved institutional architecture boundary.
|
||||||
|
|
||||||
Everything else described as architecture in this document now has a
|
Everything else described in the accepted baseline of this document now has a
|
||||||
repository owner, versioned contract, bounded implementation, migration and
|
repository owner, versioned contract, bounded implementation, migration and
|
||||||
recovery boundary where state exists, documentation, and executable evidence.
|
recovery boundary where state exists, documentation, and executable evidence.
|
||||||
Further work in those modules is product breadth, UX depth, provider adoption,
|
Further work in those modules is product breadth, UX depth, provider adoption,
|
||||||
and evidence renewal.
|
and evidence renewal.
|
||||||
|
|
||||||
|
## Strategic extensions accepted after the baseline
|
||||||
|
|
||||||
|
The completed baseline does not imply that institutional product architecture
|
||||||
|
can no longer grow. The 2026-08-05 strategic review accepted four extensions
|
||||||
|
that consume the existing contracts without reopening the kernel or moving
|
||||||
|
domain ownership into Core:
|
||||||
|
|
||||||
|
- [Product Experience and Module Boundaries](PRODUCT_EXPERIENCE_AND_MODULE_BOUNDARIES.md)
|
||||||
|
separates technical package topology from stable task/object/product
|
||||||
|
surfaces; implementation is tracked in Core #283.
|
||||||
|
- [Federated GovOPlaN Architecture](FEDERATED_GOVOPLAN_ARCHITECTURE.md)
|
||||||
|
defines governed exchange between autonomous installations; implementation
|
||||||
|
is tracked in GovOPlaN #41.
|
||||||
|
- [Assisted and Non-Digital Channels](ASSISTED_AND_NON_DIGITAL_CHANNELS.md)
|
||||||
|
makes channel inclusion part of the service-to-decision journey; the first
|
||||||
|
reference proof is tracked in GovOPlaN #42.
|
||||||
|
- [Institutional Digital Twin](INSTITUTIONAL_DIGITAL_TWIN.md) defines a
|
||||||
|
time-aware, policy-filtered projection over owner data; implementation is
|
||||||
|
tracked in GovOPlaN #43.
|
||||||
|
|
||||||
|
The eAkte depth required by those journeys is owned by Records and specified in
|
||||||
|
`govoplan-records/docs/EAKTE_ARCHITECTURE.md`, tracked in Records #1. These are
|
||||||
|
new product-depth programs with bounded contracts and acceptance journeys, not
|
||||||
|
evidence that the original institutional semantics or module architecture
|
||||||
|
failed.
|
||||||
|
|
||||||
## Delivery tracking
|
## Delivery tracking
|
||||||
|
|
||||||
The completed cross-repository architecture epic is
|
The completed cross-repository architecture epic is
|
||||||
|
|||||||
+228
-102
@@ -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:
|
||||||
|
|
||||||
@@ -51,37 +58,73 @@ Inventory states:
|
|||||||
|
|
||||||
| Surface | Owner and code evidence | Audience/access evidence | Primary task and target archetype | Audit / rollout |
|
| Surface | Owner and code evidence | Audience/access evidence | Primary task and target archetype | Audit / rollout |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| Public landing and login | `govoplan-core` `PublicLandingPage`; rendered while no authenticated principal exists | Unauthenticated; maintenance and backend-reachability context are shell inputs | Understand the service and authenticate; public entry | Unreviewed; later public-entry audit |
|
| Public landing and login | `govoplan-core` `PublicLandingPage`; rendered while no authenticated principal exists | Unauthenticated; maintenance and backend-reachability context are shell inputs | Understand the service and authenticate; public entry | Core shell contract complete under Core #227: semantic entry/login, uniform reachable/offline/maintenance feedback, keyboard focus, responsive layout and privacy-safe pre-authentication state |
|
||||||
| 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 | Core shell contract complete: loading, unreachable, maintenance, authentication-required and module-load failure states use shared status/alert boundaries without erasing the 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 | Core route/module-permutation contract complete; permission, module, View and fallback filtering precede navigation and do not execute a domain action |
|
||||||
| `/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 | Core fallback and Dashboard module permutations complete; fallback remains usable without the optional Dashboard module |
|
||||||
| `/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 | Core shell contract complete under Core #227/#225 and Views #2: semantic global controls, scroll-safe rail, visible maintenance state, guarded navigation, configured Docs fallback, optional Search, responsive/theme/i18n checks and module permutations |
|
||||||
|
|
||||||
## 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` | Campaign | Campaign read/report/control scopes | List-detail, guided review, monitoring, reporting | Campaign pattern pilot complete in [Campaign #74](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/74); bounded product features such as watched-folder policy remain independently tracked |
|
||||||
| `/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 complete; redirect remains declared for saved links and is retired under the compatibility policy rather than through the UI migration |
|
||||||
| `/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 | Configured-system workflow/reference/pattern help complete in [Docs #15](https://git.add-ideas.de/GovOPlaN/govoplan-docs/issues/15), commit `abe2f78` |
|
||||||
| `/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` | Explained service directory and governed handoff | Portal pattern migration complete in [Portal #2](https://git.add-ideas.de/GovOPlaN/govoplan-portal/issues/2); durable evidence in `govoplan-portal/docs/INTERFACE_PATTERN_MIGRATION.md` |
|
||||||
|
| `/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` | Revisioned list-detail/project workspace | Projects pattern migration complete in [Projects #2](https://git.add-ideas.de/GovOPlaN/govoplan-projects/issues/2); durable evidence in `govoplan-projects/docs/INTERFACE_PATTERN_MIGRATION.md` |
|
||||||
|
| `/reporting`, `/reports` | Reporting | `reporting:definition:read` | Governed report catalogue, analytical workspace and evidence | Reporting pattern migration complete in [Reporting #8](https://git.add-ideas.de/GovOPlaN/govoplan-reporting/issues/8); durable evidence in `govoplan-reporting/docs/INTERFACE_PATTERN_MIGRATION.md` |
|
||||||
|
| `/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` | Keyboard-first global/context overlay and full results fallback | Search pattern migration complete in [Search #4](https://git.add-ideas.de/GovOPlaN/govoplan-search/issues/4); durable evidence in `govoplan-search/docs/INTERFACE_PATTERN_MIGRATION.md` |
|
||||||
|
| `/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 | Native BPMN editor, governed revision actions and execution evidence | Workflow pattern migration complete in [Workflow #15](https://git.add-ideas.de/GovOPlaN/govoplan-workflow/issues/15); durable evidence in `govoplan-workflow/docs/INTERFACE_PATTERN_MIGRATION.md` |
|
||||||
|
|
||||||
|
## Final Module Closure Evidence
|
||||||
|
|
||||||
|
The final five module-owned work packages complete the 2026-08-03 rollout
|
||||||
|
snapshot. Their module documents are the durable detailed inventories; the
|
||||||
|
table below records the cross-product closure evidence.
|
||||||
|
|
||||||
|
| Owner | Dominant archetype and consequential boundary | Focused evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Workflow | Definition list-detail plus specialized native BPMN editor; save/activate/archive/delete/reset and instance transitions remain revisioned, confirmed, and Engine-owned | Shared dialogs/status/alerts/help, dirty-navigation guard, keyboard palette insertion, edge inspector alternative, responsive/reduced-motion contract, TypeScript and focused structure test |
|
||||||
|
| Search | Focus-contained global/context overlay plus URL-stable full results; filters only narrow permission-aware source results | F3/Ctrl/Cmd+K, listbox keyboard navigation, provider-partial diagnostics, shared controls/help, narrow layout and focused overlay/interface tests |
|
||||||
|
| Reporting | Three-region governed analytical workspace; runs, schedules, exports and publications retain purpose, permission, source and policy provenance | Shared grid/dialog/status/help, keyboard-explainable Run blockers, responsive task order, provider/semantic backend tests and focused interface test |
|
||||||
|
| Projects | Revisioned list-detail planning workspace; visibility and saves are ACL/OCC-governed and retain a change reason | Shared dialog/status/help/field labels, save errors attached to the editor, semantic list controls, responsive/focus contract and focused interface test |
|
||||||
|
| Portal | Explained service directory and exact-revision provider handoff; Portal never owns the launched case/form/workflow effect | Shared status/alert/toggle/help/blocker controls, stable disabled Open action with actor/action/destination, guarded navigation, responsive layout and focused interface test |
|
||||||
|
|
||||||
|
Future WebUI modules and newly added routes are not grandfathered by this
|
||||||
|
snapshot. They must meet the same surface definition of done in their owning
|
||||||
|
feature issue and pass the source/runtime inventory gates; they do not reopen
|
||||||
|
this finite migration program unless the pattern contract itself changes.
|
||||||
|
|
||||||
## Manifest And Runtime Route Alignment
|
## Manifest And Runtime Route Alignment
|
||||||
|
|
||||||
@@ -91,28 +134,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 +163,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` | `govoplan-notifications` `settings.sections` | Notification preferences | Personal configuration | Unreviewed |
|
| `/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 |
|
||||||
| `/dashboard` | Dashboard host and `dashboard.widgets` | Installed-modules widget; Ops health widget when Ops contributes it | Widget ordering, staleness, permissions, destination behavior | Unreviewed |
|
| `/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`) |
|
||||||
| `/organizations` | IDM `organizations.functionActions` | Action leading to assignment view filtered by IDM scopes | Cross-module context action through explicit capability | Unreviewed |
|
| `/settings` | `govoplan-notifications` `settings.sections` | Notification preferences | Personal configuration | Pattern migration, contextual help, permission/target explanation, typed toggles and focused evidence complete in Notifications #4 (`ad6a31f`) |
|
||||||
| 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 |
|
| `/dashboard` | Dashboard host and `dashboard.widgets` | Installed-modules widget; Ops health widget when Ops contributes it | Widget ordering, staleness, permissions, destination behavior | Pattern migration, view-aware composition, keyboard/drag alternatives, responsive packing, module filtering and focused evidence complete in Dashboard #3 (`da3947f`) |
|
||||||
| 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 |
|
| `/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 | Campaign #74 pilot complete; watched-folder and duplicate-attachment product policy remain independent Campaign #60/#61 features |
|
||||||
|
| 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
|
||||||
@@ -156,70 +281,71 @@ prove that the composition or states satisfy the pattern.
|
|||||||
|
|
||||||
| Surface / code evidence | Primary task | Target pattern | Material consequence/state | Known issue / rollout |
|
| Surface / code evidence | Primary task | Target pattern | Material consequence/state | Known issue / rollout |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| Campaign list (`CampaignListPage`) | Find, compare, create, open | List-detail entry | Campaign lifecycle/status and creation | Audit in [#74](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/74); guided entry [#35](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/35) |
|
| Campaign list (`CampaignListPage`) | Find, compare, create, open | List-detail entry | Campaign lifecycle/status and creation | #74 and guided entry [#35](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/35) complete |
|
||||||
| Overview (`CampaignOverviewPage`) | Understand/edit campaign identity, version, access, lifecycle | Object overview plus adaptive edit | Lock/archive/delete/access changes need real consequence and reversibility wording | #74 remaining audit |
|
| Overview (`CampaignOverviewPage`) | Understand/edit campaign identity, version, access, lifecycle | Object overview plus adaptive edit | Lock/archive/delete/access changes expose consequence, reversibility, owner/access and lifecycle evidence | #74 complete; lifecycle policy is independently extended in Campaign #26 |
|
||||||
| Fields (`CampaignFieldsPage`) | Define recipient/template field schema | Structured editor | Schema changes can invalidate recipient/template data | #74 audit |
|
| Fields (`CampaignFieldsPage`) | Define recipient/template field schema | Structured editor | Schema changes can invalidate recipient/template data | #74 complete |
|
||||||
| Attachments/files (`AttachmentsDataPage`, `AttachmentRulesOverlay`) | Select sources and attachment/ZIP rules | Directory chooser plus adaptive rule editor | Missing or mismatched files affect built messages | #74; attachment-detail [#59](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/59) |
|
| Attachments/files (`AttachmentsDataPage`, `AttachmentRulesOverlay`) | Select sources and attachment/ZIP rules | Directory chooser plus adaptive rule editor | Missing or mismatched files affect built messages | #74 and attachment-detail [#59](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/59) complete |
|
||||||
| Recipients (`RecipientDataPage`) | Select/import/map/edit recipients, address fields and per-recipient values/files | Import/mapping plus list-detail editor | Personal data, validation, bulk activation, file links | Consolidated editor delivered in #67; #74 remaining audit and guided entry #35 |
|
| Recipients (`RecipientDataPage`) | Select/import/map/edit recipients, address fields and per-recipient values/files | Import/mapping plus list-detail editor | Personal data, validation, bulk activation, file links | Consolidated editor #67, guided entry #35 and #74 audit complete; independent bulk action #68 remains product scope |
|
||||||
| Template (`TemplateDataPage`, placeholder/expression dialogs) | Author subject/body and preview substitutions | Adaptive editor plus stable preview | Generated communication content and unresolved expressions | #74; stable overlay [#73](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/73) |
|
| Template (`TemplateDataPage`, placeholder/expression dialogs) | Author subject/body and preview substitutions | Adaptive editor plus stable preview | Generated communication content and unresolved expressions | #74 and stable overlay [#73](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/73) complete |
|
||||||
| Mail settings (`MailSettingsPage` settings view) | Select/configure campaign mail transport | Adaptive configuration | Credentials, SMTP/IMAP destinations, test outcomes | #74; align with Core #225 mail pattern |
|
| Mail settings (`MailSettingsPage` settings view) | Select/configure campaign mail transport | Adaptive configuration | Credentials, SMTP/IMAP destinations, test outcomes | #74 and Core #225 shared mail pattern complete; final credential hierarchy remains Mail #10 |
|
||||||
| 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 complete |
|
||||||
| 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 and Core #225 effective-policy pattern complete |
|
||||||
| 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 and Core #225 effective-policy pattern complete |
|
||||||
| 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 | Interventions #63, send/progress #62/#79 and #74 wording/accessibility audit complete |
|
||||||
| 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`) | Reach campaign evidence/history | Explained provenance handoff | Campaign emits platform evidence; Audit owns reading, retention and bundles | #74 complete as an explicit Audit handoff; object-scoped projection may follow Audit #3 without a sibling-private import |
|
||||||
| JSON (`CampaignJsonView`) | Inspect expert representation | Advanced diagnostics/reference | Raw data may contain personal/configuration values; not a primary editor | #74 privacy/redaction audit |
|
| JSON (`CampaignJsonView`) | Inspect/download expert representation | Advanced diagnostics/reference | Full authorized configuration may contain personal data but no inline transport secrets | #74 privacy audit complete with explicit sensitivity warning and campaign-read boundary |
|
||||||
| Create wizard (`CreateWizard`) | Seed a campaign through basics, sender, fields, recipients, template, attachments, review, send | Guided setup | Current steps mix creation and later consequential delivery; completion semantics need audit | Guided first campaign [#35](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/35) |
|
| Create wizard (`CreateWizard`) | Seed a campaign through basics, sender, fields, recipients, template, attachments, review, send | Guided setup | Current steps mix creation and later consequential delivery; completion semantics need audit | Guided first campaign [#35](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/35) |
|
||||||
| Review/send wizard routes | Alternate guided review/send shells | Guided review | Tracked routes exist; implementation relationship to `ReviewSendPage` must be established, not guessed | #74 inventory decision |
|
| Review/send wizard routes | Focus the canonical review or send stage | Guided review | Thin wrappers render the same `ReviewSendPage` with a stable initial stage; no parallel workflow state exists | #74 inventory decision complete |
|
||||||
| Operator queue (`OperatorQueuePage`) | Monitor jobs and intervene | Monitoring/work queue | Campaign/version/job identity, historical active-version discovery, fixed action positions, authority-aware disabled states, exact non-overlapping queue counts, server-paged jobs, bounded refresh, retry/queue/reconcile per version, campaign-wide pause/resume/cancel, and leave/return progress | Durable operator controls delivered in [#78](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/78); #74 wording/accessibility audit remains |
|
| Operator queue (`OperatorQueuePage`) | Monitor jobs and intervene | Monitoring/work queue | Campaign/version/job identity, historical active-version discovery, fixed action positions, authority-aware disabled states, exact non-overlapping queue counts, server-paged jobs, bounded refresh, retry/queue/reconcile per version, campaign-wide pause/resume/cancel, and leave/return progress | Durable controls #78 and #74 wording/accessibility audit complete |
|
||||||
| Aggregate reports (`AggregateReportsPage`) | Compare cross-campaign delivery outcomes | Privacy-preserving aggregate reporting | Tenant/campaign ACL, deployment/tenant small-cell policy, complementary and overlapping-cell suppression, explicit denominator, and no recipient detail/diagnostics/export/drill-down | Separate aggregate-reader surface delivered in [#80](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/80); not parity with the permission-gated per-campaign detail report |
|
| Aggregate reports (`AggregateReportsPage`) | Compare cross-campaign delivery outcomes | Privacy-preserving aggregate reporting | Tenant/campaign ACL, deployment/tenant small-cell policy, complementary and overlapping-cell suppression, explicit denominator, and no recipient detail/diagnostics/export/drill-down | Separate aggregate-reader surface delivered in [#80](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/80); not parity with the permission-gated per-campaign detail report |
|
||||||
| Templates route (`TemplatesPage`) | Browse template records | Directory/list-detail | Template availability and later generated outputs | #74 audit; verify missing route guard intent |
|
|
||||||
|
|
||||||
The five review stages currently named in code are `Validate and inspect`,
|
The five review stages currently named in code are `Validate and inspect`,
|
||||||
`Build and review`, `Mock send and verify`, `Confirm and send`, and `Delivery
|
`Build and review`, `Mock send and verify`, `Confirm and send`, and `Delivery
|
||||||
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
|
||||||
|
|
||||||
| Order | Scope | Current evidence | Target | Owner / issue | Verification gate | Status |
|
| Order | Scope | Current evidence | Target | Owner / issue | Verification gate | Status |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| 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) | Reviewed route/component inventory, module documents, manifest shapes and focused contracts | Complete 2026-08-03 |
|
||||||
| 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 | Eight-stage creation flow persists current step/draft and hands off to ordinary review/delivery preparation | Task-oriented entry that hands off clearly to normal editing/review | Campaign #35 | First-run flow, resume/back, partial validation, immutable-history and optional-module behavior, no implicit send | Complete 2026-07-30 |
|
||||||
| 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 | Complete: prior 28 recorded commits plus Workflow #15, Search #4, Reporting #8, Projects #2 and Portal #2 verified 2026-08-03 |
|
||||||
| 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 | Authenticated canonical routes align; public signed-token and compatibility routes are explicit exceptions | Stable declarations reconcile with source and any effective runtime module combination | [Meta #25](https://git.add-ideas.de/GovOPlaN/govoplan/issues/25) | Strict duplicate/stale/undeclared declaration CI, per-module digests, and authorized read-only runtime inventory | Complete 2026-08-04 |
|
||||||
|
|
||||||
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
|
||||||
editor workstream, not because it is postponed. Focused views can be specified,
|
editor workstream, not because it is postponed. Focused views can be specified,
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
# Kubernetes VM Test Lab
|
||||||
|
|
||||||
|
`tools/lab/govoplan-lab.py` creates and operates an amd64 Ubuntu/K3s test
|
||||||
|
environment on local or SSH-accessible libvirt hypervisors. It provides the
|
||||||
|
commands requested for the complete VM lifecycle:
|
||||||
|
|
||||||
|
| Command | Effect |
|
||||||
|
| --- | --- |
|
||||||
|
| `doctor` | Validate the strict inventory and, with `--online`, every hypervisor. |
|
||||||
|
| `create --apply` | Download checksum-pinned cloud images, create VM overlays and boot the declared VMs. |
|
||||||
|
| `deploy --apply` | Verify the signed GovOPlaN release, deploy shared state, install pinned K3s and apply GovOPlaN. |
|
||||||
|
| `update --apply` | Pull newly pinned state images, update K3s serially and roll the selected GovOPlaN release. |
|
||||||
|
| `status` | Show libvirt VM state, Kubernetes nodes and GovOPlaN pods. |
|
||||||
|
| `pause --apply` | Gracefully shut down workers, control planes and shared state while retaining disks. |
|
||||||
|
| `resume --apply` | Start the retained environment in dependency order and wait for readiness. |
|
||||||
|
| `verify` | Collect sanitized live-cluster evidence and optionally perform the API-pod-loss drill. |
|
||||||
|
| `destroy --apply --confirm <lab>` | Delete only the lab-owned domains and overlays; local evidence is retained by default. |
|
||||||
|
|
||||||
|
Every mutating command is a dry run unless `--apply` is present. Destruction
|
||||||
|
also requires the exact lab name. Generated credentials, CA keys, manifests and
|
||||||
|
evidence are written below the configured `state_directory` with owner-only
|
||||||
|
permissions. Keep that directory outside the repository and include it in the
|
||||||
|
workstation backup policy. Existing domains are reused or removed only when
|
||||||
|
their GovOPlaN ownership description and both expected lab disk paths match.
|
||||||
|
|
||||||
|
## What The Lab Proves
|
||||||
|
|
||||||
|
The supplied inventories describe two different assurance levels:
|
||||||
|
|
||||||
|
- `tools/lab/govoplan-lab.example.toml` creates four VMs on one libvirt host.
|
||||||
|
It is suitable for development, deployment rehearsal, migration testing,
|
||||||
|
application-pod replacement and recovery-tool exercises. It cannot close
|
||||||
|
GovOPlaN #27 because one physical host remains one failure domain.
|
||||||
|
- `tools/lab/govoplan-lab.acceptance.example.toml` places the two workers on
|
||||||
|
different hypervisors and puts the control and state VMs on a third. It can
|
||||||
|
produce the bounded stateless application-tier evidence required by #27 when
|
||||||
|
the declared hypervisors are genuinely independent physical failure domains.
|
||||||
|
|
||||||
|
Both examples use one control-plane VM and one state VM. This keeps the bounded
|
||||||
|
#27 target economical, but it does not prove control-plane or state-service
|
||||||
|
high availability. For control-plane failover, declare exactly three control
|
||||||
|
nodes on independent hosts. PostgreSQL, Redis and object-storage failover must
|
||||||
|
be tested against independently operated HA services; the lab's single state
|
||||||
|
VM is intentionally a replaceable integration fixture.
|
||||||
|
|
||||||
|
Approximate minimum capacity for the four-VM profile is 10 vCPUs, 16 GiB RAM
|
||||||
|
and 192 GiB of thin-provisioned disk. A six-VM profile with three controls needs
|
||||||
|
additional capacity. Do not overcommit memory on an acceptance target.
|
||||||
|
|
||||||
|
## 1. Prepare The Hypervisors
|
||||||
|
|
||||||
|
On each Ubuntu/Debian libvirt host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
qemu-kvm libvirt-daemon-system libvirt-clients virtinst cloud-image-utils curl
|
||||||
|
sudo systemctl enable --now libvirtd
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a dedicated lab-administration account. Remote hypervisors are managed over
|
||||||
|
SSH and the lifecycle invokes `sudo -n` there, so that account needs bounded
|
||||||
|
non-interactive permission for libvirt, image and cloud-init operations.
|
||||||
|
`NOPASSWD: ALL` is acceptable only on isolated lab hypervisors.
|
||||||
|
|
||||||
|
On a local hypervisor, put the workstation account in the `libvirt` group and
|
||||||
|
point `vm_image_directory` at a directory writable by that account and
|
||||||
|
traversable by `libvirt-qemu`. The lifecycle connects explicitly to
|
||||||
|
`qemu:///system` and does not require passwordless local sudo. Log out and back
|
||||||
|
in after a new group assignment before running `doctor --online`. Create the
|
||||||
|
configured image directory before running the doctor; it deliberately rejects
|
||||||
|
a missing or non-writable storage root instead of silently falling back to a
|
||||||
|
different filesystem.
|
||||||
|
|
||||||
|
Create a dedicated SSH key on the management workstation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-keygen -t ed25519 -f "$HOME/.ssh/govoplan-lab" \
|
||||||
|
-C "GovOPlaN Kubernetes lab"
|
||||||
|
```
|
||||||
|
|
||||||
|
Install its public key for every remote hypervisor account. The same public key
|
||||||
|
is injected into the VMs. The lifecycle keeps its own `ssh_known_hosts` file,
|
||||||
|
uses `accept-new` for first contact, and rejects changed host keys until a
|
||||||
|
lab-owned VM is deliberately recreated.
|
||||||
|
|
||||||
|
### Network contract
|
||||||
|
|
||||||
|
The configured `bridge` must exist on every selected hypervisor. All VM
|
||||||
|
addresses are static. Reserve them outside DHCP allocation and ensure that the
|
||||||
|
management workstation can route directly to every VM address; the lifecycle
|
||||||
|
does not tunnel VM traffic through the hypervisor SSH connection.
|
||||||
|
|
||||||
|
Permit only these flows inside the lab network:
|
||||||
|
|
||||||
|
| Port | Source and destination | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TCP 22 | management workstation to every VM/hypervisor | Provisioning and evidence collection |
|
||||||
|
| TCP 6443 | all K3s nodes and management path to controls | Kubernetes API |
|
||||||
|
| UDP 8472 | K3s node to K3s node | Default Flannel VXLAN; never expose publicly |
|
||||||
|
| TCP 10250 | K3s node to K3s node | Kubelet metrics and API |
|
||||||
|
| TCP 2379-2380 | control to control, only with three controls | Embedded etcd |
|
||||||
|
| TCP 80/443 | test clients to K3s nodes | Traefik/ServiceLB ingress |
|
||||||
|
| TCP 5432/6379/9443 | K3s nodes to the state VM | PostgreSQL, Redis and TLS-protected Garage S3 |
|
||||||
|
| TCP 3025/3143 | approved test clients/workers to the state VM | GreenMail SMTP/IMAP test endpoints |
|
||||||
|
|
||||||
|
The official
|
||||||
|
[K3s networking requirements](https://docs.k3s.io/installation/requirements#networking)
|
||||||
|
remain authoritative. Restrict state ports to the lab network even though the
|
||||||
|
generated integration stack binds them on the state VM.
|
||||||
|
|
||||||
|
## 2. Create The Inventory
|
||||||
|
|
||||||
|
Start with the one-host rehearsal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -d -m 0700 "$HOME/.config/govoplan/labs"
|
||||||
|
cp tools/lab/govoplan-lab.example.toml \
|
||||||
|
"$HOME/.config/govoplan/labs/development.toml"
|
||||||
|
chmod 0600 "$HOME/.config/govoplan/labs/development.toml"
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit at least the bridge, network, static addresses and SSH key paths. For a
|
||||||
|
multi-host run, copy the acceptance example and replace every example hostname,
|
||||||
|
failure-domain declaration and network value. Strict parsing rejects unknown
|
||||||
|
keys, mutable HTTP inputs, malformed checksums, duplicate addresses/MACs and an
|
||||||
|
acceptance inventory that collapses workers onto one declared hypervisor or
|
||||||
|
failure domain.
|
||||||
|
|
||||||
|
Cloud image, K3s binary, K3s installer and GovOPlaN release inputs are URL plus
|
||||||
|
SHA-256 pairs. Updating means changing those reviewed pins and then running the
|
||||||
|
`update` command; the tool deliberately does not follow `latest` aliases.
|
||||||
|
|
||||||
|
The one-host example uses the dedicated `govoplan-lab` NAT network. Its DHCP
|
||||||
|
pool ends at `192.168.123.99`; the static lab addresses start at
|
||||||
|
`192.168.123.201`. Define and start it once on the local hypervisor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh --connect qemu:///system net-define \
|
||||||
|
tools/lab/libvirt/govoplan-lab-network.xml
|
||||||
|
virsh --connect qemu:///system net-autostart govoplan-lab
|
||||||
|
virsh --connect qemu:///system net-start govoplan-lab
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-running those commands is unnecessary when `virsh net-info govoplan-lab`
|
||||||
|
already reports an active, persistent network. The lab destroy command leaves
|
||||||
|
this reusable network in place.
|
||||||
|
|
||||||
|
## 3. Validate And Create The VMs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LAB="$HOME/.config/govoplan/labs/development.toml"
|
||||||
|
PYTHON="/mnt/DATA/git/govoplan/.venv/bin/python"
|
||||||
|
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" doctor --online
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" create
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" create --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The preview is safe to run repeatedly. Creation reuses a domain whose exact
|
||||||
|
lab-owned name already exists and otherwise creates a thin qcow2 overlay under
|
||||||
|
`vm_image_directory/<lab>/<node>`.
|
||||||
|
|
||||||
|
## 4. Deploy GovOPlaN
|
||||||
|
|
||||||
|
If `git.add-ideas.de` requires authentication for release images, export a
|
||||||
|
read-only package/container-registry identity for this shell. A Gitea package
|
||||||
|
token can be used as the password:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GOVOPLAN_LAB_REGISTRY_USERNAME='package-reader'
|
||||||
|
read -r -s GOVOPLAN_LAB_REGISTRY_PASSWORD
|
||||||
|
export GOVOPLAN_LAB_REGISTRY_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
Then preview and apply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" deploy
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" deploy --apply
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" status
|
||||||
|
unset GOVOPLAN_LAB_REGISTRY_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
Deployment verifies the downloaded release manifest and keyring by pinned
|
||||||
|
digest and by the existing GovOPlaN signature policy. It deploys PostgreSQL,
|
||||||
|
Redis, single-node Garage and GreenMail on the state VM. The API, WebUI, workers
|
||||||
|
and scheduler run in K3s from digest-pinned release images. A private lab CA
|
||||||
|
protects both ingress and S3; backend pods receive only the CA Secret and keep
|
||||||
|
TLS verification enabled.
|
||||||
|
|
||||||
|
The final output identifies two local files below `state_directory`:
|
||||||
|
|
||||||
|
- `hosts` maps the public GovOPlaN and S3 test names to their VM addresses;
|
||||||
|
- `pki/ca.crt` is the private lab CA certificate.
|
||||||
|
|
||||||
|
Add the host mappings to the test client's resolver and trust the CA only on
|
||||||
|
devices used for this lab. On Debian/Ubuntu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
STATE="$HOME/.local/share/govoplan/labs/govoplan-k8s-lab"
|
||||||
|
cat "$STATE/hosts"
|
||||||
|
sudo install -m 0644 "$STATE/pki/ca.crt" \
|
||||||
|
/usr/local/share/ca-certificates/govoplan-k8s-lab.crt
|
||||||
|
sudo update-ca-certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
Review mappings before adding them to `/etc/hosts`; the lifecycle does not edit
|
||||||
|
the workstation's trust or resolver configuration.
|
||||||
|
|
||||||
|
### Enroll the first administrator
|
||||||
|
|
||||||
|
The production runtime does not create a default password. Issue one expiring,
|
||||||
|
single-use first-administrator credential inside an API pod and copy its
|
||||||
|
owner-only artifact out immediately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KUBECTL="$STATE/bin/kubectl"
|
||||||
|
POD="$($KUBECTL -n govoplan get pods \
|
||||||
|
-l app.kubernetes.io/component=api \
|
||||||
|
-o jsonpath='{.items[0].metadata.name}')"
|
||||||
|
ARTIFACT="$STATE/first-admin-enrollment.json"
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
$KUBECTL -n govoplan exec "$POD" -- \
|
||||||
|
python -m govoplan_core.commands.first_admin issue \
|
||||||
|
--reason 'initial Kubernetes lab enrollment' \
|
||||||
|
--output /tmp/first-admin-enrollment.json
|
||||||
|
$KUBECTL -n govoplan exec "$POD" -- \
|
||||||
|
cat /tmp/first-admin-enrollment.json > "$ARTIFACT"
|
||||||
|
$KUBECTL -n govoplan exec "$POD" -- \
|
||||||
|
rm -f /tmp/first-admin-enrollment.json
|
||||||
|
chmod 0600 "$ARTIFACT"
|
||||||
|
```
|
||||||
|
|
||||||
|
Submit the token from that artifact once to
|
||||||
|
`/api/v1/bootstrap/first-admin` with the administrator email, display name,
|
||||||
|
password, tenant slug and tenant name. The password must contain at least 12
|
||||||
|
characters. The lab command performs that exchange without placing either the
|
||||||
|
token or password in process arguments, rejects redirects, and removes the
|
||||||
|
artifact only after HTTP 201:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" enroll-admin \
|
||||||
|
--email 'owner@example.org' \
|
||||||
|
--display-name 'System Owner' \
|
||||||
|
--tenant-slug default \
|
||||||
|
--tenant-name 'Default Tenant'
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" enroll-admin \
|
||||||
|
--email 'owner@example.org' \
|
||||||
|
--display-name 'System Owner' \
|
||||||
|
--tenant-slug default \
|
||||||
|
--tenant-name 'Default Tenant' \
|
||||||
|
--apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The public lab hostname must already resolve on the management workstation;
|
||||||
|
the command verifies TLS through the generated private CA directly.
|
||||||
|
|
||||||
|
## 5. Collect #27 Evidence
|
||||||
|
|
||||||
|
Create a short-lived API key authorized to read the Ops status endpoint. In the
|
||||||
|
current Access administration UI, open **Tenant API keys** and select only
|
||||||
|
**View tenant settings** (`admin:settings:read`); the Ops endpoint explicitly
|
||||||
|
accepts that compatibility scope. A dedicated operator credential may instead
|
||||||
|
use `ops:operations:read`. Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GOVOPLAN_OPS_API_KEY='short-lived-value'
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" verify \
|
||||||
|
--exercise-api-pod-loss
|
||||||
|
unset GOVOPLAN_OPS_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
The verifier requires ready API and WebUI pods across at least two Kubernetes
|
||||||
|
nodes, all Deployments available, consistent runtime composition, queue
|
||||||
|
coverage and a valid database-connection budget. During the optional drill it
|
||||||
|
deletes one ready API pod, probes public readiness and waits for replacement.
|
||||||
|
It writes sanitized output to
|
||||||
|
`state_directory/evidence/kubernetes-multi-host.json` and never stores the API
|
||||||
|
key. A rehearsal inventory prints an explicit warning that its result is not
|
||||||
|
independent-failure-domain evidence.
|
||||||
|
|
||||||
|
Retain these private artifacts together for review:
|
||||||
|
|
||||||
|
1. `inventory.json` and the reviewed inventory TOML;
|
||||||
|
2. the adopted release manifest/keyring and installation receipt;
|
||||||
|
3. `kubernetes.json`;
|
||||||
|
4. the Kubernetes verifier output;
|
||||||
|
5. private cluster logs for the approved drill window;
|
||||||
|
6. the operator's out-of-band evidence that the worker hypervisors are
|
||||||
|
independent physical hosts or availability zones.
|
||||||
|
|
||||||
|
GovOPlaN #37 additionally requires independent assessment and production
|
||||||
|
approval keys. Running its evidence jobs in containers is supported, but a
|
||||||
|
container does not create an independent authority. Follow
|
||||||
|
`TARGET_MATURITY_EVIDENCE_RUNBOOK.md` after the #27 drill passes.
|
||||||
|
|
||||||
|
## 6. Update, Pause, Resume And Remove
|
||||||
|
|
||||||
|
After reviewing and changing pinned image/K3s/release values in the inventory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" update
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" update --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Workers are cordoned, drained, updated and uncordoned one at a time. K3s
|
||||||
|
controls are reconciled serially. The release-specific migration Job remains
|
||||||
|
subject to GovOPlaN's signed backup-evidence gate. The lab update command is not
|
||||||
|
a substitute for creating recovery evidence before a destructive state-schema
|
||||||
|
change.
|
||||||
|
|
||||||
|
To stop compute use without deleting disks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" pause --apply
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" resume --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
To remove VM resources while preserving local evidence:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" destroy
|
||||||
|
"$PYTHON" tools/lab/govoplan-lab.py --config "$LAB" destroy \
|
||||||
|
--apply --confirm govoplan-k8s-lab
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `--purge-local-state` only after evidence and recovery material have been
|
||||||
|
retained elsewhere. That option deletes the generated local CA, secrets,
|
||||||
|
manifests and evidence as well as the VMs.
|
||||||
|
|
||||||
|
## Acceptance Boundary
|
||||||
|
|
||||||
|
This tool supplies reproducible infrastructure and executes the bounded
|
||||||
|
stateless-node drill. It does not certify the truth of operator-entered failure
|
||||||
|
domains, provide HA PostgreSQL/Redis/Garage, create production backup evidence,
|
||||||
|
or approve its own results. Those boundaries are deliberate: #27 can close
|
||||||
|
after a passing run on independently controlled hosts; broader production
|
||||||
|
maturity remains governed by #35, #37 and the target evidence runbook.
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Package Registry Releases
|
||||||
|
|
||||||
|
GovOPlaN publishes reusable module artifacts through Gitea's native PyPI and
|
||||||
|
npm registries. These packages improve developer installation, release
|
||||||
|
resolution, cacheability, and artifact inspection. They do not replace the
|
||||||
|
signed runtime distribution: the signed manifest and digest-pinned OCI images
|
||||||
|
remain the production deployment authority.
|
||||||
|
|
||||||
|
## Publication boundary
|
||||||
|
|
||||||
|
Every repository with a `pyproject.toml` contains
|
||||||
|
`.gitea/workflows/module-package-release.yml`. The meta repository owns the
|
||||||
|
canonical template and installs it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/repo/sync-module-package-workflows.py --write
|
||||||
|
python tools/repo/sync-module-package-workflows.py --check
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow runs for `v*` tags and may be dispatched manually for an existing
|
||||||
|
tag. The organization preflight verifies that every package repository protects
|
||||||
|
the `v*` namespace. Before building, the workflow itself verifies that:
|
||||||
|
|
||||||
|
- the tagged commit is contained in `main`;
|
||||||
|
- the tag, Python project version, and optional WebUI package version agree;
|
||||||
|
- package names remain in the `govoplan-*` and `@govoplan/*-webui` namespaces.
|
||||||
|
|
||||||
|
The workflow binds the repository explicitly from the Gitea Actions context.
|
||||||
|
Do not rely on GitHub-compatible environment variables being injected by the
|
||||||
|
runner image; Gitea runners may expose only the context values. Gitea 1.24 job
|
||||||
|
tokens cannot read repository tag-protection settings, so package jobs must not
|
||||||
|
receive a broad administrator token merely to repeat the organization preflight.
|
||||||
|
Run the following before the first publication and after repository or tag-rule
|
||||||
|
changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/gitea/gitea-configure-package-releases.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview and dispatch the exact wheel/WebUI versions selected by the developer
|
||||||
|
meta-package with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/gitea/gitea-dispatch-package-set.py \
|
||||||
|
--env-file ~/.config/gitea/gitea.env
|
||||||
|
python tools/gitea/gitea-dispatch-package-set.py \
|
||||||
|
--env-file ~/.config/gitea/gitea.env \
|
||||||
|
--apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The dispatcher reads exact versions from `packages/govoplan-meta/pyproject.toml`,
|
||||||
|
inspects the selected tag to determine whether a WebUI package is expected,
|
||||||
|
skips complete registry pairs and does not duplicate an active workflow. Use
|
||||||
|
`--repository govoplan-core` for a bounded dispatch or `--verify-existing` to
|
||||||
|
rebuild and hash-verify versions already present in both registries.
|
||||||
|
|
||||||
|
For coordinated lockstep tags, `push-release-tag.sh` pushes module tags first,
|
||||||
|
Core next, and the meta tag last. This is a dependency guarantee for a
|
||||||
|
single-capacity Actions runner: the developer package cannot run before its
|
||||||
|
exact Core and module versions have entered the queue.
|
||||||
|
|
||||||
|
The same release entry point first validates the migration graph, then records
|
||||||
|
the reviewed current Alembic heads under the target release version and reruns
|
||||||
|
the strict migration audit before it changes package versions, commits, or
|
||||||
|
tags. The default preflight intentionally does not require those heads to exist
|
||||||
|
in the previous release baseline. A failed candidate-baseline check therefore
|
||||||
|
cannot produce a protected package release.
|
||||||
|
|
||||||
|
The source gate validates `pyproject.toml`, the module version declaration
|
||||||
|
(`MODULE_VERSION` or the top-level `ModuleManifest.version`), public package
|
||||||
|
`__version__`, and WebUI metadata before creating tags. Release-tag artifact
|
||||||
|
checks run only after the candidate tags and immutable WebUI lock have been
|
||||||
|
created locally.
|
||||||
|
|
||||||
|
Release-lock regeneration resolves a fresh immutable lock from the reviewed
|
||||||
|
candidate manifests; it does not seed resolution from the previous release
|
||||||
|
lock. This prevents removed transitive packages and stale peer metadata from
|
||||||
|
blocking or contaminating the new release. Candidate resolution also uses an
|
||||||
|
isolated temporary npm cache, so a locally replaced tag cannot reuse metadata
|
||||||
|
from a failed, unpushed release attempt.
|
||||||
|
|
||||||
|
Modules that retain the same WebUI package identity in both a root publish
|
||||||
|
manifest and `webui/package.json` use the WebUI manifest as the canonical peer
|
||||||
|
contract. The coordinated release synchronizes `peerDependencies` and
|
||||||
|
`peerDependenciesMeta` into the publish manifest before creating the module
|
||||||
|
tag, then synchronizes each lockfile root from the final package metadata. A
|
||||||
|
distinct root package remains independent.
|
||||||
|
|
||||||
|
It builds one wheel and, where applicable, one npm tarball. The workflow records
|
||||||
|
the source tag, source commit, filename, size, and SHA-256 in
|
||||||
|
`package-artifacts.json` before publishing. Gitea rejects a second upload of the
|
||||||
|
same package version, so correction requires a new version rather than artifact
|
||||||
|
replacement.
|
||||||
|
|
||||||
|
A retry after partial publication is safe. Before upload, the workflow reads the
|
||||||
|
native package registry file record and compares its SHA-256 with the artifact
|
||||||
|
rebuilt from the protected tag. An exact existing artifact is skipped; a
|
||||||
|
same-version artifact with another digest or an unexpected file set fails
|
||||||
|
closed. This permits a failed npm publication to resume without weakening
|
||||||
|
package immutability or accepting `--skip-existing` blindly.
|
||||||
|
|
||||||
|
The npm tarball is always published through an explicit local `./dist/...`
|
||||||
|
path. Without that prefix, npm may interpret a relative tarball name as a Git
|
||||||
|
package shorthand before it ever contacts the configured registry.
|
||||||
|
|
||||||
|
Published WebUI packages contain registry-compatible dependencies only. The
|
||||||
|
workflow converts an internal dependency pinned to a protected `vX.Y.Z` Git tag
|
||||||
|
into the exact `X.Y.Z` registry version and rejects unresolved `file:` or Git
|
||||||
|
dependencies. Repository development metadata may therefore keep local or Git
|
||||||
|
references without leaking them into the published package contract.
|
||||||
|
Historical `add-ideas` and current `GovOPlaN` organization URLs are accepted
|
||||||
|
for immutable tagged releases; both normalize to the same exact registry
|
||||||
|
dependency and no branch or unversioned Git reference is accepted.
|
||||||
|
|
||||||
|
## One-time Gitea setup
|
||||||
|
|
||||||
|
Protect `v*` tags in every package repository and the meta repository. Allow
|
||||||
|
only the `Owners` team to create or delete those tags.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a
|
||||||
|
. ~/.config/gitea/gitea.env
|
||||||
|
set +a
|
||||||
|
python tools/gitea/gitea-configure-package-releases.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a dedicated personal access token with only `write:package` scope and
|
||||||
|
store these organization-level Actions secrets on `GovOPlaN`:
|
||||||
|
|
||||||
|
- `GOVOPLAN_PACKAGE_USERNAME`: account owning the package token;
|
||||||
|
- `GOVOPLAN_PACKAGE_TOKEN`: dedicated package-write token.
|
||||||
|
|
||||||
|
Do not use an administrator or general release token. Gitea 1.24 does not grant
|
||||||
|
package publication to the automatic Actions job token. Organization secrets
|
||||||
|
allow the same least-privilege credential to serve every module workflow.
|
||||||
|
|
||||||
|
## Exact release consumption
|
||||||
|
|
||||||
|
`tools/release/generate-release-package-set.py` translates the reviewed Git
|
||||||
|
source refs in `requirements-release.txt` into an exact registry package set.
|
||||||
|
It resolves each version tag to its commit and verifies the package metadata in
|
||||||
|
that tag.
|
||||||
|
|
||||||
|
`tools/release/resolve-package-artifacts.py` then downloads exactly those wheel
|
||||||
|
and WebUI versions from Gitea. It reads the identity embedded in every wheel and
|
||||||
|
npm tarball, rejects missing, duplicate, unexpected, or oversized artifacts,
|
||||||
|
and writes `package-artifacts.lock.json` with SHA-256 values and npm integrity
|
||||||
|
values. Credentials are accepted only through environment variables and are
|
||||||
|
never written to the lock. Python resolution ignores ambient pip configuration
|
||||||
|
and extra indexes for GovOPlaN roots, preventing an internal package name from
|
||||||
|
being selected from an undeclared registry.
|
||||||
|
|
||||||
|
The runtime distribution workflow uses the verified wheelhouse directly and
|
||||||
|
installs module WebUI tarballs only after matching them to the lock. It publishes
|
||||||
|
the package set, package lock, and hash-locked requirements as release assets.
|
||||||
|
The WebUI installer receives the absolute runtime-build interpreter path so its
|
||||||
|
directory changes cannot escape the isolated release environment.
|
||||||
|
Gitea 1.24 dispatches this workflow from a branch, but that branch is only the
|
||||||
|
workflow implementation. The job fetches and peels the protected `v<version>`
|
||||||
|
tag explicitly, then binds both the signed distribution source and the Gitea
|
||||||
|
release assets to that exact commit. A post-tag workflow repair can therefore
|
||||||
|
retry publication without relabelling the later branch commit as released
|
||||||
|
source.
|
||||||
|
The package-lock SHA-256 is part of the signed distribution manifest. Runtime
|
||||||
|
finalization also requires the lock's package versions and hashes to match the
|
||||||
|
wheel composition embedded in the images. OCI assembly remains network-free
|
||||||
|
after package and third-party dependency resolution.
|
||||||
|
|
||||||
|
The source refs remain in the module catalog for source provenance and release
|
||||||
|
planning. Production installation consumes the signed runtime images rather
|
||||||
|
than invoking `pip`, `npm`, or Git on the target host.
|
||||||
|
|
||||||
|
## Developer meta-package
|
||||||
|
|
||||||
|
`packages/govoplan-meta` builds the optional `govoplan` package. Its default
|
||||||
|
dependencies mirror the reviewed runtime roots; `govoplan[full]` adds all
|
||||||
|
currently packageable workspace modules. Regenerate it after changing release
|
||||||
|
requirements or package versions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/release/generate-developer-meta-package.py
|
||||||
|
python tools/release/generate-developer-meta-package.py --check
|
||||||
|
```
|
||||||
|
|
||||||
|
`push-release-tag.sh` performs this synchronization before release commits and
|
||||||
|
tags. The meta-package is for editable/developer setup and composition tests. It
|
||||||
|
does not enable modules, apply migrations, provision services, or establish
|
||||||
|
backup and recovery evidence.
|
||||||
|
|
||||||
|
If the tag-triggered developer meta-package job fails before publication, rerun
|
||||||
|
`publish-developer-meta-package.yml` with the existing protected version. The
|
||||||
|
manual path validates that tag against `main`, checks out its exact commit, and
|
||||||
|
publishes only when the registry does not already contain the same wheel hash.
|
||||||
|
|
||||||
|
Generic Packages are intentionally not used. Add that transport only when a
|
||||||
|
consumer needs an artifact format unsupported by PyPI, npm, Gitea Releases, or
|
||||||
|
the OCI registry.
|
||||||
@@ -25,6 +25,7 @@ releases, module boundaries, migrations, and security controls.
|
|||||||
| Labels and translations | Generated translation catalogs plus source usage |
|
| Labels and translations | Generated translation catalogs plus source usage |
|
||||||
| Fields and help coverage | Shared form components plus generated TypeScript AST inventory |
|
| Fields and help coverage | Shared form components plus generated TypeScript AST inventory |
|
||||||
| API use by the WebUI | Typed API clients plus generated static reference inventory |
|
| API use by the WebUI | Typed API clients plus generated static reference inventory |
|
||||||
|
| Stable platform interface IDs | Typed manifest/WebUI declarations plus line-independent source anchors for low-level controls |
|
||||||
| Effective configuration | Owning module data plus Policy provenance |
|
| Effective configuration | Owning module data plus Policy provenance |
|
||||||
|
|
||||||
Runtime introspection is authoritative for an installed system. Static source
|
Runtime introspection is authoritative for an installed system. Static source
|
||||||
@@ -45,9 +46,13 @@ The command writes:
|
|||||||
- `audit-reports/platform-inventory/platform-interface-inventory.json`
|
- `audit-reports/platform-inventory/platform-interface-inventory.json`
|
||||||
- `audit-reports/platform-inventory/platform-interface-inventory.md`
|
- `audit-reports/platform-inventory/platform-interface-inventory.md`
|
||||||
|
|
||||||
Use `--strict` in CI. In addition to translation coverage, strict mode requires
|
Use `--strict` for the combined translation, endpoint, and declaration audit.
|
||||||
every backend endpoint without a statically visible WebUI path to have an exact
|
Use `--strict-declarations` for duplicate/stale/undeclared interface checks
|
||||||
entry in
|
without making existing translation coverage a release blocker. Use
|
||||||
|
`--strict-endpoints` in the endpoint-surface CI gate so unrelated translation
|
||||||
|
catalog work cannot disable route classification enforcement. Both strict modes
|
||||||
|
require every backend endpoint without a statically visible WebUI path to have
|
||||||
|
an exact entry in
|
||||||
`tools/inventory/endpoint-surface-declarations.json`. The registry is keyed by
|
`tools/inventory/endpoint-surface-declarations.json`. The registry is keyed by
|
||||||
repository, HTTP method, and canonical version-independent path. It accepts:
|
repository, HTTP method, and canonical version-independent path. It accepts:
|
||||||
|
|
||||||
@@ -73,6 +78,16 @@ It combines:
|
|||||||
2. TypeScript AST extraction of fields, label attributes, visible text,
|
2. TypeScript AST extraction of fields, label attributes, visible text,
|
||||||
translations, frontend routes, navigation, capabilities, and API references
|
translations, frontend routes, navigation, capabilities, and API references
|
||||||
3. Python AST extraction of FastAPI route decorators and router prefixes
|
3. Python AST extraction of FastAPI route decorators and router prefixes
|
||||||
|
4. normalized runtime declarations from every loaded `ModuleManifest`
|
||||||
|
|
||||||
|
The declaration set covers routes, navigation, View surfaces, fields, actions,
|
||||||
|
help references, translations, admin/settings sections, widgets, search
|
||||||
|
objects, permissions, provided interfaces, and backend capabilities. Typed
|
||||||
|
module contributions keep their declared IDs. Shared controls may declare
|
||||||
|
`interfaceId` and `helpTopicId`; otherwise the extractor assigns a deterministic
|
||||||
|
source anchor based on repository, file, component context, control type, and
|
||||||
|
semantic label rather than a line number. The JSON records which identity
|
||||||
|
source was used.
|
||||||
|
|
||||||
The JSON includes exact repository, file, and line evidence. A missing-help
|
The JSON includes exact repository, file, and line evidence. A missing-help
|
||||||
entry is a review candidate because dynamic parent components may supply help.
|
entry is a review candidate because dynamic parent components may supply help.
|
||||||
@@ -80,9 +95,40 @@ A backend route without a static frontend reference is also a review candidate:
|
|||||||
public APIs, workers, callbacks, health checks, connectors, and dynamic URL
|
public APIs, workers, callbacks, health checks, connectors, and dynamic URL
|
||||||
assembly are valid explanations.
|
assembly are valid explanations.
|
||||||
|
|
||||||
`--strict` currently enforces only translation-catalog completeness. Endpoint
|
The module matrix enforces endpoint and interface declarations with
|
||||||
and help classifications need narrow reviewed baselines before they can become
|
`--strict-endpoints --strict-declarations`.
|
||||||
release gates.
|
Combined `--strict` additionally fails when used translation keys are absent
|
||||||
|
from generated locale catalogs. Help-text findings remain review candidates
|
||||||
|
rather than a release gate because dynamic parent components can supply help.
|
||||||
|
|
||||||
|
## Runtime Comparison
|
||||||
|
|
||||||
|
Core exposes a sanitized read-only catalog at
|
||||||
|
`GET /api/v1/platform/interface-catalog`. Access requires
|
||||||
|
`admin:module:read` or `system:settings:read`. Tenant module entitlements are
|
||||||
|
applied before serialization, so the response describes only the effective
|
||||||
|
installed combination. It contains IDs, paths, authorization metadata,
|
||||||
|
versions, counts, and canonical digests; it excludes factories, callbacks,
|
||||||
|
credentials, and mutable runtime state.
|
||||||
|
|
||||||
|
Capture and compare a running installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --fail --silent \
|
||||||
|
-H "Authorization: Bearer $GOVOPLAN_ACCESS_TOKEN" \
|
||||||
|
"$GOVOPLAN_URL/api/v1/platform/interface-catalog" \
|
||||||
|
> /tmp/govoplan-runtime-interface.json
|
||||||
|
|
||||||
|
./.venv/bin/python tools/inventory/platform-interface-inventory.py \
|
||||||
|
--runtime-snapshot /tmp/govoplan-runtime-interface.json \
|
||||||
|
--strict-declarations \
|
||||||
|
--strict-endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
The comparison accepts any installed subset. Every module present in the
|
||||||
|
runtime response must have the same contract version, module version, and
|
||||||
|
declaration digest as the static release inventory. Unknown, duplicate, or
|
||||||
|
mismatched runtime modules fail strict declaration mode.
|
||||||
|
|
||||||
## Admin Information Architecture
|
## Admin Information Architecture
|
||||||
|
|
||||||
@@ -136,13 +182,20 @@ Custom code, new routes, arbitrary SQL, and executable workflow nodes remain
|
|||||||
release artifacts. Modeling them as ordinary configuration would create an
|
release artifacts. Modeling them as ordinary configuration would create an
|
||||||
unreviewed code-execution and migration channel.
|
unreviewed code-execution and migration channel.
|
||||||
|
|
||||||
## Next Enforcement Slices
|
## Enforced Contract
|
||||||
|
|
||||||
1. Require every WebUI module route and admin/settings contribution to have
|
1. Public WebUI routes and View surfaces must reconcile with runtime manifest
|
||||||
matching manifest metadata or a reviewed exception.
|
metadata; stale runtime routes and source-only public surfaces fail CI.
|
||||||
2. Add stable field IDs and optional help-topic IDs to shared field components.
|
2. Duplicate stable IDs fail CI. Shared controls support explicit field/action
|
||||||
3. Classify each statically unreferenced backend endpoint by consumer type.
|
and help-topic identities; fallback anchors remain visible review evidence.
|
||||||
4. Compare a running installation's OpenAPI and module registry against the
|
3. Every statically unreferenced backend endpoint has an exact reviewed
|
||||||
release inventory.
|
consumer classification, and stale classifications fail CI.
|
||||||
5. Publish the sanitized installed-system structure through Ops/Docs for
|
4. Runtime module combinations can be compared exactly with static release
|
||||||
authorized administrators.
|
evidence through versioned per-module digests.
|
||||||
|
5. Runtime introspection is authorized, tenant-filtered, and read-only. It is
|
||||||
|
safe for Ops/Docs projection but is not a generic configuration or code
|
||||||
|
mutation channel.
|
||||||
|
|
||||||
|
Generated JSON and Markdown remain build/audit artifacts. Do not hand-edit or
|
||||||
|
use them as a backlog; change the owning manifest, typed WebUI contribution,
|
||||||
|
translation/help declaration, or exact endpoint classification instead.
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# GovOPlaN Platform Core Ideas
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
GovOPlaN is an institutional governance and operations layer. Its central
|
||||||
|
promise is:
|
||||||
|
|
||||||
|
> Model the institution, orchestrate its work, connect its systems, and
|
||||||
|
> preserve why and under whose authority it acted.
|
||||||
|
|
||||||
|
The platform should let people complete a real task without understanding its
|
||||||
|
repository or module graph. It should let institutions retain control over
|
||||||
|
their data, procedures, providers, and deployment while still sharing
|
||||||
|
interoperable definitions and evidence.
|
||||||
|
|
||||||
|
This document is the stable summary of the ideas that every product package,
|
||||||
|
module, interface, and integration must preserve. Current implementation state
|
||||||
|
lives in [Strategy Status](STRATEGY_STATUS.md).
|
||||||
|
|
||||||
|
## Ten Core Ideas
|
||||||
|
|
||||||
|
### 1. Institutional context before application context
|
||||||
|
|
||||||
|
Work happens for a tenant, institution, organizational unit, function,
|
||||||
|
mandate, jurisdiction, service, case, and represented party. The real actor
|
||||||
|
and represented capacity remain distinct. Application permissions alone do not
|
||||||
|
prove institutional competence.
|
||||||
|
|
||||||
|
### 2. Governance is executable
|
||||||
|
|
||||||
|
Policy is not explanatory prose around an operation. Consequential actions
|
||||||
|
must expose applicable rules, authority, purpose, expected effects, review
|
||||||
|
requirements, recovery behavior, and evidence. Inheritance may tighten a rule
|
||||||
|
but must not silently loosen an upstream constraint.
|
||||||
|
|
||||||
|
### 3. Time has two independent meanings
|
||||||
|
|
||||||
|
Valid time answers when a fact applied. Recorded time answers what the system
|
||||||
|
knew at a point in history. Historical browsing changes the business-data
|
||||||
|
projection, never the current authorization context. Corrections and
|
||||||
|
supersession remain visible rather than rewriting history.
|
||||||
|
|
||||||
|
### 4. One context, many owners
|
||||||
|
|
||||||
|
Cases, tasks, decisions, records, messages, files, appointments, reports, and
|
||||||
|
external objects remain owned by their domain modules or source systems. Stable
|
||||||
|
references create one navigable context without a universal copied master
|
||||||
|
record or cross-module table access.
|
||||||
|
|
||||||
|
### 5. Native and connected operation are peers
|
||||||
|
|
||||||
|
For every integration, GovOPlaN states whether it is authoritative, mirrors an
|
||||||
|
external source, synchronizes governed fields, adds a governance overlay, or
|
||||||
|
keeps a link only. An external system can be used today and replaced later
|
||||||
|
without losing provenance or institutional control.
|
||||||
|
|
||||||
|
### 6. Human work is a first-class system object
|
||||||
|
|
||||||
|
An intake becomes owned, reviewable work. A person can see the current context,
|
||||||
|
next responsible action, reason, deadline, consequence, and completion
|
||||||
|
evidence. Workflow Engine coordinates machine and human transitions; focused
|
||||||
|
views guide people through the relevant platform surfaces.
|
||||||
|
|
||||||
|
### 7. Views reduce complexity without changing authority
|
||||||
|
|
||||||
|
The interface is a task- and role-sensitive projection of installed
|
||||||
|
capabilities. Views, dashboards, search, documentation, and workflow-guided
|
||||||
|
surfaces may hide irrelevant functions, but they never grant access. Users can
|
||||||
|
escape a focused mode when policy permits and can always understand why
|
||||||
|
something is unavailable.
|
||||||
|
|
||||||
|
### 8. Evidence and recovery are part of the operation
|
||||||
|
|
||||||
|
Intent, exact input versions, approvals, external effects, receipts,
|
||||||
|
outcome-unknown states, reconciliation, corrections, retention, and recovery
|
||||||
|
belong to one evidence chain. A retry must be idempotent; rollback claims must
|
||||||
|
distinguish reversible local state from effects already observed elsewhere.
|
||||||
|
|
||||||
|
### 9. Inclusion is multi-channel, not portal-only
|
||||||
|
|
||||||
|
Public portal, postbox, mail, telephone, paper, in-person assistance, APIs, and
|
||||||
|
external systems are channels around the same governed work. Assisted entry
|
||||||
|
records who entered information, for whom, from which source, with which
|
||||||
|
attestation, and how the affected person receives a usable receipt and
|
||||||
|
correction path.
|
||||||
|
|
||||||
|
### 10. Successful configurations are portable products
|
||||||
|
|
||||||
|
Modules are ingredients. A usable product is a signed configuration package
|
||||||
|
with terminology, forms, policies, workflows, views, reports, provider
|
||||||
|
profiles, documentation, migration rules, and evidence. Institutions derive
|
||||||
|
local packages without forking code or weakening inherited constraints.
|
||||||
|
|
||||||
|
## Platform Planes
|
||||||
|
|
||||||
|
The planes below are ownership lenses, not navigation groups or mandatory
|
||||||
|
deployment tiers.
|
||||||
|
|
||||||
|
| Plane | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| Experience | Shell, views, dashboard, search, help, accessibility, and task-focused composition |
|
||||||
|
| Participation and channels | Portal, postbox, mail, campaigns, calendar, scheduling, consultation, and assisted channels |
|
||||||
|
| Human work and procedure | Services, forms/runtime, cases, tasks, approvals, workflow execution, and domain procedures |
|
||||||
|
| Content, records, and evidence | Files, templates, DMS, eAkte/records, audit, reporting, transparency, and publication |
|
||||||
|
| Institutional governance | Identity, access, tenancy, organizations, functions, mandates, policy, trust, and formal decisions |
|
||||||
|
| Data and integration | Connectors, datasources, dataflow, search, external references, provider health, and reconciliation |
|
||||||
|
| Runtime and assurance | Module composition, operations, deployment, recovery, security evidence, and signed packages |
|
||||||
|
|
||||||
|
## Canonical Distinctions
|
||||||
|
|
||||||
|
The platform must not collapse these pairs:
|
||||||
|
|
||||||
|
- identity vs account vs represented capacity;
|
||||||
|
- role/permission vs function/mandate/competence;
|
||||||
|
- valid time vs recorded time;
|
||||||
|
- purpose for use vs general technical access;
|
||||||
|
- document content vs managed file bytes vs institutional record;
|
||||||
|
- task vs workflow definition vs workflow instance;
|
||||||
|
- approval vs formal decision;
|
||||||
|
- message intent vs transport delivery vs recipient acknowledgement;
|
||||||
|
- source authority vs connector maturity;
|
||||||
|
- current state vs historical evidence;
|
||||||
|
- correction/compensation vs erasure of an observed effect;
|
||||||
|
- a module boundary vs a user-visible product boundary.
|
||||||
|
|
||||||
|
## Product Experience Rule
|
||||||
|
|
||||||
|
The normal user interface speaks in services, work, records, messages,
|
||||||
|
meetings, decisions, and outcomes. Module names, provider IDs, capability names,
|
||||||
|
package coordinates, and schema details are technical provenance. They are
|
||||||
|
visible to administrators and in expandable diagnostics, but they are not the
|
||||||
|
primary information architecture for ordinary work.
|
||||||
|
|
||||||
|
## Maturity Rule
|
||||||
|
|
||||||
|
A repository, route, model, or unit test does not make a capability complete.
|
||||||
|
Claims advance only with evidence appropriate to the claim:
|
||||||
|
|
||||||
|
1. `scaffold`: boundary and documentation exist;
|
||||||
|
2. `vertical_slice`: useful behavior has focused tests;
|
||||||
|
3. `reference_ready`: an end-to-end reference journey passed target,
|
||||||
|
accessibility, privacy, security, operations, and recovery evidence;
|
||||||
|
4. `supported`: upgrades, interoperability, support procedures, and release
|
||||||
|
guarantees are defined;
|
||||||
|
5. `lts`: compatibility and maintenance windows are contractual.
|
||||||
|
|
||||||
|
## Deliberate Non-Goals
|
||||||
|
|
||||||
|
GovOPlaN does not aim to:
|
||||||
|
|
||||||
|
- replace every specialist system, ERP, DMS, groupware, or data tool;
|
||||||
|
- make one database authoritative for every connected fact;
|
||||||
|
- expose every installed capability to every person;
|
||||||
|
- infer authority from organizational membership alone;
|
||||||
|
- make historical browsing weaken current security;
|
||||||
|
- treat AI output as an unaccountable institutional decision;
|
||||||
|
- create a repository for every noun in the information model;
|
||||||
|
- claim production maturity from local development evidence.
|
||||||
|
|
||||||
|
## Decision Test
|
||||||
|
|
||||||
|
A proposed feature fits the platform when it improves at least one real
|
||||||
|
institutional journey and can answer:
|
||||||
|
|
||||||
|
1. Who owns the object and source of truth?
|
||||||
|
2. In which institutional and temporal context does it apply?
|
||||||
|
3. For which declared purpose may it be used?
|
||||||
|
4. Which policy and authority permit the action?
|
||||||
|
5. What effect, evidence, retention, and recovery behavior result?
|
||||||
|
6. How can it operate with an external owner without losing autonomy?
|
||||||
|
7. How will a person discover and complete it without learning the module
|
||||||
|
graph?
|
||||||
|
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Production Target And Independent Evidence Handoff
|
||||||
|
|
||||||
|
This runbook identifies the external inputs needed to finish
|
||||||
|
[GovOPlaN #27](https://git.add-ideas.de/GovOPlaN/govoplan/issues/27) and
|
||||||
|
[GovOPlaN #37](https://git.add-ideas.de/GovOPlaN/govoplan/issues/37). The
|
||||||
|
repository can render, inspect and sign evidence for a target, but it cannot
|
||||||
|
manufacture an independent failure domain or an independent approval authority.
|
||||||
|
|
||||||
|
## GovOPlaN #27: real two-node target
|
||||||
|
|
||||||
|
The bounded acceptance target is two independently schedulable worker nodes.
|
||||||
|
The API and WebUI must each have ready replicas on both nodes, all Deployments
|
||||||
|
must be available, the active module composition and software versions must be
|
||||||
|
consistent, every configured queue must have a worker, and the database
|
||||||
|
connection budget must pass. The validation then deletes one ready API pod and
|
||||||
|
requires replacement without an observed readiness outage.
|
||||||
|
|
||||||
|
Two virtual machines on different physical hosts or availability zones meet the
|
||||||
|
failure-domain intent. Two containers, VMs or Kubernetes nodes on one physical
|
||||||
|
host are useful development targets but do not close #27. A two-worker cluster
|
||||||
|
also does not prove control-plane high availability. For a self-managed
|
||||||
|
production cluster, use three control-plane nodes plus at least two workers; a
|
||||||
|
managed control plane plus two workers is the shorter path.
|
||||||
|
|
||||||
|
### What the target owner must provide
|
||||||
|
|
||||||
|
Provide these through a secure handoff, not an issue, chat message or Git:
|
||||||
|
|
||||||
|
1. A kubeconfig path with access to the target, for example
|
||||||
|
`~/.config/govoplan/targets/<target>.kubeconfig`, mode `0600`.
|
||||||
|
2. A stable installation ID, public HTTPS hostname, namespace, ingress class and
|
||||||
|
TLS-secret or certificate-manager arrangement.
|
||||||
|
3. Two independently schedulable workers and permission to place API and WebUI
|
||||||
|
replicas on both.
|
||||||
|
4. External, logically shared PostgreSQL, Redis and S3 endpoints with trusted
|
||||||
|
CA material and network reachability from every worker. Do not co-locate the
|
||||||
|
only copies of these services on the two workers used for the failure drill.
|
||||||
|
5. The six runtime secret values required by the generated manifest:
|
||||||
|
`MASTER_KEY_B64`, `DATABASE_URL`, `GOVOPLAN_DATABASE_URL_PGTOOLS`,
|
||||||
|
`REDIS_URL`, `FILE_STORAGE_S3_ACCESS_KEY_ID` and
|
||||||
|
`FILE_STORAGE_S3_SECRET_ACCESS_KEY`.
|
||||||
|
6. A short-lived GovOPlaN API key limited to `ops:operations:read`, supplied in
|
||||||
|
`GOVOPLAN_OPS_API_KEY` only for evidence collection.
|
||||||
|
7. An approved drill window and permission to delete one API pod.
|
||||||
|
|
||||||
|
If no Kubernetes target exists, provide hostnames/IP addresses for the machines,
|
||||||
|
an SSH user and key path, the internal/external DNS plan, and the permitted
|
||||||
|
firewall ports. Those inputs are sufficient to provision a k3s target. They are
|
||||||
|
not sufficient to claim control-plane HA unless three control-plane failure
|
||||||
|
domains are present.
|
||||||
|
|
||||||
|
The repository now supplies the strict libvirt/K3s lifecycle and example
|
||||||
|
inventories for this handoff in
|
||||||
|
[`KUBERNETES_TEST_LAB.md`](KUBERNETES_TEST_LAB.md). Its `acceptance` mode
|
||||||
|
rejects a declared topology unless the workers and shared-state fixture occupy
|
||||||
|
different hypervisor and failure-domain identifiers. Reviewers must still
|
||||||
|
verify that those identifiers correspond to genuinely independent hosts.
|
||||||
|
|
||||||
|
### Separate deployment and evidence authorities
|
||||||
|
|
||||||
|
The deployment identity may create and update the namespace, Secret,
|
||||||
|
ConfigMap, Deployments, Services, Jobs, PodDisruptionBudgets and Ingress. The
|
||||||
|
evidence collector only needs:
|
||||||
|
|
||||||
|
- cluster scope: `get` and `list` for `nodes`;
|
||||||
|
- target namespace: `get` and `list` for `pods` and `deployments`;
|
||||||
|
- target namespace during the approved drill: `delete` for `pods`.
|
||||||
|
|
||||||
|
Use separate kubeconfig contexts or service accounts when the same person does
|
||||||
|
not hold both roles.
|
||||||
|
|
||||||
|
### Render, apply and verify
|
||||||
|
|
||||||
|
Use the signed, digest-pinned installation bundle selected for the target:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KUBECONFIG="$HOME/.config/govoplan/targets/<target>.kubeconfig"
|
||||||
|
|
||||||
|
python tools/deployment/govoplan-deploy.py render-kubernetes \
|
||||||
|
--directory /srv/govoplan/<installation-id> \
|
||||||
|
--namespace govoplan \
|
||||||
|
--secret-name govoplan-runtime \
|
||||||
|
--tls-secret-name govoplan-tls \
|
||||||
|
--s3-ca-secret-name govoplan-s3-ca \
|
||||||
|
--ingress-class-name nginx \
|
||||||
|
--output /srv/govoplan/<installation-id>/kubernetes.json
|
||||||
|
|
||||||
|
kubectl apply -f /srv/govoplan/<installation-id>/kubernetes.json
|
||||||
|
kubectl -n govoplan wait --for=condition=available deployment --all --timeout=10m
|
||||||
|
|
||||||
|
export GOVOPLAN_OPS_API_KEY="$(cat /run/secrets/govoplan-ops-evidence-key)"
|
||||||
|
python tools/deployment/govoplan-deploy.py verify-kubernetes \
|
||||||
|
--directory /srv/govoplan/<installation-id> \
|
||||||
|
--namespace govoplan \
|
||||||
|
--exercise-api-pod-loss \
|
||||||
|
--output /srv/govoplan/<installation-id>/evidence/kubernetes-multi-host.json
|
||||||
|
unset GOVOPLAN_OPS_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
The verifier emits sanitized JSON and exits nonzero if the topology, runtime,
|
||||||
|
queue, connection-budget or pod-loss checks fail. Preserve the private cluster
|
||||||
|
logs and manifest alongside the sanitized result in the controlled evidence
|
||||||
|
store.
|
||||||
|
|
||||||
|
## GovOPlaN #37: controlled signed target evidence
|
||||||
|
|
||||||
|
Yes, collection, review and signing can run in containers. A container provides
|
||||||
|
repeatability and process isolation; it does not create independent authority.
|
||||||
|
The production approver must control a different private key from the target
|
||||||
|
operator/assessor and must review the evidence before signing the
|
||||||
|
`production_approval` scope.
|
||||||
|
|
||||||
|
Use at least these three key boundaries:
|
||||||
|
|
||||||
|
1. **Installer authority:** signs installed-release-origin receipts only.
|
||||||
|
2. **Target assessment authority:** signs the permitted target, accessibility,
|
||||||
|
privacy, security, operations and recovery scopes.
|
||||||
|
3. **Production approval authority:** independently signs only
|
||||||
|
`production_approval` after reviewing the other evidence.
|
||||||
|
|
||||||
|
Do not reuse release-catalog keys for any of these roles. Keep private Ed25519
|
||||||
|
keys outside Git, Gitea, GovOPlaN application storage and chat. Publish only the
|
||||||
|
public keyrings. The proof issuer already rejects key reuse across release,
|
||||||
|
installer and proof trust domains.
|
||||||
|
|
||||||
|
### Generate independently held keys
|
||||||
|
|
||||||
|
Each authority runs this command in its own `0700` directory. The generator
|
||||||
|
refuses existing output paths and writes both files as `0600`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -d -m 0700 "$HOME/.config/govoplan/authority-keys"
|
||||||
|
|
||||||
|
python tools/assessments/generate-authority-keypair.py \
|
||||||
|
--purpose proof \
|
||||||
|
--key-id authority:target-2026 \
|
||||||
|
--scope target_environment \
|
||||||
|
--scope accessibility \
|
||||||
|
--scope privacy \
|
||||||
|
--scope security \
|
||||||
|
--scope operations \
|
||||||
|
--scope recovery \
|
||||||
|
--private-key "$HOME/.config/govoplan/authority-keys/target-2026.pem" \
|
||||||
|
--keyring "$HOME/.config/govoplan/authority-keys/target-2026-public.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
The independent production approver generates another key with only
|
||||||
|
`--scope production_approval`. An installer authority uses `--purpose installer`
|
||||||
|
and no `--scope`. Merge public key entries into the separately controlled
|
||||||
|
keyrings only after the responsible authorities verify fingerprints out of
|
||||||
|
band.
|
||||||
|
|
||||||
|
### Container boundary
|
||||||
|
|
||||||
|
Use two one-shot jobs or containers:
|
||||||
|
|
||||||
|
- **Collector/assessor:** network access, read-only source and trust mounts,
|
||||||
|
read/write private evidence output, and the narrowly scoped kubeconfig. It
|
||||||
|
must not receive the production-approval private key.
|
||||||
|
- **Production approver:** `--network none`, read-only assessment/evidence/trust
|
||||||
|
mounts, a read-only secret mount containing only the approval key, and a
|
||||||
|
separate output mount. It must not receive deployment credentials.
|
||||||
|
|
||||||
|
Build or select the assessment image by digest and record that digest in the
|
||||||
|
evidence log. A representative runtime shape is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --network none --read-only --tmpfs /tmp \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--mount type=bind,src="$PWD/evidence",dst=/evidence,readonly \
|
||||||
|
--mount type=bind,src="$PWD/trust",dst=/trust,readonly \
|
||||||
|
--mount type=bind,src="$HOME/.config/govoplan/authority-keys",dst=/run/keys,readonly \
|
||||||
|
--mount type=bind,src="$PWD/approved",dst=/output \
|
||||||
|
<assessment-image>@sha256:<digest> \
|
||||||
|
<assessment command>
|
||||||
|
```
|
||||||
|
|
||||||
|
The current evidence commands and required scopes are documented in
|
||||||
|
[`TARGET_MATURITY_EVIDENCE_RUNBOOK.md`](TARGET_MATURITY_EVIDENCE_RUNBOOK.md).
|
||||||
|
The final proof must cover `target_environment`, `accessibility`, `privacy`,
|
||||||
|
`security`, `operations`, `recovery` and independent `production_approval`, and
|
||||||
|
must bind to the verified installed composition and installer receipt.
|
||||||
|
|
||||||
|
## Completion boundary
|
||||||
|
|
||||||
|
#27 can close after the real target produces a passing pod-loss result. #37 can
|
||||||
|
close after an independently approved, schema-valid proof is generated for that
|
||||||
|
same installed composition and the public authority keyrings, proof and private
|
||||||
|
evidence custody references are recorded. Neither issue should close from a
|
||||||
|
single-host simulation or a self-approved signature.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Product Experience and Module Boundaries
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
GovOPlaN's runtime modularity is a strength, but the implementation structure
|
||||||
|
is exposed too directly in the product. Ordinary users encounter module names,
|
||||||
|
one top-level route per module, one navigation item per repository, package and
|
||||||
|
provider identifiers, and errors framed as missing modules. This makes the
|
||||||
|
system look like a toolbox of adjacent applications instead of one operating
|
||||||
|
environment for institutional work.
|
||||||
|
|
||||||
|
The correction is not a monolithic frontend and not hidden provenance. It is a
|
||||||
|
separate product information architecture assembled from typed module
|
||||||
|
contributions.
|
||||||
|
|
||||||
|
Implementation is tracked in
|
||||||
|
[Core #283](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/283).
|
||||||
|
|
||||||
|
## Current Exposure Inventory
|
||||||
|
|
||||||
|
| Surface | Direct exposure | Appropriate audience | Product-facing alternative |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Side rail | One icon and route for many installed modules | Administrators and power users | Work areas, services, inboxes, records, communication, data and assurance |
|
||||||
|
| Route paths | Technical owners such as `/dataflow`, `/forms`, or `/postbox` | Deep links and diagnostics | Stable product aliases and journey routes that resolve to owner surfaces |
|
||||||
|
| Dashboard | Installed module count and module-owned widget library | Operators | Outcome, obligation, work, exception, and service widgets |
|
||||||
|
| Administration | Package names, database state, capabilities, providers | Module and system administrators | Guided product/package configuration with technical details on demand |
|
||||||
|
| Errors | "Module/capability not installed" | Diagnostics | Explain the unavailable outcome, responsible administrator, and enabling path |
|
||||||
|
| Documentation | Topics grouped primarily by module | Administrators | Task, role, service, and object documentation with module provenance secondary |
|
||||||
|
| Permissions | Module-namespaced scopes | Access administrators | Human-readable responsibility bundles; exact scopes remain inspectable |
|
||||||
|
| Search | Provider/module as a result facet | Advanced filtering | Object type, institution, time, purpose, case/service, and source authority |
|
||||||
|
| Workflow | Steps can expose target route/module details | Workflow designers | User-facing action and expected result; technical binding in definition details |
|
||||||
|
| Connector state | Provider IDs and source types | Integration owners | Named source, authority, freshness, health, last effect, and recovery state |
|
||||||
|
|
||||||
|
## Boundary Decision
|
||||||
|
|
||||||
|
Three layers remain distinct:
|
||||||
|
|
||||||
|
1. **Technical module layer:** package ownership, dependencies, capabilities,
|
||||||
|
permissions, migrations, routes, and provider identifiers.
|
||||||
|
2. **Product composition layer:** work areas, object types, journeys, commands,
|
||||||
|
inboxes, configuration packages, and role-based defaults.
|
||||||
|
3. **Presentation projection:** active view, tenant policy, current task,
|
||||||
|
temporal context, language, accessibility preferences, and device layout.
|
||||||
|
|
||||||
|
Modules own implementation and contribute typed product metadata. Core
|
||||||
|
assembles it. Views filters it. Policy constrains it. Access authorizes the
|
||||||
|
underlying actions. No consumer imports another optional module's UI directly.
|
||||||
|
|
||||||
|
## Product Surface Contract
|
||||||
|
|
||||||
|
Each WebUI module should be able to announce:
|
||||||
|
|
||||||
|
- `product_areas`: stable areas to which a route, command, widget, or object
|
||||||
|
belongs;
|
||||||
|
- `object_types`: user-facing nouns, icons, search context, detail route, and
|
||||||
|
owner provenance;
|
||||||
|
- `work_item_sources`: open work, exceptions, deadlines, and responsible
|
||||||
|
capacity;
|
||||||
|
- `journey_actions`: launch, resume, review, correct, decide, publish, and
|
||||||
|
reconcile commands;
|
||||||
|
- `workspace_surfaces`: embeddable but owner-rendered list, detail, editor, and
|
||||||
|
status surfaces;
|
||||||
|
- `configuration_contributions`: guided settings with consequence and
|
||||||
|
prerequisite metadata;
|
||||||
|
- `help_contexts`: user/admin documentation for the product identity as well as
|
||||||
|
the technical owner;
|
||||||
|
- `technical_provenance`: module, interface version, capability, and provider
|
||||||
|
identifiers shown only in details and evidence.
|
||||||
|
|
||||||
|
The contract references surfaces. It does not permit Core or a product package
|
||||||
|
to import their implementation.
|
||||||
|
|
||||||
|
## Navigation Model
|
||||||
|
|
||||||
|
The default shell should prioritize:
|
||||||
|
|
||||||
|
1. global search and create/resume commands;
|
||||||
|
2. personal and function-bound work;
|
||||||
|
3. configured product areas;
|
||||||
|
4. pinned user destinations;
|
||||||
|
5. administration and technical module inspection when authorized.
|
||||||
|
|
||||||
|
A module route remains a valid deep link. A product area may combine links and
|
||||||
|
owner-rendered surfaces from several modules. When a required contribution is
|
||||||
|
absent, the area explains the missing outcome rather than rendering a broken
|
||||||
|
placeholder.
|
||||||
|
|
||||||
|
Views remain the projection mechanism. They may select product areas, routes,
|
||||||
|
sections, commands, widgets, and fields. A view must not grant a permission or
|
||||||
|
change data semantics. Policy can force, allow, or prohibit a surface at system,
|
||||||
|
tenant, group, or user scope.
|
||||||
|
|
||||||
|
## Error And Provenance Language
|
||||||
|
|
||||||
|
Normal errors answer:
|
||||||
|
|
||||||
|
- what the person was trying to achieve;
|
||||||
|
- why it is unavailable or failed;
|
||||||
|
- whether data was saved or an external effect may have occurred;
|
||||||
|
- who can resolve it and where;
|
||||||
|
- the correlation/evidence reference.
|
||||||
|
|
||||||
|
An expandable technical section may then identify the module, capability,
|
||||||
|
provider, request, and version. This keeps the product intelligible without
|
||||||
|
hiding operational truth.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
### Slice 1: inventory and aliases
|
||||||
|
|
||||||
|
- classify every route, navigation item, widget, setting, search object, and
|
||||||
|
help context by product area and object type;
|
||||||
|
- add product aliases without removing existing deep links;
|
||||||
|
- flag raw module IDs in ordinary-user labels and errors.
|
||||||
|
|
||||||
|
### Slice 2: work-first shell
|
||||||
|
|
||||||
|
- provide a generic work/exception/deadline aggregation capability;
|
||||||
|
- make work areas and configured packages the default navigation;
|
||||||
|
- move the complete module catalogue to administration and an optional power-
|
||||||
|
user surface.
|
||||||
|
|
||||||
|
### Slice 3: composite journeys
|
||||||
|
|
||||||
|
- let product packages define journey launch/resume actions and default views;
|
||||||
|
- let Workflow Engine activate a view and focus an owner surface without
|
||||||
|
controlling authorization;
|
||||||
|
- expose provider provenance and technical bindings on demand.
|
||||||
|
|
||||||
|
### Slice 4: enforceability
|
||||||
|
|
||||||
|
- make product classification mandatory for user-visible manifest surfaces;
|
||||||
|
- reject duplicate product identities and missing owner routes in CI;
|
||||||
|
- add browser tests proving that reference users can complete a journey without
|
||||||
|
knowing module names.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- An ordinary user can describe every primary navigation item as work or an
|
||||||
|
institutional object, not as a package.
|
||||||
|
- A product package can remove irrelevant navigation while retaining deep-link
|
||||||
|
and help integrity.
|
||||||
|
- Missing optional modules produce an actionable product explanation.
|
||||||
|
- Administrators can still inspect exact module, capability, provider, schema,
|
||||||
|
and evidence provenance.
|
||||||
|
- Module permutation tests prove that no product surface assumes an optional
|
||||||
|
owner is installed.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# GovOPlaN Documentation Map
|
||||||
|
|
||||||
|
This directory contains cross-repository product, architecture, release, and
|
||||||
|
operational documentation. The map below defines which document answers which
|
||||||
|
question. A document not listed as the current status source must not present
|
||||||
|
volatile repository, issue, release, or maturity counts as current facts.
|
||||||
|
|
||||||
|
## Strategy
|
||||||
|
|
||||||
|
| Question | Canonical source |
|
||||||
|
| --- | --- |
|
||||||
|
| What are the stable ideas and boundaries of the platform? | [Platform Core Ideas](PLATFORM_CORE_IDEAS.md) |
|
||||||
|
| What product outcomes should GovOPlaN pursue? | [Connected Governance Platform Roadmap](CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md) |
|
||||||
|
| Which institutional concepts and owners form the target architecture? | [Institutional Governance Target Architecture](INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md) |
|
||||||
|
| Which end-to-end proofs should guide implementation? | [Reference Journey Program](REFERENCE_JOURNEY_PROGRAM.md) |
|
||||||
|
| What is the reconciled state now? | [Strategy Status](STRATEGY_STATUS.md) |
|
||||||
|
|
||||||
|
The dated [Strategic Review](STRATEGIC_REVIEW_2026-08-05.md) explains why the
|
||||||
|
current reset and sequencing were chosen. It is an assessment record, not a
|
||||||
|
second live status page.
|
||||||
|
|
||||||
|
## Product Architecture
|
||||||
|
|
||||||
|
| Topic | Canonical source |
|
||||||
|
| --- | --- |
|
||||||
|
| Product-facing experience and hiding technical module boundaries | [Product Experience and Module Boundaries](PRODUCT_EXPERIENCE_AND_MODULE_BOUNDARIES.md) |
|
||||||
|
| Federation between autonomous installations | [Federated GovOPlaN Architecture](FEDERATED_GOVOPLAN_ARCHITECTURE.md) |
|
||||||
|
| Institutional digital twin and continuous assurance | [Institutional Digital Twin](INSTITUTIONAL_DIGITAL_TWIN.md) |
|
||||||
|
| Assisted and non-digital channels | [Assisted and Non-Digital Channels](ASSISTED_AND_NON_DIGITAL_CHANNELS.md) |
|
||||||
|
| Cross-module temporal, purpose, retention, and institutional-context adoption | `govoplan-core/docs/INFORMATION_GOVERNANCE_ADOPTION.md` |
|
||||||
|
| eAkte and digital-record ownership | `govoplan-records/docs/EAKTE_ARCHITECTURE.md` |
|
||||||
|
| Data source, definition, and transformation graph | [Datasource and Definition Graph Architecture](DATASOURCE_AND_DEFINITION_GRAPH_ARCHITECTURE.md) |
|
||||||
|
| Focused task views | [Views Architecture](VIEWS_ARCHITECTURE.md) |
|
||||||
|
| Shared interface patterns | [Interface Pattern Language](INTERFACE_PATTERN_LANGUAGE.md) |
|
||||||
|
|
||||||
|
## Runtime And Delivery
|
||||||
|
|
||||||
|
- [Module Contracts and Installs](MODULE_CONTRACTS_AND_INSTALLS.md)
|
||||||
|
- [Platform Control Plane](PLATFORM_CONTROL_PLANE.md)
|
||||||
|
- [Installation and Deployment Architecture](INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md)
|
||||||
|
- [Kubernetes VM Test Lab](KUBERNETES_TEST_LAB.md)
|
||||||
|
- [Scaling and Multi-Host Deployment](SCALING_AND_MULTI_HOST_DEPLOYMENT.md)
|
||||||
|
- [Recovery and Rollback Guarantees](RECOVERY_AND_ROLLBACK_GUARANTEES.md)
|
||||||
|
- [Recovery Ledger Adoption](RECOVERY_LEDGER_ADOPTION.md)
|
||||||
|
- [Package Registry Releases](PACKAGE_REGISTRY_RELEASES.md)
|
||||||
|
|
||||||
|
## Evidence And Snapshots
|
||||||
|
|
||||||
|
These documents are intentionally dated or pinned. They may remain useful even
|
||||||
|
after the product changes, but they do not override `STRATEGY_STATUS.md`.
|
||||||
|
|
||||||
|
- [Capability and Infrastructure Fit Assessment](CAPABILITY_AND_INFRASTRUCTURE_FIT.md), pinned to the 2026-07-22 Campaign composition
|
||||||
|
- [Strategic Review 2026-08-05](STRATEGIC_REVIEW_2026-08-05.md)
|
||||||
|
- [Backup and Restore Evidence](BACKUP_AND_RESTORE_EVIDENCE.md)
|
||||||
|
- [Production Target Handoff](PRODUCTION_TARGET_HANDOFF.md)
|
||||||
|
- [Target Maturity Evidence Runbook](TARGET_MATURITY_EVIDENCE_RUNBOOK.md)
|
||||||
|
|
||||||
|
Machine-readable schemas and evidence files belong beside the document that
|
||||||
|
defines them. Generated inventories belong in `audit-reports/` and should not
|
||||||
|
be edited manually.
|
||||||
|
|
||||||
|
## Maintenance Rules
|
||||||
|
|
||||||
|
1. Gitea issues are the only live work-state source.
|
||||||
|
2. `STRATEGY_STATUS.md` is the only prose reconciliation of current portfolio
|
||||||
|
state. Refresh it from manifests, inventories, tests, and Gitea; do not copy
|
||||||
|
its counts into durable architecture pages.
|
||||||
|
3. Durable documents state decisions, invariants, ownership, and acceptance
|
||||||
|
gates. They link to status and issues for implementation depth.
|
||||||
|
4. Dated assessments retain their original composition and conclusion. Add a
|
||||||
|
snapshot notice rather than silently updating their claims.
|
||||||
|
5. Module-specific behavior and user/admin documentation remain in the owning
|
||||||
|
repository. Meta documentation defines cross-module outcomes and contracts.
|
||||||
|
6. A new strategy document must replace, narrow, or link an existing source;
|
||||||
|
it must not introduce a parallel roadmap.
|
||||||
@@ -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.
|
||||||
@@ -18,6 +18,27 @@ delivered as small, reviewable, green increments and is complete only when its
|
|||||||
user journey, failure behavior, documentation, and operator evidence work in a
|
user journey, failure behavior, documentation, and operator evidence work in a
|
||||||
pinned composition.
|
pinned composition.
|
||||||
|
|
||||||
|
## 2026 outcome reset
|
||||||
|
|
||||||
|
Repository completion is not product completion. From 2026-08-05 onward, work
|
||||||
|
is accepted primarily through three maintained real-life journeys:
|
||||||
|
|
||||||
|
1. **Governed communication:** select accountable recipients, prepare content
|
||||||
|
and attachments, approve, deliver through Mail and/or a function-bound
|
||||||
|
Postbox, reconcile uncertain outcomes, and file the evidence.
|
||||||
|
2. **Inclusive service-to-decision:** accept a request through a digital or
|
||||||
|
assisted channel, establish identity and purpose, guide the case through
|
||||||
|
human and automatic work, decide, notify, and file the resulting eAkte.
|
||||||
|
3. **Monthly data and sanctions:** acquire immutable source snapshots, validate
|
||||||
|
and reconcile them interactively, preserve decisions and lineage, produce
|
||||||
|
reports and files, and deliver the accepted result through Campaign.
|
||||||
|
|
||||||
|
The staged program below remains the architectural build order. These journeys
|
||||||
|
are the acceptance lens across those stages. Every significant feature should
|
||||||
|
identify the journey it improves, or provide security, operability, recovery,
|
||||||
|
accessibility, or usability evidence that those journeys require. Work that
|
||||||
|
does neither stays in the backlog until a concrete consumer exists.
|
||||||
|
|
||||||
## Why this sequence
|
## Why this sequence
|
||||||
|
|
||||||
The sequence grows one connected product rather than advancing repositories in
|
The sequence grows one connected product rather than advancing repositories in
|
||||||
@@ -86,6 +107,12 @@ journey needs and supplies contracts shared by all five stages.
|
|||||||
execution. Database, broker, cache, and worker channels are constrained by
|
execution. Database, broker, cache, and worker channels are constrained by
|
||||||
deployment network policy and authenticated transport rather than treated as
|
deployment network policy and authenticated transport rather than treated as
|
||||||
tenant connector profiles.
|
tenant connector profiles.
|
||||||
|
10. **Information governance.** Temporal browsing, purpose-aware access,
|
||||||
|
retention/legal-hold behavior, and institutional acting context are applied
|
||||||
|
to every owned object type. Historical reads use current authorization.
|
||||||
|
Module manifests state `contract_only`, `partial`, `enforced`, or
|
||||||
|
`not_applicable` adoption with evidence; supported maturity is blocked until
|
||||||
|
every applicable dimension is enforced.
|
||||||
|
|
||||||
## Documentation contract for every reference stage
|
## Documentation contract for every reference stage
|
||||||
|
|
||||||
@@ -108,6 +135,9 @@ Every demonstrated journey provides:
|
|||||||
provenance, evidence, retention, and destructive actions.
|
provenance, evidence, retention, and destructive actions.
|
||||||
- **Acceptance view:** runnable examples, expected results, failure injection,
|
- **Acceptance view:** runnable examples, expected results, failure injection,
|
||||||
and release gates.
|
and release gates.
|
||||||
|
- **Channel and records view:** assisted/non-digital intake and output,
|
||||||
|
representation, provenance, filing, retention, legal hold, and archive
|
||||||
|
consequences where the journey creates evidence or a record.
|
||||||
|
|
||||||
The Docs module selects and links these views according to installed
|
The Docs module selects and links these views according to installed
|
||||||
capabilities and actor context. Feature repositories remain the source of
|
capabilities and actor context. Feature repositories remain the source of
|
||||||
@@ -436,6 +466,9 @@ or the external editor the document-lifecycle owner.
|
|||||||
link, callback, webhook, file, identity, or data row.
|
link, callback, webhook, file, identity, or data row.
|
||||||
- Do not claim a stage complete from local unit tests. Use pinned composition,
|
- Do not claim a stage complete from local unit tests. Use pinned composition,
|
||||||
target integration, failure drills, adaptive docs, and operator evidence.
|
target integration, failure drills, adaptive docs, and operator evidence.
|
||||||
|
- Do not claim a module complete while its relevant information-governance
|
||||||
|
dimensions remain `contract_only` or while the reference journey lacks an
|
||||||
|
assisted-channel and records outcome where those are applicable.
|
||||||
- A later stage may prototype contracts while the preceding gate is being
|
- A later stage may prototype contracts while the preceding gate is being
|
||||||
proven, but it may not redefine an owning module's boundary by convenience.
|
proven, but it may not redefine an owning module's boundary by convenience.
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ one place. If a deployment profile later needs pinned SHAs for every repository,
|
|||||||
generate that lock as a release artifact instead of making day-to-day
|
generate that lock as a release artifact instead of making day-to-day
|
||||||
development depend on submodule updates.
|
development depend on submodule updates.
|
||||||
|
|
||||||
|
Module release tags also publish wheels and WebUI tarballs to the organization
|
||||||
|
PyPI/npm registries. The meta release resolves exact versions into a hash-bound
|
||||||
|
package lock before producing the signed OCI runtime. See
|
||||||
|
`docs/PACKAGE_REGISTRY_RELEASES.md`. Git tags remain source provenance; package
|
||||||
|
registries are reusable artifact transport; the signed runtime manifest and
|
||||||
|
digest-pinned images remain production authority.
|
||||||
|
|
||||||
## Docker Placement
|
## Docker Placement
|
||||||
|
|
||||||
Whole-product Docker and production-like deployment composition belongs in
|
Whole-product Docker and production-like deployment composition belongs in
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Scaling And Multi-Host Deployment
|
# Scaling And Multi-Host Deployment
|
||||||
|
|
||||||
|
For the exact external handoff, least-privilege collector permissions and live
|
||||||
|
two-node acceptance procedure, see
|
||||||
|
[`PRODUCTION_TARGET_HANDOFF.md`](PRODUCTION_TARGET_HANDOFF.md).
|
||||||
|
For a reproducible local or multi-hypervisor libvirt/K3s target, use
|
||||||
|
[`KUBERNETES_TEST_LAB.md`](KUBERNETES_TEST_LAB.md).
|
||||||
|
|
||||||
## Implemented Contract
|
## Implemented Contract
|
||||||
|
|
||||||
GovOPlaN now supports a stateless application tier backed by logically shared
|
GovOPlaN now supports a stateless application tier backed by logically shared
|
||||||
@@ -70,6 +76,7 @@ python tools/deployment/govoplan-deploy.py render-kubernetes \
|
|||||||
--namespace govoplan \
|
--namespace govoplan \
|
||||||
--secret-name govoplan-runtime \
|
--secret-name govoplan-runtime \
|
||||||
--tls-secret-name govoplan-tls \
|
--tls-secret-name govoplan-tls \
|
||||||
|
--s3-ca-secret-name govoplan-s3-ca \
|
||||||
--ingress-class-name nginx \
|
--ingress-class-name nginx \
|
||||||
--output /srv/govoplan/default/kubernetes.json
|
--output /srv/govoplan/default/kubernetes.json
|
||||||
```
|
```
|
||||||
@@ -86,11 +93,25 @@ command prints the exact required key contract. Review the generated
|
|||||||
`FORWARDED_ALLOW_IPS` value and replace it with the exact ingress-proxy network
|
`FORWARDED_ALLOW_IPS` value and replace it with the exact ingress-proxy network
|
||||||
before production use.
|
before production use.
|
||||||
|
|
||||||
|
When an external S3 endpoint is signed by a private CA, create the optional CA
|
||||||
|
Secret with a `ca.crt` key and pass `--s3-ca-secret-name`. The renderer mounts
|
||||||
|
that Secret read-only and sets `AWS_CA_BUNDLE` for API, worker, scheduler,
|
||||||
|
migration and database-wait containers. It does not disable certificate
|
||||||
|
verification or replace the WebUI trust store.
|
||||||
|
|
||||||
The generated containers run as non-root with a read-only root filesystem and
|
The generated containers run as non-root with a read-only root filesystem and
|
||||||
an ephemeral `/tmp`. Runtime Deployments wait for the exact configured database
|
an ephemeral `/tmp`. Celery Beat keeps its replaceable schedule database there;
|
||||||
migration heads before starting. The API exposes `/health/ready`, which fails
|
durable schedule definitions remain in shared state. The WebUI resolves its
|
||||||
while that API node is draining or cannot prove its runtime-coordination
|
configured API Service when the container starts, so Kubernetes deployments do
|
||||||
heartbeat.
|
not inherit the Compose-only `load-balancer` hostname. Runtime Deployments wait
|
||||||
|
for the exact dependency-resolved database migration heads before starting.
|
||||||
|
The API exposes `/health/ready`, which fails while that API node is draining or
|
||||||
|
cannot prove its runtime-coordination heartbeat.
|
||||||
|
|
||||||
|
Replicated API, WebUI, and worker Deployments use a hard hostname-spread
|
||||||
|
constraint scoped to the current pod-template hash. A rollout therefore keeps
|
||||||
|
each replica set distributed across independently schedulable nodes instead of
|
||||||
|
allowing all replacement pods to settle on one node after the old set exits.
|
||||||
|
|
||||||
## Runtime Coordination
|
## Runtime Coordination
|
||||||
|
|
||||||
@@ -190,19 +211,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
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Strategic Review - 2026-08-05
|
||||||
|
|
||||||
|
## Assessment
|
||||||
|
|
||||||
|
GovOPlaN has not lost its central direction. The architecture now expresses a
|
||||||
|
coherent institutional governance platform, but architecture and repository
|
||||||
|
breadth have advanced faster than complete, usable outcomes. The immediate
|
||||||
|
need is convergence: fewer simultaneous fronts, stronger cross-cutting
|
||||||
|
adoption, and end-to-end reference journeys that non-developers can complete.
|
||||||
|
|
||||||
|
This is a dated review. Current status belongs in
|
||||||
|
[Strategy Status](STRATEGY_STATUS.md); stable direction belongs in
|
||||||
|
[Platform Core Ideas](PLATFORM_CORE_IDEAS.md).
|
||||||
|
|
||||||
|
## What Is Already Strong
|
||||||
|
|
||||||
|
- A modular runtime with manifests, capabilities, interfaces, migrations,
|
||||||
|
optional integrations, signed releases, and permutation checks.
|
||||||
|
- Explicit institutional semantics for identity, representation,
|
||||||
|
organization, function, mandate, service, case, party, approval, decision,
|
||||||
|
evidence, and record references.
|
||||||
|
- Governed communication foundations spanning Campaign, Mail, Files, Postbox,
|
||||||
|
Addresses, Distribution Lists, Templates, Audit, and Policy.
|
||||||
|
- Governed data foundations spanning Connectors, Datasources, Dataflow,
|
||||||
|
Reporting, Search, and immutable provenance.
|
||||||
|
- Bitemporal browsing, views, contextual documentation, action/effect
|
||||||
|
contracts, event delivery, recovery ledgers, and stateless deployment
|
||||||
|
contracts.
|
||||||
|
- A credible deployment and release foundation with signed artifacts and
|
||||||
|
reproducible composition evidence.
|
||||||
|
|
||||||
|
## Where The Program Veered
|
||||||
|
|
||||||
|
### Repository breadth preceded product proof
|
||||||
|
|
||||||
|
Logical modularity often became a repository before a reference journey proved
|
||||||
|
that an independent release boundary was required. Scaffolds are useful as
|
||||||
|
ownership markers, but their number makes the product appear broader and more
|
||||||
|
complete than its supported outcomes.
|
||||||
|
|
||||||
|
### Foundations outran reference gates
|
||||||
|
|
||||||
|
Later-stage contracts such as federation, encryption, formal governance,
|
||||||
|
deployment evidence, and broad module metadata were developed while basic
|
||||||
|
human-work and records journeys remained incomplete. Those foundations are not
|
||||||
|
wasted; they now need to be consumed by a small number of demonstrable
|
||||||
|
products.
|
||||||
|
|
||||||
|
### The module graph leaked into the experience
|
||||||
|
|
||||||
|
Navigation, routes, administration, errors, documentation, and configuration
|
||||||
|
often present module names and package structure directly. This is appropriate
|
||||||
|
for operators, but ordinary users should see work, services, records, and
|
||||||
|
outcomes.
|
||||||
|
|
||||||
|
### Status became duplicated
|
||||||
|
|
||||||
|
Roadmaps, target architecture, fit assessments, issue comments, and release
|
||||||
|
documents each contained partial implementation snapshots. Their stable
|
||||||
|
decisions remain valuable, but volatile counts and maturity claims diverged.
|
||||||
|
|
||||||
|
### Too much work remained active simultaneously
|
||||||
|
|
||||||
|
The issue portfolio had many high-priority and in-progress items without
|
||||||
|
milestones. This reduces the signal of both labels and roadmap order and makes
|
||||||
|
completion harder to demonstrate.
|
||||||
|
|
||||||
|
## Where GovOPlaN Has Not Gone Far Enough
|
||||||
|
|
||||||
|
1. No composition has yet crossed the full `reference_ready` gate.
|
||||||
|
2. The human-work spine is incomplete: work queues, tasks, handoffs, deadlines,
|
||||||
|
reminders, escalation, and resumption need a coherent user experience.
|
||||||
|
3. Records and document management remain too shallow for a public-sector
|
||||||
|
operating platform.
|
||||||
|
4. Real target integrations and GovOPlaN-to-GovOPlaN federation are not yet
|
||||||
|
proven.
|
||||||
|
5. Temporal browsing, purpose-aware access, retention, and institutional
|
||||||
|
context exist as contracts but are not adopted uniformly by domain reads
|
||||||
|
and effects.
|
||||||
|
6. German completeness, contextual help, accessibility, responsive behavior,
|
||||||
|
and browser-level journey testing are not yet release gates everywhere.
|
||||||
|
7. Multi-host, backup/restore, provider interoperability, and independent
|
||||||
|
signed target evidence still require real environments and operators.
|
||||||
|
|
||||||
|
## Important Omissions
|
||||||
|
|
||||||
|
- a named first institution, bounded users, volumes, and operating constraints;
|
||||||
|
- measurable usability outcomes, not only functional tests;
|
||||||
|
- installable sector packages and migration/exit demonstrations;
|
||||||
|
- support, upgrade, deprecation, and LTS promises;
|
||||||
|
- complete assisted, paper, telephone, and in-person channel handling;
|
||||||
|
- a native eAkte/records model that can also overlay an external DMS or archive.
|
||||||
|
|
||||||
|
## Opportunities Beyond The Original Idea
|
||||||
|
|
||||||
|
- an institutional digital twin that exposes responsibilities, dependencies,
|
||||||
|
obligations, services, work, data, controls, and change impact over time;
|
||||||
|
- continuous assurance that evaluates controls and evidence as work happens;
|
||||||
|
- process mining and conformance analysis over governed event histories;
|
||||||
|
- federated product packages and inter-institution case/evidence exchange;
|
||||||
|
- accountable assistance that drafts and explains without obscuring authority;
|
||||||
|
- public evidence chains that disclose decisions and provenance without
|
||||||
|
exposing protected source data.
|
||||||
|
|
||||||
|
## Recommended Reset
|
||||||
|
|
||||||
|
1. Freeze new repositories unless a real journey proves an independent owner,
|
||||||
|
release lifecycle, security boundary, or optional installation need.
|
||||||
|
2. Use one generated maturity/status dashboard and one current status document.
|
||||||
|
3. Complete governed communication and function-bound Postbox against a real
|
||||||
|
target.
|
||||||
|
4. Complete the monthly-data journey, then sanctions screening on the same
|
||||||
|
data foundations.
|
||||||
|
5. Complete one browser-driven service-to-decision journey, including assisted
|
||||||
|
intake and records.
|
||||||
|
6. Make eAkte/records the next major product-depth program.
|
||||||
|
7. Tie feature work to a reference journey, a security/recovery gate, or a
|
||||||
|
measured usability defect.
|
||||||
|
|
||||||
|
## Success Criterion
|
||||||
|
|
||||||
|
The reset succeeds when a public institution can install a signed composition,
|
||||||
|
configure a named procedure, complete it through digital and assisted channels,
|
||||||
|
connect an external source, reconstruct the authority and evidence, recover it
|
||||||
|
after failure, and transfer or retire it without custom code.
|
||||||
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# GovOPlaN Strategy Status
|
||||||
|
|
||||||
|
## Status Record
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Reconciled on | 2026-08-05 |
|
||||||
|
| Source scope | Local workspace manifests, source inventory, focused journey checks, signed release evidence, and live Gitea issue state |
|
||||||
|
| Stable direction | [Platform Core Ideas](PLATFORM_CORE_IDEAS.md) and [Connected Governance Platform Roadmap](CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md) |
|
||||||
|
| Delivery source | Gitea issues |
|
||||||
|
|
||||||
|
This is the only prose source for current cross-product status. It is a
|
||||||
|
reconciliation, not a release certification. Module manifests and target
|
||||||
|
evidence remain authoritative for specific maturity claims.
|
||||||
|
|
||||||
|
## Portfolio Snapshot
|
||||||
|
|
||||||
|
- 65 source module manifests were loadable and architecture-declared.
|
||||||
|
- 47 modules declared `vertical_slice`; 18 declared `scaffold`.
|
||||||
|
- No module declared `reference_ready`, `supported`, or `lts`.
|
||||||
|
- The live portfolio had 133 open issues, including 39 priority-P1 items.
|
||||||
|
- 117 open issues had no milestone, so issue labels do not yet express a
|
||||||
|
reliable completion sequence on their own.
|
||||||
|
- Three product package manifests existed: governed communication, governed
|
||||||
|
data and assurance, and service to decision. None had crossed the complete
|
||||||
|
target-evidence gate.
|
||||||
|
|
||||||
|
These counts are dated. Refresh them rather than copying them into another
|
||||||
|
document.
|
||||||
|
|
||||||
|
## Interface And Contract Evidence
|
||||||
|
|
||||||
|
The 2026-08-05 source inventory found:
|
||||||
|
|
||||||
|
- 1,247 UI fields and 1,220 UI actions;
|
||||||
|
- 7,929 stable interface declarations with no duplicate IDs;
|
||||||
|
- 39 frontend routes and 872 backend endpoints;
|
||||||
|
- no public WebUI surfaces missing runtime declarations;
|
||||||
|
- no stale runtime route declarations;
|
||||||
|
- no unclassified endpoint without a static UI reference;
|
||||||
|
- all 1,247 fields with a resolvable F1 context; 1,087 remain candidates for
|
||||||
|
richer field-specific content beyond page/module fallback;
|
||||||
|
- German (`de`) as the complete reference locale and no used key missing from
|
||||||
|
the required German or English catalogs;
|
||||||
|
- 260 module information-governance dimensions classified as `contract_only`.
|
||||||
|
This is an honest platform-wide baseline, not a claim that temporal,
|
||||||
|
purpose, retention, and institutional-context adoption is complete.
|
||||||
|
|
||||||
|
## Credible Current Outcomes
|
||||||
|
|
||||||
|
### Platform foundation
|
||||||
|
|
||||||
|
Module discovery, optional dependency validation, migrations, shared WebUI,
|
||||||
|
tenant and access foundations, signed catalogs/packages, event delivery,
|
||||||
|
recovery contracts, contextual help, views, temporal titlebar context, and
|
||||||
|
stateless-runtime patterns are implemented and tested at varying depths.
|
||||||
|
|
||||||
|
### Governed communication
|
||||||
|
|
||||||
|
Campaign authoring, recipient data, attachments, templates, mail profiles,
|
||||||
|
mock/real delivery paths, audit evidence, reporting, distribution-list
|
||||||
|
composition, and optional Postbox delivery form the deepest product cluster.
|
||||||
|
Target provider, accessibility, recovery, and high-volume evidence still
|
||||||
|
prevent a reference-ready claim.
|
||||||
|
|
||||||
|
### Institutional service and decision
|
||||||
|
|
||||||
|
Services, Forms, Forms Runtime, Cases, Parties, Mandates, Approvals, Committee,
|
||||||
|
Voting, Decisions, Portal, Postbox, and Audit have an executable service-to-
|
||||||
|
decision fixture. Browser-complete assisted intake, production identity,
|
||||||
|
records, delivery, and target evidence remain.
|
||||||
|
|
||||||
|
### Governed data and assurance
|
||||||
|
|
||||||
|
Connectors, Datasources, Dataflow, Reporting, Search, Policy, Risk Compliance,
|
||||||
|
and Workflow provide source governance, immutable snapshots, transformation,
|
||||||
|
quality, semantic reporting, and provenance foundations. The monthly-data and
|
||||||
|
sanctions journeys still need real connectors, complete interactive
|
||||||
|
reconciliation, publication/export, and guided handoff evidence.
|
||||||
|
|
||||||
|
## Material Gaps
|
||||||
|
|
||||||
|
| Gap | Consequence | Next proof |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| No reference-ready product package | The platform cannot yet make a bounded supported-product claim | Complete one named target composition and evidence bundle |
|
||||||
|
| Human-work spine incomplete | Users still navigate modules and remember unfinished work | Task/work inbox, resumable guided journey, deadlines and handoffs |
|
||||||
|
| Records/eAkte shallow | Institutional memory and disposition remain fragmented | Native record lifecycle plus external DMS/archive overlay |
|
||||||
|
| Cross-cutting governance adoption uneven | Historical and purpose-sensitive behavior varies by module | Enforced adoption declarations and route/query/effect migration |
|
||||||
|
| Explicit help/accessibility depth incomplete | German/reference and F1 association gates now pass, but generic fallback remains too common | High-risk German help content and browser/a11y matrix |
|
||||||
|
| Real federation absent | Cross-institution exchange remains connector-specific | Paired-instance signed exchange and reconciliation proof |
|
||||||
|
| External production evidence incomplete | Scale, restore, interoperability and custody claims remain conditional | Real target drills and independent signed evidence |
|
||||||
|
|
||||||
|
## Active Strategic Order
|
||||||
|
|
||||||
|
1. Establish German, help, temporal, purpose, retention, and institutional
|
||||||
|
context as enforceable platform quality contracts.
|
||||||
|
2. Complete governed communication and Postbox against a named target.
|
||||||
|
3. Complete the monthly-data flow and use it as the data foundation for
|
||||||
|
sanctions screening.
|
||||||
|
4. Complete one digital and assisted service-to-decision journey with an eAkte.
|
||||||
|
5. Add native PostgreSQL search coverage for the objects used by those
|
||||||
|
journeys; keep OpenSearch optional.
|
||||||
|
6. Prove one external product connector and one GovOPlaN federation exchange.
|
||||||
|
7. Finish multi-host, restore, provider, accessibility, and independent signed
|
||||||
|
target evidence before increasing maturity claims.
|
||||||
|
|
||||||
|
## Refresh Procedure
|
||||||
|
|
||||||
|
Refresh this page only from evidence:
|
||||||
|
|
||||||
|
1. run `tools/checks/check-manifest-shapes.py`;
|
||||||
|
2. run `tools/inventory/platform-interface-inventory.py --strict
|
||||||
|
--strict-declarations --strict-endpoints`;
|
||||||
|
3. run the selected reference-journey checks;
|
||||||
|
4. inspect signed release and target evidence;
|
||||||
|
5. query live Gitea issue/milestone state;
|
||||||
|
6. update the dated values and material gaps here;
|
||||||
|
7. retain prior assessments as dated evidence rather than rewriting them.
|
||||||
@@ -141,6 +141,16 @@ The canonical backlog item is
|
|||||||
|
|
||||||
Implementation status as of the current source tree:
|
Implementation status as of the current source tree:
|
||||||
|
|
||||||
|
- Slice 1 has a published production-artifact baseline. Immutable
|
||||||
|
[`v0.1.14`](https://git.add-ideas.de/GovOPlaN/govoplan/releases/tag/v0.1.14)
|
||||||
|
binds source commit `1f039dd39c1ce2672f4978c8abc6dff862ef1445`, a signed
|
||||||
|
one-file deployer, exact API/Web and managed-dependency image digests,
|
||||||
|
composition, SBOMs, and provenance. Runtime Distribution
|
||||||
|
[run #459](https://git.add-ideas.de/GovOPlaN/govoplan/actions/runs/459)
|
||||||
|
passed migrations, schema checks, non-root API/Web readiness, and worker
|
||||||
|
delivery/shutdown on both amd64 and arm64. Each future release must renew the
|
||||||
|
evidence, and a real installation must still produce topology-specific
|
||||||
|
ingress, failover, backup, and recovery receipts.
|
||||||
- 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
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Target Maturity Evidence Runbook
|
# Target Maturity Evidence Runbook
|
||||||
|
|
||||||
|
For authority-key generation, container isolation and the concrete inputs that
|
||||||
|
must be supplied by the target owner and independent production approver, see
|
||||||
|
[`PRODUCTION_TARGET_HANDOFF.md`](PRODUCTION_TARGET_HANDOFF.md).
|
||||||
|
|
||||||
This runbook turns retained target-environment results into a sanitized,
|
This runbook turns retained target-environment results into a sanitized,
|
||||||
signed GovOPlaN capability-fit proof. It does not make a deployment suitable,
|
signed GovOPlaN capability-fit proof. It does not make a deployment suitable,
|
||||||
certified, supported, or production-approved by itself. The proof records what
|
certified, supported, or production-approved by itself. The proof records what
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -353,6 +353,12 @@
|
|||||||
"description": "GovOPlaN Projects module behavior or integration.",
|
"description": "GovOPlaN Projects module behavior or integration.",
|
||||||
"exclusive": false
|
"exclusive": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "module/records",
|
||||||
|
"color": "0052cc",
|
||||||
|
"description": "GovOPlaN Records and eAkte lifecycle behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module/reporting",
|
"name": "module/reporting",
|
||||||
"color": "c2e0c6",
|
"color": "c2e0c6",
|
||||||
|
|||||||
@@ -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,125 @@
|
|||||||
|
{
|
||||||
|
"$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",
|
||||||
|
"package_lock",
|
||||||
|
"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" },
|
||||||
|
"package_lock": { "$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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# GovOPlaN developer meta-package
|
||||||
|
|
||||||
|
`govoplan` is an optional convenience package for local development and
|
||||||
|
composition tests. The default dependency set matches the reviewed runtime
|
||||||
|
release roots; `govoplan[full]` adds every packageable module present in the
|
||||||
|
workspace at generation time.
|
||||||
|
|
||||||
|
This package is not a production deployment artifact. Production installations
|
||||||
|
consume the signed runtime distribution manifest and digest-pinned OCI images.
|
||||||
|
The package does not enable modules, apply migrations, choose infrastructure,
|
||||||
|
or replace installation and recovery evidence.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan"
|
||||||
|
version = "0.1.18"
|
||||||
|
description = "Developer convenience package for a versioned GovOPlaN composition"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
license = { text = "AGPL-3.0-or-later" }
|
||||||
|
dependencies = [
|
||||||
|
"govoplan-core[server]==0.1.18",
|
||||||
|
"govoplan-tenancy==0.1.18",
|
||||||
|
"govoplan-organizations==0.1.18",
|
||||||
|
"govoplan-identity==0.1.18",
|
||||||
|
"govoplan-idm==0.1.18",
|
||||||
|
"govoplan-access==0.1.18",
|
||||||
|
"govoplan-admin==0.1.18",
|
||||||
|
"govoplan-policy==0.1.18",
|
||||||
|
"govoplan-audit==0.1.18",
|
||||||
|
"govoplan-dashboard==0.1.18",
|
||||||
|
"govoplan-files==0.1.18",
|
||||||
|
"govoplan-mail==0.1.18",
|
||||||
|
"govoplan-campaign==0.1.18",
|
||||||
|
"govoplan-calendar==0.1.18",
|
||||||
|
"govoplan-docs==0.1.18",
|
||||||
|
"govoplan-ops==0.1.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
full = [
|
||||||
|
"govoplan-addresses==0.1.18",
|
||||||
|
"govoplan-approvals==0.1.18",
|
||||||
|
"govoplan-assets==0.1.18",
|
||||||
|
"govoplan-booking==0.1.18",
|
||||||
|
"govoplan-cases==0.1.18",
|
||||||
|
"govoplan-certificates==0.1.18",
|
||||||
|
"govoplan-committee==0.1.18",
|
||||||
|
"govoplan-connectors==0.1.18",
|
||||||
|
"govoplan-consultation==0.1.18",
|
||||||
|
"govoplan-contracts==0.1.18",
|
||||||
|
"govoplan-dataflow==0.1.18",
|
||||||
|
"govoplan-datasources==0.1.18",
|
||||||
|
"govoplan-decisions==0.1.18",
|
||||||
|
"govoplan-dist-lists==0.1.18",
|
||||||
|
"govoplan-encryption==0.1.18",
|
||||||
|
"govoplan-evaluation==0.1.18",
|
||||||
|
"govoplan-facilities==0.1.18",
|
||||||
|
"govoplan-forms==0.1.18",
|
||||||
|
"govoplan-forms-runtime==0.1.18",
|
||||||
|
"govoplan-grants==0.1.18",
|
||||||
|
"govoplan-helpdesk==0.1.18",
|
||||||
|
"govoplan-identity-trust==0.1.18",
|
||||||
|
"govoplan-inspections==0.1.18",
|
||||||
|
"govoplan-learning==0.1.18",
|
||||||
|
"govoplan-mandates==0.1.18",
|
||||||
|
"govoplan-notifications==0.1.18",
|
||||||
|
"govoplan-parties==0.1.18",
|
||||||
|
"govoplan-permits==0.1.18",
|
||||||
|
"govoplan-poll==0.1.18",
|
||||||
|
"govoplan-portal==0.1.18",
|
||||||
|
"govoplan-postbox==0.1.18",
|
||||||
|
"govoplan-procurement==0.1.18",
|
||||||
|
"govoplan-projects==0.1.18",
|
||||||
|
"govoplan-records==0.1.18",
|
||||||
|
"govoplan-reporting==0.1.18",
|
||||||
|
"govoplan-resources==0.1.18",
|
||||||
|
"govoplan-rest==0.1.18",
|
||||||
|
"govoplan-risk-compliance==0.1.18",
|
||||||
|
"govoplan-scheduling==0.1.18",
|
||||||
|
"govoplan-search==0.1.18",
|
||||||
|
"govoplan-services==0.1.18",
|
||||||
|
"govoplan-soap==0.1.18",
|
||||||
|
"govoplan-templates==0.1.18",
|
||||||
|
"govoplan-tickets==0.1.18",
|
||||||
|
"govoplan-transparency==0.1.18",
|
||||||
|
"govoplan-views==0.1.18",
|
||||||
|
"govoplan-voting==0.1.18",
|
||||||
|
"govoplan-wiki==0.1.18",
|
||||||
|
"govoplan-workflow==0.1.18",
|
||||||
|
"govoplan-workflow-engine==0.1.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://git.add-ideas.de/GovOPlaN/govoplan"
|
||||||
|
Documentation = "https://govoplan.add-ideas.de"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Metadata helpers for the optional GovOPlaN developer composition."""
|
||||||
|
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
__version__ = version("govoplan")
|
||||||
|
except PackageNotFoundError: # pragma: no cover - source checkout only
|
||||||
|
__version__ = "0+unknown"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
@@ -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
|
||||||
|
|||||||
+15
-15
@@ -1,18 +1,18 @@
|
|||||||
# Whole-product release install from immutable, independently versioned module tags.
|
# Whole-product release install from immutable, independently versioned module tags.
|
||||||
# Only add a module after its referenced tag has been published.
|
# Only add a module after its referenced tag has been published.
|
||||||
../govoplan-core[server]
|
../govoplan-core[server]
|
||||||
govoplan-tenancy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git@v0.1.8
|
govoplan-tenancy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git@v0.1.18
|
||||||
govoplan-organizations @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git@v0.1.8
|
govoplan-organizations @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git@v0.1.18
|
||||||
govoplan-identity @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity.git@v0.1.8
|
govoplan-identity @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity.git@v0.1.18
|
||||||
govoplan-idm @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git@v0.1.8
|
govoplan-idm @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git@v0.1.18
|
||||||
govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.8
|
govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.18
|
||||||
govoplan-admin @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git@v0.1.8
|
govoplan-admin @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git@v0.1.18
|
||||||
govoplan-policy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git@v0.1.8
|
govoplan-policy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git@v0.1.18
|
||||||
govoplan-audit @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git@v0.1.8
|
govoplan-audit @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git@v0.1.18
|
||||||
govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.8
|
govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.18
|
||||||
govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.8
|
govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.18
|
||||||
govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.10
|
govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.18
|
||||||
govoplan-campaign @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git@v0.1.11
|
govoplan-campaign @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git@v0.1.18
|
||||||
govoplan-calendar @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git@v0.1.8
|
govoplan-calendar @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git@v0.1.18
|
||||||
govoplan-docs @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git@v0.1.8
|
govoplan-docs @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git@v0.1.18
|
||||||
govoplan-ops @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git@v0.1.8
|
govoplan-ops @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git@v0.1.18
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from jsonschema import Draft202012Validator, FormatChecker
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
GENERATOR = META_ROOT / "tools" / "assessments" / "generate-authority-keypair.py"
|
||||||
|
|
||||||
|
|
||||||
|
class AssessmentAuthorityKeypairTests(unittest.TestCase):
|
||||||
|
def test_generates_schema_valid_scoped_proof_authority(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
output_dir = Path(temp_dir)
|
||||||
|
output_dir.chmod(0o700)
|
||||||
|
private_path = output_dir / "target.pem"
|
||||||
|
keyring_path = output_dir / "target.json"
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
(
|
||||||
|
sys.executable,
|
||||||
|
str(GENERATOR),
|
||||||
|
"--purpose",
|
||||||
|
"proof",
|
||||||
|
"--key-id",
|
||||||
|
"authority:target-2026",
|
||||||
|
"--scope",
|
||||||
|
"target_environment",
|
||||||
|
"--scope",
|
||||||
|
"operations",
|
||||||
|
"--private-key",
|
||||||
|
str(private_path),
|
||||||
|
"--keyring",
|
||||||
|
str(keyring_path),
|
||||||
|
),
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(private_path.stat().st_mode))
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(keyring_path.stat().st_mode))
|
||||||
|
keyring = json.loads(keyring_path.read_text(encoding="utf-8"))
|
||||||
|
schema = json.loads(
|
||||||
|
(
|
||||||
|
META_ROOT
|
||||||
|
/ "docs"
|
||||||
|
/ "capability-fit-proof-authority-keyring.schema.json"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
errors = tuple(
|
||||||
|
Draft202012Validator(
|
||||||
|
schema, format_checker=FormatChecker()
|
||||||
|
).iter_errors(keyring)
|
||||||
|
)
|
||||||
|
self.assertEqual((), errors)
|
||||||
|
self.assertEqual(
|
||||||
|
["target_environment", "operations"],
|
||||||
|
keyring["keys"][0]["allowed_scopes"],
|
||||||
|
)
|
||||||
|
private_key = serialization.load_pem_private_key(
|
||||||
|
private_path.read_bytes(), password=None
|
||||||
|
)
|
||||||
|
self.assertIsInstance(private_key, Ed25519PrivateKey)
|
||||||
|
public_key = base64.b64encode(
|
||||||
|
private_key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw,
|
||||||
|
format=serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
).decode("ascii")
|
||||||
|
self.assertEqual(public_key, keyring["keys"][0]["public_key"])
|
||||||
|
|
||||||
|
def test_installer_authority_uses_fixed_scope_and_refuses_overwrite(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
output_dir = Path(temp_dir)
|
||||||
|
output_dir.chmod(0o700)
|
||||||
|
private_path = output_dir / "installer.pem"
|
||||||
|
keyring_path = output_dir / "installer.json"
|
||||||
|
command = (
|
||||||
|
sys.executable,
|
||||||
|
str(GENERATOR),
|
||||||
|
"--purpose",
|
||||||
|
"installer",
|
||||||
|
"--key-id",
|
||||||
|
"authority:installer-2026",
|
||||||
|
"--private-key",
|
||||||
|
str(private_path),
|
||||||
|
"--keyring",
|
||||||
|
str(keyring_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
first = subprocess.run(
|
||||||
|
command, check=False, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
second = subprocess.run(
|
||||||
|
command, check=False, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, first.returncode, first.stderr)
|
||||||
|
self.assertNotEqual(0, second.returncode)
|
||||||
|
keyring = json.loads(keyring_path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(
|
||||||
|
["installed_release_origin"],
|
||||||
|
keyring["keys"][0]["allowed_scopes"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -156,6 +158,81 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
evidence["snapshot"]["ready_node_names"],
|
evidence["snapshot"]["ready_node_names"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_kubernetes_api_loss_uses_a_non_json_mutation_command(self) -> None:
|
||||||
|
initial_pods = [
|
||||||
|
_kubernetes_test_pod("api-a", "api", "node-a"),
|
||||||
|
_kubernetes_test_pod("api-b", "api", "node-b"),
|
||||||
|
_kubernetes_test_pod("web-a", "web", "node-a"),
|
||||||
|
_kubernetes_test_pod("web-b", "web", "node-b"),
|
||||||
|
]
|
||||||
|
replacement_pods = [
|
||||||
|
_kubernetes_test_pod("api-b", "api", "node-b"),
|
||||||
|
_kubernetes_test_pod("api-c", "api", "node-a"),
|
||||||
|
]
|
||||||
|
deleted = False
|
||||||
|
actions: list[tuple[str, ...]] = []
|
||||||
|
|
||||||
|
def run(arguments):
|
||||||
|
if "nodes" in arguments:
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"metadata": {"name": name},
|
||||||
|
"spec": {},
|
||||||
|
"status": {
|
||||||
|
"conditions": [
|
||||||
|
{"type": "Ready", "status": "True"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name in ("node-a", "node-b")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if "deployments" in arguments:
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
_kubernetes_test_deployment("api", 2),
|
||||||
|
_kubernetes_test_deployment("web", 2),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return {"items": replacement_pods if deleted else initial_pods}
|
||||||
|
|
||||||
|
def act(arguments):
|
||||||
|
nonlocal deleted
|
||||||
|
actions.append(tuple(arguments))
|
||||||
|
deleted = True
|
||||||
|
|
||||||
|
evidence = collect_kubernetes_evidence(
|
||||||
|
installation_id="govoplan-cluster",
|
||||||
|
namespace="govoplan",
|
||||||
|
ops_url="https://govoplan.example.test/api/v1/ops/status",
|
||||||
|
api_key="not-retained",
|
||||||
|
exercise_api_pod_loss=True,
|
||||||
|
command_runner=run,
|
||||||
|
action_runner=act,
|
||||||
|
json_fetcher=lambda _url, _key: {
|
||||||
|
"readiness": {"ready": True},
|
||||||
|
"runtime_cluster": {
|
||||||
|
"composition": {"skewed": False},
|
||||||
|
"software_versions": {"skewed": False},
|
||||||
|
"queues": {"missing": []},
|
||||||
|
},
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"id": "database_capacity",
|
||||||
|
"state": "ok",
|
||||||
|
"detail": "Within budget",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("passed", evidence["api_pod_loss"]["state"])
|
||||||
|
self.assertEqual(1, len(actions))
|
||||||
|
self.assertIn("delete", actions[0])
|
||||||
|
self.assertNotIn("-o", actions[0])
|
||||||
|
self.assertNotIn("not-retained", json.dumps(evidence))
|
||||||
|
|
||||||
def test_pre_migration_failure_restores_checksum_verified_applied_bundle(
|
def test_pre_migration_failure_restores_checksum_verified_applied_bundle(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -316,7 +393,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 = {
|
||||||
@@ -336,6 +422,10 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
"template"
|
"template"
|
||||||
]["spec"]["containers"][0]["command"]
|
]["spec"]["containers"][0]["command"]
|
||||||
self.assertIn("govoplan_core.commands.fenced_run", scheduler_command)
|
self.assertIn("govoplan_core.commands.fenced_run", scheduler_command)
|
||||||
|
self.assertEqual(
|
||||||
|
["--schedule", "/tmp/celerybeat-schedule"],
|
||||||
|
scheduler_command[-2:],
|
||||||
|
)
|
||||||
api_init_command = deployments["govoplan-cluster-api"]["spec"]["template"][
|
api_init_command = deployments["govoplan-cluster-api"]["spec"]["template"][
|
||||||
"spec"
|
"spec"
|
||||||
]["initContainers"][0]["command"]
|
]["initContainers"][0]["command"]
|
||||||
@@ -349,6 +439,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"])
|
||||||
@@ -362,6 +464,12 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
"spec"
|
"spec"
|
||||||
]["containers"][0]["command"]
|
]["containers"][0]["command"]
|
||||||
self.assertIn("--concurrency", worker_command)
|
self.assertIn("--concurrency", worker_command)
|
||||||
|
for deployment in deployments.values():
|
||||||
|
spread = deployment["spec"]["template"]["spec"][
|
||||||
|
"topologySpreadConstraints"
|
||||||
|
][0]
|
||||||
|
self.assertEqual("DoNotSchedule", spread["whenUnsatisfiable"])
|
||||||
|
self.assertEqual(["pod-template-hash"], spread["matchLabelKeys"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"62",
|
"62",
|
||||||
manifest["metadata"]["annotations"][
|
manifest["metadata"]["annotations"][
|
||||||
@@ -369,6 +477,99 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_kubernetes_export_mounts_an_optional_s3_ca_on_backend_roles(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
spec = default_spec(
|
||||||
|
installation_id="govoplan-cluster",
|
||||||
|
postgres_mode="external",
|
||||||
|
redis_mode="external",
|
||||||
|
storage_mode="s3",
|
||||||
|
api_replicas=2,
|
||||||
|
web_replicas=2,
|
||||||
|
worker_replicas=2,
|
||||||
|
api_image="registry.example.test/govoplan-api@sha256:" + "a" * 64,
|
||||||
|
web_image="registry.example.test/govoplan-web@sha256:" + "b" * 64,
|
||||||
|
)
|
||||||
|
environment = initial_secrets(
|
||||||
|
spec,
|
||||||
|
supplied={
|
||||||
|
"DATABASE_URL": "postgresql+psycopg://user:secret@postgres.example.test/govoplan",
|
||||||
|
"GOVOPLAN_DATABASE_URL_PGTOOLS": "postgresql://user:secret@postgres.example.test/govoplan",
|
||||||
|
"REDIS_URL": "rediss://:secret@redis.example.test/0",
|
||||||
|
"FILE_STORAGE_S3_ENDPOINT_URL": "https://s3.example.test",
|
||||||
|
"FILE_STORAGE_S3_REGION": "eu-test-1",
|
||||||
|
"FILE_STORAGE_S3_ACCESS_KEY_ID": "object-key",
|
||||||
|
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "object-secret",
|
||||||
|
"FILE_STORAGE_S3_BUCKET": "govoplan",
|
||||||
|
"GOVOPLAN_DB_CONNECTION_LIMIT": "100",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = render_kubernetes(
|
||||||
|
spec,
|
||||||
|
environment,
|
||||||
|
s3_ca_secret_name="govoplan-s3-ca",
|
||||||
|
backup_required=False,
|
||||||
|
)
|
||||||
|
backend_pods = [
|
||||||
|
item["spec"]["template"]["spec"]
|
||||||
|
for item in manifest["items"]
|
||||||
|
if item["kind"] in {"Deployment", "Job"}
|
||||||
|
and item["metadata"]["labels"].get("app.kubernetes.io/component")
|
||||||
|
in {"api", "worker", "scheduler", "migration"}
|
||||||
|
]
|
||||||
|
web = next(
|
||||||
|
item
|
||||||
|
for item in manifest["items"]
|
||||||
|
if item["kind"] == "Deployment"
|
||||||
|
and item["metadata"]["labels"].get("app.kubernetes.io/component")
|
||||||
|
== "web"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(backend_pods)
|
||||||
|
for pod in backend_pods:
|
||||||
|
self.assertIn(
|
||||||
|
{
|
||||||
|
"name": "s3-ca",
|
||||||
|
"secret": {
|
||||||
|
"secretName": "govoplan-s3-ca",
|
||||||
|
"items": [{"key": "ca.crt", "path": "s3-ca.crt"}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pod["volumes"],
|
||||||
|
)
|
||||||
|
for container in [*pod.get("initContainers", []), *pod["containers"]]:
|
||||||
|
self.assertIn(
|
||||||
|
{
|
||||||
|
"name": "AWS_CA_BUNDLE",
|
||||||
|
"value": "/etc/govoplan/trust/s3-ca.crt",
|
||||||
|
},
|
||||||
|
container["env"],
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
{
|
||||||
|
"name": "s3-ca",
|
||||||
|
"mountPath": "/etc/govoplan/trust",
|
||||||
|
"readOnly": True,
|
||||||
|
},
|
||||||
|
container["volumeMounts"],
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"s3-ca",
|
||||||
|
{
|
||||||
|
volume["name"]
|
||||||
|
for volume in web["spec"]["template"]["spec"]["volumes"]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "S3 CA secret"):
|
||||||
|
render_kubernetes(
|
||||||
|
spec,
|
||||||
|
environment,
|
||||||
|
s3_ca_secret_name="INVALID_NAME",
|
||||||
|
backup_required=False,
|
||||||
|
)
|
||||||
|
|
||||||
def test_kubernetes_export_splits_worker_queues_and_rejects_capacity_overrun(
|
def test_kubernetes_export_splits_worker_queues_and_rejects_capacity_overrun(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -509,6 +710,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 +973,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 +983,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,239 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
LAB_TOOLS = META_ROOT / "tools" / "lab"
|
||||||
|
if str(LAB_TOOLS) not in sys.path:
|
||||||
|
sys.path.insert(0, str(LAB_TOOLS))
|
||||||
|
|
||||||
|
from govoplan_lab.cli import main # noqa: E402
|
||||||
|
from govoplan_lab.config import LabConfigError, load_config # noqa: E402
|
||||||
|
from govoplan_lab.lifecycle import ( # noqa: E402
|
||||||
|
CommandRunner,
|
||||||
|
LabOperationError,
|
||||||
|
_assert_domain_owned,
|
||||||
|
_domain_description,
|
||||||
|
_render_kubectl_wrapper,
|
||||||
|
destroy,
|
||||||
|
)
|
||||||
|
from govoplan_lab.render import ( # noqa: E402
|
||||||
|
render_k3s_config,
|
||||||
|
render_registry_config,
|
||||||
|
render_state_compose,
|
||||||
|
write_private,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
REHEARSAL_CONFIG = LAB_TOOLS / "govoplan-lab.example.toml"
|
||||||
|
ACCEPTANCE_CONFIG = LAB_TOOLS / "govoplan-lab.acceptance.example.toml"
|
||||||
|
|
||||||
|
|
||||||
|
class KubernetesLabTests(unittest.TestCase):
|
||||||
|
def test_example_inventories_describe_their_evidence_boundary(self) -> None:
|
||||||
|
rehearsal = load_config(REHEARSAL_CONFIG)
|
||||||
|
acceptance = load_config(ACCEPTANCE_CONFIG)
|
||||||
|
|
||||||
|
self.assertEqual("rehearsal", rehearsal.mode)
|
||||||
|
self.assertFalse(rehearsal.evidence_capable)
|
||||||
|
self.assertEqual(2, len(rehearsal.workers))
|
||||||
|
self.assertEqual("acceptance", acceptance.mode)
|
||||||
|
self.assertTrue(acceptance.evidence_capable)
|
||||||
|
self.assertEqual(3, len({node.hypervisor for node in acceptance.nodes}))
|
||||||
|
self.assertEqual(3, len({node.failure_domain for node in acceptance.nodes}))
|
||||||
|
|
||||||
|
def test_acceptance_inventory_rejects_collapsed_worker_failure_domains(self) -> None:
|
||||||
|
source = ACCEPTANCE_CONFIG.read_text(encoding="utf-8")
|
||||||
|
collapsed = source.replace(
|
||||||
|
'hypervisor = "lab-admin@hypervisor-b.example.org"',
|
||||||
|
'hypervisor = "lab-admin@hypervisor-a.example.org"',
|
||||||
|
).replace('failure_domain = "rack-b"', 'failure_domain = "rack-a"')
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-lab-config-") as directory:
|
||||||
|
path = Path(directory) / "lab.toml"
|
||||||
|
path.write_text(collapsed, encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(LabConfigError, "acceptance mode"):
|
||||||
|
load_config(path)
|
||||||
|
|
||||||
|
def test_create_without_apply_is_a_non_mutating_preview(self) -> None:
|
||||||
|
output = io.StringIO()
|
||||||
|
with redirect_stdout(output):
|
||||||
|
exit_code = main(["--config", str(REHEARSAL_CONFIG), "create"])
|
||||||
|
|
||||||
|
self.assertEqual(0, exit_code)
|
||||||
|
self.assertIn("Dry run: create", output.getvalue())
|
||||||
|
self.assertIn("Re-run with --apply", output.getvalue())
|
||||||
|
|
||||||
|
def test_local_hypervisor_uses_system_libvirt_without_sudo(self) -> None:
|
||||||
|
config = load_config(REHEARSAL_CONFIG)
|
||||||
|
runner = CommandRunner(config)
|
||||||
|
runner.run = MagicMock(
|
||||||
|
return_value=subprocess.CompletedProcess([], 0, stdout=b"", stderr=b"")
|
||||||
|
)
|
||||||
|
|
||||||
|
runner.hypervisor(config.nodes[0], ["virsh", "dominfo", "test-domain"])
|
||||||
|
|
||||||
|
runner.run.assert_called_once_with(
|
||||||
|
[
|
||||||
|
"virsh",
|
||||||
|
"--connect",
|
||||||
|
"qemu:///system",
|
||||||
|
"dominfo",
|
||||||
|
"test-domain",
|
||||||
|
],
|
||||||
|
capture=False,
|
||||||
|
check=True,
|
||||||
|
timeout=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_hypervisor_file_operations_do_not_use_sudo(self) -> None:
|
||||||
|
config = load_config(REHEARSAL_CONFIG)
|
||||||
|
runner = CommandRunner(config)
|
||||||
|
runner.run = MagicMock(
|
||||||
|
return_value=subprocess.CompletedProcess([], 0, stdout=b"", stderr=b"")
|
||||||
|
)
|
||||||
|
|
||||||
|
runner.hypervisor(config.nodes[0], ["install", "-d", "/tmp/lab"])
|
||||||
|
|
||||||
|
runner.run.assert_called_once_with(
|
||||||
|
["install", "-d", "/tmp/lab"],
|
||||||
|
capture=False,
|
||||||
|
check=True,
|
||||||
|
timeout=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_kubectl_wrapper_quotes_remote_arguments(self) -> None:
|
||||||
|
wrapper = _render_kubectl_wrapper(
|
||||||
|
["ssh", "-i", "/tmp/lab key", "govoplan@example.test"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("shlex.join(_REMOTE)", wrapper)
|
||||||
|
self.assertIn('["sudo", "--", "k3s", "kubectl", *sys.argv[1:]]', wrapper)
|
||||||
|
self.assertNotIn('kubectl \"$@\"', wrapper)
|
||||||
|
|
||||||
|
def test_destroy_requires_the_exact_lab_name(self) -> None:
|
||||||
|
config = load_config(REHEARSAL_CONFIG)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(LabOperationError, "--confirm"):
|
||||||
|
destroy(
|
||||||
|
config,
|
||||||
|
apply=True,
|
||||||
|
confirmation="wrong-lab",
|
||||||
|
purge_local_state=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enroll_admin_without_apply_is_a_non_mutating_preview(self) -> None:
|
||||||
|
output = io.StringIO()
|
||||||
|
with redirect_stdout(output):
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(REHEARSAL_CONFIG),
|
||||||
|
"enroll-admin",
|
||||||
|
"--email",
|
||||||
|
"owner@example.test",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, exit_code)
|
||||||
|
self.assertIn("Dry run: enroll-admin", output.getvalue())
|
||||||
|
self.assertIn("owner@example.test", output.getvalue())
|
||||||
|
|
||||||
|
def test_domain_ownership_requires_marker_and_expected_disks(self) -> None:
|
||||||
|
config = load_config(REHEARSAL_CONFIG)
|
||||||
|
node = config.nodes[0]
|
||||||
|
node_directory = f"{config.vm_image_directory}/{config.name}/{node.name}"
|
||||||
|
runner = MagicMock()
|
||||||
|
runner.hypervisor.side_effect = [
|
||||||
|
subprocess.CompletedProcess(
|
||||||
|
[],
|
||||||
|
0,
|
||||||
|
stdout=(_domain_description(config, node) + "\n").encode(),
|
||||||
|
stderr=b"",
|
||||||
|
),
|
||||||
|
subprocess.CompletedProcess(
|
||||||
|
[],
|
||||||
|
0,
|
||||||
|
stdout=(
|
||||||
|
f"file disk vda {node_directory}/root.qcow2\n"
|
||||||
|
f"file cdrom sda {node_directory}/seed.img\n"
|
||||||
|
).encode(),
|
||||||
|
stderr=b"",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
_assert_domain_owned(config, runner, node)
|
||||||
|
|
||||||
|
runner.hypervisor.side_effect = [
|
||||||
|
subprocess.CompletedProcess(
|
||||||
|
[],
|
||||||
|
0,
|
||||||
|
stdout=b"unrelated domain\n",
|
||||||
|
stderr=b"",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
with self.assertRaisesRegex(LabOperationError, "ownership marker"):
|
||||||
|
_assert_domain_owned(config, runner, node)
|
||||||
|
|
||||||
|
def test_state_compose_uses_only_supplied_pinned_images(self) -> None:
|
||||||
|
names = ("postgres", "redis", "garage", "managed_ingress", "test_mail")
|
||||||
|
images = {
|
||||||
|
name: f"registry.example.test/{name}@sha256:{index:064x}"
|
||||||
|
for index, name in enumerate(names, start=1)
|
||||||
|
}
|
||||||
|
|
||||||
|
compose = json.loads(render_state_compose(images))
|
||||||
|
|
||||||
|
self.assertEqual(images["postgres"], compose["services"]["postgres"]["image"])
|
||||||
|
self.assertEqual(images["redis"], compose["services"]["redis"]["image"])
|
||||||
|
self.assertEqual(images["garage"], compose["services"]["garage"]["image"])
|
||||||
|
self.assertEqual(
|
||||||
|
images["managed_ingress"], compose["services"]["s3-tls"]["image"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
images["test_mail"], compose["services"]["test-mail"]["image"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_k3s_workers_join_the_primary_control_and_receive_failure_labels(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
config = load_config(ACCEPTANCE_CONFIG)
|
||||||
|
worker = config.workers[0]
|
||||||
|
|
||||||
|
rendered = render_k3s_config(config, worker, cluster_token="test-token")
|
||||||
|
|
||||||
|
self.assertIn(f'server: "https://{config.primary_control.address}:6443"', rendered)
|
||||||
|
self.assertIn(
|
||||||
|
f'topology.govoplan.add-ideas.de/failure-domain={worker.failure_domain}',
|
||||||
|
rendered,
|
||||||
|
)
|
||||||
|
self.assertNotIn("cluster-init", rendered)
|
||||||
|
|
||||||
|
def test_registry_credentials_are_all_or_nothing(self) -> None:
|
||||||
|
self.assertEqual("", render_registry_config("", ""))
|
||||||
|
with self.assertRaisesRegex(ValueError, "supplied together"):
|
||||||
|
render_registry_config("publisher", "")
|
||||||
|
rendered = render_registry_config("publisher", "secret")
|
||||||
|
self.assertIn('"git.add-ideas.de"', rendered)
|
||||||
|
self.assertIn("publisher", rendered)
|
||||||
|
|
||||||
|
def test_private_writer_enforces_owner_only_permissions(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-lab-private-") as directory:
|
||||||
|
path = Path(directory) / "nested" / "secret.txt"
|
||||||
|
write_private(path, "secret\n")
|
||||||
|
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(path.stat().st_mode))
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class ModulePackageWorkflowTests(unittest.TestCase):
|
||||||
|
def test_template_enforces_tag_version_hash_and_registry_contract(self) -> None:
|
||||||
|
workflow = (
|
||||||
|
META_ROOT / "tools/repo/templates/module-package-release.yml"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("GITEA_REPOSITORY: ${{ gitea.repository }}", workflow)
|
||||||
|
self.assertNotIn("tag_protections", workflow)
|
||||||
|
self.assertNotIn("secrets.GITEA_TOKEN", workflow)
|
||||||
|
self.assertIn("git merge-base --is-ancestor", workflow)
|
||||||
|
self.assertIn("does not match", workflow)
|
||||||
|
self.assertIn("package-artifacts.json", workflow)
|
||||||
|
self.assertIn("api/packages/GovOPlaN/pypi", workflow)
|
||||||
|
self.assertIn("api/packages/GovOPlaN/npm", workflow)
|
||||||
|
self.assertIn('npm publish "./${webui_packages[0]}"', workflow)
|
||||||
|
self.assertIn("Check immutable registry state", workflow)
|
||||||
|
self.assertIn('files[0].get("sha256") != expected_sha256', workflow)
|
||||||
|
self.assertIn('if [[ "$PUBLISH_PYPI" == 1 ]]', workflow)
|
||||||
|
self.assertIn('[[ "$PUBLISH_NPM" == 1 ]]', workflow)
|
||||||
|
self.assertIn("GOVOPLAN_PACKAGE_TOKEN", workflow)
|
||||||
|
self.assertIn("must resolve to an exact registry version", workflow)
|
||||||
|
self.assertIn("git\\\\.add-ideas\\\\.de/(?:GovOPlaN|add-ideas)", workflow)
|
||||||
|
self.assertIn("release package identity does not match", workflow)
|
||||||
|
self.assertNotIn("Generic", workflow)
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("node"), "Node.js is required")
|
||||||
|
def test_webui_publication_normalizes_internal_git_dependencies(self) -> None:
|
||||||
|
workflow = (
|
||||||
|
META_ROOT / "tools/repo/templates/module-package-release.yml"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
marker = " node <<'NODE'\n"
|
||||||
|
script = workflow.split(marker, 1)[1].split("\n NODE", 1)[0]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
package_dir = root / ".package-webui"
|
||||||
|
package_dir.mkdir()
|
||||||
|
package_path = package_dir / "package.json"
|
||||||
|
package_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "@govoplan/core-webui",
|
||||||
|
"version": "0.1.14",
|
||||||
|
"private": True,
|
||||||
|
"dependencies": {
|
||||||
|
"@govoplan/access-webui": (
|
||||||
|
"git+ssh://git@git.add-ideas.de/GovOPlaN/"
|
||||||
|
"govoplan-access.git#v0.1.11"
|
||||||
|
),
|
||||||
|
"@govoplan/admin-webui": (
|
||||||
|
"git+ssh://git@git.add-ideas.de/add-ideas/"
|
||||||
|
"govoplan-admin.git#v0.1.8"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
["node"],
|
||||||
|
input=script,
|
||||||
|
cwd=root,
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
package = json.loads(package_path.read_text(encoding="utf-8"))
|
||||||
|
self.assertNotIn("private", package)
|
||||||
|
self.assertEqual(
|
||||||
|
"0.1.11", package["dependencies"]["@govoplan/access-webui"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"0.1.8", package["dependencies"]["@govoplan/admin-webui"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sync_script_only_targets_packageable_govoplan_repositories(self) -> None:
|
||||||
|
namespace: dict[str, object] = {
|
||||||
|
"__file__": str(META_ROOT / "tools/repo/sync-module-package-workflows.py"),
|
||||||
|
"__name__": "test_sync_module_package_workflows",
|
||||||
|
}
|
||||||
|
script = (META_ROOT / "tools/repo/sync-module-package-workflows.py").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
exec(compile(script, str(namespace["__file__"]), "exec"), namespace)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
parent = Path(temporary)
|
||||||
|
package_repositories = namespace["package_repositories"]
|
||||||
|
# The production inventory is authoritative, so a temporary parent
|
||||||
|
# only exposes matching paths that are present in that inventory.
|
||||||
|
known = parent / "govoplan-core"
|
||||||
|
known.mkdir()
|
||||||
|
(known / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
||||||
|
self.assertEqual(package_repositories(parent), (known,))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import tomllib
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
PACKAGE_SET = _load(
|
||||||
|
"generate_release_package_set",
|
||||||
|
ROOT / "tools/release/generate-release-package-set.py",
|
||||||
|
)
|
||||||
|
ARTIFACTS = _load(
|
||||||
|
"resolve_package_artifacts",
|
||||||
|
ROOT / "tools/release/resolve-package-artifacts.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PackageRegistryReleaseTests(unittest.TestCase):
|
||||||
|
def test_current_release_sources_form_a_hash_bound_package_set(self) -> None:
|
||||||
|
core_version = tomllib.loads(
|
||||||
|
(ROOT.parent / "govoplan-core/pyproject.toml").read_text(encoding="utf-8")
|
||||||
|
)["project"]["version"]
|
||||||
|
payload = PACKAGE_SET.generate_package_set(
|
||||||
|
core_version=core_version,
|
||||||
|
requirements=ROOT / "requirements-release.txt",
|
||||||
|
workspace=ROOT.parent,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("1", payload["schema_version"])
|
||||||
|
self.assertEqual("govoplan-core", payload["python"][0]["name"])
|
||||||
|
self.assertIn(
|
||||||
|
"@govoplan/core-webui",
|
||||||
|
{item["name"] for item in payload["webui"]},
|
||||||
|
)
|
||||||
|
unsigned = dict(payload)
|
||||||
|
digest = unsigned.pop("package_set_sha256")
|
||||||
|
self.assertEqual(ARTIFACTS._canonical_sha256(unsigned), digest)
|
||||||
|
|
||||||
|
def test_wheel_and_webui_artifacts_are_verified_by_embedded_identity(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-package-artifacts-") as value:
|
||||||
|
root = Path(value)
|
||||||
|
wheels = root / "wheels"
|
||||||
|
webui = root / "webui"
|
||||||
|
wheels.mkdir()
|
||||||
|
webui.mkdir()
|
||||||
|
wheel = wheels / "govoplan_demo-1.2.3-py3-none-any.whl"
|
||||||
|
with zipfile.ZipFile(wheel, "w") as archive:
|
||||||
|
archive.writestr(
|
||||||
|
"govoplan_demo-1.2.3.dist-info/METADATA",
|
||||||
|
"Metadata-Version: 2.1\nName: govoplan-demo\nVersion: 1.2.3\n",
|
||||||
|
)
|
||||||
|
package_json = json.dumps(
|
||||||
|
{"name": "@govoplan/demo-webui", "version": "1.2.3"}
|
||||||
|
).encode("utf-8")
|
||||||
|
npm = webui / "govoplan-demo-webui-1.2.3.tgz"
|
||||||
|
with tarfile.open(npm, "w:gz") as archive:
|
||||||
|
member = tarfile.TarInfo("package/package.json")
|
||||||
|
member.size = len(package_json)
|
||||||
|
archive.addfile(member, BytesIO(package_json))
|
||||||
|
source = {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"repository": "govoplan-demo",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"commit": "1" * 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
python_rows = ARTIFACTS._verify_wheels(
|
||||||
|
({"name": "govoplan-demo", "extras": ["server"], **source},), wheels
|
||||||
|
)
|
||||||
|
webui_rows = ARTIFACTS._verify_webui(
|
||||||
|
({"name": "@govoplan/demo-webui", **source},), webui
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("govoplan-demo", python_rows[0]["name"])
|
||||||
|
self.assertEqual(["server"], python_rows[0]["extras"])
|
||||||
|
self.assertEqual("@govoplan/demo-webui", webui_rows[0]["name"])
|
||||||
|
self.assertTrue(str(webui_rows[0]["integrity"]).startswith("sha512-"))
|
||||||
|
|
||||||
|
def test_package_set_rejects_argument_shaped_package_names(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"schema_version": "1",
|
||||||
|
"release_version": "1.2.3",
|
||||||
|
"registries": {
|
||||||
|
"python": "https://packages.example.test/pypi/simple",
|
||||||
|
"npm": "https://packages.example.test/npm/",
|
||||||
|
},
|
||||||
|
"python": [
|
||||||
|
{
|
||||||
|
"name": "--index-url",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"repository": "govoplan-demo",
|
||||||
|
"extras": [],
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"commit": "1" * 40,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"webui": [
|
||||||
|
{
|
||||||
|
"name": "@govoplan/demo-webui",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"repository": "govoplan-demo",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"commit": "1" * 40,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
payload["package_set_sha256"] = ARTIFACTS._canonical_sha256(payload)
|
||||||
|
with tempfile.TemporaryDirectory() as value:
|
||||||
|
path = Path(value) / "packages.json"
|
||||||
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ARTIFACTS.PackageArtifactError, "invalid identity"
|
||||||
|
):
|
||||||
|
ARTIFACTS._load_package_set(path)
|
||||||
|
|
||||||
|
def test_runtime_workflow_consumes_registry_artifacts_and_publishes_lock(self) -> None:
|
||||||
|
workflow = (ROOT / ".gitea/workflows/runtime-distribution.yml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("resolve-package-artifacts.py", workflow)
|
||||||
|
self.assertIn("package-artifacts.lock.json", workflow)
|
||||||
|
self.assertIn(
|
||||||
|
"--package-lock runtime-output/package-artifacts.lock.json",
|
||||||
|
workflow,
|
||||||
|
)
|
||||||
|
self.assertIn('PYTHON="$PWD/.runtime-build/bin/python"', workflow)
|
||||||
|
self.assertNotIn(
|
||||||
|
"pip wheel --no-deps --wheel-dir runtime-output/local-wheels",
|
||||||
|
workflow,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_developer_meta_package_matches_workspace_versions(self) -> None:
|
||||||
|
script = _load(
|
||||||
|
"generate_developer_meta_package",
|
||||||
|
ROOT / "tools/release/generate-developer-meta-package.py",
|
||||||
|
)
|
||||||
|
expected = script.render(
|
||||||
|
workspace=ROOT.parent,
|
||||||
|
requirements=ROOT / "requirements-release.txt",
|
||||||
|
)
|
||||||
|
actual = (ROOT / "packages/govoplan-meta/pyproject.toml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
|
def test_meta_package_workflow_supports_hash_safe_tag_retry(self) -> None:
|
||||||
|
workflow = (
|
||||||
|
ROOT / ".gitea/workflows/publish-developer-meta-package.yml"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("workflow_dispatch:", workflow)
|
||||||
|
self.assertIn("TRIGGER_TAG: ${{ gitea.ref_name }}", workflow)
|
||||||
|
self.assertNotIn("GITEA_REF_NAME", workflow)
|
||||||
|
self.assertIn('refs/tags/{tag}^{{commit}}', workflow)
|
||||||
|
self.assertIn("already exists with a different SHA-256", workflow)
|
||||||
|
self.assertIn("PUBLISH_PYPI", workflow)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
TOOLS_ROOT = META_ROOT / "tools" / "gitea"
|
||||||
|
if str(TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(TOOLS_ROOT))
|
||||||
|
SCRIPT = TOOLS_ROOT / "gitea-dispatch-package-set.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("gitea_dispatch_package_set", 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 PackageSetDispatchTests(unittest.TestCase):
|
||||||
|
def test_meta_package_resolves_to_exact_tagged_repository_targets(self) -> None:
|
||||||
|
targets = MODULE.package_targets()
|
||||||
|
|
||||||
|
self.assertEqual(66, len(targets))
|
||||||
|
self.assertEqual(66, len({target.distribution for target in targets}))
|
||||||
|
by_name = {target.distribution: target for target in targets}
|
||||||
|
self.assertEqual("v0.1.14", by_name["govoplan-core"].tag)
|
||||||
|
self.assertEqual("v0.1.8", by_name["govoplan-access"].tag)
|
||||||
|
self.assertTrue(by_name["govoplan-core"].tag_exists)
|
||||||
|
self.assertTrue(by_name["govoplan-access"].has_webui)
|
||||||
|
self.assertEqual(
|
||||||
|
"@govoplan/access-webui",
|
||||||
|
by_name["govoplan-access"].webui_package,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
@@ -119,6 +141,34 @@ class PlatformInterfaceInventoryTests(unittest.TestCase):
|
|||||||
self.assertEqual(1, result["summary"]["stale_endpoint_declarations"])
|
self.assertEqual(1, result["summary"]["stale_endpoint_declarations"])
|
||||||
self.assertIsNone(result["api"]["backend_endpoints"][0]["surface"])
|
self.assertIsNone(result["api"]["backend_endpoints"][0]["surface"])
|
||||||
|
|
||||||
|
def test_endpoint_only_strict_mode_does_not_fail_on_translation_debt(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
result = {
|
||||||
|
"translation_health": {"missing_catalog_entries": ["missing.key"]},
|
||||||
|
"api": {
|
||||||
|
"unclassified_endpoints": [],
|
||||||
|
"stale_endpoint_declarations": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[],
|
||||||
|
inventory._strict_failures(
|
||||||
|
result,
|
||||||
|
check_translations=False,
|
||||||
|
check_endpoints=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["used translation keys are missing from generated catalogs"],
|
||||||
|
inventory._strict_failures(
|
||||||
|
result,
|
||||||
|
check_translations=True,
|
||||||
|
check_endpoints=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def test_fastapi_route_scanner_includes_router_prefix(self) -> None:
|
def test_fastapi_route_scanner_includes_router_prefix(self) -> None:
|
||||||
tree = ast.parse(
|
tree = ast.parse(
|
||||||
"""
|
"""
|
||||||
@@ -149,6 +199,114 @@ def read_item(item_id: str):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_source_declarations_normalize_stable_control_and_contribution_ids(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
webui = {
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"repository": "govoplan-example",
|
||||||
|
"file": "webui/src/Example.tsx",
|
||||||
|
"line": 12,
|
||||||
|
"column": 3,
|
||||||
|
"id": "govoplan-example.field.example.name.abc123",
|
||||||
|
"idSource": "source_anchor",
|
||||||
|
"explicitId": None,
|
||||||
|
"context": "Example",
|
||||||
|
"helpId": "govoplan-example.field.example.name.abc123.help",
|
||||||
|
"helpDynamic": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [],
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"repository": "govoplan-example",
|
||||||
|
"file": "webui/src/module.ts",
|
||||||
|
"line": 20,
|
||||||
|
"column": 5,
|
||||||
|
"kind": "frontend_route",
|
||||||
|
"id": "/examples/:exampleId",
|
||||||
|
"path": "/examples/:exampleId",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"translationCatalog": {"en": {}, "de": {}},
|
||||||
|
}
|
||||||
|
manifests = [{"repository": "govoplan-example", "id": "examples"}]
|
||||||
|
|
||||||
|
declarations = inventory._source_interface_declarations(webui, manifests)
|
||||||
|
keys = {item["key"] for item in declarations}
|
||||||
|
|
||||||
|
self.assertIn("field:examples.field.example.name.abc123", keys)
|
||||||
|
self.assertIn(
|
||||||
|
"help:examples.field.example.name.abc123.help",
|
||||||
|
keys,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"frontend_route:examples.route.examples.exampleid",
|
||||||
|
keys,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_declaration_health_rejects_duplicate_and_undeclared_source_ids(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
declaration = {
|
||||||
|
"key": "frontend_route:example.route.unlisted",
|
||||||
|
"id": "example.route.unlisted",
|
||||||
|
"module_id": "example",
|
||||||
|
"kind": "frontend_route",
|
||||||
|
"origin": "webui_contribution",
|
||||||
|
}
|
||||||
|
manifests = [
|
||||||
|
{
|
||||||
|
"id": "example",
|
||||||
|
"repository": "govoplan-example",
|
||||||
|
"interface_catalog": {"declarations": []},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
health = inventory._declaration_health(
|
||||||
|
[declaration, dict(declaration)],
|
||||||
|
manifests,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(health["duplicate_ids"]))
|
||||||
|
self.assertEqual(1, len(health["undeclared_source_surfaces"]))
|
||||||
|
|
||||||
|
def test_runtime_snapshot_comparison_accepts_an_installed_subset(self) -> None:
|
||||||
|
manifests = [
|
||||||
|
{
|
||||||
|
"id": "one",
|
||||||
|
"interface_catalog": {
|
||||||
|
"contract_version": "1",
|
||||||
|
"module_id": "one",
|
||||||
|
"module_version": "1.0.0",
|
||||||
|
"digest": "sha256:one",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "two",
|
||||||
|
"interface_catalog": {
|
||||||
|
"contract_version": "1",
|
||||||
|
"module_id": "two",
|
||||||
|
"module_version": "1.0.0",
|
||||||
|
"digest": "sha256:two",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
snapshot = {
|
||||||
|
"contract_version": "1",
|
||||||
|
"modules": [dict(manifests[1]["interface_catalog"])],
|
||||||
|
}
|
||||||
|
|
||||||
|
comparison = inventory._compare_runtime_snapshot(snapshot, manifests)
|
||||||
|
|
||||||
|
self.assertEqual(["two"], comparison["matched_modules"])
|
||||||
|
self.assertEqual([], comparison["mismatches"])
|
||||||
|
|
||||||
|
snapshot["modules"][0]["digest"] = "sha256:changed"
|
||||||
|
comparison = inventory._compare_runtime_snapshot(snapshot, manifests)
|
||||||
|
self.assertEqual("digest_mismatch", comparison["mismatches"][0]["reason"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import tomllib
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -26,6 +27,13 @@ from govoplan_release.catalog_entry_synthesis import ( # noqa: E402
|
|||||||
from govoplan_release.selective_catalog import apply_repo_updates # noqa: E402
|
from govoplan_release.selective_catalog import apply_repo_updates # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def repository_version(name: str) -> str:
|
||||||
|
payload = tomllib.loads(
|
||||||
|
(META_ROOT.parent / name / "pyproject.toml").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
return str(payload["project"]["version"])
|
||||||
|
|
||||||
|
|
||||||
class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
||||||
def test_catalog_entry_preserves_architecture_and_provider_declarations(
|
def test_catalog_entry_preserves_architecture_and_provider_declarations(
|
||||||
self,
|
self,
|
||||||
@@ -92,6 +100,12 @@ class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual("vertical_slice", entry["architecture"]["maturity"])
|
self.assertEqual("vertical_slice", entry["architecture"]["maturity"])
|
||||||
|
self.assertEqual(
|
||||||
|
"contract_only",
|
||||||
|
entry["information_governance"]["dimensions"]["retention"][
|
||||||
|
"adoption"
|
||||||
|
],
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"external_mirror",
|
"external_mirror",
|
||||||
entry["external_providers"][0]["objects"][0][
|
entry["external_providers"][0]["objects"][0][
|
||||||
@@ -116,9 +130,12 @@ class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
|||||||
changes = apply_repo_updates(
|
changes = apply_repo_updates(
|
||||||
payload,
|
payload,
|
||||||
repo_versions={
|
repo_versions={
|
||||||
"govoplan-addresses": "0.1.9",
|
name: repository_version(name)
|
||||||
"govoplan-poll": "0.1.11",
|
for name in (
|
||||||
"govoplan-scheduling": "0.1.11",
|
"govoplan-addresses",
|
||||||
|
"govoplan-poll",
|
||||||
|
"govoplan-scheduling",
|
||||||
|
)
|
||||||
},
|
},
|
||||||
repo_contracts={},
|
repo_contracts={},
|
||||||
repository_base="git+ssh://git@git.add-ideas.de/GovOPlaN",
|
repository_base="git+ssh://git@git.add-ideas.de/GovOPlaN",
|
||||||
|
|||||||
@@ -29,17 +29,72 @@ class ReleaseEntrypointGateTests(unittest.TestCase):
|
|||||||
workflow = script[confirm:]
|
workflow = script[confirm:]
|
||||||
|
|
||||||
source_gate = workflow.index("run_version_alignment_gate source")
|
source_gate = workflow.index("run_version_alignment_gate source")
|
||||||
|
baseline = workflow.index("record_migration_release_baseline")
|
||||||
first_commit = workflow.index('run git -C "$repo" commit')
|
first_commit = workflow.index('run git -C "$repo" commit')
|
||||||
lock_generation = workflow.index("generate_release_lock")
|
lock_generation = workflow.index("generate_release_lock")
|
||||||
full_gate = workflow.index("run_version_alignment_gate", source_gate + 1)
|
full_gate = workflow.index("run_version_alignment_gate", source_gate + 1)
|
||||||
first_push = workflow.index('run git -C "$repo" push')
|
first_push = workflow.index('run git -C "$repo" push')
|
||||||
|
|
||||||
|
self.assertLess(baseline, source_gate)
|
||||||
self.assertLess(source_gate, first_commit)
|
self.assertLess(source_gate, first_commit)
|
||||||
self.assertLess(first_commit, lock_generation)
|
self.assertLess(first_commit, lock_generation)
|
||||||
self.assertLess(lock_generation, full_gate)
|
self.assertLess(lock_generation, full_gate)
|
||||||
self.assertLess(full_gate, first_push)
|
self.assertLess(full_gate, first_push)
|
||||||
self.assertLess(manifest_gate, confirm)
|
self.assertLess(manifest_gate, confirm)
|
||||||
|
|
||||||
|
def test_lockstep_release_pushes_meta_package_after_core(self) -> None:
|
||||||
|
script = (META_ROOT / "tools" / "release" / "push-release-tag.sh").read_text()
|
||||||
|
module_push = script.index('for repo in "${MODULE_REPOS[@]}"; do\n run git -C "$repo" push')
|
||||||
|
core_push = script.index('run git -C "$ROOT" push', module_push)
|
||||||
|
support_push = script.index('for repo in "${SUPPORT_REPOS[@]}"; do\n run git -C "$repo" push', core_push)
|
||||||
|
|
||||||
|
self.assertLess(module_push, core_push)
|
||||||
|
self.assertLess(core_push, support_push)
|
||||||
|
|
||||||
|
def test_default_migration_preflight_accepts_new_release_heads(self) -> None:
|
||||||
|
script = (META_ROOT / "tools" / "release" / "push-release-tag.sh").read_text()
|
||||||
|
audit_function = script[
|
||||||
|
script.index("run_migration_release_audit()") :
|
||||||
|
script.index("record_migration_release_baseline()")
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertNotIn("--strict-if-baseline", audit_function)
|
||||||
|
self.assertIn('command+=("--strict")', audit_function)
|
||||||
|
|
||||||
|
def test_source_gate_does_not_require_tags_before_they_are_created(self) -> None:
|
||||||
|
script = (META_ROOT / "tools" / "release" / "push-release-tag.sh").read_text()
|
||||||
|
gate = script[
|
||||||
|
script.index("run_version_alignment_gate()") :
|
||||||
|
script.index("run_manifest_shape_gate()")
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertIn('command+=(--source-metadata-only)', gate)
|
||||||
|
self.assertIn('else\n command+=(--release-composition)', gate)
|
||||||
|
|
||||||
|
def test_version_updater_targets_canonical_runtime_declarations(self) -> None:
|
||||||
|
script = (META_ROOT / "tools" / "release" / "push-release-tag.sh").read_text()
|
||||||
|
|
||||||
|
self.assertIn("^manifest\\s*=\\s*ModuleManifest", script)
|
||||||
|
self.assertIn("could not update module version declaration", script)
|
||||||
|
self.assertIn("update_package_init_versions", script)
|
||||||
|
self.assertIn("synchronize-webui-package-metadata.py", script)
|
||||||
|
self.assertIn('"peerDependenciesMeta",', script)
|
||||||
|
self.assertLess(
|
||||||
|
script.index('synchronize-webui-package-metadata.py" --repo "$repo"'),
|
||||||
|
script.index('synchronize_lockfile_root "$package_path"', script.index('synchronize-webui-package-metadata.py" --repo "$repo"')),
|
||||||
|
)
|
||||||
|
self.assertNotIn("could not update ModuleManifest.version", script)
|
||||||
|
|
||||||
|
def test_release_lock_refreshes_candidate_govoplan_metadata(self) -> None:
|
||||||
|
script = (META_ROOT / "tools" / "release" / "generate-release-lock.sh").read_text()
|
||||||
|
|
||||||
|
self.assertEqual(2, script.count('"npm_config_cache=$TMP_DIR/npm-cache"'))
|
||||||
|
self.assertNotIn(
|
||||||
|
'cp "$WEBUI/package-lock.release.json" "$TMP_DIR/package-lock.json"',
|
||||||
|
script,
|
||||||
|
)
|
||||||
|
self.assertIn('cp "$WEBUI/package.release.json" "$TMP_DIR/package.json"', script)
|
||||||
|
|
||||||
def test_source_catalog_generator_enforces_explicit_repo_versions(self) -> None:
|
def test_source_catalog_generator_enforces_explicit_repo_versions(self) -> None:
|
||||||
script = (META_ROOT / "tools" / "release" / "generate-release-catalog.py").read_text()
|
script = (META_ROOT / "tools" / "release" / "generate-release-catalog.py").read_text()
|
||||||
|
|
||||||
|
|||||||
@@ -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,209 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(DistributionError, "expected 'candidate'"):
|
||||||
|
verify_manifest(
|
||||||
|
self._manifest(),
|
||||||
|
self.keyring,
|
||||||
|
expected_channel="candidate",
|
||||||
|
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,408 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
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_web_runtime_resolves_the_configured_api_service_at_startup(self) -> None:
|
||||||
|
nginx = (ROOT / "tools/release/runtime/nginx.conf").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
dockerfile = (ROOT / "tools/release/runtime/Dockerfile.web").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
entrypoint = (ROOT / "tools/release/runtime/web-entrypoint.sh").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("proxy_pass ${GOVOPLAN_API_UPSTREAM};", nginx)
|
||||||
|
self.assertIn("nginx.conf.template", dockerfile)
|
||||||
|
self.assertIn("govoplan-web-entrypoint", dockerfile)
|
||||||
|
self.assertIn("envsubst '${GOVOPLAN_API_UPSTREAM}'", entrypoint)
|
||||||
|
|
||||||
|
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_distribution_to_the_peeled_release_tag(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(
|
||||||
|
'git fetch --force --no-tags origin "refs/tags/v$VERSION:refs/tags/v$VERSION"',
|
||||||
|
workflow,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'git rev-parse "v$VERSION^{commit}" > runtime-output/release-source-commit',
|
||||||
|
workflow,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
2,
|
||||||
|
workflow.count(
|
||||||
|
'SOURCE_COMMIT="$(cat runtime-output/release-source-commit)"'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertNotIn("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")
|
||||||
|
package_lock = root / "package-artifacts.lock.json"
|
||||||
|
package_lock_value = {
|
||||||
|
"schema_version": "1",
|
||||||
|
"release_version": "1.2.3",
|
||||||
|
"python": [
|
||||||
|
{
|
||||||
|
"name": "govoplan-core",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"sha256": "8" * 64,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"webui": [],
|
||||||
|
}
|
||||||
|
package_lock_value["lock_sha256"] = hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
package_lock_value,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
package_lock.write_text(
|
||||||
|
json.dumps(package_lock_value) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
package_lock=package_lock,
|
||||||
|
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())
|
||||||
|
self.assertEqual(
|
||||||
|
hashlib.sha256(package_lock.read_bytes()).hexdigest(),
|
||||||
|
descriptor["package_lock"]["sha256"],
|
||||||
|
)
|
||||||
|
|
||||||
|
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,159 @@
|
|||||||
|
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)
|
||||||
|
self.assertEqual(
|
||||||
|
(
|
||||||
|
SCRIPT.parent / "runtime" / "web-entrypoint.sh"
|
||||||
|
).read_bytes(),
|
||||||
|
(root / "context" / "web-entrypoint.sh").read_bytes(),
|
||||||
|
)
|
||||||
|
|
||||||
|
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,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPT = META_ROOT / "tools" / "release" / "synchronize-webui-package-metadata.py"
|
||||||
|
|
||||||
|
|
||||||
|
class SynchronizeWebuiPackageMetadataTests(unittest.TestCase):
|
||||||
|
def test_copies_peer_contract_without_changing_publish_paths(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
repo = Path(directory)
|
||||||
|
(repo / "webui").mkdir()
|
||||||
|
(repo / "package.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "@govoplan/example-webui",
|
||||||
|
"exports": {".": "./webui/src/index.ts"},
|
||||||
|
"peerDependencies": {"vite": "^6"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(repo / "webui" / "package.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "@govoplan/example-webui",
|
||||||
|
"peerDependencies": {"vite": "^7"},
|
||||||
|
"peerDependenciesMeta": {"vite": {"optional": True}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), "--repo", str(repo)],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
package = json.loads((repo / "package.json").read_text())
|
||||||
|
self.assertEqual({"vite": "^7"}, package["peerDependencies"])
|
||||||
|
self.assertEqual({"vite": {"optional": True}}, package["peerDependenciesMeta"])
|
||||||
|
self.assertEqual({".": "./webui/src/index.ts"}, package["exports"])
|
||||||
|
|
||||||
|
def test_leaves_distinct_root_and_webui_packages_separate(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
repo = Path(directory)
|
||||||
|
(repo / "webui").mkdir()
|
||||||
|
(repo / "package.json").write_text(json.dumps({"name": "@govoplan/one"}))
|
||||||
|
(repo / "webui" / "package.json").write_text(json.dumps({"name": "@govoplan/two"}))
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), "--repo", str(repo)],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
root = json.loads((repo / "package.json").read_text())
|
||||||
|
self.assertEqual("@govoplan/one", root["name"])
|
||||||
|
self.assertNotIn("peerDependencies", root)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate an independently held Ed25519 assessment-authority keypair."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
|
||||||
|
KEY_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
|
||||||
|
PROOF_SCOPES = (
|
||||||
|
"target_environment",
|
||||||
|
"external_providers",
|
||||||
|
"accessibility",
|
||||||
|
"privacy",
|
||||||
|
"security",
|
||||||
|
"operations",
|
||||||
|
"recovery",
|
||||||
|
"production_approval",
|
||||||
|
)
|
||||||
|
PURPOSES = {
|
||||||
|
"proof": (
|
||||||
|
"govoplan.capability-fit-proof-authorities",
|
||||||
|
"./capability-fit-proof-authority-keyring.schema.json",
|
||||||
|
),
|
||||||
|
"installer": (
|
||||||
|
"govoplan.installer-receipt-authorities",
|
||||||
|
"./installer-receipt-authority-keyring.schema.json",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--purpose", choices=tuple(PURPOSES), required=True)
|
||||||
|
parser.add_argument("--key-id", required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--scope",
|
||||||
|
action="append",
|
||||||
|
choices=PROOF_SCOPES,
|
||||||
|
default=[],
|
||||||
|
help="Authorized proof scope; repeat as needed. Not used for installer keys.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--private-key", type=Path, required=True)
|
||||||
|
parser.add_argument("--keyring", type=Path, required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--valid-days",
|
||||||
|
type=int,
|
||||||
|
default=365,
|
||||||
|
help="Validity from generation time (default: 365 days).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--status",
|
||||||
|
choices=("active", "next"),
|
||||||
|
default="active",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if not KEY_ID_PATTERN.fullmatch(args.key_id):
|
||||||
|
parser.error("--key-id must be a valid opaque identifier")
|
||||||
|
if args.valid_days < 1 or args.valid_days > 3660:
|
||||||
|
parser.error("--valid-days must be between 1 and 3660")
|
||||||
|
scopes = _resolve_scopes(parser, purpose=args.purpose, scopes=args.scope)
|
||||||
|
|
||||||
|
private_path = args.private_key.expanduser().resolve()
|
||||||
|
keyring_path = args.keyring.expanduser().resolve()
|
||||||
|
_require_fresh_output(parser, private_path, label="private key")
|
||||||
|
_require_fresh_output(parser, keyring_path, label="keyring")
|
||||||
|
_require_private_directory(parser, private_path.parent)
|
||||||
|
_require_output_directory(parser, keyring_path.parent)
|
||||||
|
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
private_bytes = private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
public_bytes = private_key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw,
|
||||||
|
format=serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
public_base64 = base64.b64encode(public_bytes).decode("ascii")
|
||||||
|
now = datetime.now(UTC).replace(microsecond=0)
|
||||||
|
not_after = now + timedelta(days=args.valid_days)
|
||||||
|
purpose, schema = PURPOSES[args.purpose]
|
||||||
|
keyring = {
|
||||||
|
"$schema": schema,
|
||||||
|
"schema_version": "0.1.0",
|
||||||
|
"purpose": purpose,
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"key_id": args.key_id,
|
||||||
|
"status": args.status,
|
||||||
|
"public_key": public_base64,
|
||||||
|
"allowed_scopes": scopes,
|
||||||
|
"not_before": _rfc3339(now),
|
||||||
|
"not_after": _rfc3339(not_after),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
_write_new_private_file(private_path, private_bytes)
|
||||||
|
try:
|
||||||
|
_write_new_private_file(
|
||||||
|
keyring_path,
|
||||||
|
(json.dumps(keyring, indent=2, sort_keys=True) + "\n").encode("utf-8"),
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
private_path.unlink(missing_ok=True)
|
||||||
|
keyring_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
print(f"private_key={private_path}")
|
||||||
|
print(f"keyring={keyring_path}")
|
||||||
|
print(f"key_id={args.key_id}")
|
||||||
|
print(f"allowed_scopes={','.join(scopes)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_scopes(
|
||||||
|
parser: argparse.ArgumentParser, *, purpose: str, scopes: list[str]
|
||||||
|
) -> list[str]:
|
||||||
|
if purpose == "installer":
|
||||||
|
if scopes:
|
||||||
|
parser.error("installer authorities do not accept --scope")
|
||||||
|
return ["installed_release_origin"]
|
||||||
|
unique = list(dict.fromkeys(scopes))
|
||||||
|
if not unique:
|
||||||
|
parser.error("proof authorities require at least one --scope")
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _require_fresh_output(
|
||||||
|
parser: argparse.ArgumentParser, path: Path, *, label: str
|
||||||
|
) -> None:
|
||||||
|
if path.exists() or path.is_symlink():
|
||||||
|
parser.error(f"{label.capitalize()} output already exists: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_private_directory(
|
||||||
|
parser: argparse.ArgumentParser, directory: Path
|
||||||
|
) -> None:
|
||||||
|
_require_output_directory(parser, directory)
|
||||||
|
mode = stat.S_IMODE(directory.stat().st_mode)
|
||||||
|
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||||
|
parser.error(
|
||||||
|
"Private-key parent directory must not be accessible by group or others"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_output_directory(
|
||||||
|
parser: argparse.ArgumentParser, directory: Path
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
metadata = directory.lstat()
|
||||||
|
except OSError as exc:
|
||||||
|
parser.error(f"Output parent directory is unavailable: {directory}")
|
||||||
|
raise AssertionError from exc
|
||||||
|
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||||
|
parser.error(f"Output parent must be a real directory: {directory}")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_new_private_file(path: Path, payload: bytes) -> None:
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||||
|
if hasattr(os, "O_NOFOLLOW"):
|
||||||
|
flags |= os.O_NOFOLLOW
|
||||||
|
descriptor = os.open(path, flags, 0o600)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "wb", closefd=False) as handle:
|
||||||
|
handle.write(payload)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600:
|
||||||
|
raise OSError("Authority output could not be secured")
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def _rfc3339(value: datetime) -> str:
|
||||||
|
return value.isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -15,6 +15,7 @@ from govoplan_core.core.dataflows import (
|
|||||||
dataflow_run_lifecycle,
|
dataflow_run_lifecycle,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.automation import AutomationPrincipalResolution
|
from govoplan_core.core.automation import AutomationPrincipalResolution
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
)
|
)
|
||||||
@@ -25,6 +26,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 +80,10 @@ def main() -> int:
|
|||||||
Base.metadata.create_all(
|
Base.metadata.create_all(
|
||||||
engine,
|
engine,
|
||||||
tables=[
|
tables=[
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
DistributedLease.__table__,
|
||||||
|
RecoveryOperation.__table__,
|
||||||
|
RecoveryCheckpoint.__table__,
|
||||||
ConnectorTabularSource.__table__,
|
ConnectorTabularSource.__table__,
|
||||||
DatasourceRecord.__table__,
|
DatasourceRecord.__table__,
|
||||||
DatasourcePayloadRecord.__table__,
|
DatasourcePayloadRecord.__table__,
|
||||||
@@ -86,153 +97,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 +278,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
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ GOVOPLAN_CORE_ROOT="$ROOT" PYTHON="$PYTHON" CHECK_TESTCLIENT_DEPRECATIONS=1 bash
|
|||||||
PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$META_ROOT/tools/checks/check-manifest-shapes.py" --require-architecture
|
PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$META_ROOT/tools/checks/check-manifest-shapes.py" --require-architecture
|
||||||
|
|
||||||
cd "$META_ROOT"
|
cd "$META_ROOT"
|
||||||
|
"$PYTHON" tools/inventory/platform-interface-inventory.py --strict-declarations --strict-endpoints
|
||||||
|
"$PYTHON" tools/repo/sync-module-package-workflows.py --check
|
||||||
|
"$PYTHON" tools/release/generate-developer-meta-package.py --check
|
||||||
|
"$PYTHON" -m unittest tests.test_module_package_workflows tests.test_package_registry_release
|
||||||
"$PYTHON" -m unittest tests.test_deployment_installer
|
"$PYTHON" -m unittest tests.test_deployment_installer
|
||||||
"$PYTHON" -m unittest tests.test_capability_fit_evidence
|
"$PYTHON" -m unittest tests.test_capability_fit_evidence
|
||||||
"$PYTHON" -m unittest tests.test_configuration_package_artifacts
|
"$PYTHON" -m unittest tests.test_configuration_package_artifacts
|
||||||
|
|||||||
@@ -128,6 +128,13 @@ def main() -> int:
|
|||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
errors.extend(
|
||||||
|
_information_governance_evidence_errors(
|
||||||
|
repository_name=repository_name,
|
||||||
|
repository_root=repository_root,
|
||||||
|
manifest=manifest,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
manifests.append(manifest)
|
manifests.append(manifest)
|
||||||
|
|
||||||
@@ -153,6 +160,20 @@ def main() -> int:
|
|||||||
f"Architecture declaration coverage: {declared}/{len(manifests)} modules "
|
f"Architecture declaration coverage: {declared}/{len(manifests)} modules "
|
||||||
f"({(declared / len(manifests) * 100):.1f}%)."
|
f"({(declared / len(manifests) * 100):.1f}%)."
|
||||||
)
|
)
|
||||||
|
governance_counts: dict[str, int] = {}
|
||||||
|
for manifest in manifests:
|
||||||
|
for dimension in manifest.information_governance.dimensions.values():
|
||||||
|
governance_counts[dimension.adoption] = (
|
||||||
|
governance_counts.get(dimension.adoption, 0) + 1
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Information-governance adoption: "
|
||||||
|
+ ", ".join(
|
||||||
|
f"{status}={count}"
|
||||||
|
for status, count in sorted(governance_counts.items())
|
||||||
|
)
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -201,6 +222,39 @@ def _architecture_evidence_errors(
|
|||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def _information_governance_evidence_errors(
|
||||||
|
*,
|
||||||
|
repository_name: str,
|
||||||
|
repository_root: Path,
|
||||||
|
manifest: object,
|
||||||
|
) -> list[str]:
|
||||||
|
declaration = getattr(manifest, "information_governance", None)
|
||||||
|
if declaration is None:
|
||||||
|
return [
|
||||||
|
f"{repository_name}: module has no information-governance declaration"
|
||||||
|
]
|
||||||
|
errors: list[str] = []
|
||||||
|
for dimension_name, dimension in declaration.dimensions.items():
|
||||||
|
for reference in dimension.evidence:
|
||||||
|
if not _looks_like_repository_reference(reference):
|
||||||
|
continue
|
||||||
|
candidate = (repository_root / reference).resolve()
|
||||||
|
try:
|
||||||
|
candidate.relative_to(repository_root.resolve())
|
||||||
|
except ValueError:
|
||||||
|
errors.append(
|
||||||
|
f"{repository_name}: {dimension_name} evidence escapes the "
|
||||||
|
f"repository: {reference!r}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not candidate.exists():
|
||||||
|
errors.append(
|
||||||
|
f"{repository_name}: {dimension_name} evidence does not exist: "
|
||||||
|
f"{reference!r}"
|
||||||
|
)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_repository_reference(reference: str) -> bool:
|
def _looks_like_repository_reference(reference: str) -> bool:
|
||||||
normalized = reference.strip()
|
normalized = reference.strip()
|
||||||
if not normalized or "://" in normalized:
|
if not normalized or "://" in normalized:
|
||||||
|
|||||||
@@ -182,6 +182,11 @@ run_step "Validate installed module manifests and registry"
|
|||||||
"$PYTHON" "$META_ROOT/tools/checks/release_integration.py" artifacts \
|
"$PYTHON" "$META_ROOT/tools/checks/release_integration.py" artifacts \
|
||||||
--requirements "$META_ROOT/requirements-release.txt"
|
--requirements "$META_ROOT/requirements-release.txt"
|
||||||
|
|
||||||
|
run_step "Validate platform interface and endpoint declarations"
|
||||||
|
"$PYTHON" "$META_ROOT/tools/inventory/platform-interface-inventory.py" \
|
||||||
|
--strict-declarations \
|
||||||
|
--strict-endpoints
|
||||||
|
|
||||||
run_step "Generate release dependency provenance"
|
run_step "Generate release dependency provenance"
|
||||||
"$PYTHON" "$META_ROOT/tools/release/generate-release-sbom.py" \
|
"$PYTHON" "$META_ROOT/tools/release/generate-release-sbom.py" \
|
||||||
--python "$PYTHON" \
|
--python "$PYTHON" \
|
||||||
|
|||||||
@@ -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.",
|
||||||
@@ -135,6 +219,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
kubernetes.add_argument("--namespace", default="govoplan")
|
kubernetes.add_argument("--namespace", default="govoplan")
|
||||||
kubernetes.add_argument("--secret-name", default="govoplan-runtime")
|
kubernetes.add_argument("--secret-name", default="govoplan-runtime")
|
||||||
kubernetes.add_argument("--tls-secret-name", default="govoplan-tls")
|
kubernetes.add_argument("--tls-secret-name", default="govoplan-tls")
|
||||||
|
kubernetes.add_argument(
|
||||||
|
"--s3-ca-secret-name",
|
||||||
|
help=(
|
||||||
|
"Optional Secret containing ca.crt for the external S3 endpoint; "
|
||||||
|
"mounted read-only into backend runtime roles."
|
||||||
|
),
|
||||||
|
)
|
||||||
kubernetes.add_argument("--ingress-class-name")
|
kubernetes.add_argument("--ingress-class-name")
|
||||||
kubernetes.add_argument(
|
kubernetes.add_argument(
|
||||||
"--output",
|
"--output",
|
||||||
@@ -155,7 +246,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
verify_kubernetes.add_argument(
|
verify_kubernetes.add_argument(
|
||||||
"--api-key-env",
|
"--api-key-env",
|
||||||
default="GOVOPLAN_OPS_API_KEY",
|
default="GOVOPLAN_OPS_API_KEY",
|
||||||
help="Environment variable containing an API key with Ops read scope.",
|
help=(
|
||||||
|
"Environment variable containing an API key authorized to read "
|
||||||
|
"Ops status."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
verify_kubernetes.add_argument(
|
verify_kubernetes.add_argument(
|
||||||
"--exercise-api-pod-loss",
|
"--exercise-api-pod-loss",
|
||||||
@@ -268,6 +362,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 +426,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 +440,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 +473,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 +574,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 +602,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 +619,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 +696,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 +726,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,17 +812,354 @@ 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,
|
||||||
namespace=args.namespace,
|
namespace=args.namespace,
|
||||||
secret_name=args.secret_name,
|
secret_name=args.secret_name,
|
||||||
tls_secret_name=args.tls_secret_name,
|
tls_secret_name=args.tls_secret_name,
|
||||||
|
s3_ca_secret_name=args.s3_ca_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)
|
||||||
@@ -649,7 +1181,8 @@ def _verify_kubernetes(args: argparse.Namespace) -> int:
|
|||||||
api_key = str(os.environ.get(args.api_key_env) or "").strip()
|
api_key = str(os.environ.get(args.api_key_env) or "").strip()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{args.api_key_env} must contain an API key with Ops read scope"
|
f"{args.api_key_env} must contain an API key authorized to read "
|
||||||
|
"Ops status"
|
||||||
)
|
)
|
||||||
ops_url = args.ops_url or (spec.public_url.rstrip("/") + "/api/v1/ops/status")
|
ops_url = args.ops_url or (spec.public_url.rstrip("/") + "/api/v1/ops/status")
|
||||||
evidence = collect_kubernetes_evidence(
|
evidence = collect_kubernetes_evidence(
|
||||||
@@ -719,6 +1252,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 +1261,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 +1277,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 +1290,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 +1322,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 +1375,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 +1422,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 +1458,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 +1568,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(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from urllib.request import Request, urlopen
|
|||||||
|
|
||||||
JsonObject = dict[str, Any]
|
JsonObject = dict[str, Any]
|
||||||
CommandRunner = Callable[[Sequence[str]], JsonObject]
|
CommandRunner = Callable[[Sequence[str]], JsonObject]
|
||||||
|
ActionRunner = Callable[[Sequence[str]], None]
|
||||||
JsonFetcher = Callable[[str, str], JsonObject]
|
JsonFetcher = Callable[[str, str], JsonObject]
|
||||||
|
|
||||||
|
|
||||||
@@ -26,11 +27,13 @@ def collect_kubernetes_evidence(
|
|||||||
exercise_api_pod_loss: bool = False,
|
exercise_api_pod_loss: bool = False,
|
||||||
timeout_seconds: float = 180.0,
|
timeout_seconds: float = 180.0,
|
||||||
command_runner: CommandRunner | None = None,
|
command_runner: CommandRunner | None = None,
|
||||||
|
action_runner: ActionRunner | None = None,
|
||||||
json_fetcher: JsonFetcher | None = None,
|
json_fetcher: JsonFetcher | None = None,
|
||||||
) -> JsonObject:
|
) -> JsonObject:
|
||||||
"""Inspect a live cluster and optionally exercise one API pod replacement."""
|
"""Inspect a live cluster and optionally exercise one API pod replacement."""
|
||||||
|
|
||||||
run_json = command_runner or _kubectl_json
|
run_json = command_runner or _kubectl_json
|
||||||
|
run_action = action_runner or _kubectl_action
|
||||||
fetch_json = json_fetcher or _fetch_json
|
fetch_json = json_fetcher or _fetch_json
|
||||||
nodes = run_json(("get", "nodes", "-o", "json"))
|
nodes = run_json(("get", "nodes", "-o", "json"))
|
||||||
pods = run_json(
|
pods = run_json(
|
||||||
@@ -76,6 +79,7 @@ def collect_kubernetes_evidence(
|
|||||||
initial_pods=pods,
|
initial_pods=pods,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
run_json=run_json,
|
run_json=run_json,
|
||||||
|
run_action=run_action,
|
||||||
fetch_json=fetch_json,
|
fetch_json=fetch_json,
|
||||||
)
|
)
|
||||||
evidence = {
|
evidence = {
|
||||||
@@ -211,6 +215,7 @@ def _exercise_api_pod_loss(
|
|||||||
initial_pods: Mapping[str, Any],
|
initial_pods: Mapping[str, Any],
|
||||||
timeout_seconds: float,
|
timeout_seconds: float,
|
||||||
run_json: CommandRunner,
|
run_json: CommandRunner,
|
||||||
|
run_action: ActionRunner,
|
||||||
fetch_json: JsonFetcher,
|
fetch_json: JsonFetcher,
|
||||||
) -> JsonObject:
|
) -> JsonObject:
|
||||||
candidates = [
|
candidates = [
|
||||||
@@ -228,7 +233,7 @@ def _exercise_api_pod_loss(
|
|||||||
victim = sorted(candidates, key=lambda item: item["name"])[0]
|
victim = sorted(candidates, key=lambda item: item["name"])[0]
|
||||||
initial_uids = {item["uid"] for item in candidates}
|
initial_uids = {item["uid"] for item in candidates}
|
||||||
desired_ready = len(candidates)
|
desired_ready = len(candidates)
|
||||||
run_json(
|
run_action(
|
||||||
(
|
(
|
||||||
"-n",
|
"-n",
|
||||||
namespace,
|
namespace,
|
||||||
@@ -236,8 +241,6 @@ def _exercise_api_pod_loss(
|
|||||||
"pod",
|
"pod",
|
||||||
victim["name"],
|
victim["name"],
|
||||||
"--wait=false",
|
"--wait=false",
|
||||||
"-o",
|
|
||||||
"json",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
deadline = time.monotonic() + timeout_seconds
|
deadline = time.monotonic() + timeout_seconds
|
||||||
@@ -309,6 +312,22 @@ def _kubectl_json(arguments: Sequence[str]) -> JsonObject:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _kubectl_action(arguments: Sequence[str]) -> None:
|
||||||
|
kubectl = shutil.which("kubectl")
|
||||||
|
if kubectl is None:
|
||||||
|
raise ValueError("kubectl is required for Kubernetes evidence collection")
|
||||||
|
result = subprocess.run(
|
||||||
|
(kubectl, *arguments),
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
detail = result.stderr.strip() or result.stdout.strip()
|
||||||
|
raise ValueError(f"kubectl failed: {detail}")
|
||||||
|
|
||||||
|
|
||||||
def _fetch_json(url: str, api_key: str) -> JsonObject:
|
def _fetch_json(url: str, api_key: str) -> JsonObject:
|
||||||
request = Request(
|
request = Request(
|
||||||
url,
|
url,
|
||||||
|
|||||||
@@ -0,0 +1,721 @@
|
|||||||
|
"""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",
|
||||||
|
},
|
||||||
|
optional={"package_lock"},
|
||||||
|
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")
|
||||||
|
|
||||||
|
if "package_lock" in payload:
|
||||||
|
package_lock = _object(payload.get("package_lock"), "package_lock")
|
||||||
|
_exact_keys(package_lock, required={"url", "sha256"}, label="package_lock")
|
||||||
|
_https_url(package_lock.get("url"), "package_lock.url")
|
||||||
|
_sha256(package_lock.get("sha256"), "package_lock.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],
|
||||||
|
optional: set[str] | None = None,
|
||||||
|
label: str,
|
||||||
|
) -> None:
|
||||||
|
optional = optional or set()
|
||||||
|
missing = sorted(required - set(value))
|
||||||
|
extra = sorted(set(value) - required - optional)
|
||||||
|
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}$")
|
||||||
|
|
||||||
@@ -74,11 +76,20 @@ def render_kubernetes(
|
|||||||
namespace: str = "govoplan",
|
namespace: str = "govoplan",
|
||||||
secret_name: str = "govoplan-runtime",
|
secret_name: str = "govoplan-runtime",
|
||||||
tls_secret_name: str = "govoplan-tls",
|
tls_secret_name: str = "govoplan-tls",
|
||||||
|
s3_ca_secret_name: str | None = None,
|
||||||
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."""
|
||||||
|
|
||||||
_validate_cluster_profile(spec, environment, namespace, secret_name)
|
_validate_cluster_profile(
|
||||||
|
spec,
|
||||||
|
environment,
|
||||||
|
namespace,
|
||||||
|
secret_name,
|
||||||
|
s3_ca_secret_name=s3_ca_secret_name,
|
||||||
|
)
|
||||||
worker_pools = _worker_pools(spec, environment)
|
worker_pools = _worker_pools(spec, environment)
|
||||||
database_capacity = _database_capacity(spec, environment, worker_pools)
|
database_capacity = _database_capacity(spec, environment, worker_pools)
|
||||||
name = _resource_name(spec.installation_id)
|
name = _resource_name(spec.installation_id)
|
||||||
@@ -151,6 +162,7 @@ def render_kubernetes(
|
|||||||
),
|
),
|
||||||
config_name=config_name,
|
config_name=config_name,
|
||||||
secret_name=secret_name,
|
secret_name=secret_name,
|
||||||
|
s3_ca_secret_name=s3_ca_secret_name,
|
||||||
service_account=service_account,
|
service_account=service_account,
|
||||||
container_port=8000,
|
container_port=8000,
|
||||||
readiness_path="/health/ready",
|
readiness_path="/health/ready",
|
||||||
@@ -175,6 +187,7 @@ def render_kubernetes(
|
|||||||
command=(),
|
command=(),
|
||||||
config_name=None,
|
config_name=None,
|
||||||
secret_name=None,
|
secret_name=None,
|
||||||
|
s3_ca_secret_name=None,
|
||||||
service_account=service_account,
|
service_account=service_account,
|
||||||
container_port=8080,
|
container_port=8080,
|
||||||
extra_environment={"GOVOPLAN_API_UPSTREAM": f"http://{name}-api:8000"},
|
extra_environment={"GOVOPLAN_API_UPSTREAM": f"http://{name}-api:8000"},
|
||||||
@@ -194,11 +207,14 @@ def render_kubernetes(
|
|||||||
image=spec.release.api_image,
|
image=spec.release.api_image,
|
||||||
config_name=config_name,
|
config_name=config_name,
|
||||||
secret_name=secret_name,
|
secret_name=secret_name,
|
||||||
|
s3_ca_secret_name=s3_ca_secret_name,
|
||||||
service_account=service_account,
|
service_account=service_account,
|
||||||
database_environment=_role_database_environment(
|
database_environment=_role_database_environment(
|
||||||
environment,
|
environment,
|
||||||
"MIGRATION",
|
"MIGRATION",
|
||||||
),
|
),
|
||||||
|
backup_required=backup_required,
|
||||||
|
backup_evidence=backup_evidence,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
for pool in worker_pools:
|
for pool in worker_pools:
|
||||||
@@ -231,6 +247,7 @@ def render_kubernetes(
|
|||||||
),
|
),
|
||||||
config_name=config_name,
|
config_name=config_name,
|
||||||
secret_name=secret_name,
|
secret_name=secret_name,
|
||||||
|
s3_ca_secret_name=s3_ca_secret_name,
|
||||||
service_account=service_account,
|
service_account=service_account,
|
||||||
extra_environment={
|
extra_environment={
|
||||||
**_role_database_environment(environment, "WORKER"),
|
**_role_database_environment(environment, "WORKER"),
|
||||||
@@ -281,9 +298,12 @@ def render_kubernetes(
|
|||||||
"beat",
|
"beat",
|
||||||
"--loglevel",
|
"--loglevel",
|
||||||
"INFO",
|
"INFO",
|
||||||
|
"--schedule",
|
||||||
|
"/tmp/celerybeat-schedule",
|
||||||
),
|
),
|
||||||
config_name=config_name,
|
config_name=config_name,
|
||||||
secret_name=secret_name,
|
secret_name=secret_name,
|
||||||
|
s3_ca_secret_name=s3_ca_secret_name,
|
||||||
service_account=service_account,
|
service_account=service_account,
|
||||||
extra_environment=_role_database_environment(
|
extra_environment=_role_database_environment(
|
||||||
environment,
|
environment,
|
||||||
@@ -312,6 +332,7 @@ def render_kubernetes(
|
|||||||
"annotations": {
|
"annotations": {
|
||||||
"govoplan.add-ideas.de/profile": "stateless-shared-state",
|
"govoplan.add-ideas.de/profile": "stateless-shared-state",
|
||||||
"govoplan.add-ideas.de/secret-contract": ",".join(_SECRET_KEYS),
|
"govoplan.add-ideas.de/secret-contract": ",".join(_SECRET_KEYS),
|
||||||
|
"govoplan.add-ideas.de/s3-ca-secret": s3_ca_secret_name or "",
|
||||||
"govoplan.add-ideas.de/database-connection-peak": str(
|
"govoplan.add-ideas.de/database-connection-peak": str(
|
||||||
database_capacity["peak"]
|
database_capacity["peak"]
|
||||||
),
|
),
|
||||||
@@ -336,9 +357,13 @@ def _validate_cluster_profile(
|
|||||||
environment: Mapping[str, str],
|
environment: Mapping[str, str],
|
||||||
namespace: str,
|
namespace: str,
|
||||||
secret_name: str,
|
secret_name: str,
|
||||||
|
*,
|
||||||
|
s3_ca_secret_name: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not _DNS_LABEL.fullmatch(namespace) or not _DNS_LABEL.fullmatch(secret_name):
|
if not _DNS_LABEL.fullmatch(namespace) or not _DNS_LABEL.fullmatch(secret_name):
|
||||||
raise ValueError("Kubernetes namespace and secret names must be DNS labels")
|
raise ValueError("Kubernetes namespace and secret names must be DNS labels")
|
||||||
|
if s3_ca_secret_name is not None and not _DNS_LABEL.fullmatch(s3_ca_secret_name):
|
||||||
|
raise ValueError("Kubernetes S3 CA secret name must be a DNS label")
|
||||||
if spec.installation_id == "govoplan-local":
|
if spec.installation_id == "govoplan-local":
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Kubernetes export requires a non-default stable installation id"
|
"Kubernetes export requires a non-default stable installation id"
|
||||||
@@ -627,6 +652,7 @@ def _deployment(
|
|||||||
command: tuple[str, ...],
|
command: tuple[str, ...],
|
||||||
config_name: str | None,
|
config_name: str | None,
|
||||||
secret_name: str | None,
|
secret_name: str | None,
|
||||||
|
s3_ca_secret_name: str | None,
|
||||||
service_account: str,
|
service_account: str,
|
||||||
container_port: int | None = None,
|
container_port: int | None = None,
|
||||||
readiness_path: str | None = None,
|
readiness_path: str | None = None,
|
||||||
@@ -654,6 +680,10 @@ def _deployment(
|
|||||||
)
|
)
|
||||||
if secret_name:
|
if secret_name:
|
||||||
environment.extend(_secret_environment(secret_name))
|
environment.extend(_secret_environment(secret_name))
|
||||||
|
if s3_ca_secret_name:
|
||||||
|
environment.append(
|
||||||
|
{"name": "AWS_CA_BUNDLE", "value": "/etc/govoplan/trust/s3-ca.crt"}
|
||||||
|
)
|
||||||
container: dict[str, Any] = {
|
container: dict[str, Any] = {
|
||||||
"name": role,
|
"name": role,
|
||||||
"image": image,
|
"image": image,
|
||||||
@@ -698,12 +728,16 @@ def _deployment(
|
|||||||
{
|
{
|
||||||
"maxSkew": 1,
|
"maxSkew": 1,
|
||||||
"topologyKey": "kubernetes.io/hostname",
|
"topologyKey": "kubernetes.io/hostname",
|
||||||
"whenUnsatisfiable": "ScheduleAnyway",
|
"whenUnsatisfiable": "DoNotSchedule",
|
||||||
|
"matchLabelKeys": ["pod-template-hash"],
|
||||||
"labelSelector": {"matchLabels": role_labels},
|
"labelSelector": {"matchLabels": role_labels},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
container["volumeMounts"] = [{"name": "tmp", "mountPath": "/tmp"}]
|
container["volumeMounts"] = [{"name": "tmp", "mountPath": "/tmp"}]
|
||||||
|
if s3_ca_secret_name:
|
||||||
|
pod_spec["volumes"].append(_s3_ca_volume(s3_ca_secret_name))
|
||||||
|
container["volumeMounts"].append(_s3_ca_volume_mount())
|
||||||
if config_name:
|
if config_name:
|
||||||
pod_spec["containers"][0]["envFrom"] = [{"configMapRef": {"name": config_name}}]
|
pod_spec["containers"][0]["envFrom"] = [{"configMapRef": {"name": config_name}}]
|
||||||
if config_name and secret_name:
|
if config_name and secret_name:
|
||||||
@@ -725,6 +759,16 @@ def _deployment(
|
|||||||
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": "migration-wait"},
|
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": "migration-wait"},
|
||||||
{"name": "GOVOPLAN_DB_POOL_SIZE", "value": "1"},
|
{"name": "GOVOPLAN_DB_POOL_SIZE", "value": "1"},
|
||||||
{"name": "GOVOPLAN_DB_MAX_OVERFLOW", "value": "0"},
|
{"name": "GOVOPLAN_DB_MAX_OVERFLOW", "value": "0"},
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "AWS_CA_BUNDLE",
|
||||||
|
"value": "/etc/govoplan/trust/s3-ca.crt",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if s3_ca_secret_name
|
||||||
|
else []
|
||||||
|
),
|
||||||
*_secret_environment(secret_name),
|
*_secret_environment(secret_name),
|
||||||
],
|
],
|
||||||
"securityContext": {
|
"securityContext": {
|
||||||
@@ -732,7 +776,10 @@ def _deployment(
|
|||||||
"capabilities": {"drop": ["ALL"]},
|
"capabilities": {"drop": ["ALL"]},
|
||||||
"readOnlyRootFilesystem": True,
|
"readOnlyRootFilesystem": True,
|
||||||
},
|
},
|
||||||
"volumeMounts": [{"name": "tmp", "mountPath": "/tmp"}],
|
"volumeMounts": [
|
||||||
|
{"name": "tmp", "mountPath": "/tmp"},
|
||||||
|
*([_s3_ca_volume_mount()] if s3_ca_secret_name else []),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
@@ -763,11 +810,31 @@ def _migration_job(
|
|||||||
image: str,
|
image: str,
|
||||||
config_name: str,
|
config_name: str,
|
||||||
secret_name: str,
|
secret_name: str,
|
||||||
|
s3_ca_secret_name: str | None,
|
||||||
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 +844,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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -807,6 +874,16 @@ def _migration_job(
|
|||||||
"env": [
|
"env": [
|
||||||
{"name": "TMPDIR", "value": "/tmp"},
|
{"name": "TMPDIR", "value": "/tmp"},
|
||||||
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": "migration"},
|
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": "migration"},
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "AWS_CA_BUNDLE",
|
||||||
|
"value": "/etc/govoplan/trust/s3-ca.crt",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if s3_ca_secret_name
|
||||||
|
else []
|
||||||
|
),
|
||||||
*(
|
*(
|
||||||
{"name": key, "value": value}
|
{"name": key, "value": value}
|
||||||
for key, value in sorted(
|
for key, value in sorted(
|
||||||
@@ -826,10 +903,24 @@ def _migration_job(
|
|||||||
"capabilities": {"drop": ["ALL"]},
|
"capabilities": {"drop": ["ALL"]},
|
||||||
"readOnlyRootFilesystem": True,
|
"readOnlyRootFilesystem": True,
|
||||||
},
|
},
|
||||||
"volumeMounts": [{"name": "tmp", "mountPath": "/tmp"}],
|
"volumeMounts": [
|
||||||
|
{"name": "tmp", "mountPath": "/tmp"},
|
||||||
|
*(
|
||||||
|
[_s3_ca_volume_mount()]
|
||||||
|
if s3_ca_secret_name
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"volumes": [{"name": "tmp", "emptyDir": {}}],
|
"volumes": [
|
||||||
|
{"name": "tmp", "emptyDir": {}},
|
||||||
|
*(
|
||||||
|
[_s3_ca_volume(s3_ca_secret_name)]
|
||||||
|
if s3_ca_secret_name
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -848,6 +939,24 @@ def _secret_environment(secret_name: str) -> list[dict[str, Any]]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _s3_ca_volume(secret_name: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": "s3-ca",
|
||||||
|
"secret": {
|
||||||
|
"secretName": secret_name,
|
||||||
|
"items": [{"key": "ca.crt", "path": "s3-ca.crt"}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _s3_ca_volume_mount() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": "s3-ca",
|
||||||
|
"mountPath": "/etc/govoplan/trust",
|
||||||
|
"readOnly": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _service(
|
def _service(
|
||||||
*,
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Configure and verify protected GovOPlaN package-release boundaries."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from gitea_common import (
|
||||||
|
GiteaClient,
|
||||||
|
GiteaError,
|
||||||
|
RepoTarget,
|
||||||
|
load_dotenv,
|
||||||
|
org_path,
|
||||||
|
quote_path,
|
||||||
|
repo_path,
|
||||||
|
require_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
REQUIRED_SECRETS = {"GOVOPLAN_PACKAGE_USERNAME", "GOVOPLAN_PACKAGE_TOKEN"}
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--url", default="https://git.add-ideas.de")
|
||||||
|
parser.add_argument("--owner", default="GovOPlaN")
|
||||||
|
parser.add_argument("--team", default="Owners")
|
||||||
|
parser.add_argument("--pattern", default="v*")
|
||||||
|
parser.add_argument("--env-file", type=Path)
|
||||||
|
parser.add_argument("--apply", action="store_true")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def package_repositories() -> tuple[str, ...]:
|
||||||
|
inventory = json.loads((META_ROOT / "repositories.json").read_text(encoding="utf-8"))
|
||||||
|
values = ["govoplan"]
|
||||||
|
for item in inventory["repositories"]:
|
||||||
|
name = str(item["name"])
|
||||||
|
repository = META_ROOT.parent / str(item["path"])
|
||||||
|
if name.startswith("govoplan-") and (repository / "pyproject.toml").is_file():
|
||||||
|
values.append(name)
|
||||||
|
return tuple(sorted(set(values)))
|
||||||
|
|
||||||
|
|
||||||
|
def configure(
|
||||||
|
client: GiteaClient,
|
||||||
|
*,
|
||||||
|
owner: str,
|
||||||
|
team: str,
|
||||||
|
pattern: str,
|
||||||
|
apply: bool,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
missing: list[str] = []
|
||||||
|
expected = {
|
||||||
|
"name_pattern": pattern,
|
||||||
|
"whitelist_teams": [team],
|
||||||
|
"whitelist_usernames": [],
|
||||||
|
}
|
||||||
|
for repository in package_repositories():
|
||||||
|
path = repo_path(owner, repository, "/tag_protections")
|
||||||
|
protections = client.request_json("GET", path)
|
||||||
|
matching = [
|
||||||
|
item
|
||||||
|
for item in protections
|
||||||
|
if isinstance(item, dict) and item.get("name_pattern") == pattern
|
||||||
|
]
|
||||||
|
if len(matching) == 1 and _matches(matching[0], expected):
|
||||||
|
print(f"protected {repository}:{pattern}")
|
||||||
|
continue
|
||||||
|
missing.append(repository)
|
||||||
|
if not apply:
|
||||||
|
print(f"would protect {repository}:{pattern}")
|
||||||
|
continue
|
||||||
|
if len(matching) == 1:
|
||||||
|
protection_id = matching[0].get("id")
|
||||||
|
client.request_json(
|
||||||
|
"PATCH",
|
||||||
|
f"{path}/{quote_path(str(protection_id))}",
|
||||||
|
body=expected,
|
||||||
|
)
|
||||||
|
print(f"updated {repository}:{pattern}")
|
||||||
|
elif not matching:
|
||||||
|
client.request_json("POST", path, body=expected)
|
||||||
|
print(f"created {repository}:{pattern}")
|
||||||
|
else:
|
||||||
|
raise GiteaError(f"{repository} has duplicate {pattern!r} tag protections")
|
||||||
|
return tuple(missing)
|
||||||
|
|
||||||
|
|
||||||
|
def _matches(value: dict[str, object], expected: dict[str, object]) -> bool:
|
||||||
|
return (
|
||||||
|
value.get("name_pattern") == expected["name_pattern"]
|
||||||
|
and sorted(value.get("whitelist_teams") or []) == expected["whitelist_teams"]
|
||||||
|
and sorted(value.get("whitelist_usernames") or []) == expected["whitelist_usernames"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
try:
|
||||||
|
load_dotenv(args.env_file)
|
||||||
|
token = require_token()
|
||||||
|
target = RepoTarget(base_url=args.url, owner=args.owner, repo="govoplan")
|
||||||
|
with GiteaClient(target, token) as client:
|
||||||
|
mismatches = configure(
|
||||||
|
client,
|
||||||
|
owner=args.owner,
|
||||||
|
team=args.team,
|
||||||
|
pattern=args.pattern,
|
||||||
|
apply=args.apply,
|
||||||
|
)
|
||||||
|
secrets = client.request_json(
|
||||||
|
"GET", org_path(args.owner, "/actions/secrets"), query={"limit": 50}
|
||||||
|
)
|
||||||
|
names = {
|
||||||
|
str(item.get("name") or "")
|
||||||
|
for item in secrets
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
|
missing_secrets = sorted(REQUIRED_SECRETS - names)
|
||||||
|
if missing_secrets:
|
||||||
|
print(
|
||||||
|
"Missing organization Actions secrets: " + ", ".join(missing_secrets),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
unresolved = (bool(mismatches) and not args.apply) or bool(missing_secrets)
|
||||||
|
if unresolved:
|
||||||
|
return 1
|
||||||
|
print("Package release protection and credential names are configured.")
|
||||||
|
return 0
|
||||||
|
except (GiteaError, OSError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Dispatch protected package releases required by the govoplan meta-package."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from gitea_common import (
|
||||||
|
GiteaClient,
|
||||||
|
GiteaError,
|
||||||
|
RepoTarget,
|
||||||
|
load_dotenv,
|
||||||
|
org_path,
|
||||||
|
quote_path,
|
||||||
|
repo_path,
|
||||||
|
require_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
META_PROJECT = META_ROOT / "packages" / "govoplan-meta" / "pyproject.toml"
|
||||||
|
WORKFLOW_ID = "module-package-release.yml"
|
||||||
|
EXACT_REQUIREMENT = re.compile(
|
||||||
|
r"^(?P<name>govoplan-[a-z0-9-]+)(?:\[[a-z0-9_,.-]+\])?==(?P<version>[0-9]+\.[0-9]+\.[0-9]+)$"
|
||||||
|
)
|
||||||
|
ACTIVE_STATES = {"queued", "waiting", "in_progress", "running"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PackageTarget:
|
||||||
|
distribution: str
|
||||||
|
version: str
|
||||||
|
repository: str
|
||||||
|
tag_exists: bool
|
||||||
|
has_webui: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tag(self) -> str:
|
||||||
|
return f"v{self.version}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def webui_package(self) -> str | None:
|
||||||
|
if not self.has_webui:
|
||||||
|
return None
|
||||||
|
return f"@govoplan/{self.distribution.removeprefix('govoplan-')}-webui"
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--url", default="https://git.add-ideas.de")
|
||||||
|
parser.add_argument("--owner", default="GovOPlaN")
|
||||||
|
parser.add_argument("--env-file", type=Path)
|
||||||
|
parser.add_argument(
|
||||||
|
"--repository",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Limit dispatch to one repository; repeat as needed.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verify-existing",
|
||||||
|
action="store_true",
|
||||||
|
help="Also rerun exact versions already present in both registries.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--apply", action="store_true")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def package_targets(project_path: Path = META_PROJECT) -> tuple[PackageTarget, ...]:
|
||||||
|
project = tomllib.loads(project_path.read_text(encoding="utf-8"))["project"]
|
||||||
|
requirements = list(project.get("dependencies") or [])
|
||||||
|
requirements.extend(project.get("optional-dependencies", {}).get("full") or [])
|
||||||
|
parsed: dict[str, str] = {}
|
||||||
|
for requirement in requirements:
|
||||||
|
match = EXACT_REQUIREMENT.fullmatch(str(requirement))
|
||||||
|
if match is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Meta-package requirement is not an exact GovOPlaN version: {requirement!r}"
|
||||||
|
)
|
||||||
|
name = match.group("name")
|
||||||
|
version = match.group("version")
|
||||||
|
previous = parsed.setdefault(name, version)
|
||||||
|
if previous != version:
|
||||||
|
raise ValueError(f"Meta-package selects conflicting versions for {name}")
|
||||||
|
|
||||||
|
targets: list[PackageTarget] = []
|
||||||
|
for distribution, version in sorted(parsed.items()):
|
||||||
|
repository = distribution
|
||||||
|
repository_root = META_ROOT.parent / repository
|
||||||
|
if not (repository_root / ".git").is_dir():
|
||||||
|
raise ValueError(f"Package repository is not checked out: {repository}")
|
||||||
|
tag = f"v{version}"
|
||||||
|
tag_exists = _tag_exists(repository_root, tag)
|
||||||
|
has_webui = (
|
||||||
|
_tag_has_path(repository_root, tag, "webui/package.json")
|
||||||
|
if tag_exists
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
targets.append(
|
||||||
|
PackageTarget(
|
||||||
|
distribution=distribution,
|
||||||
|
version=version,
|
||||||
|
repository=repository,
|
||||||
|
tag_exists=tag_exists,
|
||||||
|
has_webui=has_webui,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(targets)
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_exists(repository: Path, tag: str) -> bool:
|
||||||
|
result = subprocess.run(
|
||||||
|
(
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
str(repository),
|
||||||
|
"rev-parse",
|
||||||
|
"--verify",
|
||||||
|
"--quiet",
|
||||||
|
f"refs/tags/{tag}",
|
||||||
|
),
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode not in {0, 1}:
|
||||||
|
raise ValueError(
|
||||||
|
f"Could not inspect {repository.name}:{tag}: {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_has_path(repository: Path, tag: str, path: str) -> bool:
|
||||||
|
result = subprocess.run(
|
||||||
|
("git", "-C", str(repository), "cat-file", "-e", f"{tag}:{path}"),
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode not in {0, 128}:
|
||||||
|
raise ValueError(
|
||||||
|
f"Could not inspect {repository.name}:{tag}:{path}: {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _published_packages(
|
||||||
|
client: GiteaClient, *, owner: str, package_type: str
|
||||||
|
) -> set[tuple[str, str]]:
|
||||||
|
values = client.paginate(
|
||||||
|
f"/packages/{quote_path(owner)}",
|
||||||
|
query={"type": package_type, "q": "govoplan"},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
(str(item.get("name") or ""), str(item.get("version") or ""))
|
||||||
|
for item in values
|
||||||
|
if item.get("type") == package_type
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_active_run(
|
||||||
|
client: GiteaClient, *, owner: str, repository: str
|
||||||
|
) -> bool:
|
||||||
|
payload = client.request_json(
|
||||||
|
"GET",
|
||||||
|
repo_path(
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
f"/actions/workflows/{quote_path(WORKFLOW_ID)}/runs",
|
||||||
|
),
|
||||||
|
query={"limit": 10},
|
||||||
|
)
|
||||||
|
runs = payload.get("workflow_runs") if isinstance(payload, dict) else None
|
||||||
|
return isinstance(runs, list) and any(
|
||||||
|
isinstance(run, dict) and str(run.get("status") or "") in ACTIVE_STATES
|
||||||
|
for run in runs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(
|
||||||
|
client: GiteaClient,
|
||||||
|
*,
|
||||||
|
owner: str,
|
||||||
|
targets: tuple[PackageTarget, ...],
|
||||||
|
published_pypi: set[tuple[str, str]],
|
||||||
|
published_npm: set[tuple[str, str]],
|
||||||
|
verify_existing: bool,
|
||||||
|
apply: bool,
|
||||||
|
) -> tuple[int, int, int]:
|
||||||
|
dispatched = 0
|
||||||
|
active = 0
|
||||||
|
complete = 0
|
||||||
|
for target in targets:
|
||||||
|
wheel_exists = (target.distribution, target.version) in published_pypi
|
||||||
|
npm_exists = target.webui_package is None or (
|
||||||
|
target.webui_package,
|
||||||
|
target.version,
|
||||||
|
) in published_npm
|
||||||
|
if wheel_exists and npm_exists and not verify_existing:
|
||||||
|
complete += 1
|
||||||
|
print(f"complete {target.repository}:{target.tag}")
|
||||||
|
continue
|
||||||
|
if _has_active_run(client, owner=owner, repository=target.repository):
|
||||||
|
active += 1
|
||||||
|
print(f"active {target.repository}:{target.tag}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
action = "dispatching" if apply else "would dispatch"
|
||||||
|
print(
|
||||||
|
f"{action} {target.repository}:{target.tag} "
|
||||||
|
f"(wheel={'present' if wheel_exists else 'missing'}, "
|
||||||
|
f"webui={'present' if npm_exists else 'missing'})"
|
||||||
|
)
|
||||||
|
if apply:
|
||||||
|
client.request_json(
|
||||||
|
"POST",
|
||||||
|
repo_path(
|
||||||
|
owner,
|
||||||
|
target.repository,
|
||||||
|
f"/actions/workflows/{quote_path(WORKFLOW_ID)}/dispatches",
|
||||||
|
),
|
||||||
|
body={"ref": "main", "inputs": {"release_tag": target.tag}},
|
||||||
|
)
|
||||||
|
dispatched += 1
|
||||||
|
return dispatched, active, complete
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
try:
|
||||||
|
load_dotenv(args.env_file)
|
||||||
|
token = require_token()
|
||||||
|
targets = package_targets()
|
||||||
|
selected = set(args.repository)
|
||||||
|
if selected:
|
||||||
|
known = {target.repository for target in targets}
|
||||||
|
unknown = sorted(selected - known)
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
"Unknown meta-package repositories: " + ", ".join(unknown)
|
||||||
|
)
|
||||||
|
targets = tuple(
|
||||||
|
target for target in targets if target.repository in selected
|
||||||
|
)
|
||||||
|
missing_tags = [
|
||||||
|
f"{target.repository}:{target.tag}"
|
||||||
|
for target in targets
|
||||||
|
if not target.tag_exists
|
||||||
|
]
|
||||||
|
if missing_tags:
|
||||||
|
raise ValueError(
|
||||||
|
"Meta-package release tags are missing: " + ", ".join(missing_tags)
|
||||||
|
)
|
||||||
|
target = RepoTarget(base_url=args.url, owner=args.owner, repo="govoplan")
|
||||||
|
with GiteaClient(target, token) as client:
|
||||||
|
secrets = client.request_json(
|
||||||
|
"GET", org_path(args.owner, "/actions/secrets"), query={"limit": 50}
|
||||||
|
)
|
||||||
|
secret_names = {
|
||||||
|
str(item.get("name") or "")
|
||||||
|
for item in secrets
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
|
required = {"GOVOPLAN_PACKAGE_USERNAME", "GOVOPLAN_PACKAGE_TOKEN"}
|
||||||
|
if not required <= secret_names:
|
||||||
|
raise ValueError(
|
||||||
|
"Organization package publisher secrets are not configured"
|
||||||
|
)
|
||||||
|
published_pypi = _published_packages(
|
||||||
|
client, owner=args.owner, package_type="pypi"
|
||||||
|
)
|
||||||
|
published_npm = _published_packages(
|
||||||
|
client, owner=args.owner, package_type="npm"
|
||||||
|
)
|
||||||
|
counts = dispatch(
|
||||||
|
client,
|
||||||
|
owner=args.owner,
|
||||||
|
targets=targets,
|
||||||
|
published_pypi=published_pypi,
|
||||||
|
published_npm=published_npm,
|
||||||
|
verify_existing=args.verify_existing,
|
||||||
|
apply=args.apply,
|
||||||
|
)
|
||||||
|
action = "dispatched" if args.apply else "planned"
|
||||||
|
print(
|
||||||
|
f"Package set {action}: {counts[0]}; active: {counts[1]}; "
|
||||||
|
f"already complete: {counts[2]}."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
except (GiteaError, OSError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -155,44 +155,39 @@
|
|||||||
"repository": "govoplan-access"
|
"repository": "govoplan-access"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/admin/service-accounts",
|
"path": "/admin/service-accounts",
|
||||||
"rationale": "Service-account lifecycle is implemented but its administration UI is tracked separately.",
|
"rationale": "Access administration lists service accounts and opens their lifecycle and credential manager.",
|
||||||
"repository": "govoplan-access",
|
"repository": "govoplan-access"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-access/issues/18"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/admin/service-accounts",
|
"path": "/admin/service-accounts",
|
||||||
"rationale": "Service-account lifecycle is implemented but its administration UI is tracked separately.",
|
"rationale": "Access administration creates service accounts through the governed editor.",
|
||||||
"repository": "govoplan-access",
|
"repository": "govoplan-access"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-access/issues/18"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/admin/service-accounts/{}",
|
"path": "/admin/service-accounts/{}",
|
||||||
"rationale": "Service-account lifecycle is implemented but its administration UI is tracked separately.",
|
"rationale": "Access administration refreshes service-account state before governed mutations.",
|
||||||
"repository": "govoplan-access",
|
"repository": "govoplan-access"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-access/issues/18"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "PATCH",
|
"method": "PATCH",
|
||||||
"path": "/admin/service-accounts/{}",
|
"path": "/admin/service-accounts/{}",
|
||||||
"rationale": "Service-account lifecycle is implemented but its administration UI is tracked separately.",
|
"rationale": "Access administration edits, activates, and deactivates service accounts with revision checks.",
|
||||||
"repository": "govoplan-access",
|
"repository": "govoplan-access"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-access/issues/18"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/admin/service-accounts/{}/retire",
|
"path": "/admin/service-accounts/{}/retire",
|
||||||
"rationale": "Service-account lifecycle is implemented but its administration UI is tracked separately.",
|
"rationale": "Access administration exposes separately confirmed retirement and credential revocation.",
|
||||||
"repository": "govoplan-access",
|
"repository": "govoplan-access"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-access/issues/18"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "intentionally_headless",
|
"category": "intentionally_headless",
|
||||||
@@ -237,44 +232,39 @@
|
|||||||
"repository": "govoplan-admin"
|
"repository": "govoplan-admin"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/approvals/templates",
|
"path": "/approvals/templates",
|
||||||
"rationale": "Approval-template and escalation administration UI is tracked separately.",
|
"rationale": "Approval administration lists immutable template revisions.",
|
||||||
"repository": "govoplan-approvals",
|
"repository": "govoplan-approvals"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-approvals/issues/2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/approvals/templates",
|
"path": "/approvals/templates",
|
||||||
"rationale": "Approval-template and escalation administration UI is tracked separately.",
|
"rationale": "Approval administration creates validated draft templates.",
|
||||||
"repository": "govoplan-approvals",
|
"repository": "govoplan-approvals"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-approvals/issues/2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/approvals/templates/{}",
|
"path": "/approvals/templates/{}",
|
||||||
"rationale": "Approval-template and escalation administration UI is tracked separately.",
|
"rationale": "Approval administration revises templates through optimistic immutable revisions.",
|
||||||
"repository": "govoplan-approvals",
|
"repository": "govoplan-approvals"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-approvals/issues/2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/approvals/templates/{}/publish",
|
"path": "/approvals/templates/{}/publish",
|
||||||
"rationale": "Approval-template and escalation administration UI is tracked separately.",
|
"rationale": "Approval administration publishes a draft only after an explicit confirmation.",
|
||||||
"repository": "govoplan-approvals",
|
"repository": "govoplan-approvals"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-approvals/issues/2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/approvals/{}/escalate",
|
"path": "/approvals/{}/escalate",
|
||||||
"rationale": "Approval-template and escalation administration UI is tracked separately.",
|
"rationale": "The request dialog exposes escalation only for due pending requests and authorized administrators.",
|
||||||
"repository": "govoplan-approvals",
|
"repository": "govoplan-approvals"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-approvals/issues/2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "ui_reachable",
|
"category": "ui_reachable",
|
||||||
@@ -305,12 +295,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 +309,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",
|
||||||
@@ -357,6 +343,20 @@
|
|||||||
"rationale": "Published integration, interoperability, public-participant, or health endpoint.",
|
"rationale": "Published integration, interoperability, public-participant, or health endpoint.",
|
||||||
"repository": "govoplan-calendar"
|
"repository": "govoplan-calendar"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"category": "intentionally_headless",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/bootstrap/first-admin",
|
||||||
|
"rationale": "The restricted first-run endpoint is consumed by the local bootstrap handoff and can only create the first durable administrator.",
|
||||||
|
"repository": "govoplan-core"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "intentionally_headless",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/bootstrap/status",
|
||||||
|
"rationale": "The local bootstrap handoff reads only minimum first-run readiness before a normal authenticated shell exists.",
|
||||||
|
"repository": "govoplan-core"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"category": "public_integration",
|
"category": "public_integration",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
@@ -372,12 +372,11 @@
|
|||||||
"repository": "govoplan-calendar"
|
"repository": "govoplan-calendar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/campaigns/{}/archive",
|
"path": "/campaigns/{}/archive",
|
||||||
"rationale": "Campaign archive lifecycle UI remains tracked by the archive policy issue.",
|
"rationale": "The Campaign overview exposes the governed archive action with permission checks and an evidence-retention confirmation.",
|
||||||
"repository": "govoplan-campaign",
|
"repository": "govoplan-campaign"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/26"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "intentionally_headless",
|
"category": "intentionally_headless",
|
||||||
@@ -393,6 +392,13 @@
|
|||||||
"rationale": "Campaign delivery diagnostic/status API is retained for bounded support and automation consumers.",
|
"rationale": "Campaign delivery diagnostic/status API is retained for bounded support and automation consumers.",
|
||||||
"repository": "govoplan-campaign"
|
"repository": "govoplan-campaign"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"category": "worker_internal",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/campaigns/operations/artifacts/reconcile",
|
||||||
|
"rationale": "Privileged, bounded artifact recovery operation used by operators and recovery automation rather than an end-user surface.",
|
||||||
|
"repository": "govoplan-campaign"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"category": "ui_reachable",
|
"category": "ui_reachable",
|
||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
@@ -575,6 +581,13 @@
|
|||||||
"rationale": "The shared ownership UI uses a dynamic transfer-action path that the static string scan cannot resolve.",
|
"rationale": "The shared ownership UI uses a dynamic transfer-action path that the static string scan cannot resolve.",
|
||||||
"repository": "govoplan-core"
|
"repository": "govoplan-core"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"category": "intentionally_headless",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/platform/interface-catalog",
|
||||||
|
"rationale": "Authorized module and system administrators consume the read-only control-plane inventory directly or through Ops/Docs projections.",
|
||||||
|
"repository": "govoplan-core"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"category": "compatibility",
|
"category": "compatibility",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
@@ -660,156 +673,172 @@
|
|||||||
"repository": "govoplan-docs"
|
"repository": "govoplan-docs"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/encryption/disable-preflight",
|
"path": "/encryption/disable-preflight",
|
||||||
"rationale": "Encryption custodian, lifecycle, and recovery administration UI is tracked separately.",
|
"rationale": "The Encryption administration panel displays disable readiness and bounded blocking envelope references.",
|
||||||
"repository": "govoplan-encryption",
|
"repository": "govoplan-encryption"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "intentionally_headless",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/encryption/envelopes",
|
"path": "/encryption/envelopes",
|
||||||
"rationale": "Encryption custodian, lifecycle, and recovery administration UI is tracked separately.",
|
"rationale": "Feature modules register envelopes while retaining content ownership; the operator UI must not construct feature content envelopes.",
|
||||||
"repository": "govoplan-encryption",
|
"repository": "govoplan-encryption"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/encryption/envelopes",
|
||||||
|
"rationale": "The Encryption administration panel consumes the bounded, secret-free envelope summary contract.",
|
||||||
|
"repository": "govoplan-encryption"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "intentionally_headless",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/encryption/envelopes/{}",
|
"path": "/encryption/envelopes/{}",
|
||||||
"rationale": "Encryption custodian, lifecycle, and recovery administration UI is tracked separately.",
|
"rationale": "Owning modules resolve a specific full envelope through the capability/API contract; the operator UI uses the secret-free summary projection.",
|
||||||
"repository": "govoplan-encryption",
|
"repository": "govoplan-encryption"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/encryption/migrations",
|
||||||
|
"rationale": "The Encryption administration panel displays bounded migration state and evidence counts.",
|
||||||
|
"repository": "govoplan-encryption"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/encryption/migrations",
|
"path": "/encryption/migrations",
|
||||||
"rationale": "Encryption custodian, lifecycle, and recovery administration UI is tracked separately.",
|
"rationale": "Encryption custodians can authorize a two-phase migration from a bounded envelope summary.",
|
||||||
"repository": "govoplan-encryption",
|
"repository": "govoplan-encryption"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "intentionally_headless",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/encryption/migrations/{}/outcome",
|
"path": "/encryption/migrations/{}/outcome",
|
||||||
"rationale": "Encryption custodian, lifecycle, and recovery administration UI is tracked separately.",
|
"rationale": "Only the owning module or governed worker can attest the durable content outcome and exact target envelope.",
|
||||||
"repository": "govoplan-encryption",
|
"repository": "govoplan-encryption"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/encryption/migrations/{}/reconcile",
|
||||||
|
"rationale": "Encryption custodians can re-read recorded provider migration state without declaring an outcome.",
|
||||||
|
"repository": "govoplan-encryption"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "ui_reachable",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/encryption/recoveries",
|
||||||
|
"rationale": "The Encryption administration panel displays bounded recovery requests, quorum, expiry, and state.",
|
||||||
|
"repository": "govoplan-encryption"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/encryption/recoveries",
|
"path": "/encryption/recoveries",
|
||||||
"rationale": "Encryption custodian, lifecycle, and recovery administration UI is tracked separately.",
|
"rationale": "Authorized custodians can request an expiring high-assurance recovery ceremony.",
|
||||||
"repository": "govoplan-encryption",
|
"repository": "govoplan-encryption"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/encryption/recoveries/{}/decision",
|
"path": "/encryption/recoveries/{}/decision",
|
||||||
"rationale": "Encryption custodian, lifecycle, and recovery administration UI is tracked separately.",
|
"rationale": "The recovery table exposes explicit approve/reject decisions with assurance, reason, and optimistic revision.",
|
||||||
"repository": "govoplan-encryption",
|
"repository": "govoplan-encryption"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/encryption/vaults",
|
||||||
|
"rationale": "The Encryption administration panel consumes the bounded, secret-free vault summary contract.",
|
||||||
|
"repository": "govoplan-encryption"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/encryption/vaults",
|
"path": "/encryption/vaults",
|
||||||
"rationale": "Encryption custodian, lifecycle, and recovery administration UI is tracked separately.",
|
"rationale": "Encryption custodians can create a governed vault without entering or receiving raw key material.",
|
||||||
"repository": "govoplan-encryption",
|
"repository": "govoplan-encryption"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "intentionally_headless",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/encryption/vaults/{}",
|
"path": "/encryption/vaults/{}",
|
||||||
"rationale": "Encryption custodian, lifecycle, and recovery administration UI is tracked separately.",
|
"rationale": "Capability consumers may resolve the complete vault reference; the operator UI uses the secret-free summary projection.",
|
||||||
"repository": "govoplan-encryption",
|
"repository": "govoplan-encryption"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/encryption/vaults/{}/destruction",
|
"path": "/encryption/vaults/{}/destruction",
|
||||||
"rationale": "Encryption custodian, lifecycle, and recovery administration UI is tracked separately.",
|
"rationale": "The vault action group schedules destructive key lifecycle operations with explicit consequences.",
|
||||||
"repository": "govoplan-encryption",
|
"repository": "govoplan-encryption"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/encryption/vaults/{}/reconcile",
|
"path": "/encryption/vaults/{}/reconcile",
|
||||||
"rationale": "Encryption custodian, lifecycle, and recovery administration UI is tracked separately.",
|
"rationale": "The vault action group reconciles an outcome-unknown provider operation.",
|
||||||
"repository": "govoplan-encryption",
|
"repository": "govoplan-encryption"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/encryption/vaults/{}/revoke",
|
"path": "/encryption/vaults/{}/revoke",
|
||||||
"rationale": "Encryption custodian, lifecycle, and recovery administration UI is tracked separately.",
|
"rationale": "The vault action group revokes the current key with policy, assurance, reason, and revision evidence.",
|
||||||
"repository": "govoplan-encryption",
|
"repository": "govoplan-encryption"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/encryption/vaults/{}/rotate",
|
"path": "/encryption/vaults/{}/rotate",
|
||||||
"rationale": "Encryption custodian, lifecycle, and recovery administration UI is tracked separately.",
|
"rationale": "The vault action group rotates the current key with policy, assurance, reason, and revision evidence.",
|
||||||
"repository": "govoplan-encryption",
|
"repository": "govoplan-encryption"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/4"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/files/integrity/findings/{}/cleanup",
|
"path": "/files/integrity/findings/{}/cleanup",
|
||||||
"rationale": "File-integrity operations UI is tracked separately.",
|
"rationale": "The Files integrity panel requires a dry-run preview and separate confirmation before cleanup.",
|
||||||
"repository": "govoplan-files",
|
"repository": "govoplan-files"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/40"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/files/integrity/findings/{}/recheck",
|
"path": "/files/integrity/findings/{}/recheck",
|
||||||
"rationale": "File-integrity operations UI is tracked separately.",
|
"rationale": "The Files integrity panel rechecks findings against their displayed revision.",
|
||||||
"repository": "govoplan-files",
|
"repository": "govoplan-files"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/40"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/files/integrity/scans",
|
"path": "/files/integrity/scans",
|
||||||
"rationale": "File-integrity operations UI is tracked separately.",
|
"rationale": "The Files integrity administration panel lists bounded reconciliation scans.",
|
||||||
"repository": "govoplan-files",
|
"repository": "govoplan-files"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/40"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/files/integrity/scans",
|
"path": "/files/integrity/scans",
|
||||||
"rationale": "File-integrity operations UI is tracked separately.",
|
"rationale": "Authorized Files operators can create a bounded integrity scan from administration.",
|
||||||
"repository": "govoplan-files",
|
"repository": "govoplan-files"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/40"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/files/integrity/scans/{}/findings",
|
"path": "/files/integrity/scans/{}/findings",
|
||||||
"rationale": "File-integrity operations UI is tracked separately.",
|
"rationale": "The Files integrity panel displays findings and blocker evidence for the selected scan.",
|
||||||
"repository": "govoplan-files",
|
"repository": "govoplan-files"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/40"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/files/integrity/scans/{}/run",
|
"path": "/files/integrity/scans/{}/run",
|
||||||
"rationale": "File-integrity operations UI is tracked separately.",
|
"rationale": "The Files integrity panel runs and resumes bounded batches with stale-action protection.",
|
||||||
"repository": "govoplan-files",
|
"repository": "govoplan-files"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/40"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "compatibility",
|
"category": "compatibility",
|
||||||
@@ -861,60 +890,53 @@
|
|||||||
"repository": "govoplan-identity"
|
"repository": "govoplan-identity"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "intentionally_headless",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/identity-trust/assurance/check",
|
"path": "/identity-trust/assurance/check",
|
||||||
"rationale": "Identity-assurance and device-key administration UI is tracked separately.",
|
"rationale": "Assurance checks are capability operations performed by protected module workflows rather than direct user commands.",
|
||||||
"repository": "govoplan-identity-trust",
|
"repository": "govoplan-identity-trust"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-identity-trust/issues/2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "intentionally_headless",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/identity-trust/assurance/evidence",
|
"path": "/identity-trust/assurance/evidence",
|
||||||
"rationale": "Identity-assurance and device-key administration UI is tracked separately.",
|
"rationale": "Trusted assurance providers record evidence through this capability endpoint; users inspect the resulting projection.",
|
||||||
"repository": "govoplan-identity-trust",
|
"repository": "govoplan-identity-trust"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-identity-trust/issues/2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/identity-trust/device-keys",
|
"path": "/identity-trust/device-keys",
|
||||||
"rationale": "Identity-assurance and device-key administration UI is tracked separately.",
|
"rationale": "Identity Trust settings and administration list permission-filtered public device keys.",
|
||||||
"repository": "govoplan-identity-trust",
|
"repository": "govoplan-identity-trust"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-identity-trust/issues/2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "intentionally_headless",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/identity-trust/device-keys",
|
"path": "/identity-trust/device-keys",
|
||||||
"rationale": "Identity-assurance and device-key administration UI is tracked separately.",
|
"rationale": "Device onboarding registers public key material through the trust capability; the UI manages registered keys without handling private material.",
|
||||||
"repository": "govoplan-identity-trust",
|
"repository": "govoplan-identity-trust"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-identity-trust/issues/2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/identity-trust/device-keys/{}/revoke",
|
"path": "/identity-trust/device-keys/{}/revoke",
|
||||||
"rationale": "Identity-assurance and device-key administration UI is tracked separately.",
|
"rationale": "Authorized users and trust officers revoke public device keys through a consequence-aware confirmation.",
|
||||||
"repository": "govoplan-identity-trust",
|
"repository": "govoplan-identity-trust"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-identity-trust/issues/2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "ui_reachable",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/identity-trust/epochs/rotate",
|
"path": "/identity-trust/epochs/rotate",
|
||||||
"rationale": "Identity-assurance and device-key administration UI is tracked separately.",
|
"rationale": "Identity Trust administration exposes governed epoch rotation with history and consequence explanations.",
|
||||||
"repository": "govoplan-identity-trust",
|
"repository": "govoplan-identity-trust"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-identity-trust/issues/2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "missing_ui",
|
"category": "intentionally_headless",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/identity-trust/key-access/decide",
|
"path": "/identity-trust/key-access/decide",
|
||||||
"rationale": "Identity-assurance and device-key administration UI is tracked separately.",
|
"rationale": "Protected modules request immutable key-access decisions through the capability; trust officers inspect the resulting decision projection.",
|
||||||
"repository": "govoplan-identity-trust",
|
"repository": "govoplan-identity-trust"
|
||||||
"tracking_issue": "https://git.add-ideas.de/GovOPlaN/govoplan-identity-trust/issues/2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "ui_reachable",
|
"category": "ui_reachable",
|
||||||
@@ -1585,6 +1607,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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
const [metaRootArgument] = process.argv.slice(2);
|
const [metaRootArgument] = process.argv.slice(2);
|
||||||
@@ -13,7 +14,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,9 +61,19 @@ const helpAttributes = new Set([
|
|||||||
"helperText",
|
"helperText",
|
||||||
"helpText"
|
"helpText"
|
||||||
]);
|
]);
|
||||||
|
const actionComponentPattern = /(?:Action|Button|Link)$/;
|
||||||
|
const contributionTypes = new Map([
|
||||||
|
["AdminSectionsUiCapability", "admin_section"],
|
||||||
|
["DashboardWidgetsUiCapability", "widget"],
|
||||||
|
["OrganizationFunctionActionsUiCapability", "action"],
|
||||||
|
["SearchContextsUiCapability", "search_object"],
|
||||||
|
["SettingsSectionsUiCapability", "setting"],
|
||||||
|
["WizardDirectoriesUiCapability", "workflow_directory"]
|
||||||
|
]);
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
fields: [],
|
fields: [],
|
||||||
|
actions: [],
|
||||||
labels: [],
|
labels: [],
|
||||||
visibleText: [],
|
visibleText: [],
|
||||||
translationCatalog: {},
|
translationCatalog: {},
|
||||||
@@ -65,7 +82,8 @@ const result = {
|
|||||||
routes: [],
|
routes: [],
|
||||||
navigation: [],
|
navigation: [],
|
||||||
frontendApiReferences: [],
|
frontendApiReferences: [],
|
||||||
uiCapabilities: []
|
uiCapabilities: [],
|
||||||
|
contributions: []
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const repository of repositoryCatalog.repositories) {
|
for (const repository of repositoryCatalog.repositories) {
|
||||||
@@ -109,6 +127,7 @@ function inspectSource(repository, sourceRoot, sourcePath) {
|
|||||||
sourcePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
sourcePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
||||||
);
|
);
|
||||||
const relativeFile = path.relative(path.join(workspaceRoot, repository), sourcePath);
|
const relativeFile = path.relative(path.join(workspaceRoot, repository), sourcePath);
|
||||||
|
const identityCounters = new Map();
|
||||||
|
|
||||||
function location(node) {
|
function location(node) {
|
||||||
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
||||||
@@ -172,6 +191,7 @@ function inspectSource(repository, sourceRoot, sourcePath) {
|
|||||||
const isField =
|
const isField =
|
||||||
fieldComponents.has(component) ||
|
fieldComponents.has(component) ||
|
||||||
(fieldComponentPattern.test(component) && component !== "FormField");
|
(fieldComponentPattern.test(component) && component !== "FormField");
|
||||||
|
inspectAction(node, component, attributes, locate);
|
||||||
if (!isField) return;
|
if (!isField) return;
|
||||||
|
|
||||||
const parentFormField = nearestFormField(node);
|
const parentFormField = nearestFormField(node);
|
||||||
@@ -185,8 +205,25 @@ function inspectSource(repository, sourceRoot, sourcePath) {
|
|||||||
null;
|
null;
|
||||||
const help = firstAttribute(attributes, helpAttributes) ??
|
const help = firstAttribute(attributes, helpAttributes) ??
|
||||||
firstAttribute(parentAttributes, helpAttributes);
|
firstAttribute(parentAttributes, helpAttributes);
|
||||||
|
const hasHelp = hasAnyAttribute(attributes, helpAttributes) ||
|
||||||
|
hasAnyAttribute(parentAttributes, helpAttributes);
|
||||||
|
const explicitId = firstAttribute(
|
||||||
|
attributes,
|
||||||
|
new Set(["interfaceId", "data-interface-id", "id", "name", "field"])
|
||||||
|
);
|
||||||
|
const context = nearestNamedContext(node);
|
||||||
|
const stableId = sourceIdentity(
|
||||||
|
"field",
|
||||||
|
node,
|
||||||
|
component,
|
||||||
|
explicitId ?? label ?? attributes.get("placeholder") ?? "field"
|
||||||
|
);
|
||||||
result.fields.push({
|
result.fields.push({
|
||||||
...locate(node),
|
...locate(node),
|
||||||
|
id: stableId,
|
||||||
|
explicitId,
|
||||||
|
idSource: explicitId === null ? "source_anchor" : "explicit",
|
||||||
|
context,
|
||||||
component,
|
component,
|
||||||
name:
|
name:
|
||||||
attributes.get("name") ??
|
attributes.get("name") ??
|
||||||
@@ -196,8 +233,102 @@ function inspectSource(repository, sourceRoot, sourcePath) {
|
|||||||
label,
|
label,
|
||||||
placeholder: attributes.get("placeholder") ?? null,
|
placeholder: attributes.get("placeholder") ?? null,
|
||||||
help: help ?? null,
|
help: help ?? null,
|
||||||
helpCandidate: help === null
|
helpId: hasHelp ? `${stableId}.help` : null,
|
||||||
|
helpDynamic: hasHelp && help === null,
|
||||||
|
helpCandidate: !hasHelp
|
||||||
});
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectAction(node, component, attributes, locate) {
|
||||||
|
const lowerComponent = component.toLowerCase();
|
||||||
|
const inputType = attributes.get("type")?.toLowerCase();
|
||||||
|
const isAction = lowerComponent === "button" ||
|
||||||
|
lowerComponent === "a" ||
|
||||||
|
actionComponentPattern.test(component) ||
|
||||||
|
(lowerComponent === "input" && ["button", "reset", "submit"].includes(inputType));
|
||||||
|
if (!isAction) return;
|
||||||
|
|
||||||
|
const label = attributes.get("aria-label") ??
|
||||||
|
attributes.get("title") ??
|
||||||
|
attributes.get("label") ??
|
||||||
|
staticJsxChildText(node) ??
|
||||||
|
null;
|
||||||
|
const explicitId = firstAttribute(
|
||||||
|
attributes,
|
||||||
|
new Set(["interfaceId", "data-interface-id", "id", "name"])
|
||||||
|
);
|
||||||
|
const context = nearestNamedContext(node);
|
||||||
|
result.actions.push({
|
||||||
|
...locate(node),
|
||||||
|
id: sourceIdentity(
|
||||||
|
"action",
|
||||||
|
node,
|
||||||
|
component,
|
||||||
|
explicitId ?? label ?? "action"
|
||||||
|
),
|
||||||
|
explicitId,
|
||||||
|
idSource: explicitId === null ? "source_anchor" : "explicit",
|
||||||
|
context,
|
||||||
|
component,
|
||||||
|
label
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestNamedContext(node) {
|
||||||
|
let current = node.parent;
|
||||||
|
while (current) {
|
||||||
|
if (ts.isFunctionDeclaration(current) && current.name) {
|
||||||
|
return current.name.text;
|
||||||
|
}
|
||||||
|
if (ts.isMethodDeclaration(current) && current.name) {
|
||||||
|
return current.name.getText(sourceFile);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(ts.isArrowFunction(current) || ts.isFunctionExpression(current)) &&
|
||||||
|
ts.isVariableDeclaration(current.parent) &&
|
||||||
|
ts.isIdentifier(current.parent.name)
|
||||||
|
) {
|
||||||
|
return current.parent.name.text;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(ts.isArrowFunction(current) || ts.isFunctionExpression(current)) &&
|
||||||
|
ts.isPropertyAssignment(current.parent)
|
||||||
|
) {
|
||||||
|
return propertyNameText(current.parent.name) ?? "anonymous";
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return path.basename(relativeFile).replace(/\.[^.]+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceIdentity(kind, node, component, semantic) {
|
||||||
|
const context = nearestNamedContext(node);
|
||||||
|
const normalizedSemantic = slug(String(semantic));
|
||||||
|
const counterKey = `${kind}:${context}:${component}:${normalizedSemantic}`;
|
||||||
|
const occurrence = (identityCounters.get(counterKey) ?? 0) + 1;
|
||||||
|
identityCounters.set(counterKey, occurrence);
|
||||||
|
const anchor = [relativeFile, context, component, normalizedSemantic, occurrence].join(":");
|
||||||
|
const digest = createHash("sha256").update(anchor).digest("hex").slice(0, 12);
|
||||||
|
return `${repository}.${kind}.${slug(context)}.${normalizedSemantic}.${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function staticJsxChildText(node) {
|
||||||
|
if (!ts.isJsxOpeningElement(node) || !ts.isJsxElement(node.parent)) return null;
|
||||||
|
const values = [];
|
||||||
|
for (const child of node.parent.children) {
|
||||||
|
if (ts.isJsxText(child)) {
|
||||||
|
const value = child.getText(sourceFile).replace(/\s+/g, " ").trim();
|
||||||
|
if (value) values.push(value);
|
||||||
|
} else if (
|
||||||
|
ts.isJsxExpression(child) &&
|
||||||
|
child.expression &&
|
||||||
|
ts.isStringLiteralLike(child.expression)
|
||||||
|
) {
|
||||||
|
values.push(child.expression.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values.length > 0 ? values.join(" ") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nearestFormField(node) {
|
function nearestFormField(node) {
|
||||||
@@ -269,22 +400,103 @@ function inspectSource(repository, sourceRoot, sourcePath) {
|
|||||||
|
|
||||||
function inspectProperty(node, locate) {
|
function inspectProperty(node, locate) {
|
||||||
const propertyName = propertyNameText(node.name);
|
const propertyName = propertyNameText(node.name);
|
||||||
|
if (isDirectUiCapabilityProperty(node) && typeof propertyName === "string") {
|
||||||
|
result.uiCapabilities.push({ ...locate(node), name: propertyName });
|
||||||
|
result.contributions.push({
|
||||||
|
...locate(node),
|
||||||
|
kind: "ui_capability",
|
||||||
|
id: propertyName
|
||||||
|
});
|
||||||
|
}
|
||||||
const value = staticExpressionText(node.initializer);
|
const value = staticExpressionText(node.initializer);
|
||||||
if (value === null) return;
|
if (value === null) return;
|
||||||
if (propertyName === "path" && value.startsWith("/") && !value.includes("/api/")) {
|
if (propertyName === "path" && value.startsWith("/") && !value.includes("/api/")) {
|
||||||
result.routes.push({ ...locate(node), path: value });
|
result.routes.push({ ...locate(node), path: value });
|
||||||
|
const routeCollection = nearestCollectionProperty(node);
|
||||||
|
if (routeCollection === "routes" || routeCollection === "publicRoutes") {
|
||||||
|
result.contributions.push({
|
||||||
|
...locate(node),
|
||||||
|
kind: routeCollection === "publicRoutes" ? "public_route" : "frontend_route",
|
||||||
|
id: value,
|
||||||
|
path: value
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (propertyName === "to" && value.startsWith("/")) {
|
if (propertyName === "to" && value.startsWith("/")) {
|
||||||
result.navigation.push({ ...locate(node), path: value });
|
result.navigation.push({ ...locate(node), path: value });
|
||||||
|
if (nearestCollectionProperty(node) === "navItems") {
|
||||||
|
result.contributions.push({
|
||||||
|
...locate(node),
|
||||||
|
kind: "navigation",
|
||||||
|
id: value,
|
||||||
|
path: value
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (propertyName === "id") {
|
||||||
ancestorPropertyName(node, "uiCapabilities") &&
|
const contributionKind = contributionKindFor(node);
|
||||||
typeof propertyName === "string"
|
if (contributionKind !== null) {
|
||||||
) {
|
result.contributions.push({
|
||||||
result.uiCapabilities.push({ ...locate(node), name: propertyName });
|
...locate(node),
|
||||||
|
kind: contributionKind,
|
||||||
|
id: value
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contributionKindFor(node) {
|
||||||
|
const collection = nearestCollectionProperty(node);
|
||||||
|
if (collection === "viewSurfaces") return "view_surface";
|
||||||
|
if (collection === "widgets") return "widget";
|
||||||
|
if (collection === "contexts") return "search_object";
|
||||||
|
if (collection === "actions") return "action";
|
||||||
|
if (collection === "directories") return "workflow_directory";
|
||||||
|
if (collection !== "sections") return null;
|
||||||
|
const variableType = nearestVariableType(node);
|
||||||
|
for (const [typeName, kind] of contributionTypes) {
|
||||||
|
if (variableType.includes(typeName)) return kind;
|
||||||
|
}
|
||||||
|
return ancestorPropertyName(node, "admin.sections")
|
||||||
|
? "admin_section"
|
||||||
|
: ancestorPropertyName(node, "settings.sections")
|
||||||
|
? "setting"
|
||||||
|
: "section";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestCollectionProperty(node) {
|
||||||
|
let current = node.parent;
|
||||||
|
while (current) {
|
||||||
|
if (
|
||||||
|
ts.isArrayLiteralExpression(current) &&
|
||||||
|
ts.isPropertyAssignment(current.parent)
|
||||||
|
) {
|
||||||
|
return propertyNameText(current.parent.name);
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestVariableType(node) {
|
||||||
|
let current = node.parent;
|
||||||
|
while (current) {
|
||||||
|
if (ts.isVariableDeclaration(current)) {
|
||||||
|
return current.type?.getText(sourceFile) ?? "";
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectUiCapabilityProperty(node) {
|
||||||
|
const parent = node.parent;
|
||||||
|
const uiCapabilities = parent?.parent;
|
||||||
|
return ts.isObjectLiteralExpression(parent) &&
|
||||||
|
ts.isPropertyAssignment(uiCapabilities) &&
|
||||||
|
propertyNameText(uiCapabilities.name) === "uiCapabilities";
|
||||||
|
}
|
||||||
|
|
||||||
function inspectTranslationProperty(node, locate) {
|
function inspectTranslationProperty(node, locate) {
|
||||||
const key = propertyNameText(node.name);
|
const key = propertyNameText(node.name);
|
||||||
if (!key?.startsWith("i18n:")) return;
|
if (!key?.startsWith("i18n:")) return;
|
||||||
@@ -309,11 +521,32 @@ function inspectSource(repository, sourceRoot, sourcePath) {
|
|||||||
) {
|
) {
|
||||||
return propertyNameText(current.name);
|
return propertyNameText(current.name);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
ts.isVariableDeclaration(current) &&
|
||||||
|
ts.isIdentifier(current.name) &&
|
||||||
|
(current.name.text === "en" || current.name.text === "de") &&
|
||||||
|
current.initializer &&
|
||||||
|
isCatalogObject(current.initializer)
|
||||||
|
) {
|
||||||
|
return current.name.text;
|
||||||
|
}
|
||||||
current = current.parent;
|
current = current.parent;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCatalogObject(node) {
|
||||||
|
let current = node;
|
||||||
|
while (
|
||||||
|
ts.isAsExpression(current) ||
|
||||||
|
ts.isSatisfiesExpression(current) ||
|
||||||
|
ts.isParenthesizedExpression(current)
|
||||||
|
) {
|
||||||
|
current = current.expression;
|
||||||
|
}
|
||||||
|
return ts.isObjectLiteralExpression(current);
|
||||||
|
}
|
||||||
|
|
||||||
function ancestorPropertyName(node, expected) {
|
function ancestorPropertyName(node, expected) {
|
||||||
let current = node.parent;
|
let current = node.parent;
|
||||||
while (current) {
|
while (current) {
|
||||||
@@ -337,6 +570,23 @@ function firstAttribute(attributes, names) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasAnyAttribute(attributes, names) {
|
||||||
|
for (const name of names) {
|
||||||
|
if (attributes.has(name)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slug(value) {
|
||||||
|
const normalized = value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^i18n:/, "")
|
||||||
|
.replace(/\.[a-f0-9]{8}$/, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, ".")
|
||||||
|
.replace(/^\.+|\.+$/g, "");
|
||||||
|
return (normalized || "unnamed").slice(0, 72);
|
||||||
|
}
|
||||||
|
|
||||||
function propertyNameText(name) {
|
function propertyNameText(name) {
|
||||||
if (
|
if (
|
||||||
ts.isIdentifier(name) ||
|
ts.isIdentifier(name) ||
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ ENDPOINT_SURFACE_CATEGORIES = {
|
|||||||
DEFAULT_ENDPOINT_DECLARATIONS = (
|
DEFAULT_ENDPOINT_DECLARATIONS = (
|
||||||
META_ROOT / "tools" / "inventory" / "endpoint-surface-declarations.json"
|
META_ROOT / "tools" / "inventory" / "endpoint-surface-declarations.json"
|
||||||
)
|
)
|
||||||
|
REQUIRED_LOCALES = ("de", "en")
|
||||||
|
REFERENCE_LOCALE = "de"
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -45,6 +47,28 @@ def main() -> int:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Fail on missing translations or incomplete endpoint-surface declarations.",
|
help="Fail on missing translations or incomplete endpoint-surface declarations.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--strict-endpoints",
|
||||||
|
action="store_true",
|
||||||
|
help="Fail only on incomplete or stale endpoint-surface declarations.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--strict-declarations",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Fail on duplicate stable IDs, WebUI surfaces absent from runtime "
|
||||||
|
"metadata, or stale runtime route declarations."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--runtime-snapshot",
|
||||||
|
type=Path,
|
||||||
|
help=(
|
||||||
|
"Compare a saved /api/v1/platform/interface-catalog response with "
|
||||||
|
"the static manifest inventory. Any installed module combination "
|
||||||
|
"is accepted; every module present in the snapshot must match."
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--endpoint-declarations",
|
"--endpoint-declarations",
|
||||||
type=Path,
|
type=Path,
|
||||||
@@ -54,7 +78,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)
|
||||||
@@ -66,6 +90,11 @@ def main() -> int:
|
|||||||
backend_endpoints=backend_endpoints,
|
backend_endpoints=backend_endpoints,
|
||||||
manifests=manifests,
|
manifests=manifests,
|
||||||
endpoint_declarations=endpoint_declarations,
|
endpoint_declarations=endpoint_declarations,
|
||||||
|
runtime_snapshot=(
|
||||||
|
_load_runtime_snapshot(args.runtime_snapshot.resolve())
|
||||||
|
if args.runtime_snapshot is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
output_dir = args.output_dir.resolve()
|
output_dir = args.output_dir.resolve()
|
||||||
@@ -80,20 +109,13 @@ def main() -> int:
|
|||||||
print(f"Platform inventory JSON: {json_path}")
|
print(f"Platform inventory JSON: {json_path}")
|
||||||
print(f"Platform inventory summary: {markdown_path}")
|
print(f"Platform inventory summary: {markdown_path}")
|
||||||
|
|
||||||
if args.strict:
|
if args.strict or args.strict_endpoints or args.strict_declarations:
|
||||||
failures: list[str] = []
|
failures = _strict_failures(
|
||||||
if inventory["translation_health"]["missing_catalog_entries"]:
|
inventory,
|
||||||
failures.append("used translation keys are missing from generated catalogs")
|
check_translations=args.strict,
|
||||||
if inventory["api"]["unclassified_endpoints"]:
|
check_endpoints=args.strict or args.strict_endpoints,
|
||||||
failures.append(
|
check_declarations=args.strict or args.strict_declarations,
|
||||||
f"{len(inventory['api']['unclassified_endpoints'])} backend "
|
)
|
||||||
"endpoints have no WebUI evidence or surface declaration"
|
|
||||||
)
|
|
||||||
if inventory["api"]["stale_endpoint_declarations"]:
|
|
||||||
failures.append(
|
|
||||||
f"{len(inventory['api']['stale_endpoint_declarations'])} "
|
|
||||||
"endpoint declarations do not match a backend endpoint"
|
|
||||||
)
|
|
||||||
if failures:
|
if failures:
|
||||||
print(
|
print(
|
||||||
"Strict platform inventory failed: " + "; ".join(failures) + ".",
|
"Strict platform inventory failed: " + "; ".join(failures) + ".",
|
||||||
@@ -103,6 +125,79 @@ def main() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _strict_failures(
|
||||||
|
inventory: dict[str, Any],
|
||||||
|
*,
|
||||||
|
check_translations: bool,
|
||||||
|
check_endpoints: bool,
|
||||||
|
check_declarations: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
failures: list[str] = []
|
||||||
|
if (
|
||||||
|
check_translations
|
||||||
|
and inventory["translation_health"]["missing_catalog_entries"]
|
||||||
|
):
|
||||||
|
failures.append("used translation keys are missing from generated catalogs")
|
||||||
|
if check_endpoints and inventory["api"]["unclassified_endpoints"]:
|
||||||
|
failures.append(
|
||||||
|
f"{len(inventory['api']['unclassified_endpoints'])} backend "
|
||||||
|
"endpoints have no WebUI evidence or surface declaration"
|
||||||
|
)
|
||||||
|
if check_endpoints and inventory["api"]["stale_endpoint_declarations"]:
|
||||||
|
failures.append(
|
||||||
|
f"{len(inventory['api']['stale_endpoint_declarations'])} "
|
||||||
|
"endpoint declarations do not match a backend endpoint"
|
||||||
|
)
|
||||||
|
declaration_health = inventory.get("declaration_health", {})
|
||||||
|
if check_declarations and declaration_health.get("duplicate_ids"):
|
||||||
|
failures.append(
|
||||||
|
f"{len(declaration_health['duplicate_ids'])} platform interface "
|
||||||
|
"IDs are declared more than once"
|
||||||
|
)
|
||||||
|
if check_declarations and declaration_health.get("undeclared_source_surfaces"):
|
||||||
|
failures.append(
|
||||||
|
f"{len(declaration_health['undeclared_source_surfaces'])} public "
|
||||||
|
"WebUI surfaces have no runtime manifest declaration"
|
||||||
|
)
|
||||||
|
if check_declarations and declaration_health.get("stale_runtime_routes"):
|
||||||
|
failures.append(
|
||||||
|
f"{len(declaration_health['stale_runtime_routes'])} runtime route "
|
||||||
|
"declarations have no WebUI implementation"
|
||||||
|
)
|
||||||
|
runtime_comparison = inventory.get("runtime_comparison")
|
||||||
|
if (
|
||||||
|
check_declarations
|
||||||
|
and runtime_comparison is not None
|
||||||
|
and runtime_comparison.get("mismatches")
|
||||||
|
):
|
||||||
|
failures.append(
|
||||||
|
f"{len(runtime_comparison['mismatches'])} runtime catalog entries "
|
||||||
|
"do not match the release inventory"
|
||||||
|
)
|
||||||
|
return failures
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
@@ -222,6 +317,10 @@ def _extract_manifests(
|
|||||||
if (workspace_root / repository["path"] / "src").is_dir()
|
if (workspace_root / repository["path"] / "src").is_dir()
|
||||||
]
|
]
|
||||||
sys.path[:0] = [str(path) for path in source_roots]
|
sys.path[:0] = [str(path) for path in source_roots]
|
||||||
|
from govoplan_core.core.platform_interfaces import ( # noqa: PLC0415
|
||||||
|
manifest_interface_catalog,
|
||||||
|
)
|
||||||
|
|
||||||
manifests: list[dict[str, Any]] = []
|
manifests: list[dict[str, Any]] = []
|
||||||
for repository in catalog["repositories"]:
|
for repository in catalog["repositories"]:
|
||||||
source_root = workspace_root / repository["path"] / "src"
|
source_root = workspace_root / repository["path"] / "src"
|
||||||
@@ -256,15 +355,32 @@ def _extract_manifests(
|
|||||||
}
|
}
|
||||||
for permission in manifest.permissions
|
for permission in manifest.permissions
|
||||||
],
|
],
|
||||||
|
"architecture": (
|
||||||
|
manifest.architecture.to_dict()
|
||||||
|
if manifest.architecture is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"information_governance": (
|
||||||
|
manifest.information_governance.to_dict()
|
||||||
|
),
|
||||||
|
"interface_catalog": manifest_interface_catalog(manifest),
|
||||||
"frontend": (
|
"frontend": (
|
||||||
{
|
{
|
||||||
"package": frontend.package_name,
|
"package": frontend.package_name,
|
||||||
"routes": [
|
"routes": [
|
||||||
_plain_value(route) for route in frontend.routes
|
_plain_value(route) for route in frontend.routes
|
||||||
],
|
],
|
||||||
|
"public_routes": [
|
||||||
|
_plain_value(route)
|
||||||
|
for route in frontend.public_routes
|
||||||
|
],
|
||||||
"nav_items": [
|
"nav_items": [
|
||||||
_plain_value(item) for item in frontend.nav_items
|
_plain_value(item) for item in frontend.nav_items
|
||||||
],
|
],
|
||||||
|
"settings_routes": [
|
||||||
|
_plain_value(route)
|
||||||
|
for route in frontend.settings_routes
|
||||||
|
],
|
||||||
"view_surfaces": [
|
"view_surfaces": [
|
||||||
_plain_value(surface)
|
_plain_value(surface)
|
||||||
for surface in frontend.view_surfaces
|
for surface in frontend.view_surfaces
|
||||||
@@ -284,6 +400,7 @@ def _assemble_inventory(
|
|||||||
backend_endpoints: list[dict[str, Any]],
|
backend_endpoints: list[dict[str, Any]],
|
||||||
manifests: list[dict[str, Any]],
|
manifests: list[dict[str, Any]],
|
||||||
endpoint_declarations: dict[tuple[str, str, str], dict[str, Any]],
|
endpoint_declarations: dict[tuple[str, str, str], dict[str, Any]],
|
||||||
|
runtime_snapshot: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
frontend_refs = webui["frontendApiReferences"]
|
frontend_refs = webui["frontendApiReferences"]
|
||||||
frontend_paths = {
|
frontend_paths = {
|
||||||
@@ -340,38 +457,60 @@ def _assemble_inventory(
|
|||||||
usages = {item["key"] for item in webui["translationUsages"]}
|
usages = {item["key"] for item in webui["translationUsages"]}
|
||||||
catalogs = webui["translationCatalog"]
|
catalogs = webui["translationCatalog"]
|
||||||
catalog_keys = {locale: set(entries) for locale, entries in catalogs.items()}
|
catalog_keys = {locale: set(entries) for locale, entries in catalogs.items()}
|
||||||
expected_locales = sorted(catalog_keys)
|
expected_locales = sorted(set(catalog_keys) | set(REQUIRED_LOCALES))
|
||||||
missing_catalog_entries = [
|
missing_catalog_entries = [
|
||||||
{
|
{
|
||||||
"key": key,
|
"key": key,
|
||||||
"missing_locales": [
|
"missing_locales": [
|
||||||
locale for locale in expected_locales if key not in catalog_keys[locale]
|
locale
|
||||||
|
for locale in expected_locales
|
||||||
|
if key not in catalog_keys.get(locale, set())
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
for key in sorted(usages)
|
for key in sorted(usages)
|
||||||
if any(key not in catalog_keys[locale] for locale in expected_locales)
|
if any(key not in catalog_keys.get(locale, set()) for locale in expected_locales)
|
||||||
]
|
]
|
||||||
fields = webui["fields"]
|
fields = webui["fields"]
|
||||||
help_candidates = [field for field in fields if field["helpCandidate"]]
|
help_candidates = [field for field in fields if field["helpCandidate"]]
|
||||||
|
dynamic_help = [field for field in fields if field.get("helpDynamic")]
|
||||||
|
governance_adoption = Counter(
|
||||||
|
dimension["adoption"]
|
||||||
|
for manifest in manifests
|
||||||
|
for dimension in manifest["information_governance"]["dimensions"].values()
|
||||||
|
)
|
||||||
|
source_declarations = _source_interface_declarations(webui, manifests)
|
||||||
|
declaration_health = _declaration_health(source_declarations, manifests)
|
||||||
|
runtime_comparison = (
|
||||||
|
_compare_runtime_snapshot(runtime_snapshot, manifests)
|
||||||
|
if runtime_snapshot is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"schema_version": 1,
|
"schema_version": 2,
|
||||||
"scope": {
|
"scope": {
|
||||||
"source": "local GovOPlaN repository catalog",
|
"source": "local GovOPlaN repository catalog",
|
||||||
"limitations": [
|
"limitations": [
|
||||||
"Static extraction cannot resolve runtime-computed labels, routes, or API paths.",
|
"Static extraction cannot resolve runtime-computed labels, routes, or API paths.",
|
||||||
"A backend endpoint without a static WebUI reference may intentionally serve public clients, workers, connectors, or external integrations.",
|
"A backend endpoint without a static WebUI reference may intentionally serve public clients, workers, connectors, or external integrations.",
|
||||||
"A field marked as a help candidate may receive contextual help from a surrounding dynamic component.",
|
"A field marked as a help candidate may receive contextual help from a surrounding dynamic component.",
|
||||||
|
"Low-level field and action IDs use line-independent source anchors unless an explicit interfaceId, DOM id, or name is declared.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"modules": manifests,
|
"modules": manifests,
|
||||||
|
"interface_declarations": source_declarations,
|
||||||
|
"declaration_health": declaration_health,
|
||||||
|
"runtime_comparison": runtime_comparison,
|
||||||
"ui": {
|
"ui": {
|
||||||
"fields": fields,
|
"fields": fields,
|
||||||
|
"actions": webui.get("actions", []),
|
||||||
"labels": webui["labels"],
|
"labels": webui["labels"],
|
||||||
"visible_text": webui["visibleText"],
|
"visible_text": webui["visibleText"],
|
||||||
"routes": webui["routes"],
|
"routes": webui["routes"],
|
||||||
"navigation": webui["navigation"],
|
"navigation": webui["navigation"],
|
||||||
"ui_capabilities": webui["uiCapabilities"],
|
"ui_capabilities": webui["uiCapabilities"],
|
||||||
"help_candidates": help_candidates,
|
"help_candidates": help_candidates,
|
||||||
|
"dynamic_help": dynamic_help,
|
||||||
|
"contributions": webui.get("contributions", []),
|
||||||
},
|
},
|
||||||
"translations": {
|
"translations": {
|
||||||
"catalog": catalogs,
|
"catalog": catalogs,
|
||||||
@@ -380,9 +519,26 @@ def _assemble_inventory(
|
|||||||
},
|
},
|
||||||
"translation_health": {
|
"translation_health": {
|
||||||
"locales": expected_locales,
|
"locales": expected_locales,
|
||||||
|
"reference_locale": REFERENCE_LOCALE,
|
||||||
|
"reference_locale_entries": len(catalog_keys.get(REFERENCE_LOCALE, set())),
|
||||||
|
"reference_locale_complete": not any(
|
||||||
|
REFERENCE_LOCALE in item["missing_locales"]
|
||||||
|
for item in missing_catalog_entries
|
||||||
|
),
|
||||||
"used_keys": len(usages),
|
"used_keys": len(usages),
|
||||||
"missing_catalog_entries": missing_catalog_entries,
|
"missing_catalog_entries": missing_catalog_entries,
|
||||||
},
|
},
|
||||||
|
"information_governance_health": {
|
||||||
|
"dimensions": len(manifests) * 4,
|
||||||
|
"adoption_counts": dict(sorted(governance_adoption.items())),
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"module_id": manifest["id"],
|
||||||
|
"dimensions": manifest["information_governance"]["dimensions"],
|
||||||
|
}
|
||||||
|
for manifest in manifests
|
||||||
|
],
|
||||||
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"backend_endpoints": classified_endpoints,
|
"backend_endpoints": classified_endpoints,
|
||||||
"frontend_references": frontend_refs,
|
"frontend_references": frontend_refs,
|
||||||
@@ -395,7 +551,18 @@ def _assemble_inventory(
|
|||||||
"modules": len(manifests),
|
"modules": len(manifests),
|
||||||
"ui_fields": len(fields),
|
"ui_fields": len(fields),
|
||||||
"ui_fields_with_static_help": len(fields) - len(help_candidates),
|
"ui_fields_with_static_help": len(fields) - len(help_candidates),
|
||||||
|
"ui_fields_with_resolvable_f1_context": len(fields),
|
||||||
"help_review_candidates": len(help_candidates),
|
"help_review_candidates": len(help_candidates),
|
||||||
|
"dynamic_help_references": len(dynamic_help),
|
||||||
|
"ui_actions": len(webui.get("actions", [])),
|
||||||
|
"interface_declarations": len(source_declarations),
|
||||||
|
"duplicate_interface_ids": len(declaration_health["duplicate_ids"]),
|
||||||
|
"undeclared_source_surfaces": len(
|
||||||
|
declaration_health["undeclared_source_surfaces"]
|
||||||
|
),
|
||||||
|
"stale_runtime_routes": len(
|
||||||
|
declaration_health["stale_runtime_routes"]
|
||||||
|
),
|
||||||
"label_attributes": len(webui["labels"]),
|
"label_attributes": len(webui["labels"]),
|
||||||
"visible_text_nodes": len(webui["visibleText"]),
|
"visible_text_nodes": len(webui["visibleText"]),
|
||||||
"frontend_routes": len(webui["routes"]),
|
"frontend_routes": len(webui["routes"]),
|
||||||
@@ -404,10 +571,309 @@ def _assemble_inventory(
|
|||||||
"backend_endpoints_without_static_webui_reference": len(unreferenced),
|
"backend_endpoints_without_static_webui_reference": len(unreferenced),
|
||||||
"unclassified_backend_endpoints": len(unclassified),
|
"unclassified_backend_endpoints": len(unclassified),
|
||||||
"stale_endpoint_declarations": len(stale_declarations),
|
"stale_endpoint_declarations": len(stale_declarations),
|
||||||
|
"information_governance_dimensions": len(manifests) * 4,
|
||||||
|
"information_governance_enforced": governance_adoption["enforced"],
|
||||||
|
"information_governance_partial": governance_adoption["partial"],
|
||||||
|
"information_governance_contract_only": governance_adoption[
|
||||||
|
"contract_only"
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _source_interface_declarations(
|
||||||
|
webui: dict[str, Any],
|
||||||
|
manifests: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
module_by_repository = {
|
||||||
|
str(manifest["repository"]): str(manifest["id"])
|
||||||
|
for manifest in manifests
|
||||||
|
}
|
||||||
|
declarations: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def module_id(repository: str) -> str:
|
||||||
|
if repository in module_by_repository:
|
||||||
|
return module_by_repository[repository]
|
||||||
|
if repository == "govoplan-core":
|
||||||
|
return "core"
|
||||||
|
return repository.removeprefix("govoplan-").replace("-", "_")
|
||||||
|
|
||||||
|
def source_evidence(item: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
key: item[key]
|
||||||
|
for key in ("repository", "file", "line", "column")
|
||||||
|
if key in item
|
||||||
|
}
|
||||||
|
|
||||||
|
for kind, items in (
|
||||||
|
("field", webui["fields"]),
|
||||||
|
("action", webui.get("actions", [])),
|
||||||
|
):
|
||||||
|
for item in items:
|
||||||
|
repository = str(item["repository"])
|
||||||
|
owner = module_id(repository)
|
||||||
|
raw_id = str(item["id"])
|
||||||
|
stable_id = (
|
||||||
|
f"{owner}.{raw_id[len(repository) + 1:]}"
|
||||||
|
if raw_id.startswith(f"{repository}.")
|
||||||
|
else _namespaced_interface_id(owner, kind, raw_id)
|
||||||
|
)
|
||||||
|
declarations.append(
|
||||||
|
{
|
||||||
|
"key": f"{kind}:{stable_id}",
|
||||||
|
"id": stable_id,
|
||||||
|
"module_id": owner,
|
||||||
|
"kind": kind,
|
||||||
|
"origin": "webui_source",
|
||||||
|
"id_source": item.get("idSource", "source_anchor"),
|
||||||
|
"explicit_id": item.get("explicitId"),
|
||||||
|
"context": item.get("context"),
|
||||||
|
**source_evidence(item),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if kind == "field" and item.get("helpId"):
|
||||||
|
help_raw_id = str(item["helpId"])
|
||||||
|
help_id = (
|
||||||
|
f"{owner}.{help_raw_id[len(repository) + 1:]}"
|
||||||
|
if help_raw_id.startswith(f"{repository}.")
|
||||||
|
else _namespaced_interface_id(owner, "help", help_raw_id)
|
||||||
|
)
|
||||||
|
declarations.append(
|
||||||
|
{
|
||||||
|
"key": f"help:{help_id}",
|
||||||
|
"id": help_id,
|
||||||
|
"module_id": owner,
|
||||||
|
"kind": "help",
|
||||||
|
"origin": "webui_source",
|
||||||
|
"field_id": stable_id,
|
||||||
|
"dynamic": bool(item.get("helpDynamic")),
|
||||||
|
**source_evidence(item),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in webui.get("contributions", []):
|
||||||
|
repository = str(item["repository"])
|
||||||
|
owner = module_id(repository)
|
||||||
|
kind = str(item["kind"])
|
||||||
|
raw_id = str(item["id"])
|
||||||
|
path = item.get("path")
|
||||||
|
if kind == "frontend_route" and isinstance(path, str):
|
||||||
|
stable_id = f"{owner}.route.{_surface_slug(path)}"
|
||||||
|
elif kind == "public_route" and isinstance(path, str):
|
||||||
|
stable_id = f"{owner}.public.{_surface_slug(path)}"
|
||||||
|
elif kind == "navigation" and isinstance(path, str):
|
||||||
|
stable_id = f"{owner}.nav.{_surface_slug(path)}"
|
||||||
|
else:
|
||||||
|
stable_id = _namespaced_interface_id(owner, kind, raw_id)
|
||||||
|
declarations.append(
|
||||||
|
{
|
||||||
|
"key": f"{kind}:{stable_id}",
|
||||||
|
"id": stable_id,
|
||||||
|
"module_id": owner,
|
||||||
|
"kind": kind,
|
||||||
|
"origin": "webui_contribution",
|
||||||
|
"declared_value": raw_id,
|
||||||
|
**({"path": path} if isinstance(path, str) else {}),
|
||||||
|
**source_evidence(item),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
translation_entries: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
for locale, entries in webui["translationCatalog"].items():
|
||||||
|
for key, item in entries.items():
|
||||||
|
repository = str(item["repository"])
|
||||||
|
owner = module_id(repository)
|
||||||
|
declaration_key = (owner, str(key))
|
||||||
|
declaration = translation_entries.setdefault(
|
||||||
|
declaration_key,
|
||||||
|
{
|
||||||
|
"key": f"translation:{key}",
|
||||||
|
"id": key,
|
||||||
|
"module_id": owner,
|
||||||
|
"kind": "translation",
|
||||||
|
"origin": "translation_catalog",
|
||||||
|
"locales": [],
|
||||||
|
**source_evidence(item),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
declaration["locales"].append(locale)
|
||||||
|
declarations.extend(translation_entries.values())
|
||||||
|
|
||||||
|
return sorted(
|
||||||
|
declarations,
|
||||||
|
key=lambda item: (
|
||||||
|
item["module_id"],
|
||||||
|
item["kind"],
|
||||||
|
item["id"],
|
||||||
|
item.get("file", ""),
|
||||||
|
item.get("line", 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _declaration_health(
|
||||||
|
source_declarations: list[dict[str, Any]],
|
||||||
|
manifests: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
|
||||||
|
for declaration in source_declarations:
|
||||||
|
key = (
|
||||||
|
str(declaration["module_id"]),
|
||||||
|
str(declaration["kind"]),
|
||||||
|
str(declaration["id"]),
|
||||||
|
)
|
||||||
|
grouped.setdefault(key, []).append(declaration)
|
||||||
|
duplicate_ids = [
|
||||||
|
{
|
||||||
|
"module_id": key[0],
|
||||||
|
"kind": key[1],
|
||||||
|
"id": key[2],
|
||||||
|
"evidence": values,
|
||||||
|
}
|
||||||
|
for key, values in sorted(grouped.items())
|
||||||
|
if len(values) > 1
|
||||||
|
]
|
||||||
|
|
||||||
|
comparable_kinds = {
|
||||||
|
"frontend_route",
|
||||||
|
"navigation",
|
||||||
|
"public_route",
|
||||||
|
"view_surface",
|
||||||
|
}
|
||||||
|
source_surfaces = {
|
||||||
|
(str(item["module_id"]), str(item["kind"]), str(item["id"])): item
|
||||||
|
for item in source_declarations
|
||||||
|
if item["origin"] == "webui_contribution"
|
||||||
|
and item["kind"] in comparable_kinds
|
||||||
|
}
|
||||||
|
runtime_surfaces: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||||
|
for manifest in manifests:
|
||||||
|
catalog = manifest["interface_catalog"]
|
||||||
|
for declaration in catalog["declarations"]:
|
||||||
|
if declaration["kind"] not in comparable_kinds:
|
||||||
|
continue
|
||||||
|
key = (
|
||||||
|
str(manifest["id"]),
|
||||||
|
str(declaration["kind"]),
|
||||||
|
str(declaration["id"]),
|
||||||
|
)
|
||||||
|
runtime_surfaces[key] = {
|
||||||
|
"repository": manifest["repository"],
|
||||||
|
**declaration,
|
||||||
|
}
|
||||||
|
surface_id = declaration.get("metadata", {}).get("surface_id")
|
||||||
|
if (
|
||||||
|
declaration["kind"]
|
||||||
|
in {"frontend_route", "navigation", "settings_route"}
|
||||||
|
and isinstance(surface_id, str)
|
||||||
|
and surface_id
|
||||||
|
):
|
||||||
|
runtime_surfaces[
|
||||||
|
(str(manifest["id"]), "view_surface", surface_id)
|
||||||
|
] = {
|
||||||
|
"repository": manifest["repository"],
|
||||||
|
"key": f"view_surface:{surface_id}",
|
||||||
|
"id": surface_id,
|
||||||
|
"module_id": manifest["id"],
|
||||||
|
"kind": "view_surface",
|
||||||
|
"metadata": {
|
||||||
|
"derived_from": declaration["kind"],
|
||||||
|
"path": declaration.get("path"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
undeclared_source_surfaces = [
|
||||||
|
source_surfaces[key]
|
||||||
|
for key in sorted(source_surfaces.keys() - runtime_surfaces.keys())
|
||||||
|
]
|
||||||
|
route_kinds = {"frontend_route", "public_route"}
|
||||||
|
stale_runtime_routes = [
|
||||||
|
runtime_surfaces[key]
|
||||||
|
for key in sorted(runtime_surfaces.keys() - source_surfaces.keys())
|
||||||
|
if key[1] in route_kinds
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"duplicate_ids": duplicate_ids,
|
||||||
|
"undeclared_source_surfaces": undeclared_source_surfaces,
|
||||||
|
"stale_runtime_routes": stale_runtime_routes,
|
||||||
|
"source_declaration_count": len(source_declarations),
|
||||||
|
"runtime_declaration_count": sum(
|
||||||
|
len(manifest["interface_catalog"]["declarations"])
|
||||||
|
for manifest in manifests
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_runtime_snapshot(
|
||||||
|
snapshot: dict[str, Any],
|
||||||
|
manifests: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
static_by_module = {
|
||||||
|
str(manifest["id"]): manifest["interface_catalog"]
|
||||||
|
for manifest in manifests
|
||||||
|
}
|
||||||
|
modules = snapshot.get("modules")
|
||||||
|
if not isinstance(modules, list):
|
||||||
|
raise ValueError("Runtime interface snapshot must contain a modules list.")
|
||||||
|
mismatches: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
matched: list[str] = []
|
||||||
|
for item in modules:
|
||||||
|
if not isinstance(item, dict) or not isinstance(item.get("module_id"), str):
|
||||||
|
raise ValueError("Runtime interface snapshot has an invalid module entry.")
|
||||||
|
module_id = item["module_id"]
|
||||||
|
if module_id in seen:
|
||||||
|
mismatches.append({"module_id": module_id, "reason": "duplicate_module"})
|
||||||
|
continue
|
||||||
|
seen.add(module_id)
|
||||||
|
expected = static_by_module.get(module_id)
|
||||||
|
if expected is None:
|
||||||
|
mismatches.append({"module_id": module_id, "reason": "unknown_module"})
|
||||||
|
continue
|
||||||
|
for field in ("contract_version", "module_version", "digest"):
|
||||||
|
if item.get(field) != expected.get(field):
|
||||||
|
mismatches.append(
|
||||||
|
{
|
||||||
|
"module_id": module_id,
|
||||||
|
"reason": f"{field}_mismatch",
|
||||||
|
"expected": expected.get(field),
|
||||||
|
"actual": item.get(field),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not any(
|
||||||
|
mismatch["module_id"] == module_id for mismatch in mismatches
|
||||||
|
):
|
||||||
|
matched.append(module_id)
|
||||||
|
return {
|
||||||
|
"contract_version": snapshot.get("contract_version"),
|
||||||
|
"matched_modules": sorted(matched),
|
||||||
|
"mismatches": mismatches,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_runtime_snapshot(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ValueError(f"Runtime interface snapshot does not exist: {path}") from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"Runtime interface snapshot is invalid JSON: {exc}") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("Runtime interface snapshot must be a JSON object.")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _namespaced_interface_id(module_id: str, kind: str, value: str) -> str:
|
||||||
|
if value.startswith(f"{module_id}."):
|
||||||
|
return value
|
||||||
|
return f"{module_id}.{kind}.{_surface_slug(value)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _surface_slug(value: str) -> str:
|
||||||
|
normalized = re.sub(r"[^a-z0-9]+", ".", value.strip().lower()).strip(".")
|
||||||
|
return normalized or "root"
|
||||||
|
|
||||||
|
|
||||||
def _render_markdown(inventory: dict[str, Any]) -> str:
|
def _render_markdown(inventory: dict[str, Any]) -> str:
|
||||||
summary = inventory["summary"]
|
summary = inventory["summary"]
|
||||||
missing = inventory["translation_health"]["missing_catalog_entries"]
|
missing = inventory["translation_health"]["missing_catalog_entries"]
|
||||||
@@ -429,8 +895,15 @@ def _render_markdown(inventory: dict[str, Any]) -> str:
|
|||||||
"",
|
"",
|
||||||
f"- Modules: {summary['modules']}",
|
f"- Modules: {summary['modules']}",
|
||||||
f"- UI fields: {summary['ui_fields']}",
|
f"- UI fields: {summary['ui_fields']}",
|
||||||
|
f"- UI actions: {summary['ui_actions']}",
|
||||||
f"- Fields with statically associated help: {summary['ui_fields_with_static_help']}",
|
f"- Fields with statically associated help: {summary['ui_fields_with_static_help']}",
|
||||||
|
f"- Fields with a resolvable F1 context: {summary['ui_fields_with_resolvable_f1_context']}",
|
||||||
|
f"- Fields with dynamic help references: {summary['dynamic_help_references']}",
|
||||||
f"- Help review candidates: {summary['help_review_candidates']}",
|
f"- Help review candidates: {summary['help_review_candidates']}",
|
||||||
|
f"- Stable interface declarations: {summary['interface_declarations']}",
|
||||||
|
f"- Duplicate interface IDs: {summary['duplicate_interface_ids']}",
|
||||||
|
f"- WebUI surfaces missing runtime declarations: {summary['undeclared_source_surfaces']}",
|
||||||
|
f"- Runtime routes missing WebUI implementations: {summary['stale_runtime_routes']}",
|
||||||
f"- Label attributes: {summary['label_attributes']}",
|
f"- Label attributes: {summary['label_attributes']}",
|
||||||
f"- Frontend routes: {summary['frontend_routes']}",
|
f"- Frontend routes: {summary['frontend_routes']}",
|
||||||
f"- Backend endpoints: {summary['backend_endpoints']}",
|
f"- Backend endpoints: {summary['backend_endpoints']}",
|
||||||
@@ -441,7 +914,12 @@ def _render_markdown(inventory: dict[str, Any]) -> str:
|
|||||||
),
|
),
|
||||||
f"- Unclassified backend endpoints: {summary['unclassified_backend_endpoints']}",
|
f"- Unclassified backend endpoints: {summary['unclassified_backend_endpoints']}",
|
||||||
f"- Stale endpoint declarations: {summary['stale_endpoint_declarations']}",
|
f"- Stale endpoint declarations: {summary['stale_endpoint_declarations']}",
|
||||||
f"- Used translation keys missing from a locale catalog: {len(missing)}",
|
f"- Reference locale: `{inventory['translation_health']['reference_locale']}`",
|
||||||
|
f"- Reference locale complete: `{str(inventory['translation_health']['reference_locale_complete']).lower()}`",
|
||||||
|
f"- Used translation keys missing from a required locale catalog: {len(missing)}",
|
||||||
|
f"- Information-governance dimensions enforced: {summary['information_governance_enforced']}",
|
||||||
|
f"- Information-governance dimensions partial: {summary['information_governance_partial']}",
|
||||||
|
f"- Information-governance dimensions contract-only: {summary['information_governance_contract_only']}",
|
||||||
"",
|
"",
|
||||||
"## Help Review Candidates",
|
"## Help Review Candidates",
|
||||||
"",
|
"",
|
||||||
@@ -484,13 +962,24 @@ def _render_markdown(inventory: dict[str, Any]) -> str:
|
|||||||
)
|
)
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
|
"",
|
||||||
|
"## Declaration Reconciliation",
|
||||||
|
"",
|
||||||
|
"Routes, navigation, View surfaces, fields, actions, help references,",
|
||||||
|
"translations, settings, widgets, search objects, and backend",
|
||||||
|
"capabilities use normalized stable IDs. CI rejects duplicate IDs,",
|
||||||
|
"WebUI public surfaces absent from runtime metadata, and runtime routes",
|
||||||
|
"without a WebUI implementation.",
|
||||||
|
"",
|
||||||
"",
|
"",
|
||||||
"## Interpretation",
|
"## Interpretation",
|
||||||
"",
|
"",
|
||||||
"Use the JSON artifact for exact file and line evidence. Missing help is",
|
"Use the JSON artifact for exact file and line evidence. Missing help is",
|
||||||
"a triage list, not an automatic defect. Endpoint coverage requires an",
|
"a triage list, not an automatic defect. Endpoint coverage requires an",
|
||||||
"owner classification before enforcement. Runtime-computed structures",
|
"owner classification before enforcement. Runtime-computed structures",
|
||||||
"need explicit manifest metadata to become canonically visible.",
|
"need explicit manifest or typed PlatformWebModule metadata to become",
|
||||||
|
"canonically visible. The generated files are release evidence, not an",
|
||||||
|
"editable source of platform behavior.",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
schema_version = 1
|
||||||
|
name = "govoplan-k8s-acceptance"
|
||||||
|
mode = "acceptance"
|
||||||
|
state_directory = "~/.local/share/govoplan/labs/govoplan-k8s-acceptance"
|
||||||
|
vm_image_directory = "/var/lib/libvirt/images/govoplan-labs"
|
||||||
|
ssh_user = "govoplan"
|
||||||
|
ssh_private_key = "~/.ssh/govoplan-lab"
|
||||||
|
ssh_public_key = "~/.ssh/govoplan-lab.pub"
|
||||||
|
namespace = "govoplan"
|
||||||
|
public_host = "govoplan.acceptance.example.org"
|
||||||
|
s3_host = "s3.govoplan.acceptance.example.org"
|
||||||
|
ingress_class = "traefik"
|
||||||
|
module_set = "base"
|
||||||
|
api_replicas = 2
|
||||||
|
web_replicas = 2
|
||||||
|
worker_replicas = 2
|
||||||
|
db_connection_limit = 100
|
||||||
|
|
||||||
|
[network]
|
||||||
|
prefix_length = 24
|
||||||
|
gateway = "10.77.10.1"
|
||||||
|
dns_servers = ["10.77.10.1", "1.1.1.1"]
|
||||||
|
bridge = "br0"
|
||||||
|
|
||||||
|
[image]
|
||||||
|
url = "https://cloud-images.ubuntu.com/releases/noble/release-20260801/ubuntu-24.04-server-cloudimg-amd64.img"
|
||||||
|
sha256 = "0533b0655c32e68b31d792ecd6ccfca95abdbc536c4446874fe0513bd4140ffe"
|
||||||
|
|
||||||
|
[k3s]
|
||||||
|
version = "v1.36.1+k3s1"
|
||||||
|
binary_url = "https://github.com/k3s-io/k3s/releases/download/v1.36.1%2Bk3s1/k3s"
|
||||||
|
binary_sha256 = "a443db3fe9820cd93617ae67e4386d87c1514c1e96ceb30f4c2791c39065653c"
|
||||||
|
install_script_url = "https://raw.githubusercontent.com/k3s-io/k3s/v1.36.1%2Bk3s1/install.sh"
|
||||||
|
install_script_sha256 = "46177d4c99440b4c0311b67233823a8e8a2fc09693f6c89af1a7161e152fbfad"
|
||||||
|
|
||||||
|
[release]
|
||||||
|
manifest_url = "https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/v0.1.15/distribution-manifest.json"
|
||||||
|
manifest_sha256 = "09ac1ade6ede4958bab0dfb7fd8f99246f4d991846308db1f410b25b46267840"
|
||||||
|
keyring_url = "https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/v0.1.15/distribution-keyring.json"
|
||||||
|
keyring_sha256 = "92a9f8e3bac0ef525ad9a063c76faa36233a9070cd4a5db9ee9b3f92323b602f"
|
||||||
|
channel = "stable"
|
||||||
|
|
||||||
|
[[nodes]]
|
||||||
|
name = "control-1"
|
||||||
|
role = "control"
|
||||||
|
address = "10.77.10.21"
|
||||||
|
hypervisor = "lab-admin@hypervisor-state.example.org"
|
||||||
|
failure_domain = "rack-c"
|
||||||
|
mac_address = "52:54:00:68:01:01"
|
||||||
|
cpus = 2
|
||||||
|
memory_mib = 4096
|
||||||
|
disk_gib = 32
|
||||||
|
|
||||||
|
[[nodes]]
|
||||||
|
name = "worker-1"
|
||||||
|
role = "worker"
|
||||||
|
address = "10.77.10.22"
|
||||||
|
hypervisor = "lab-admin@hypervisor-a.example.org"
|
||||||
|
failure_domain = "rack-a"
|
||||||
|
mac_address = "52:54:00:68:01:02"
|
||||||
|
cpus = 2
|
||||||
|
memory_mib = 4096
|
||||||
|
disk_gib = 40
|
||||||
|
|
||||||
|
[[nodes]]
|
||||||
|
name = "worker-2"
|
||||||
|
role = "worker"
|
||||||
|
address = "10.77.10.23"
|
||||||
|
hypervisor = "lab-admin@hypervisor-b.example.org"
|
||||||
|
failure_domain = "rack-b"
|
||||||
|
mac_address = "52:54:00:68:01:03"
|
||||||
|
cpus = 2
|
||||||
|
memory_mib = 4096
|
||||||
|
disk_gib = 40
|
||||||
|
|
||||||
|
[[nodes]]
|
||||||
|
name = "state-1"
|
||||||
|
role = "state"
|
||||||
|
address = "10.77.10.24"
|
||||||
|
hypervisor = "lab-admin@hypervisor-state.example.org"
|
||||||
|
failure_domain = "rack-c"
|
||||||
|
mac_address = "52:54:00:68:01:04"
|
||||||
|
cpus = 4
|
||||||
|
memory_mib = 8192
|
||||||
|
disk_gib = 120
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
schema_version = 1
|
||||||
|
name = "govoplan-k8s-lab"
|
||||||
|
mode = "rehearsal"
|
||||||
|
state_directory = "~/.local/share/govoplan/labs/govoplan-k8s-lab"
|
||||||
|
vm_image_directory = "/var/lib/libvirt/images/govoplan-labs"
|
||||||
|
ssh_user = "govoplan"
|
||||||
|
ssh_private_key = "~/.ssh/govoplan-lab"
|
||||||
|
ssh_public_key = "~/.ssh/govoplan-lab.pub"
|
||||||
|
namespace = "govoplan"
|
||||||
|
public_host = "govoplan.lab.test"
|
||||||
|
s3_host = "s3.govoplan.lab.test"
|
||||||
|
ingress_class = "traefik"
|
||||||
|
module_set = "base"
|
||||||
|
api_replicas = 2
|
||||||
|
web_replicas = 2
|
||||||
|
worker_replicas = 2
|
||||||
|
db_connection_limit = 100
|
||||||
|
|
||||||
|
[network]
|
||||||
|
prefix_length = 24
|
||||||
|
gateway = "192.168.123.1"
|
||||||
|
dns_servers = ["192.168.123.1", "1.1.1.1"]
|
||||||
|
bridge = "virbr-gplab"
|
||||||
|
|
||||||
|
[image]
|
||||||
|
url = "https://cloud-images.ubuntu.com/releases/noble/release-20260801/ubuntu-24.04-server-cloudimg-amd64.img"
|
||||||
|
sha256 = "0533b0655c32e68b31d792ecd6ccfca95abdbc536c4446874fe0513bd4140ffe"
|
||||||
|
|
||||||
|
[k3s]
|
||||||
|
version = "v1.36.1+k3s1"
|
||||||
|
binary_url = "https://github.com/k3s-io/k3s/releases/download/v1.36.1%2Bk3s1/k3s"
|
||||||
|
binary_sha256 = "a443db3fe9820cd93617ae67e4386d87c1514c1e96ceb30f4c2791c39065653c"
|
||||||
|
install_script_url = "https://raw.githubusercontent.com/k3s-io/k3s/v1.36.1%2Bk3s1/install.sh"
|
||||||
|
install_script_sha256 = "46177d4c99440b4c0311b67233823a8e8a2fc09693f6c89af1a7161e152fbfad"
|
||||||
|
|
||||||
|
[release]
|
||||||
|
manifest_url = "https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/v0.1.15/distribution-manifest.json"
|
||||||
|
manifest_sha256 = "09ac1ade6ede4958bab0dfb7fd8f99246f4d991846308db1f410b25b46267840"
|
||||||
|
keyring_url = "https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/v0.1.15/distribution-keyring.json"
|
||||||
|
keyring_sha256 = "92a9f8e3bac0ef525ad9a063c76faa36233a9070cd4a5db9ee9b3f92323b602f"
|
||||||
|
channel = "stable"
|
||||||
|
|
||||||
|
[[nodes]]
|
||||||
|
name = "control-1"
|
||||||
|
role = "control"
|
||||||
|
address = "192.168.123.201"
|
||||||
|
hypervisor = "local"
|
||||||
|
failure_domain = "local-host"
|
||||||
|
mac_address = "52:54:00:67:01:01"
|
||||||
|
cpus = 2
|
||||||
|
memory_mib = 4096
|
||||||
|
disk_gib = 32
|
||||||
|
|
||||||
|
[[nodes]]
|
||||||
|
name = "worker-1"
|
||||||
|
role = "worker"
|
||||||
|
address = "192.168.123.202"
|
||||||
|
hypervisor = "local"
|
||||||
|
failure_domain = "local-host"
|
||||||
|
mac_address = "52:54:00:67:01:02"
|
||||||
|
cpus = 2
|
||||||
|
memory_mib = 4096
|
||||||
|
disk_gib = 40
|
||||||
|
|
||||||
|
[[nodes]]
|
||||||
|
name = "worker-2"
|
||||||
|
role = "worker"
|
||||||
|
address = "192.168.123.203"
|
||||||
|
hypervisor = "local"
|
||||||
|
failure_domain = "local-host"
|
||||||
|
mac_address = "52:54:00:67:01:03"
|
||||||
|
cpus = 2
|
||||||
|
memory_mib = 4096
|
||||||
|
disk_gib = 40
|
||||||
|
|
||||||
|
[[nodes]]
|
||||||
|
name = "state-1"
|
||||||
|
role = "state"
|
||||||
|
address = "192.168.123.204"
|
||||||
|
hypervisor = "local"
|
||||||
|
failure_domain = "local-host"
|
||||||
|
mac_address = "52:54:00:67:01:04"
|
||||||
|
cpus = 4
|
||||||
|
memory_mib = 4096
|
||||||
|
disk_gib = 80
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Executable entry point for the GovOPlaN Kubernetes VM lab."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from govoplan_lab.cli import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Reproducible GovOPlaN Kubernetes acceptance lab."""
|
||||||
|
|
||||||
|
from .config import LabConfig, LabConfigError, LabNode, load_config
|
||||||
|
|
||||||
|
__all__ = ["LabConfig", "LabConfigError", "LabNode", "load_config"]
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Command-line interface for the GovOPlaN Kubernetes VM lab."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from .config import LabConfigError, load_config
|
||||||
|
from .lifecycle import (
|
||||||
|
LabOperationError,
|
||||||
|
create,
|
||||||
|
deploy,
|
||||||
|
destroy,
|
||||||
|
doctor,
|
||||||
|
enroll_admin,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
status,
|
||||||
|
update,
|
||||||
|
verify,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="govoplan-lab",
|
||||||
|
description="Create and operate a libvirt-backed GovOPlaN Kubernetes test lab.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
type=Path,
|
||||||
|
default=Path("govoplan-lab.toml"),
|
||||||
|
help="Strict TOML lab inventory (default: ./govoplan-lab.toml).",
|
||||||
|
)
|
||||||
|
parser.add_argument("--verbose", action="store_true")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
doctor_parser = subparsers.add_parser("doctor", help="Validate inventory and prerequisites.")
|
||||||
|
doctor_parser.add_argument(
|
||||||
|
"--online",
|
||||||
|
action="store_true",
|
||||||
|
help="Also connect to every hypervisor and verify its toolchain.",
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers.add_parser("status", help="Show VM, Kubernetes node, and pod state.")
|
||||||
|
_mutation_parser(subparsers, "create", "Create or reuse all declared VMs.")
|
||||||
|
_mutation_parser(subparsers, "deploy", "Deploy shared state, K3s, and GovOPlaN.")
|
||||||
|
_mutation_parser(subparsers, "update", "Reconcile pinned K3s and GovOPlaN inputs serially.")
|
||||||
|
_mutation_parser(subparsers, "pause", "Gracefully stop the lab while preserving disks.")
|
||||||
|
_mutation_parser(subparsers, "resume", "Start a paused lab in dependency order.")
|
||||||
|
|
||||||
|
destroy_parser = _mutation_parser(
|
||||||
|
subparsers,
|
||||||
|
"destroy",
|
||||||
|
"Destroy lab-owned VMs and disks with an explicit name confirmation.",
|
||||||
|
)
|
||||||
|
destroy_parser.add_argument("--confirm", default="")
|
||||||
|
destroy_parser.add_argument(
|
||||||
|
"--purge-local-state",
|
||||||
|
action="store_true",
|
||||||
|
help="Also delete local secrets, manifests, and evidence after VM teardown.",
|
||||||
|
)
|
||||||
|
|
||||||
|
verify_parser = subparsers.add_parser(
|
||||||
|
"verify",
|
||||||
|
help=(
|
||||||
|
"Collect sanitized live-cluster evidence using an "
|
||||||
|
"Ops-read-authorized GOVOPLAN_OPS_API_KEY."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
verify_parser.add_argument(
|
||||||
|
"--exercise-api-pod-loss",
|
||||||
|
action="store_true",
|
||||||
|
help="Delete one ready API pod during the bounded availability drill.",
|
||||||
|
)
|
||||||
|
enroll_parser = _mutation_parser(
|
||||||
|
subparsers,
|
||||||
|
"enroll-admin",
|
||||||
|
"Securely consume the first-administrator enrollment artifact.",
|
||||||
|
)
|
||||||
|
enroll_parser.add_argument("--email", required=True)
|
||||||
|
enroll_parser.add_argument("--display-name", default=None)
|
||||||
|
enroll_parser.add_argument("--tenant-slug", default="default")
|
||||||
|
enroll_parser.add_argument("--tenant-name", default="Default Tenant")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _mutation_parser(
|
||||||
|
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
||||||
|
name: str,
|
||||||
|
help_text: str,
|
||||||
|
) -> argparse.ArgumentParser:
|
||||||
|
parser = subparsers.add_parser(name, help=help_text)
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply",
|
||||||
|
action="store_true",
|
||||||
|
help="Perform mutations; without this flag the command is a dry run.",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
try:
|
||||||
|
config = load_config(args.config)
|
||||||
|
if args.command == "doctor":
|
||||||
|
return doctor(config, online=args.online, verbose=args.verbose)
|
||||||
|
if args.command == "status":
|
||||||
|
return status(config, verbose=args.verbose)
|
||||||
|
if args.command == "create":
|
||||||
|
create(config, apply=args.apply, verbose=args.verbose)
|
||||||
|
elif args.command == "deploy":
|
||||||
|
deploy(config, apply=args.apply, verbose=args.verbose)
|
||||||
|
elif args.command == "update":
|
||||||
|
update(config, apply=args.apply, verbose=args.verbose)
|
||||||
|
elif args.command == "pause":
|
||||||
|
pause(config, apply=args.apply, verbose=args.verbose)
|
||||||
|
elif args.command == "resume":
|
||||||
|
resume(config, apply=args.apply, verbose=args.verbose)
|
||||||
|
elif args.command == "destroy":
|
||||||
|
destroy(
|
||||||
|
config,
|
||||||
|
apply=args.apply,
|
||||||
|
confirmation=args.confirm,
|
||||||
|
purge_local_state=args.purge_local_state,
|
||||||
|
verbose=args.verbose,
|
||||||
|
)
|
||||||
|
elif args.command == "verify":
|
||||||
|
verify(
|
||||||
|
config,
|
||||||
|
exercise_api_pod_loss=args.exercise_api_pod_loss,
|
||||||
|
verbose=args.verbose,
|
||||||
|
)
|
||||||
|
elif args.command == "enroll-admin":
|
||||||
|
enroll_admin(
|
||||||
|
config,
|
||||||
|
email=args.email,
|
||||||
|
display_name=args.display_name,
|
||||||
|
tenant_slug=args.tenant_slug,
|
||||||
|
tenant_name=args.tenant_name,
|
||||||
|
apply=args.apply,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"unsupported command: {args.command}")
|
||||||
|
return 0
|
||||||
|
except (LabConfigError, LabOperationError, OSError, ValueError) as exc:
|
||||||
|
print(f"error: {exc}", file=__import__("sys").stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
"""Strict TOML model for the GovOPlaN Kubernetes VM lab."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import ipaddress
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
from typing import Any, Mapping
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
_NAME = re.compile(r"^[a-z][a-z0-9-]{1,47}$")
|
||||||
|
_HOSTNAME = re.compile(
|
||||||
|
r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*"
|
||||||
|
r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$"
|
||||||
|
)
|
||||||
|
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
_K3S_VERSION = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+\+k3s[0-9]+$")
|
||||||
|
_SSH_TARGET = re.compile(r"^(?:[A-Za-z0-9_.-]+@)?[A-Za-z0-9_.:-]+$")
|
||||||
|
_BRIDGE = re.compile(r"^[A-Za-z0-9_.:-]{1,32}$")
|
||||||
|
_ROLE = {"control", "worker", "state"}
|
||||||
|
_MODE = {"rehearsal", "acceptance"}
|
||||||
|
|
||||||
|
|
||||||
|
class LabConfigError(ValueError):
|
||||||
|
"""Raised when the lab inventory cannot be used safely."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LabNode:
|
||||||
|
name: str
|
||||||
|
role: str
|
||||||
|
address: ipaddress.IPv4Address
|
||||||
|
hypervisor: str
|
||||||
|
failure_domain: str
|
||||||
|
mac_address: str
|
||||||
|
cpus: int
|
||||||
|
memory_mib: int
|
||||||
|
disk_gib: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_local(self) -> bool:
|
||||||
|
return self.hypervisor == "local"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class NetworkConfig:
|
||||||
|
prefix_length: int
|
||||||
|
gateway: ipaddress.IPv4Address
|
||||||
|
dns_servers: tuple[ipaddress.IPv4Address, ...]
|
||||||
|
bridge: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ImageConfig:
|
||||||
|
url: str
|
||||||
|
sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class K3sConfig:
|
||||||
|
version: str
|
||||||
|
binary_url: str
|
||||||
|
binary_sha256: str
|
||||||
|
install_script_url: str
|
||||||
|
install_script_sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReleaseConfig:
|
||||||
|
manifest_url: str
|
||||||
|
manifest_sha256: str
|
||||||
|
keyring_url: str
|
||||||
|
keyring_sha256: str
|
||||||
|
channel: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LabConfig:
|
||||||
|
source: Path
|
||||||
|
schema_version: int
|
||||||
|
name: str
|
||||||
|
mode: str
|
||||||
|
state_directory: Path
|
||||||
|
vm_image_directory: str
|
||||||
|
ssh_user: str
|
||||||
|
ssh_private_key: Path
|
||||||
|
ssh_public_key: Path
|
||||||
|
namespace: str
|
||||||
|
public_host: str
|
||||||
|
s3_host: str
|
||||||
|
ingress_class: str
|
||||||
|
module_set: str
|
||||||
|
api_replicas: int
|
||||||
|
web_replicas: int
|
||||||
|
worker_replicas: int
|
||||||
|
db_connection_limit: int
|
||||||
|
network: NetworkConfig
|
||||||
|
image: ImageConfig
|
||||||
|
k3s: K3sConfig
|
||||||
|
release: ReleaseConfig
|
||||||
|
nodes: tuple[LabNode, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def controls(self) -> tuple[LabNode, ...]:
|
||||||
|
return tuple(node for node in self.nodes if node.role == "control")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def workers(self) -> tuple[LabNode, ...]:
|
||||||
|
return tuple(node for node in self.nodes if node.role == "worker")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_node(self) -> LabNode:
|
||||||
|
return next(node for node in self.nodes if node.role == "state")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_control(self) -> LabNode:
|
||||||
|
return self.controls[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_url(self) -> str:
|
||||||
|
return f"https://{self.public_host}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def s3_url(self) -> str:
|
||||||
|
return f"https://{self.s3_host}:9443"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def evidence_capable(self) -> bool:
|
||||||
|
worker_domains = {node.failure_domain for node in self.workers}
|
||||||
|
worker_hypervisors = {node.hypervisor for node in self.workers}
|
||||||
|
return (
|
||||||
|
self.mode == "acceptance"
|
||||||
|
and len(worker_domains) == len(self.workers)
|
||||||
|
and len(worker_hypervisors) == len(self.workers)
|
||||||
|
and self.state_node.failure_domain not in worker_domains
|
||||||
|
and self.state_node.hypervisor not in worker_hypervisors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> LabConfig:
|
||||||
|
source = path.expanduser().resolve()
|
||||||
|
try:
|
||||||
|
raw = tomllib.loads(source.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise LabConfigError(f"lab configuration does not exist: {source}") from exc
|
||||||
|
except tomllib.TOMLDecodeError as exc:
|
||||||
|
raise LabConfigError(f"lab configuration is not valid TOML: {exc}") from exc
|
||||||
|
root = _mapping(raw, "lab configuration")
|
||||||
|
_only_keys(
|
||||||
|
root,
|
||||||
|
{
|
||||||
|
"schema_version",
|
||||||
|
"name",
|
||||||
|
"mode",
|
||||||
|
"state_directory",
|
||||||
|
"vm_image_directory",
|
||||||
|
"ssh_user",
|
||||||
|
"ssh_private_key",
|
||||||
|
"ssh_public_key",
|
||||||
|
"namespace",
|
||||||
|
"public_host",
|
||||||
|
"s3_host",
|
||||||
|
"ingress_class",
|
||||||
|
"module_set",
|
||||||
|
"api_replicas",
|
||||||
|
"web_replicas",
|
||||||
|
"worker_replicas",
|
||||||
|
"db_connection_limit",
|
||||||
|
"network",
|
||||||
|
"image",
|
||||||
|
"k3s",
|
||||||
|
"release",
|
||||||
|
"nodes",
|
||||||
|
},
|
||||||
|
"lab configuration",
|
||||||
|
)
|
||||||
|
schema_version = _integer(root, "schema_version")
|
||||||
|
if schema_version != SCHEMA_VERSION:
|
||||||
|
raise LabConfigError(
|
||||||
|
f"schema_version must be {SCHEMA_VERSION}; found {schema_version}"
|
||||||
|
)
|
||||||
|
name = _pattern(root, "name", _NAME)
|
||||||
|
mode = _choice(root, "mode", _MODE)
|
||||||
|
base = source.parent
|
||||||
|
state_directory = _path(root, "state_directory", base)
|
||||||
|
vm_image_directory = _absolute_posix_path(root, "vm_image_directory")
|
||||||
|
ssh_user = _pattern(root, "ssh_user", re.compile(r"^[a-z_][a-z0-9_-]{0,31}$"))
|
||||||
|
ssh_private_key = _path(root, "ssh_private_key", base)
|
||||||
|
ssh_public_key = _path(root, "ssh_public_key", base)
|
||||||
|
namespace = _pattern(root, "namespace", _NAME)
|
||||||
|
public_host = _pattern(root, "public_host", _HOSTNAME)
|
||||||
|
s3_host = _pattern(root, "s3_host", _HOSTNAME)
|
||||||
|
if public_host == s3_host:
|
||||||
|
raise LabConfigError("public_host and s3_host must be different")
|
||||||
|
ingress_class = _pattern(root, "ingress_class", _NAME)
|
||||||
|
module_set = _choice(root, "module_set", {"core", "base", "full"})
|
||||||
|
api_replicas = _bounded_integer(root, "api_replicas", 2, 32)
|
||||||
|
web_replicas = _bounded_integer(root, "web_replicas", 2, 32)
|
||||||
|
worker_replicas = _bounded_integer(root, "worker_replicas", 2, 64)
|
||||||
|
db_connection_limit = _bounded_integer(root, "db_connection_limit", 50, 10000)
|
||||||
|
network = _parse_network(_mapping(root.get("network"), "network"))
|
||||||
|
image = _parse_image(_mapping(root.get("image"), "image"))
|
||||||
|
k3s = _parse_k3s(_mapping(root.get("k3s"), "k3s"))
|
||||||
|
release = _parse_release(_mapping(root.get("release"), "release"))
|
||||||
|
raw_nodes = root.get("nodes")
|
||||||
|
if not isinstance(raw_nodes, list) or not raw_nodes:
|
||||||
|
raise LabConfigError("nodes must be a non-empty array of tables")
|
||||||
|
nodes = tuple(_parse_node(item, index=index) for index, item in enumerate(raw_nodes))
|
||||||
|
config = LabConfig(
|
||||||
|
source=source,
|
||||||
|
schema_version=schema_version,
|
||||||
|
name=name,
|
||||||
|
mode=mode,
|
||||||
|
state_directory=state_directory,
|
||||||
|
vm_image_directory=vm_image_directory.rstrip("/"),
|
||||||
|
ssh_user=ssh_user,
|
||||||
|
ssh_private_key=ssh_private_key,
|
||||||
|
ssh_public_key=ssh_public_key,
|
||||||
|
namespace=namespace,
|
||||||
|
public_host=public_host,
|
||||||
|
s3_host=s3_host,
|
||||||
|
ingress_class=ingress_class,
|
||||||
|
module_set=module_set,
|
||||||
|
api_replicas=api_replicas,
|
||||||
|
web_replicas=web_replicas,
|
||||||
|
worker_replicas=worker_replicas,
|
||||||
|
db_connection_limit=db_connection_limit,
|
||||||
|
network=network,
|
||||||
|
image=image,
|
||||||
|
k3s=k3s,
|
||||||
|
release=release,
|
||||||
|
nodes=nodes,
|
||||||
|
)
|
||||||
|
_validate_topology(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_network(raw: Mapping[str, Any]) -> NetworkConfig:
|
||||||
|
_only_keys(raw, {"prefix_length", "gateway", "dns_servers", "bridge"}, "network")
|
||||||
|
prefix_length = _bounded_integer(raw, "prefix_length", 8, 30)
|
||||||
|
gateway = _ipv4(raw, "gateway")
|
||||||
|
dns_raw = raw.get("dns_servers")
|
||||||
|
if not isinstance(dns_raw, list) or not dns_raw or len(dns_raw) > 4:
|
||||||
|
raise LabConfigError("network.dns_servers must contain 1-4 IPv4 addresses")
|
||||||
|
dns_servers = tuple(_ipv4_value(value, "network.dns_servers") for value in dns_raw)
|
||||||
|
bridge = _pattern(raw, "bridge", _BRIDGE)
|
||||||
|
return NetworkConfig(prefix_length, gateway, dns_servers, bridge)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_image(raw: Mapping[str, Any]) -> ImageConfig:
|
||||||
|
_only_keys(raw, {"url", "sha256"}, "image")
|
||||||
|
return ImageConfig(
|
||||||
|
url=_https_url(raw, "url"),
|
||||||
|
sha256=_pattern(raw, "sha256", _SHA256),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_k3s(raw: Mapping[str, Any]) -> K3sConfig:
|
||||||
|
_only_keys(
|
||||||
|
raw,
|
||||||
|
{
|
||||||
|
"version",
|
||||||
|
"binary_url",
|
||||||
|
"binary_sha256",
|
||||||
|
"install_script_url",
|
||||||
|
"install_script_sha256",
|
||||||
|
},
|
||||||
|
"k3s",
|
||||||
|
)
|
||||||
|
return K3sConfig(
|
||||||
|
version=_pattern(raw, "version", _K3S_VERSION),
|
||||||
|
binary_url=_https_url(raw, "binary_url"),
|
||||||
|
binary_sha256=_pattern(raw, "binary_sha256", _SHA256),
|
||||||
|
install_script_url=_https_url(raw, "install_script_url"),
|
||||||
|
install_script_sha256=_pattern(raw, "install_script_sha256", _SHA256),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_release(raw: Mapping[str, Any]) -> ReleaseConfig:
|
||||||
|
_only_keys(
|
||||||
|
raw,
|
||||||
|
{"manifest_url", "manifest_sha256", "keyring_url", "keyring_sha256", "channel"},
|
||||||
|
"release",
|
||||||
|
)
|
||||||
|
return ReleaseConfig(
|
||||||
|
manifest_url=_https_url(raw, "manifest_url"),
|
||||||
|
manifest_sha256=_pattern(raw, "manifest_sha256", _SHA256),
|
||||||
|
keyring_url=_https_url(raw, "keyring_url"),
|
||||||
|
keyring_sha256=_pattern(raw, "keyring_sha256", _SHA256),
|
||||||
|
channel=_pattern(raw, "channel", _NAME),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_node(value: object, *, index: int) -> LabNode:
|
||||||
|
raw = _mapping(value, f"nodes[{index}]")
|
||||||
|
_only_keys(
|
||||||
|
raw,
|
||||||
|
{
|
||||||
|
"name",
|
||||||
|
"role",
|
||||||
|
"address",
|
||||||
|
"hypervisor",
|
||||||
|
"failure_domain",
|
||||||
|
"mac_address",
|
||||||
|
"cpus",
|
||||||
|
"memory_mib",
|
||||||
|
"disk_gib",
|
||||||
|
},
|
||||||
|
f"nodes[{index}]",
|
||||||
|
)
|
||||||
|
hypervisor = _string(raw, "hypervisor")
|
||||||
|
if hypervisor != "local" and not _SSH_TARGET.fullmatch(hypervisor):
|
||||||
|
raise LabConfigError(
|
||||||
|
f"nodes[{index}].hypervisor must be 'local' or a simple SSH target"
|
||||||
|
)
|
||||||
|
mac_address = _string(raw, "mac_address").lower()
|
||||||
|
try:
|
||||||
|
octets = mac_address.split(":")
|
||||||
|
valid_mac = len(octets) == 6 and all(
|
||||||
|
len(octet) == 2 and 0 <= int(octet, 16) <= 255 for octet in octets
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
valid_mac = False
|
||||||
|
if not valid_mac:
|
||||||
|
raise LabConfigError(f"nodes[{index}].mac_address is not a canonical MAC address")
|
||||||
|
return LabNode(
|
||||||
|
name=_pattern(raw, "name", _NAME),
|
||||||
|
role=_choice(raw, "role", _ROLE),
|
||||||
|
address=_ipv4(raw, "address"),
|
||||||
|
hypervisor=hypervisor,
|
||||||
|
failure_domain=_pattern(raw, "failure_domain", _NAME),
|
||||||
|
mac_address=mac_address,
|
||||||
|
cpus=_bounded_integer(raw, "cpus", 1, 64),
|
||||||
|
memory_mib=_bounded_integer(raw, "memory_mib", 2048, 262144),
|
||||||
|
disk_gib=_bounded_integer(raw, "disk_gib", 16, 4096),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_topology(config: LabConfig) -> None:
|
||||||
|
names = [node.name for node in config.nodes]
|
||||||
|
addresses = [node.address for node in config.nodes]
|
||||||
|
mac_addresses = [node.mac_address for node in config.nodes]
|
||||||
|
for label, values in (
|
||||||
|
("node names", names),
|
||||||
|
("node addresses", addresses),
|
||||||
|
("node MAC addresses", mac_addresses),
|
||||||
|
):
|
||||||
|
if len(values) != len(set(values)):
|
||||||
|
raise LabConfigError(f"{label} must be unique")
|
||||||
|
too_long = [
|
||||||
|
node.name
|
||||||
|
for node in config.nodes
|
||||||
|
if len(f"{config.name}-{node.name}") > 63
|
||||||
|
]
|
||||||
|
if too_long:
|
||||||
|
raise LabConfigError(
|
||||||
|
"lab name plus node name must fit the 63-character libvirt domain limit: "
|
||||||
|
+ ", ".join(too_long)
|
||||||
|
)
|
||||||
|
if len(config.controls) not in {1, 3}:
|
||||||
|
raise LabConfigError("the lab requires exactly one or three control-plane nodes")
|
||||||
|
if len(config.workers) < 2:
|
||||||
|
raise LabConfigError("the lab requires at least two worker nodes")
|
||||||
|
if sum(node.role == "state" for node in config.nodes) != 1:
|
||||||
|
raise LabConfigError("the lab requires exactly one external shared-state node")
|
||||||
|
network = ipaddress.ip_network(
|
||||||
|
f"{config.network.gateway}/{config.network.prefix_length}", strict=False
|
||||||
|
)
|
||||||
|
if any(node.address not in network for node in config.nodes):
|
||||||
|
raise LabConfigError("every node address must be in the configured IPv4 network")
|
||||||
|
if config.network.gateway in addresses:
|
||||||
|
raise LabConfigError("the network gateway cannot also be a node address")
|
||||||
|
if config.mode == "acceptance" and not config.evidence_capable:
|
||||||
|
raise LabConfigError(
|
||||||
|
"acceptance mode requires each worker and the shared-state node to use "
|
||||||
|
"distinct hypervisors and failure_domain values"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: object, label: str) -> Mapping[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise LabConfigError(f"{label} must be a table")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _only_keys(raw: Mapping[str, Any], allowed: set[str], label: str) -> None:
|
||||||
|
unexpected = sorted(set(raw) - allowed)
|
||||||
|
if unexpected:
|
||||||
|
raise LabConfigError(f"{label} contains unsupported keys: {', '.join(unexpected)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _string(raw: Mapping[str, Any], key: str) -> str:
|
||||||
|
value = raw.get(key)
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise LabConfigError(f"{key} must be a non-empty string")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _integer(raw: Mapping[str, Any], key: str) -> int:
|
||||||
|
value = raw.get(key)
|
||||||
|
if not isinstance(value, int) or isinstance(value, bool):
|
||||||
|
raise LabConfigError(f"{key} must be an integer")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_integer(raw: Mapping[str, Any], key: str, minimum: int, maximum: int) -> int:
|
||||||
|
value = _integer(raw, key)
|
||||||
|
if not minimum <= value <= maximum:
|
||||||
|
raise LabConfigError(f"{key} must be between {minimum} and {maximum}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _pattern(raw: Mapping[str, Any], key: str, pattern: re.Pattern[str]) -> str:
|
||||||
|
value = _string(raw, key)
|
||||||
|
if pattern.fullmatch(value) is None:
|
||||||
|
raise LabConfigError(f"{key} has an unsupported format")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _choice(raw: Mapping[str, Any], key: str, choices: set[str]) -> str:
|
||||||
|
value = _string(raw, key)
|
||||||
|
if value not in choices:
|
||||||
|
raise LabConfigError(f"{key} must be one of: {', '.join(sorted(choices))}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _ipv4(raw: Mapping[str, Any], key: str) -> ipaddress.IPv4Address:
|
||||||
|
return _ipv4_value(_string(raw, key), key)
|
||||||
|
|
||||||
|
|
||||||
|
def _ipv4_value(value: object, label: str) -> ipaddress.IPv4Address:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise LabConfigError(f"{label} must contain strings")
|
||||||
|
try:
|
||||||
|
parsed = ipaddress.ip_address(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise LabConfigError(f"{label} contains an invalid IP address") from exc
|
||||||
|
if not isinstance(parsed, ipaddress.IPv4Address):
|
||||||
|
raise LabConfigError(f"{label} supports IPv4 only in schema version 1")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _path(raw: Mapping[str, Any], key: str, base: Path) -> Path:
|
||||||
|
value = Path(_string(raw, key)).expanduser()
|
||||||
|
return (value if value.is_absolute() else base / value).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _absolute_posix_path(raw: Mapping[str, Any], key: str) -> str:
|
||||||
|
value = _string(raw, key)
|
||||||
|
if not value.startswith("/") or ".." in Path(value).parts:
|
||||||
|
raise LabConfigError(f"{key} must be an absolute path without '..'")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _https_url(raw: Mapping[str, Any], key: str) -> str:
|
||||||
|
value = _string(raw, key)
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
|
||||||
|
raise LabConfigError(f"{key} must be a credential-free HTTPS URL")
|
||||||
|
if parsed.fragment:
|
||||||
|
raise LabConfigError(f"{key} must not contain a fragment")
|
||||||
|
return value
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user