Compare commits
56
Commits
ddee5c00dc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
186aa104ce | ||
|
|
ff9d2404ab | ||
|
|
ba82a85547 | ||
|
|
f1fd143ef5 | ||
|
|
b4248a849e | ||
|
|
ff47659899 | ||
|
|
908090dd0f | ||
|
|
3864ce28b1 | ||
|
|
857dbe55f7 | ||
|
|
c9fcdc90c1 | ||
|
|
82e836b720 | ||
|
|
fcb8296812 | ||
|
|
f2e2eb5517 | ||
|
|
1aea3e7c4f | ||
|
|
3f9567af18 | ||
|
|
de16f11ce8 | ||
|
|
9d6cdff4b8 | ||
|
|
aa4050c0ca | ||
|
|
d3cdbd8c7a | ||
|
|
7115c4711d | ||
|
|
a3beca6fc5 | ||
|
|
11d45bce25 | ||
|
|
7b6135b89b | ||
|
|
5b79e7d377 | ||
|
|
6afb8fea76 | ||
|
|
163b35c0af | ||
|
|
603e07cec5 | ||
|
|
97dfd333c6 | ||
|
|
ba88c574b9 | ||
|
|
8d292184d4 | ||
|
|
52bd3527cd | ||
|
|
ff49dabf8f | ||
|
|
cd15aa514e | ||
|
|
a042baa1d3 | ||
|
|
e80de00f29 | ||
|
|
c69acf0dee | ||
|
|
8b93bbc6b6 | ||
|
|
3fc17701df | ||
|
|
9788bdde0c | ||
|
|
a10a01c903 | ||
|
|
e005e54333 | ||
|
|
76e4baa76e | ||
|
|
b6e9a9bd81 | ||
|
|
dc46bda224 | ||
|
|
b00d4e55ee | ||
|
|
76f19dc602 | ||
|
|
5381e37a9e | ||
|
|
7ecf1f17b0 | ||
|
|
349a099e5a | ||
|
|
fdee766993 | ||
|
|
47b9c05bde | ||
|
|
4e42911477 | ||
|
|
9c0650b2ed | ||
|
|
4dc0c8d013 | ||
|
|
35c346a1fa | ||
|
|
4edbc11fe5 |
+13
-2
@@ -2,17 +2,24 @@
|
|||||||
# Copy to a deployment-local .env or secret store. Do not commit populated secrets.
|
# Copy to a deployment-local .env or secret store. Do not commit populated secrets.
|
||||||
|
|
||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
|
# Live graph changes are useful in development. Production should apply saved
|
||||||
|
# module state through a coordinated restart of all API and worker processes.
|
||||||
|
GOVOPLAN_MODULE_LIVE_APPLY_ENABLED=
|
||||||
GOVOPLAN_INSTALL_PROFILE=self-hosted
|
GOVOPLAN_INSTALL_PROFILE=self-hosted
|
||||||
MASTER_KEY_B64=<generate-with-govoplan-config-env-template-generate-secrets>
|
MASTER_KEY_B64=<generate-with-govoplan-config-env-template-generate-secrets>
|
||||||
|
|
||||||
DATABASE_URL=postgresql+psycopg://govoplan:change-me@127.0.0.1:5432/govoplan
|
DATABASE_URL=postgresql+psycopg://govoplan:change-me@127.0.0.1:5432/govoplan
|
||||||
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:change-me@127.0.0.1:5432/govoplan
|
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:change-me@127.0.0.1:5432/govoplan
|
||||||
|
GOVOPLAN_DB_POOL_SIZE=5
|
||||||
|
GOVOPLAN_DB_MAX_OVERFLOW=10
|
||||||
|
GOVOPLAN_DB_POOL_TIMEOUT_SECONDS=30
|
||||||
|
GOVOPLAN_DB_POOL_RECYCLE_SECONDS=1800
|
||||||
|
|
||||||
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,files,mail,campaigns,calendar,docs,ops
|
ENABLED_MODULES=tenancy,organizations,identity,idm,access,admin,dashboard,policy,audit,files,mail,campaigns,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,views,search,risk_compliance,postbox,notifications,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
|
||||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,events,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
|
||||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
|
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
|
||||||
@@ -24,10 +31,14 @@ GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
|||||||
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
|
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
|
||||||
|
|
||||||
AUTH_LOGIN_THROTTLE_ENABLED=true
|
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||||
|
AUTH_ACTIVITY_TOUCH_INTERVAL_SECONDS=300
|
||||||
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||||
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||||
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||||
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
||||||
|
# Production startup fails without Redis unless this explicit single-process
|
||||||
|
# risk acknowledgement is enabled.
|
||||||
|
GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=false
|
||||||
|
|
||||||
CORS_ORIGINS=https://govoplan.example.org
|
CORS_ORIGINS=https://govoplan.example.org
|
||||||
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
|
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
|
||||||
|
|||||||
@@ -21,23 +21,13 @@ jobs:
|
|||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
- name: Configure SSH for release dependencies
|
- name: Use anonymous HTTPS for public GovOPlaN repositories
|
||||||
env:
|
|
||||||
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan"
|
||||||
chmod 700 ~/.ssh
|
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan"
|
||||||
if [ -z "${GOVOPLAN_RELEASE_SSH_KEY_B64:-}" ]; then
|
|
||||||
echo "GOVOPLAN_RELEASE_SSH_KEY_B64 secret is required for git+ssh release dependencies."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s' "$GOVOPLAN_RELEASE_SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
echo 'git.add-ideas.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDe48IOof2fJS1dTbJtLWQnWnr+JorZXKIFdOAM9ct8G' > ~/.ssh/known_hosts
|
|
||||||
chmod 600 ~/.ssh/known_hosts
|
|
||||||
- name: Bootstrap GovOPlaN repositories
|
- name: Bootstrap GovOPlaN repositories
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: python tools/repo/bootstrap-repositories.py --parent ..
|
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --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: |
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Deployment Installer
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deployment-installer:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
path: govoplan
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Compile deployment tooling
|
||||||
|
working-directory: govoplan
|
||||||
|
run: python -m py_compile tools/deployment/govoplan-deploy.py tools/deployment/govoplan_deploy/*.py
|
||||||
|
- name: Test declarative deployment bundle
|
||||||
|
working-directory: govoplan
|
||||||
|
run: python -m unittest -v tests.test_deployment_installer
|
||||||
|
- name: Build single-file deployer artifact
|
||||||
|
working-directory: govoplan
|
||||||
|
run: |
|
||||||
|
python tools/deployment/build-deployer-zipapp.py --output /tmp/govoplan-deploy.pyz
|
||||||
|
python /tmp/govoplan-deploy.pyz --help
|
||||||
@@ -7,6 +7,18 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
module-matrix:
|
module-matrix:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: govoplan_test
|
||||||
|
POSTGRES_USER: govoplan
|
||||||
|
POSTGRES_PASSWORD: govoplan_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U govoplan -d govoplan_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
with:
|
with:
|
||||||
@@ -17,32 +29,38 @@ jobs:
|
|||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
- name: Configure SSH for release dependencies
|
- name: Use anonymous HTTPS for public GovOPlaN repositories
|
||||||
env:
|
|
||||||
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan"
|
||||||
chmod 700 ~/.ssh
|
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan"
|
||||||
if [ -z "${GOVOPLAN_RELEASE_SSH_KEY_B64:-}" ]; then
|
|
||||||
echo "GOVOPLAN_RELEASE_SSH_KEY_B64 secret is required for git+ssh release dependencies."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s' "$GOVOPLAN_RELEASE_SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
echo 'git.add-ideas.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDe48IOof2fJS1dTbJtLWQnWnr+JorZXKIFdOAM9ct8G' > ~/.ssh/known_hosts
|
|
||||||
chmod 600 ~/.ssh/known_hosts
|
|
||||||
- name: Bootstrap GovOPlaN repositories
|
- name: Bootstrap GovOPlaN repositories
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: python tools/repo/bootstrap-repositories.py --parent ..
|
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website
|
||||||
- name: Install backend release dependencies
|
- name: Install backend release dependencies
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: |
|
run: |
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-release.txt --python .venv/bin/python --upgrade-pip
|
.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-release.txt --python .venv/bin/python --upgrade-pip
|
||||||
.venv/bin/python -m pip install '../govoplan-core[dev]'
|
.venv/bin/python -m pip install '../govoplan-core[dev]'
|
||||||
|
.venv/bin/python -m pip install --no-deps ../govoplan-search
|
||||||
- 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 Search against PostgreSQL
|
||||||
|
working-directory: govoplan
|
||||||
|
env:
|
||||||
|
GOVOPLAN_SEARCH_POSTGRES_URL: postgresql+psycopg://govoplan:govoplan_test@postgres:5432/govoplan_test
|
||||||
|
run: |
|
||||||
|
GOVOPLAN_CORE_ROOT="$PWD/../govoplan-core" .venv/bin/python tools/checks/postgres-integration-check.py \
|
||||||
|
--database-url "$GOVOPLAN_SEARCH_POSTGRES_URL" \
|
||||||
|
--module-set search=tenancy,access,search \
|
||||||
|
--reset-schema \
|
||||||
|
--skip-retirement-atomicity
|
||||||
|
PYTHONPATH="$PWD/../govoplan-search/src:$PWD/../govoplan-core/src" \
|
||||||
|
.venv/bin/python -m unittest discover \
|
||||||
|
-s ../govoplan-search/tests \
|
||||||
|
-p test_postgres_search.py \
|
||||||
|
-v
|
||||||
- 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
|
||||||
|
|||||||
@@ -16,29 +16,19 @@ jobs:
|
|||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
- name: Configure SSH for release dependencies
|
- name: Use anonymous HTTPS for public GovOPlaN repositories
|
||||||
env:
|
|
||||||
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan"
|
||||||
chmod 700 ~/.ssh
|
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan"
|
||||||
if [ -z "${GOVOPLAN_RELEASE_SSH_KEY_B64:-}" ]; then
|
|
||||||
echo "GOVOPLAN_RELEASE_SSH_KEY_B64 secret is required for git+ssh release dependencies."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s' "$GOVOPLAN_RELEASE_SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
echo 'git.add-ideas.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDe48IOof2fJS1dTbJtLWQnWnr+JorZXKIFdOAM9ct8G' > ~/.ssh/known_hosts
|
|
||||||
chmod 600 ~/.ssh/known_hosts
|
|
||||||
- name: Bootstrap GovOPlaN repositories
|
- name: Bootstrap GovOPlaN repositories
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: python tools/repo/bootstrap-repositories.py --parent ..
|
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website
|
||||||
- name: Install backend release integration dependencies
|
- name: Install backend release integration dependencies
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: |
|
run: |
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-release.txt --python .venv/bin/python --upgrade-pip
|
.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-release.txt --python .venv/bin/python --upgrade-pip
|
||||||
.venv/bin/python -m pip install '../govoplan-core[dev]'
|
.venv/bin/python -m pip install -r requirements-release-tests.txt '../govoplan-core[dev]'
|
||||||
- name: Install WebUI release dependencies
|
- name: Install WebUI release dependencies
|
||||||
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
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
name: Security Audit
|
name: Security Audit
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -13,37 +12,31 @@ jobs:
|
|||||||
security-audit:
|
security-audit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
SECURITY_AUDIT_MODE: ci
|
SECURITY_AUDIT_MODE: full
|
||||||
SECURITY_AUDIT_SCOPE: govoplan
|
SECURITY_AUDIT_SCOPE: govoplan
|
||||||
SECURITY_AUDIT_FAIL_ON_FINDINGS: "0"
|
SECURITY_AUDIT_FAIL_ON_FINDINGS: "0"
|
||||||
SECURITY_AUDIT_REQUIRE_TOOLS: "1"
|
SECURITY_AUDIT_REQUIRE_TOOLS: "1"
|
||||||
|
GIT_CONFIG_COUNT: "2"
|
||||||
|
GIT_CONFIG_KEY_0: url.https://git.add-ideas.de/GovOPlaN/govoplan.insteadOf
|
||||||
|
GIT_CONFIG_VALUE_0: git@git.add-ideas.de:GovOPlaN/govoplan
|
||||||
|
GIT_CONFIG_KEY_1: url.https://git.add-ideas.de/GovOPlaN/govoplan.insteadOf
|
||||||
|
GIT_CONFIG_VALUE_1: ssh://git@git.add-ideas.de/GovOPlaN/govoplan
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
with:
|
with:
|
||||||
path: govoplan
|
path: govoplan
|
||||||
- name: Configure SSH for repository bootstrap
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||||
env:
|
with:
|
||||||
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
|
python-version: "3.12"
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
chmod 700 ~/.ssh
|
|
||||||
if [ -z "${GOVOPLAN_RELEASE_SSH_KEY_B64:-}" ]; then
|
|
||||||
echo "GOVOPLAN_RELEASE_SSH_KEY_B64 secret is required for git+ssh repository bootstrap."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s' "$GOVOPLAN_RELEASE_SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
echo 'git.add-ideas.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDe48IOof2fJS1dTbJtLWQnWnr+JorZXKIFdOAM9ct8G' > ~/.ssh/known_hosts
|
|
||||||
chmod 600 ~/.ssh/known_hosts
|
|
||||||
- name: Bootstrap GovOPlaN repositories
|
- name: Bootstrap GovOPlaN repositories
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: python tools/repo/bootstrap-repositories.py --parent ..
|
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website
|
||||||
- name: Run 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
|
||||||
- name: Upload audit reports
|
- name: Upload audit reports
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||||
with:
|
with:
|
||||||
name: security-audit-reports
|
name: security-audit-reports
|
||||||
path: govoplan/audit-reports
|
path: govoplan/audit-reports
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
**Repository type:** system (meta).
|
**Repository type:** system (meta).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
[](https://git.add-ideas.de/GovOPlaN/govoplan/actions?workflow=module-matrix.yml&actor=0&status=0)
|
||||||
|
[](https://git.add-ideas.de/GovOPlaN/govoplan/actions?workflow=release-integration.yml&actor=0&status=0)
|
||||||
|
[](https://git.add-ideas.de/GovOPlaN/govoplan/actions?workflow=deployment-installer.yml&actor=0&status=0)
|
||||||
|
[](https://git.add-ideas.de/GovOPlaN/govoplan/actions?workflow=dependency-audit.yml&actor=0&status=0)
|
||||||
|
[](https://git.add-ideas.de/GovOPlaN/govoplan/actions?workflow=security-audit.yml&actor=0&status=0)
|
||||||
|
|
||||||
This is the GovOPlaN meta repository. It is the operator entry point for
|
This is the GovOPlaN meta repository. It is the operator entry point for
|
||||||
whole-product development, release orchestration, repository bootstrap, and
|
whole-product development, release orchestration, repository bootstrap, and
|
||||||
system-level Docker composition.
|
system-level Docker composition.
|
||||||
@@ -36,6 +42,16 @@ Open the WebUI in a browser after launch only when explicitly requested:
|
|||||||
GOVOPLAN_OPEN_BROWSER=1 ./tools/launch/launch-dev.sh
|
GOVOPLAN_OPEN_BROWSER=1 ./tools/launch/launch-dev.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Limit backend reload triggers during focused module work without changing the
|
||||||
|
enabled module graph:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOVOPLAN_BACKEND_RELOAD_MODULES=calendar,campaign ./tools/launch/launch-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `GOVOPLAN_BACKEND_RELOAD_MODULES=none` to watch only core/config sources.
|
||||||
|
Leaving it unset keeps the broad default and watches all enabled modules.
|
||||||
|
|
||||||
Start the shared development PostgreSQL service:
|
Start the shared development PostgreSQL service:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -126,6 +142,20 @@ Start the local release console:
|
|||||||
./.venv/bin/python tools/release/release-console.py
|
./.venv/bin/python tools/release/release-console.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Create and validate a private, declarative installation bundle:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/deployment/govoplan-deploy.py init \
|
||||||
|
--directory ~/.local/share/govoplan/installations/default
|
||||||
|
./.venv/bin/python tools/deployment/govoplan-deploy.py doctor \
|
||||||
|
--directory ~/.local/share/govoplan/installations/default
|
||||||
|
```
|
||||||
|
|
||||||
|
The current executable slice and remaining production gates are documented in
|
||||||
|
[Installation and Deployment Architecture](docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md).
|
||||||
|
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).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The repository root `.env.example` is the self-hosted operator template for a
|
The repository root `.env.example` is the self-hosted operator template for a
|
||||||
@@ -146,6 +176,9 @@ Meta ownership and module install/contract boundaries are documented in
|
|||||||
`docs/META_REPO_SCAN.md` and `docs/MODULE_CONTRACTS_AND_INSTALLS.md`.
|
`docs/META_REPO_SCAN.md` and `docs/MODULE_CONTRACTS_AND_INSTALLS.md`.
|
||||||
Frontend layout principles for module pages are documented in
|
Frontend layout principles for module pages are documented in
|
||||||
`docs/FRONTEND_LAYOUT_PRINCIPLES.md`.
|
`docs/FRONTEND_LAYOUT_PRINCIPLES.md`.
|
||||||
|
The provider-neutral datasource boundary and reusable Dataflow/Workflow graph
|
||||||
|
contract are documented in
|
||||||
|
`docs/DATASOURCE_AND_DEFINITION_GRAPH_ARCHITECTURE.md`.
|
||||||
The cross-product destination, stakeholder visions, configuration archetypes,
|
The cross-product destination, stakeholder visions, configuration archetypes,
|
||||||
connected outcome stories, and capability horizons are documented in
|
connected outcome stories, and capability horizons are documented in
|
||||||
the [Connected Governance Platform Roadmap](docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md).
|
the [Connected Governance Platform Roadmap](docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md).
|
||||||
@@ -155,6 +188,11 @@ including stage gates and shared documentation expectations, is in the
|
|||||||
The administrator journey from Core-only bootstrap through online module
|
The administrator journey from Core-only bootstrap through online module
|
||||||
installation, scale-out, and reversible environment promotion is defined in
|
installation, scale-out, and reversible environment promotion is defined in
|
||||||
[System Administrator Lifecycle User Story](docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md).
|
[System Administrator Lifecycle User Story](docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md).
|
||||||
|
The corresponding host deployment compiler, managed/external component choices,
|
||||||
|
reconfiguration semantics, and safe Web update boundary are defined in
|
||||||
|
[Installation and Deployment Architecture](docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md).
|
||||||
|
The concrete replica, worker-node, load-balancer, and shared-state topology is
|
||||||
|
defined in [Scaling and Multi-Host Deployment](docs/SCALING_AND_MULTI_HOST_DEPLOYMENT.md).
|
||||||
The first Campaign-centric capability and infrastructure fit assessment is in
|
The first Campaign-centric capability and infrastructure fit assessment is in
|
||||||
`docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md`. Its rerun tooling can collect and
|
`docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md`. Its rerun tooling can collect and
|
||||||
verify a bounded installed composition; target, provider and production claims
|
verify a bounded installed composition; target, provider and production claims
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ Current shared profiles:
|
|||||||
- `govoplan/dev/postgres`
|
- `govoplan/dev/postgres`
|
||||||
- `govoplan/dev/production-like`
|
- `govoplan/dev/production-like`
|
||||||
|
|
||||||
|
The generated whole-product Compose profile is owned by
|
||||||
|
`tools/deployment/govoplan-deploy.py`. It renders a deployment-specific
|
||||||
|
`compose.json` from a versioned installation specification; generated files and
|
||||||
|
secrets remain outside the repository.
|
||||||
|
|
||||||
Module-specific Docker test beds remain in their owning repositories:
|
Module-specific Docker test beds remain in their owning repositories:
|
||||||
|
|
||||||
- `govoplan-campaign/dev/mail-testbed`
|
- `govoplan-campaign/dev/mail-testbed`
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ profile intentionally runs only PostgreSQL and Redis in containers; API,
|
|||||||
WebUI, worker, and scheduler processes still run from editable source trees. A
|
WebUI, worker, and scheduler processes still run from editable source trees. A
|
||||||
target deployment must supply TLS termination, process supervision, secret
|
target deployment must supply TLS termination, process supervision, secret
|
||||||
injection, monitoring, backup storage, and recovery procedures. The open
|
injection, monitoring, backup storage, and recovery procedures. The open
|
||||||
[Core backup/restore issue #29](https://git.add-ideas.de/add-ideas/govoplan-core/issues/29)
|
[Core backup/restore issue #29](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/29)
|
||||||
is a production gate. Identity federation, external audit export, and a tested
|
is a production gate. Identity federation, external audit export, and a tested
|
||||||
disaster-recovery plan are also not complete.
|
disaster-recovery plan are also not complete.
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ persistence, and durable shared or object storage. Run one scheduler only when a
|
|||||||
selected module needs scheduled recovery. Multiple scheduler replicas require
|
selected module needs scheduled recovery. Multiple scheduler replicas require
|
||||||
leader election or an external lock, which is not established by this report.
|
leader election or an external lock, which is not established by this report.
|
||||||
|
|
||||||
This topology is a recommendation from the [Ops scalability profiles](https://git.add-ideas.de/add-ideas/govoplan-ops/src/branch/main/docs/SCALABILITY_PROFILES.md),
|
This topology is a recommendation from the [Ops scalability profiles](https://git.add-ideas.de/GovOPlaN/govoplan-ops/src/branch/main/docs/SCALABILITY_PROFILES.md),
|
||||||
not a currently shipped production Compose/Kubernetes/systemd package.
|
not a currently shipped production Compose/Kubernetes/systemd package.
|
||||||
|
|
||||||
## Functional capability matrix
|
## Functional capability matrix
|
||||||
@@ -154,7 +154,7 @@ not a currently shipped production Compose/Kubernetes/systemd package.
|
|||||||
| Configured-system documentation and Ops status pages | `verified` | Docs/Ops manifests contribute protected routes and WebUI packages; Ops code checks database, Redis, workers, storage and deployment-security settings. | This does not replace external monitoring or a target runbook. |
|
| Configured-system documentation and Ops status pages | `verified` | Docs/Ops manifests contribute protected routes and WebUI packages; Ops code checks database, Redis, workers, storage and deployment-security settings. | This does not replace external monitoring or a target runbook. |
|
||||||
| Calendar/CalDAV integration | `partial` | Calendar `v0.1.8` supplies the catalogued storage/sync foundation. The durable external-write outbox, worker recovery, reconciliation, and retention work is committed, tested, and pushed on Calendar `main` after that tag. | The post-tag outbox work is remote-integrated source, not local-only WIP, but it is not in the signed stable package baseline and has not passed a target CalDAV drill. Bulk synchronized-calendar migration semantics remain separate work. |
|
| Calendar/CalDAV integration | `partial` | Calendar `v0.1.8` supplies the catalogued storage/sync foundation. The durable external-write outbox, worker recovery, reconciliation, and retention work is committed, tested, and pushed on Calendar `main` after that tag. | The post-tag outbox work is remote-integrated source, not local-only WIP, but it is not in the signed stable package baseline and has not passed a target CalDAV drill. Bulk synchronized-calendar migration semantics remain separate work. |
|
||||||
| External LDAP/AD, OIDC/SAML or SCIM identity integration | `scaffold` | IDM owns normalized assignment APIs and documents connector boundaries. | Provider connectors, login callback flow and target directory reconciliation are not an implemented end-to-end capability. Use local accounts for this pilot. |
|
| External LDAP/AD, OIDC/SAML or SCIM identity integration | `scaffold` | IDM owns normalized assignment APIs and documents connector boundaries. | Provider connectors, login callback flow and target directory reconciliation are not an implemented end-to-end capability. Use local accounts for this pilot. |
|
||||||
| Export-control/embargo-list screening | `planned` | Product-level [GovOPlaN #12](https://git.add-ideas.de/add-ideas/govoplan/issues/12) defines the consumer-independent user story. | No screening provider, list provenance, matching policy, review flow or legal evidence exists in this composition. |
|
| Export-control/embargo-list screening | `planned` | Product-level [GovOPlaN #12](https://git.add-ideas.de/GovOPlaN/govoplan/issues/12) defines the consumer-independent user story. | No screening provider, list provenance, matching policy, review flow or legal evidence exists in this composition. |
|
||||||
| Workflow-driven journeys and views | `planned` | Workflow contracts/concepts exist outside this assessment. | Explicitly postponed. Do not include Workflow in pilot or production claims from this report. |
|
| Workflow-driven journeys and views | `planned` | Workflow contracts/concepts exist outside this assessment. | Explicitly postponed. Do not include Workflow in pilot or production claims from this report. |
|
||||||
|
|
||||||
## Infrastructure matrix
|
## Infrastructure matrix
|
||||||
@@ -177,13 +177,13 @@ not a currently shipped production Compose/Kubernetes/systemd package.
|
|||||||
| Health/readiness | `verified` | `/health`, protected `/health/details`, and Ops checks for DB, Redis, workers, storage, maintenance and cookie/CORS posture. | Add external probes and distinguish liveness from dependency readiness for the chosen orchestrator. |
|
| Health/readiness | `verified` | `/health`, protected `/health/details`, and Ops checks for DB, Redis, workers, storage, maintenance and cookie/CORS posture. | Add external probes and distinguish liveness from dependency readiness for the chosen orchestrator. |
|
||||||
| Metrics, logs and alerting | `partial` | Correlation IDs, slow-request/query metrics in logs, worker inspection and operator status are implemented. | No bundled metrics exporter, log collector, dashboards, queue-depth alerts, pager route or SLO is verified. |
|
| Metrics, logs and alerting | `partial` | Correlation IDs, slow-request/query metrics in logs, worker inspection and operator status are implemented. | No bundled metrics exporter, log collector, dashboards, queue-depth alerts, pager route or SLO is verified. |
|
||||||
| Audit | `partial` | Local audit tables and retry outbox are verified. | Retention, tamper-evident export, privileged access review and SIEM integration are unproved. |
|
| Audit | `partial` | Local audit tables and retry outbox are verified. | Retention, tamper-evident export, privileged access review and SIEM integration are unproved. |
|
||||||
| Backup and restore | `partial` | Operator guide and installer hooks describe `pg_dump`/`pg_restore`; SQLite and simulated installer rollback drills exist. | [Core #29](https://git.add-ideas.de/add-ideas/govoplan-core/issues/29) remains open. No target PostgreSQL + files + secrets restore drill or measured RTO/RPO exists. |
|
| Backup and restore | `partial` | Operator guide and installer hooks describe `pg_dump`/`pg_restore`; SQLite and simulated installer rollback drills exist. | [Core #29](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/29) remains open. No target PostgreSQL + files + secrets restore drill or measured RTO/RPO exists. |
|
||||||
| Disaster recovery | `not_assessed` | The Ops guide asks for RPO/RTO and restore drills. | No agreed RPO/RTO, off-site copy, failover topology, dependency recovery order, communications plan or exercise evidence was supplied. |
|
| Disaster recovery | `not_assessed` | The Ops guide asks for RPO/RTO and restore drills. | No agreed RPO/RTO, off-site copy, failover topology, dependency recovery order, communications plan or exercise evidence was supplied. |
|
||||||
|
|
||||||
The scalability and sizing documentation delivered the documentation portions
|
The scalability and sizing documentation delivered the documentation portions
|
||||||
of [Core #217](https://git.add-ideas.de/add-ideas/govoplan-core/issues/217) and
|
of [Core #217](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/217) and
|
||||||
[Core #219](https://git.add-ideas.de/add-ideas/govoplan-core/issues/219).
|
[Core #219](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/219).
|
||||||
[Core #28](https://git.add-ideas.de/add-ideas/govoplan-core/issues/28) records the
|
[Core #28](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/28) records the
|
||||||
operator-documentation slice. These closed tickets are evidence of documented
|
operator-documentation slice. These closed tickets are evidence of documented
|
||||||
models, not evidence that an organization's production environment has passed
|
models, not evidence that an organization's production environment has passed
|
||||||
them.
|
them.
|
||||||
@@ -389,7 +389,7 @@ that observation to the assessment and signed catalog:
|
|||||||
--output /var/tmp/govoplan-fit-review.json
|
--output /var/tmp/govoplan-fit-review.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The collector follows the strict version `0.3.0`
|
The collector follows the strict version `0.4.0`
|
||||||
[`installed-composition-evidence.schema.json`](installed-composition-evidence.schema.json)
|
[`installed-composition-evidence.schema.json`](installed-composition-evidence.schema.json)
|
||||||
contract. It enumerates all installed distributions whose normalized name starts
|
contract. It enumerates all installed distributions whose normalized name starts
|
||||||
with `govoplan-`, compares the enabled assessed package and module-manifest
|
with `govoplan-`, compares the enabled assessed package and module-manifest
|
||||||
@@ -419,6 +419,9 @@ For each distribution, the collector also:
|
|||||||
package-index/unknown origins and malformed metadata;
|
package-index/unknown origins and malformed metadata;
|
||||||
- compares a non-editable VCS commit with the assessment commit and, when one is
|
- compares a non-editable VCS commit with the assessment commit and, when one is
|
||||||
present, the signed catalog `selected_units` commit;
|
present, the signed catalog `selected_units` commit;
|
||||||
|
- derives two content identities from bytes it actually verifies: an
|
||||||
|
install-stable identity for immutable wheel-declared files and a full
|
||||||
|
installed-RECORD identity, while retaining only SHA-256 digests and counts;
|
||||||
- records only package/module identifiers, versions, bounded counters, hashes
|
- records only package/module identifiers, versions, bounded counters, hashes
|
||||||
and stable error codes. It never writes direct URLs, paths, hostnames,
|
and stable error codes. It never writes direct URLs, paths, hostnames,
|
||||||
usernames, exception text or file contents.
|
usernames, exception text or file contents.
|
||||||
@@ -436,25 +439,31 @@ remain mutable even when their small editable-install `RECORD` is valid. Archive
|
|||||||
or index artifacts without a hash anchored by the assessed release remain
|
or index artifacts without a hash anchored by the assessed release remain
|
||||||
unanchored. A matching declaration is reported under
|
unanchored. A matching declaration is reported under
|
||||||
`installed_source_provenance` as local consistency only, with
|
`installed_source_provenance` as local consistency only, with
|
||||||
`release_origin_bound: false`. The separate `installed_release_origin` scope
|
`release_origin_bound: false` until the separate origin proof succeeds. A signed
|
||||||
remains unchecked until an independently anchored artifact digest or signed
|
catalog can contain `release.artifacts` identities computed directly from built,
|
||||||
installer receipt binds each installed payload to the signed catalog. The tool
|
bounded wheel files. Wheels with installer-transformed scripts or headers are
|
||||||
does not accept a digest merely asserted inside the installed evidence document.
|
marked `requires_installer_receipt`; catalog metadata alone cannot attest those
|
||||||
|
post-install bytes. A role-scoped installer receipt instead binds the exact
|
||||||
|
installed-evidence digest, full installed payload identities, catalog archive
|
||||||
|
digests and canonical signed-catalog digest. The tool never accepts an installed
|
||||||
|
hash merely because the installed evidence asserted it.
|
||||||
|
|
||||||
Only evidence produced in-process by `--collect-installed-evidence` is a local
|
Only evidence produced in-process by `--collect-installed-evidence` is a local
|
||||||
observation, and it must be no more than five minutes old (with at most 30
|
observation, and it must be no more than five minutes old (with at most 30
|
||||||
seconds of future clock skew) at the selected verification time. Use
|
seconds of future clock skew) at the selected verification time. Use
|
||||||
`--installed-evidence PATH` to compare evidence collected in a separate
|
`--installed-evidence PATH` to compare evidence collected in a separate
|
||||||
environment. Imported JSON is unsigned and therefore always carries a blocker:
|
environment. Imported JSON without a valid independent installer receipt
|
||||||
it can identify differences but cannot establish checked or valid installed
|
carries a blocker: it can identify differences but cannot establish checked or
|
||||||
proof. A future signed collector-attestation format is required to cross that
|
valid installed proof. A receipt signed by a separately provisioned installer
|
||||||
|
authority authenticates that exact imported evidence across the process
|
||||||
boundary. The proof scope records observation mode, collection/evaluation time,
|
boundary. The proof scope records observation mode, collection/evaluation time,
|
||||||
freshness, and whether evaluation used current time or an explicit historical
|
receipt authentication, freshness, and whether evaluation used current time or
|
||||||
override. Evidence input and output are bounded to 4 MiB. Collection loads
|
an explicit historical override. Evidence input and output are bounded to 4
|
||||||
the `govoplan.modules` entry-point factories in order to read module IDs and
|
MiB. Collection loads the `govoplan.modules` entry-point factories in order to
|
||||||
manifest versions; that executes installed GovOPlaN manifest code. Run it only
|
read module IDs and manifest versions; that executes installed GovOPlaN manifest
|
||||||
inside the installation being assessed and with the same isolation expected for
|
code. Run it only inside the installation being assessed and with the same
|
||||||
other installed-artifact acceptance checks. This collector does not observe
|
isolation expected for other installed-artifact acceptance checks. This
|
||||||
|
collector does not observe
|
||||||
which modules a running service activated, migrations, configuration, health,
|
which modules a running service activated, migrations, configuration, health,
|
||||||
or reference-journey behavior. Runtime activation therefore remains an explicit
|
or reference-journey behavior. Runtime activation therefore remains an explicit
|
||||||
unchecked boundary.
|
unchecked boundary.
|
||||||
@@ -495,14 +504,50 @@ the tool's deterministic canonicalization.
|
|||||||
```
|
```
|
||||||
|
|
||||||
With `--installed-evidence`, this command performs comparison and proof-binding
|
With `--installed-evidence`, this command performs comparison and proof-binding
|
||||||
diagnostics only: the imported installed document is unsigned, so neither it nor
|
diagnostics. Without an installer receipt, the imported document remains
|
||||||
the boundary claim becomes accepted proof. Direct in-process collection removes
|
unsigned, so neither it nor the boundary claim becomes accepted proof. Direct
|
||||||
that unsigned-import gap, but still establishes only local metadata consistency.
|
in-process collection can match catalog-anchored artifact identities for wheels
|
||||||
No boundary claim is admitted by the current format because it has no separately
|
without installer-transformed files. The installer-receipt flow below
|
||||||
anchored artifact digest or signed installer receipt. A future evidence contract
|
authenticates a durable cross-process observation and is required for transformed
|
||||||
must bind such an independently trusted release-artifact receipt to the installed
|
script/header payloads.
|
||||||
evidence digest; a signed collector-attestation format should also make that
|
|
||||||
exchange durable across processes.
|
Issue a receipt only in the installation process performing the live collection.
|
||||||
|
The command refuses evidence supplied from a file and first validates the
|
||||||
|
assessment, independently trusted signed catalog, composition and RECORD bytes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python tools/assessments/installer-receipt.py \
|
||||||
|
--assessment /srv/govoplan/assessment.json \
|
||||||
|
--catalog /srv/govoplan/catalogs/stable.json \
|
||||||
|
--keyring /srv/govoplan/catalogs/keyring.json \
|
||||||
|
--trusted-keyring /srv/govoplan/trust/catalog-keyring.json \
|
||||||
|
--receipt-id install:production:20260722 \
|
||||||
|
--signing-key installer-production=/run/secrets/installer-ed25519.pem \
|
||||||
|
--python-artifact govoplan-core=/srv/govoplan/staged/govoplan_core-0.1.13-py3-none-any.whl \
|
||||||
|
--python-artifact govoplan-campaign=/srv/govoplan/staged/govoplan_campaign-0.1.10-py3-none-any.whl \
|
||||||
|
--evidence-output /srv/govoplan/evidence/installed.json \
|
||||||
|
--receipt-output /srv/govoplan/evidence/installer-receipt.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat `--python-artifact PACKAGE=/exact/consumed.whl` for every enabled
|
||||||
|
GovOPlaN distribution. The issuer reopens each exact wheel, verifies its archive
|
||||||
|
and payload identities against the signed catalog, and refuses a different
|
||||||
|
build with the same name and version. Receipt issuance must follow collection
|
||||||
|
within five minutes, and live verification must still fall inside that bounded
|
||||||
|
window; retain the pair for a reproducible historical review at its explicit
|
||||||
|
verification time.
|
||||||
|
|
||||||
|
Verify that durable pair with its separately managed trust root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python tools/assessments/capability-fit.py \
|
||||||
|
--catalog /srv/govoplan/catalogs/stable.json \
|
||||||
|
--keyring /srv/govoplan/catalogs/keyring.json \
|
||||||
|
--trusted-keyring /srv/govoplan/trust/catalog-keyring.json \
|
||||||
|
--installed-evidence /srv/govoplan/evidence/installed.json \
|
||||||
|
--installer-receipt /srv/govoplan/evidence/installer-receipt.json \
|
||||||
|
--installer-authority-keyring /srv/govoplan/trust/installer-authorities.json
|
||||||
|
```
|
||||||
|
|
||||||
Target-environment and production-approval claims must use the assessment's
|
Target-environment and production-approval claims must use the assessment's
|
||||||
`deployment_profile.id` as `subject_id`. External-provider claims require the
|
`deployment_profile.id` as `subject_id`. External-provider claims require the
|
||||||
@@ -510,11 +555,16 @@ operator to supply a bounded opaque expected subject with
|
|||||||
`--expected-external-provider-subject`; without it, such a claim remains
|
`--expected-external-provider-subject`; without it, such a claim remains
|
||||||
unchecked and blocks. Expected and observed IDs are retained in proof scope.
|
unchecked and blocks. Expected and observed IDs are retained in proof scope.
|
||||||
|
|
||||||
The authority keyring is a governance trust root. Do not download or generate it
|
Both authority keyrings are governance trust roots. Installer receipt keys use
|
||||||
from the proof bundle being checked. The checker rejects proof-authority public
|
the strict
|
||||||
keys reused by either the published or independently trusted catalog keyring,
|
[`installer-receipt-authority-keyring.schema.json`](installer-receipt-authority-keyring.schema.json)
|
||||||
and rejects the same proof public-key material assigned to multiple authority
|
contract and may attest only `installed_release_origin`; their public material
|
||||||
IDs. A malformed key, an invalid or empty validity interval, or ambiguous key
|
must not be reused by catalog or boundary-proof authorities. Do not download or
|
||||||
|
generate them from the proof bundle being checked. The checker rejects
|
||||||
|
proof-authority public keys reused by either the published or independently
|
||||||
|
trusted catalog keyring, and rejects the same proof public-key material assigned
|
||||||
|
to multiple authority IDs. A malformed key, an invalid or empty validity
|
||||||
|
interval, or ambiguous key
|
||||||
identity invalidates the supplied authority set. Authority `not_after` is
|
identity invalidates the supplied authority set. Authority `not_after` is
|
||||||
exclusive. A target operator or approver must validate the referenced
|
exclusive. A target operator or approver must validate the referenced
|
||||||
drill/result artifacts before signing. The rerun verifies the
|
drill/result artifacts before signing. The rerun verifies the
|
||||||
@@ -534,13 +584,13 @@ source tests or signed release metadata.
|
|||||||
- [Production-like profile](../dev/production-like/README.md) and
|
- [Production-like profile](../dev/production-like/README.md) and
|
||||||
[Compose dependencies](../dev/production-like/docker-compose.yml)
|
[Compose dependencies](../dev/production-like/docker-compose.yml)
|
||||||
- [Module contracts and install boundaries](MODULE_CONTRACTS_AND_INSTALLS.md)
|
- [Module contracts and install boundaries](MODULE_CONTRACTS_AND_INSTALLS.md)
|
||||||
- [Core deployment operator guide](https://git.add-ideas.de/add-ideas/govoplan-core/src/branch/main/docs/DEPLOYMENT_OPERATOR_GUIDE.md)
|
- [Core deployment operator guide](https://git.add-ideas.de/GovOPlaN/govoplan-core/src/branch/main/docs/DEPLOYMENT_OPERATOR_GUIDE.md)
|
||||||
- [Ops scalability profiles](https://git.add-ideas.de/add-ideas/govoplan-ops/src/branch/main/docs/SCALABILITY_PROFILES.md)
|
- [Ops scalability profiles](https://git.add-ideas.de/GovOPlaN/govoplan-ops/src/branch/main/docs/SCALABILITY_PROFILES.md)
|
||||||
- Actual module manifests in the pinned repositories and the static contract
|
- Actual module manifests in the pinned repositories and the static contract
|
||||||
checker in this meta repository
|
checker in this meta repository
|
||||||
- Core module-system/API smoke/auth/install-config tests, plus focused Campaign,
|
- Core module-system/API smoke/auth/install-config tests, plus focused Campaign,
|
||||||
Files, Mail, Audit and Addresses tests
|
Files, Mail, Audit and Addresses tests
|
||||||
- [Campaign delivery runbook](https://git.add-ideas.de/add-ideas/govoplan-campaign/src/branch/main/docs/CAMPAIGN_DELIVERY_RUNBOOK.md)
|
- [Campaign delivery runbook](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/src/branch/main/docs/CAMPAIGN_DELIVERY_RUNBOOK.md)
|
||||||
- [Live signed stable catalog](https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json)
|
- [Live signed stable catalog](https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json)
|
||||||
and [published keyring](https://govoplan.add-ideas.de/catalogs/v1/keyring.json),
|
and [published keyring](https://govoplan.add-ideas.de/catalogs/v1/keyring.json),
|
||||||
verified against a separately provisioned local trust keyring
|
verified against a separately provisioned local trust keyring
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ baseline to that destination.
|
|||||||
|
|
||||||
It is a durable direction, not a release promise or a substitute for issue
|
It is a durable direction, not a release promise or a substitute for issue
|
||||||
tracking. Live work state belongs in Gitea issues. The
|
tracking. Live work state belongs in Gitea issues. The
|
||||||
[Core master roadmap](https://git.add-ideas.de/add-ideas/govoplan-core/src/branch/main/docs/GOVOPLAN_MASTER_ROADMAP.md)
|
[Core master roadmap](https://git.add-ideas.de/GovOPlaN/govoplan-core/src/branch/main/docs/GOVOPLAN_MASTER_ROADMAP.md)
|
||||||
remains the technical module and wave sequence; this document supplies the
|
remains the technical module and wave sequence; this document supplies the
|
||||||
cross-product vision that sequence serves.
|
cross-product vision that sequence serves.
|
||||||
|
|
||||||
@@ -448,7 +448,7 @@ without creating disconnected ticket systems.
|
|||||||
- As a manager, I can distinguish demand, backlog, SLA risk, recurring cause,
|
- As a manager, I can distinguish demand, backlog, SLA risk, recurring cause,
|
||||||
and verified resolution.
|
and verified resolution.
|
||||||
|
|
||||||
**Composition.** Issue reporting, helpdesk, cases, tasks, facilities, assets,
|
**Composition.** Tickets, helpdesk profiles, cases, tasks, facilities, assets,
|
||||||
inspections, booking, resources, calendar, files, notifications, connectors,
|
inspections, booking, resources, calendar, files, notifications, connectors,
|
||||||
reporting, policy, and audit.
|
reporting, policy, and audit.
|
||||||
|
|
||||||
@@ -530,7 +530,7 @@ access, no hidden cross-module coupling, correction rather than fictional undo,
|
|||||||
and a durable action/effect trail.
|
and a durable action/effect trail.
|
||||||
|
|
||||||
**Roadmap role.** The canonical product story is
|
**Roadmap role.** The canonical product story is
|
||||||
[meta issue #12](https://git.add-ideas.de/add-ideas/govoplan/issues/12). It is a
|
[meta issue #12](https://git.add-ideas.de/GovOPlaN/govoplan/issues/12). It is a
|
||||||
future cross-product configuration and contract program, not Campaign work and
|
future cross-product configuration and contract program, not Campaign work and
|
||||||
not a claim that screening is implemented today.
|
not a claim that screening is implemented today.
|
||||||
|
|
||||||
@@ -1279,8 +1279,9 @@ them; they do not block the product vision today.
|
|||||||
- Which exact HIS/CampusOnline interfaces, student-statistics fields and
|
- Which exact HIS/CampusOnline interfaces, student-statistics fields and
|
||||||
official keys, accepted calculation, freeze/correction policy, privacy
|
official keys, accepted calculation, freeze/correction policy, privacy
|
||||||
profile, and drill-down level should define the first analytical data product?
|
profile, and drill-down level should define the first analytical data product?
|
||||||
- After the first source-to-report proof, do repeated source/dataflow contracts
|
- Which database, REST, directory, and managed-file providers should follow the
|
||||||
justify separate `govoplan-datasources` and `govoplan-dataflow` modules?
|
implemented separation of `govoplan-connectors`, `govoplan-datasources`, and
|
||||||
|
`govoplan-dataflow`, and which quality/promotion policy should prove it first?
|
||||||
- Which collaborative editor should be the first target, and should its first
|
- Which collaborative editor should be the first target, and should its first
|
||||||
accepted experience emphasize concurrent editing, controlled check-out, or
|
accepted experience emphasize concurrent editing, controlled check-out, or
|
||||||
both?
|
both?
|
||||||
@@ -1381,7 +1382,7 @@ integration. Conversely, 30 repositories had no open issue; for many
|
|||||||
later-wave modules this meant no implementation program had been opened, not
|
later-wave modules this meant no implementation program had been opened, not
|
||||||
that the capability was complete.
|
that the capability was complete.
|
||||||
|
|
||||||
[Poll #2](https://git.add-ideas.de/add-ideas/govoplan-poll/issues/2) was a clear
|
[Poll #2](https://git.add-ideas.de/GovOPlaN/govoplan-poll/issues/2) was a clear
|
||||||
tracker-drift example: its configurable transition engine, agreed transition
|
tracker-drift example: its configurable transition engine, agreed transition
|
||||||
matrix/history, idempotent keyed retries, re-decision audit, archive/unarchive,
|
matrix/history, idempotent keyed retries, re-decision audit, archive/unarchive,
|
||||||
and preservation behavior were implemented and pushed while the issue still
|
and preservation behavior were implemented and pushed while the issue still
|
||||||
@@ -1389,27 +1390,27 @@ reported `needs-info`.
|
|||||||
|
|
||||||
Issue anchors that informed the bridge from the baseline into this roadmap:
|
Issue anchors that informed the bridge from the baseline into this roadmap:
|
||||||
|
|
||||||
- [Meta #10](https://git.add-ideas.de/add-ideas/govoplan/issues/10) for the
|
- [Meta #10](https://git.add-ideas.de/GovOPlaN/govoplan/issues/10) for the
|
||||||
capability/infrastructure assessment and its target proof;
|
capability/infrastructure assessment and its target proof;
|
||||||
- [Meta #11](https://git.add-ideas.de/add-ideas/govoplan/issues/11) for the
|
- [Meta #11](https://git.add-ideas.de/GovOPlaN/govoplan/issues/11) for the
|
||||||
universal interface and focused-view direction;
|
universal interface and focused-view direction;
|
||||||
- [Core #225](https://git.add-ideas.de/add-ideas/govoplan-core/issues/225) for
|
- [Core #225](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/225) for
|
||||||
guided, safe configuration;
|
guided, safe configuration;
|
||||||
- [Core #29](https://git.add-ideas.de/add-ideas/govoplan-core/issues/29) for the
|
- [Core #29](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/29) for the
|
||||||
backup/restore production gate;
|
backup/restore production gate;
|
||||||
- [Core #263](https://git.add-ideas.de/add-ideas/govoplan-core/issues/263) and
|
- [Core #263](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/263) and
|
||||||
[Campaign #63](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/63),
|
[Campaign #63](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/63),
|
||||||
[#62](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/62),
|
[#62](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/62),
|
||||||
[#65](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/65), and
|
[#65](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/65), and
|
||||||
[#69](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/69) for the
|
[#69](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/69) for the
|
||||||
reference interface/delivery vocabulary and behavior;
|
reference interface/delivery vocabulary and behavior;
|
||||||
- [Poll #1](https://git.add-ideas.de/add-ideas/govoplan-poll/issues/1) for the
|
- [Poll #1](https://git.add-ideas.de/GovOPlaN/govoplan-poll/issues/1) for the
|
||||||
database-enforced respondent invariant exposed by Scheduling;
|
database-enforced respondent invariant exposed by Scheduling;
|
||||||
- [Connectors #6](https://git.add-ideas.de/add-ideas/govoplan-connectors/issues/6)
|
- [Connectors #6](https://git.add-ideas.de/GovOPlaN/govoplan-connectors/issues/6)
|
||||||
for the governed connector configuration/simulation foundation;
|
for the governed connector configuration/simulation foundation;
|
||||||
- [Meta #9](https://git.add-ideas.de/add-ideas/govoplan/issues/9) for the first
|
- [Meta #9](https://git.add-ideas.de/GovOPlaN/govoplan/issues/9) for the first
|
||||||
permit-to-payment reference process; and
|
permit-to-payment reference process; and
|
||||||
- [Meta #12](https://git.add-ideas.de/add-ideas/govoplan/issues/12) for the
|
- [Meta #12](https://git.add-ideas.de/GovOPlaN/govoplan/issues/12) for the
|
||||||
deliberately deferred, consumer-independent export-control story.
|
deliberately deferred, consumer-independent export-control story.
|
||||||
|
|
||||||
Live Gitea issue state remains canonical. These dated facts explain the roadmap
|
Live Gitea issue state remains canonical. These dated facts explain the roadmap
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Datasource And Definition Graph Architecture
|
||||||
|
|
||||||
|
## Two-Layer Data Boundary
|
||||||
|
|
||||||
|
GovOPlaN separates governed data identity from external acquisition:
|
||||||
|
|
||||||
|
| Layer | Owner | Responsibilities |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Datasource layer | `govoplan-datasources` | Catalogue, tenant visibility, live/cached/static mode, staging, immutable materializations, frozen states, schema, fingerprints, provenance, and bounded reads |
|
||||||
|
| Connector layer | `govoplan-connectors` and protocol/provider modules | External protocols, endpoints, connection profiles, credentials, discovery, provider health, source-side filtering, and query pushdown |
|
||||||
|
|
||||||
|
Connectors publish versioned datasource origins. Datasources registers those
|
||||||
|
origins and presents one stable capability to Dataflow, Workflow, Reporting,
|
||||||
|
Risk Compliance, and other consumers. Consumers must not import connector
|
||||||
|
implementations or retain credentials.
|
||||||
|
|
||||||
|
The initial provider path is:
|
||||||
|
|
||||||
|
1. Connectors imports a bounded JSON/CSV snapshot and exposes it as an origin.
|
||||||
|
2. Datasources registers it as live or cached, or accepts a direct static upload
|
||||||
|
through staging.
|
||||||
|
3. Cached refreshes and static promotions append immutable materializations.
|
||||||
|
4. Any datasource may expose a frozen state for reproducible execution evidence.
|
||||||
|
5. Dataflow stores an opaque datasource reference, state policy, and expected
|
||||||
|
fingerprint.
|
||||||
|
6. A pinned Dataflow run may publish a complete bounded result as a new
|
||||||
|
immutable materialization through an idempotent Datasources capability.
|
||||||
|
|
||||||
|
Database, REST/HTTP, LDAP/directory, managed file, watched-directory, feed, and
|
||||||
|
stream providers fit behind the same origin contract. Provider-specific
|
||||||
|
configuration remains in Connectors.
|
||||||
|
|
||||||
|
## Shared Definition Graph
|
||||||
|
|
||||||
|
Core owns domain-neutral graph primitives:
|
||||||
|
|
||||||
|
- nodes, typed ports, edges, and configuration field descriptors;
|
||||||
|
- node libraries and category labels;
|
||||||
|
- graph size, connectivity, cycle, and node-count constraints;
|
||||||
|
- shared backend validation and frontend connection checks.
|
||||||
|
|
||||||
|
Domain modules own their semantics:
|
||||||
|
|
||||||
|
- Dataflow provides load, combine, filter, transform, and output nodes. Its
|
||||||
|
graph is acyclic and has one output.
|
||||||
|
- Workflow provides trigger, activity, review, decision, wait, module-action,
|
||||||
|
Dataflow, and outcome nodes. It permits governed loops and has exactly one
|
||||||
|
trigger plus one or more outcomes.
|
||||||
|
|
||||||
|
This division permits a shared editor shell without making Workflow a special
|
||||||
|
kind of Dataflow or leaking either module into Core.
|
||||||
|
|
||||||
|
## Current Implementation
|
||||||
|
|
||||||
|
- Core graph and datasource contracts are versioned at `0.1.0`.
|
||||||
|
- Workflow exposes a reusable graph editor, node-library discovery, validation,
|
||||||
|
tenant-isolated definitions, immutable revisions, and activation pinning.
|
||||||
|
- Datasources exposes catalogue, origins, staging, promotion, preview,
|
||||||
|
materialization history, refresh, freeze, retirement, and producer
|
||||||
|
publication APIs.
|
||||||
|
- Datasources WebUI exposes all current lifecycle views.
|
||||||
|
- Connectors adapts existing tabular snapshots to datasource origins.
|
||||||
|
- Dataflow consumes only Datasources catalogue/lifecycle capabilities and can
|
||||||
|
request current, live, or latest-frozen state.
|
||||||
|
- Dataflow exposes a pinned run-lifecycle capability and a Run/Publish surface.
|
||||||
|
Its first synchronous runner records lineage and terminal state, publishes
|
||||||
|
only complete bounded results, and retains output datasource/materialization
|
||||||
|
references.
|
||||||
|
- The focused composition check proves Connector origin -> Datasource ->
|
||||||
|
pinned Dataflow run -> frozen published materialization, including replay.
|
||||||
|
|
||||||
|
## Next Slices
|
||||||
|
|
||||||
|
1. Add persisted Workflow instances, resumable transitions, human activities,
|
||||||
|
retry policy, and event subscriptions against pinned definition revisions.
|
||||||
|
2. Add SQL database and governed REST origin providers with credential-envelope
|
||||||
|
references and bounded pushdown.
|
||||||
|
3. Add managed-file and directory origins.
|
||||||
|
4. Add datasource quality rules, schema compatibility policy, retention, and
|
||||||
|
promotion approvals.
|
||||||
|
5. Add asynchronous Dataflow workers and durable artifact-backed outputs for
|
||||||
|
runs that exceed the synchronous row/time/byte limits.
|
||||||
@@ -8,11 +8,11 @@ The same pattern is reusable outside GovOPlaN for any project where Codex works
|
|||||||
|
|
||||||
The repository contains Gitea issue templates in `.gitea/ISSUE_TEMPLATE`, a pull request template in `.gitea/PULL_REQUEST_TEMPLATE.md`, and the label taxonomy in `docs/gitea-labels.json`.
|
The repository contains Gitea issue templates in `.gitea/ISSUE_TEMPLATE`, a pull request template in `.gitea/PULL_REQUEST_TEMPLATE.md`, and the label taxonomy in `docs/gitea-labels.json`.
|
||||||
|
|
||||||
The scripts infer this repository from `origin` (`git@git.add-ideas.de:add-ideas/govoplan.git`). Override inference when needed:
|
The scripts infer this repository from `origin` (`git@git.add-ideas.de:GovOPlaN/govoplan.git`). Override inference when needed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export GITEA_URL=https://git.add-ideas.de
|
export GITEA_URL=https://git.add-ideas.de
|
||||||
export GITEA_OWNER=add-ideas
|
export GITEA_OWNER=GovOPlaN
|
||||||
export GITEA_REPO=govoplan
|
export GITEA_REPO=govoplan
|
||||||
export GITEA_TOKEN=...
|
export GITEA_TOKEN=...
|
||||||
```
|
```
|
||||||
@@ -23,7 +23,7 @@ The API scripts also read `GITEA_*` values from the target repository's `.env` f
|
|||||||
GITEA_TOKEN=...
|
GITEA_TOKEN=...
|
||||||
# Optional if origin inference is not enough:
|
# Optional if origin inference is not enough:
|
||||||
GITEA_URL=https://git.add-ideas.de
|
GITEA_URL=https://git.add-ideas.de
|
||||||
GITEA_OWNER=add-ideas
|
GITEA_OWNER=GovOPlaN
|
||||||
GITEA_REPO=govoplan
|
GITEA_REPO=govoplan
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
# Installation And Deployment Architecture
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A supported GovOPlaN installation starts with one downloaded, verified
|
||||||
|
bootstrap artifact. The administrator answers a bounded set of questions and
|
||||||
|
receives a working base system. Re-running the same tool repairs or
|
||||||
|
reconfigures that installation instead of creating unrelated state.
|
||||||
|
|
||||||
|
The canonical product journey remains
|
||||||
|
[System Administrator Lifecycle User Story](SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md).
|
||||||
|
This document defines the deployer boundary and the first executable slice.
|
||||||
|
|
||||||
|
## First Executable Slice
|
||||||
|
|
||||||
|
`tools/deployment/govoplan-deploy.py` is a standard-library-only deployment
|
||||||
|
compiler and reconciler. It can be tested without installing GovOPlaN itself.
|
||||||
|
|
||||||
|
It currently supports:
|
||||||
|
|
||||||
|
- evaluation and self-hosted profiles;
|
||||||
|
- managed or external PostgreSQL;
|
||||||
|
- managed, external, or evaluation-only disabled Redis;
|
||||||
|
- disabled mail, an external relay declaration, or an evaluation-only
|
||||||
|
GreenMail service;
|
||||||
|
- durable local file storage, managed single-node Garage S3, or external
|
||||||
|
S3-compatible storage;
|
||||||
|
- an explicit HAProxy service that load-balances configured WebUI and API
|
||||||
|
replicas without access to the Docker socket;
|
||||||
|
- declarative API, WebUI, and worker replica counts while keeping migrations
|
||||||
|
and the scheduler singleton;
|
||||||
|
- Core, base, or full initial module selections;
|
||||||
|
- deterministic Compose JSON accepted by Compose v2;
|
||||||
|
- generated secrets stored in a private `0600` file;
|
||||||
|
- service-specific environment allowlists so infrastructure containers do not
|
||||||
|
receive unrelated application credentials;
|
||||||
|
- plan, render, doctor, status, and apply commands;
|
||||||
|
- an installation lock, migration-before-start ordering, readiness polling,
|
||||||
|
and an applied-state receipt;
|
||||||
|
- idempotent reconfiguration that preserves generated secrets;
|
||||||
|
- a keyed environment fingerprint that detects private binding changes without
|
||||||
|
writing secret values to plans or receipts;
|
||||||
|
- host CPU, memory, disk, entropy, architecture, Docker daemon, Compose,
|
||||||
|
listen-port, and external endpoint preflight checks;
|
||||||
|
- service removal without implicit data-volume deletion.
|
||||||
|
|
||||||
|
Create a local evaluation bundle:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/deployment/govoplan-deploy.py init \
|
||||||
|
--directory /tmp/govoplan-evaluation \
|
||||||
|
--profile evaluation \
|
||||||
|
--postgres managed \
|
||||||
|
--redis managed \
|
||||||
|
--storage garage \
|
||||||
|
--mail test-mail \
|
||||||
|
--api-replicas 2 \
|
||||||
|
--web-replicas 2 \
|
||||||
|
--worker-replicas 2 \
|
||||||
|
--module-set base
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect the generated intent and host requirements:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/deployment/govoplan-deploy.py doctor \
|
||||||
|
--directory /tmp/govoplan-evaluation
|
||||||
|
```
|
||||||
|
|
||||||
|
Change a component without rotating existing generated secrets:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/deployment/govoplan-deploy.py configure \
|
||||||
|
--directory /tmp/govoplan-evaluation \
|
||||||
|
--redis external \
|
||||||
|
--redis-url 'rediss://:password@redis.example.org:6379/0'
|
||||||
|
```
|
||||||
|
|
||||||
|
The private installation directory contains:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `installation.json` | Versioned, non-secret desired state |
|
||||||
|
| `secrets.env` | Deployment-local secrets and external service bindings |
|
||||||
|
| `compose.json` | Deterministic generated Compose definition |
|
||||||
|
| `garage.toml` | Non-secret managed Garage server configuration |
|
||||||
|
| `load-balancer.cfg` | Non-secret HAProxy WebUI/API discovery configuration |
|
||||||
|
| `plan.json` | Latest desired-state diff and readiness findings |
|
||||||
|
| `receipt.json` | Last successfully applied immutable identities |
|
||||||
|
| `.deployment.lock` | Same-host operation exclusion |
|
||||||
|
|
||||||
|
The specification contract is
|
||||||
|
[`installation-spec.schema.json`](installation-spec.schema.json).
|
||||||
|
|
||||||
|
Build the same dependency-free tool as one downloadable artifact:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/deployment/build-deployer-zipapp.py \
|
||||||
|
--output /tmp/govoplan-deploy.pyz
|
||||||
|
python /tmp/govoplan-deploy.pyz --help
|
||||||
|
```
|
||||||
|
|
||||||
|
To exercise reconciliation with locally available evaluation images:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python /tmp/govoplan-deploy.pyz init \
|
||||||
|
--non-interactive \
|
||||||
|
--directory /tmp/govoplan-evaluation \
|
||||||
|
--profile evaluation \
|
||||||
|
--api-image local/govoplan-api:test \
|
||||||
|
--web-image local/govoplan-web:test
|
||||||
|
python /tmp/govoplan-deploy.pyz apply \
|
||||||
|
--directory /tmp/govoplan-evaluation \
|
||||||
|
--allow-unverified-images \
|
||||||
|
--skip-pull
|
||||||
|
```
|
||||||
|
|
||||||
|
Those images must already contain the selected module set. The override exists
|
||||||
|
only to exercise local orchestration before release artifacts exist; it is
|
||||||
|
rejected for `self-hosted`.
|
||||||
|
|
||||||
|
## Current Production Gates
|
||||||
|
|
||||||
|
The tool deliberately reports blockers instead of pretending the source tree is
|
||||||
|
a production distribution:
|
||||||
|
|
||||||
|
1. **OCI release artifacts.** The release pipeline does not yet publish pinned
|
||||||
|
multi-architecture API and WebUI images.
|
||||||
|
2. **Signed distribution manifest.** A channel manifest must bind exact image
|
||||||
|
digests, Compose compatibility, SBOM/provenance references, and revocation
|
||||||
|
state. Recording a URL and checksum is not signature verification.
|
||||||
|
3. **First administrator.** Production needs a one-time, restricted enrollment
|
||||||
|
identity. The development bootstrap must not be enabled in production.
|
||||||
|
4. **Image/module composition.** The selected module set must be proven present
|
||||||
|
in the exact image or installed from verified offline artifacts before it is
|
||||||
|
enabled.
|
||||||
|
5. **Deployment agent.** Web updates need a separate privileged reconciler with
|
||||||
|
a typed command allowlist. The API and browser must never receive the Docker
|
||||||
|
socket or arbitrary shell access.
|
||||||
|
6. **Ingress and certificates.** The managed HAProxy service provides HTTP
|
||||||
|
load balancing inside the deployment boundary; it does not issue or renew
|
||||||
|
certificates. A self-hosted profile still needs an explicit choice
|
||||||
|
between an existing reverse proxy and a supported managed ingress, including
|
||||||
|
trusted-proxy boundaries, TLS certificate issuance, renewal, and health
|
||||||
|
probing through the public route.
|
||||||
|
|
||||||
|
`apply --allow-unverified-images` is therefore restricted to the evaluation
|
||||||
|
profile. It explicitly acknowledges both mutable image identities and
|
||||||
|
unverified image/module composition. It is a local test escape hatch, not a
|
||||||
|
production setting.
|
||||||
|
|
||||||
|
## Component Choices
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
`managed` creates a persistent PostgreSQL container and private generated
|
||||||
|
credentials. `external` requires an explicit `DATABASE_URL`; switching from
|
||||||
|
managed to external cannot reuse the old `postgres` Docker hostname
|
||||||
|
accidentally.
|
||||||
|
|
||||||
|
Interactive entry hides external URLs because they commonly contain
|
||||||
|
credentials. For unattended automation, provide them through a protected
|
||||||
|
operator mechanism and avoid storing secret-bearing flags in shell history.
|
||||||
|
|
||||||
|
Production policy should support external managed databases and local managed
|
||||||
|
PostgreSQL equally at the application boundary. Backup, point-in-time recovery,
|
||||||
|
high availability, and major-version upgrades remain deployment properties.
|
||||||
|
|
||||||
|
### Redis
|
||||||
|
|
||||||
|
`managed` creates an authenticated, append-only Redis container. `external`
|
||||||
|
requires an explicit `REDIS_URL`. `disabled` is evaluation-only and disables
|
||||||
|
workers while recording the single-process login-throttle risk acknowledgement.
|
||||||
|
|
||||||
|
`doctor` performs a bounded TCP connection check for external PostgreSQL,
|
||||||
|
Redis, and S3 endpoints. This verifies DNS, routing, and that the port accepts a
|
||||||
|
connection; it is not an authentication or semantic health check.
|
||||||
|
|
||||||
|
Production base installations include Redis because durable queues, distributed
|
||||||
|
throttling, notifications, scheduled work, and transactional event delivery
|
||||||
|
must survive API restarts.
|
||||||
|
|
||||||
|
### Mail
|
||||||
|
|
||||||
|
The first slice distinguishes:
|
||||||
|
|
||||||
|
- `disabled`;
|
||||||
|
- `external-relay`, which records the infrastructure decision but leaves Mail
|
||||||
|
server/credential creation as a visible post-install task;
|
||||||
|
- `test-mail`, an evaluation-only GreenMail service.
|
||||||
|
|
||||||
|
A bundled production mail server is intentionally not a default. Operating one
|
||||||
|
requires DNS, reverse DNS, TLS, DKIM, SPF, DMARC, reputation, abuse handling,
|
||||||
|
queue monitoring, and upgrade policy. A later profile may support an
|
||||||
|
operator-selected MTA/relay, but it must expose these requirements rather than
|
||||||
|
presenting a container as a complete mail service.
|
||||||
|
|
||||||
|
### File Storage
|
||||||
|
|
||||||
|
`local` uses a durable Compose volume and is appropriate for one-host
|
||||||
|
installations. `garage` provisions Garage 2.3 in its supported single-node
|
||||||
|
bootstrap mode, generates a private application key and bucket, and connects
|
||||||
|
the Files S3 backend to the exact installer-owned internal endpoint. The
|
||||||
|
deployment-only trust marker cannot authorize another S3 host; arbitrary
|
||||||
|
external SDK endpoints remain fail-closed until peer pinning is implemented.
|
||||||
|
Garage metadata and object data use separate persistent volumes. `s3` requires
|
||||||
|
an external endpoint, region, access key, secret key, and bucket values.
|
||||||
|
Self-hosted external S3 endpoints must use HTTPS.
|
||||||
|
|
||||||
|
Local storage must be included in backup and restore drills. Horizontal API or
|
||||||
|
worker scale-out requires shared/object storage. The managed Garage profile is
|
||||||
|
persistent but has no data redundancy; availability-sensitive installations
|
||||||
|
must use a tested multi-node Garage cluster or another external S3 service.
|
||||||
|
|
||||||
|
### Load Balancing And Replicas
|
||||||
|
|
||||||
|
The generated Compose topology publishes only `load-balancer`. HAProxy uses
|
||||||
|
Docker DNS service discovery to distribute public traffic across WebUI replicas
|
||||||
|
and WebUI API proxy traffic across API replicas. The WebUI and API services do
|
||||||
|
not publish host ports. HAProxy has no Docker socket and discovers only the
|
||||||
|
bounded replica slots rendered into `load-balancer.cfg`.
|
||||||
|
|
||||||
|
Replica counts are desired state:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/deployment/govoplan-deploy.py configure \
|
||||||
|
--directory /tmp/govoplan-evaluation \
|
||||||
|
--api-replicas 3 \
|
||||||
|
--web-replicas 2 \
|
||||||
|
--worker-replicas 4
|
||||||
|
./.venv/bin/python tools/deployment/govoplan-deploy.py apply \
|
||||||
|
--directory /tmp/govoplan-evaluation
|
||||||
|
```
|
||||||
|
|
||||||
|
Workers are queue consumers, so they are scaled through Redis rather than put
|
||||||
|
behind an HTTP load balancer. The migration runner and Celery scheduler remain
|
||||||
|
singletons. Multiple API replicas are rejected when Redis is disabled because
|
||||||
|
distributed throttling and queued work cannot then be shared correctly.
|
||||||
|
|
||||||
|
This is same-host scaling. Docker Compose uses a bridge network and does not
|
||||||
|
place containers on another machine. See
|
||||||
|
[Scaling And Multi-Host Deployment](SCALING_AND_MULTI_HOST_DEPLOYMENT.md) for
|
||||||
|
the supported topology and promotion path.
|
||||||
|
|
||||||
|
## Reconfiguration Semantics
|
||||||
|
|
||||||
|
`installation.json` is desired state. `receipt.json` is the last successfully
|
||||||
|
applied state. `plan` compares their canonical hashes and service sets.
|
||||||
|
|
||||||
|
- Adding a managed component creates its service and persistent volume.
|
||||||
|
- Removing a component removes its service container on apply.
|
||||||
|
- Volumes are retained by default; deleting data requires a separate,
|
||||||
|
deliberately destructive workflow.
|
||||||
|
- Existing generated credentials are retained unless an explicit future rotate
|
||||||
|
operation is requested.
|
||||||
|
- Private configuration changes are represented by a keyed fingerprint in the
|
||||||
|
plan and receipt; plaintext values are never copied there.
|
||||||
|
- Managed-to-external transitions require the new endpoint in the same
|
||||||
|
operation.
|
||||||
|
- Migrations run as a one-shot service before API/worker replacement.
|
||||||
|
- The first upgrade from a direct WebUI host port stops that legacy WebUI
|
||||||
|
container immediately before HAProxy claims the same endpoint.
|
||||||
|
- Health must recover before a new receipt is committed.
|
||||||
|
|
||||||
|
This is sufficient for one-host reconciliation. Production updates additionally
|
||||||
|
need backup/restore gates, maintenance/drain state, database compatibility
|
||||||
|
windows, image signature verification, and rollback/forward-recovery policy.
|
||||||
|
|
||||||
|
## Web Update Boundary
|
||||||
|
|
||||||
|
The intended update path is:
|
||||||
|
|
||||||
|
1. Ops reads the non-secret installation receipt and reports management mode,
|
||||||
|
current release, component health, and update availability.
|
||||||
|
2. An authorized administrator asks Core to create a typed deployment request,
|
||||||
|
for example `reconcile_release` or `rollback_release`.
|
||||||
|
3. Core persists the reviewed immutable plan, actor, expected current receipt,
|
||||||
|
and idempotency key.
|
||||||
|
4. A separately deployed, narrow deployment agent claims the request.
|
||||||
|
5. The agent verifies signatures/digests, acquires a fenced deployment lock,
|
||||||
|
backs up, pulls, migrates, reconciles, probes health, and writes evidence.
|
||||||
|
6. Ops presents durable progress and the resulting receipt.
|
||||||
|
|
||||||
|
The agent owns container-runtime access. It accepts no command strings from the
|
||||||
|
browser and has no domain-data permissions. Installations managed by Kubernetes,
|
||||||
|
systemd, or another external orchestrator expose read-only status and an export
|
||||||
|
of the reviewed update recipe instead of a non-functional update button.
|
||||||
|
|
||||||
|
## Distribution Workflow
|
||||||
|
|
||||||
|
The downloadable entry point should eventually be:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl --proto '=https' --tlsv1.2 --fail --location \
|
||||||
|
https://govoplan.add-ideas.de/install/v1/bootstrap.pyz \
|
||||||
|
--output govoplan-bootstrap.pyz
|
||||||
|
python3 govoplan-bootstrap.pyz init
|
||||||
|
```
|
||||||
|
|
||||||
|
The published documentation must include an independent checksum/signature
|
||||||
|
verification command before execution. The zipapp then downloads only a signed
|
||||||
|
distribution manifest, verifies it against an embedded or explicitly installed
|
||||||
|
keyring, and renders the same installation contract implemented here.
|
||||||
|
|
||||||
|
The source-tree script is the test harness for that future zipapp. It is not yet
|
||||||
|
the internet bootstrap artifact.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run the focused tests:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python -m unittest -v tests.test_deployment_installer
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests cover profile restrictions, secret persistence, external endpoint
|
||||||
|
requirements, managed Garage bootstrap, S3 policy, replica validation, HAProxy
|
||||||
|
discovery configuration, Compose service selection, secret non-disclosure,
|
||||||
|
service-specific environment isolation, private file modes, external endpoint
|
||||||
|
preflight, first-plan generation, apply ordering, and receipt-based
|
||||||
|
idempotency.
|
||||||
@@ -444,25 +444,25 @@ reason to infer that a pattern is satisfied.
|
|||||||
and does not duplicate a central component.
|
and does not duplicate a central component.
|
||||||
- Behavioral/accessibility evidence is linked from the rollout matrix and issue.
|
- Behavioral/accessibility evidence is linked from the rollout matrix and issue.
|
||||||
- Configured-system help can reach the applicable pattern or reference topic
|
- Configured-system help can reach the applicable pattern or reference topic
|
||||||
when [Docs #15](https://git.add-ideas.de/add-ideas/govoplan-docs/issues/15)
|
when [Docs #15](https://git.add-ideas.de/GovOPlaN/govoplan-docs/issues/15)
|
||||||
supplies that experience.
|
supplies that experience.
|
||||||
|
|
||||||
## First Pilot: Campaign
|
## First Pilot: Campaign
|
||||||
|
|
||||||
[Campaign #74](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/74)
|
[Campaign #74](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/74)
|
||||||
is the first full-domain audit and migration. It should prove patterns before
|
is the first full-domain audit and migration. It should prove patterns before
|
||||||
generic extraction:
|
generic extraction:
|
||||||
|
|
||||||
- [#59](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/59) and
|
- [#59](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/59) and
|
||||||
[#73](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/73): stable,
|
[#73](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/73): stable,
|
||||||
accessible preview and attachment-detail overlays
|
accessible preview and attachment-detail overlays
|
||||||
- [#63](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/63): review
|
- [#63](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/63): review
|
||||||
stages, outcomes, blockers, and intervention vocabulary
|
stages, outcomes, blockers, and intervention vocabulary
|
||||||
- [#62](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/62): explicit
|
- [#62](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/62): explicit
|
||||||
synchronous/asynchronous send mode and durable delivery progress
|
synchronous/asynchronous send mode and durable delivery progress
|
||||||
- [#65](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/65): one
|
- [#65](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/65): one
|
||||||
coherent report filtering and count-affordance model
|
coherent report filtering and count-affordance model
|
||||||
- [#35](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/35): guided
|
- [#35](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/35): guided
|
||||||
first-campaign entry
|
first-campaign entry
|
||||||
|
|
||||||
These slices do not depend on the Workflow runtime. Campaign's current
|
These slices do not depend on the Workflow runtime. Campaign's current
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ The applicable design contract is
|
|||||||
|
|
||||||
## Snapshot And Method
|
## Snapshot And Method
|
||||||
|
|
||||||
|
The source-derived inventory command is documented in
|
||||||
|
[`PLATFORM_CONTROL_PLANE.md`](PLATFORM_CONTROL_PLANE.md). It produces
|
||||||
|
machine-readable field, label, translation, route, API-reference, and module
|
||||||
|
manifest evidence. This hand-maintained document remains the reviewed product
|
||||||
|
interpretation and rollout ledger; generated evidence does not replace it.
|
||||||
|
|
||||||
Snapshot refreshed: 2026-07-22.
|
Snapshot refreshed: 2026-07-22.
|
||||||
|
|
||||||
Evidence was read from tracked Git `HEAD` in the local GovOPlaN checkouts:
|
Evidence was read from tracked Git `HEAD` in the local GovOPlaN checkouts:
|
||||||
@@ -59,16 +65,16 @@ The access guard column reports only the route-level declaration in
|
|||||||
|
|
||||||
| Route | Owner / render evidence | Route-level access evidence | Primary task | Target archetype | Status / priority |
|
| Route | Owner / render evidence | Route-level access evidence | Primary task | Target archetype | Status / priority |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `/admin` | `govoplan-access` `AdminPage` | Any core `adminReadScopes` | Administer system and tenant concerns assembled from module sections | Administration/configuration | Contributed; unreviewed; P1 under [Core #225](https://git.add-ideas.de/add-ideas/govoplan-core/issues/225) |
|
| `/admin` | `govoplan-access` `AdminPage` | Any core `adminReadScopes` | Administer system and tenant concerns assembled from module sections | Administration/configuration | Contributed; unreviewed; P1 under [Core #225](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/225) |
|
||||||
| `/address-book` | `govoplan-addresses` `AddressBookPage` | `addresses:contact:read` | Browse and manage contacts, address books, and lists | Directory/list-detail | Contributed; unreviewed; P2 after Campaign |
|
| `/address-book` | `govoplan-addresses` `AddressBookPage` | `addresses:contact:read` | Browse and manage contacts, address books, and lists | Directory/list-detail | Contributed; unreviewed; P2 after Campaign |
|
||||||
| `/calendar` | `govoplan-calendar` `CalendarPage` | `calendar:event:read` | Browse calendars/events and act on calendar data | Directory/list-detail | Contributed; metadata gap; unreviewed; P2 after Campaign |
|
| `/calendar` | `govoplan-calendar` `CalendarPage` | `calendar:event:read` | Browse calendars/events and act on calendar data | Directory/list-detail | Contributed; metadata gap; unreviewed; P2 after Campaign |
|
||||||
| `/campaigns` | `govoplan-campaign` `CampaignListPage` | `campaigns:campaign:read` | Find, compare, create, and open campaigns | List-detail entry | Pilot; P1 [Campaign #74](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/74) |
|
| `/campaigns` | `govoplan-campaign` `CampaignListPage` | `campaigns:campaign:read` | Find, compare, create, and open campaigns | List-detail entry | Pilot; P1 [Campaign #74](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/74) |
|
||||||
| `/campaigns/:campaignId/*` | `govoplan-campaign` `CampaignResourceRoute` and `CampaignWorkspace` | `campaigns:campaign:read`, plus resource probe | Configure, review, send, and inspect one campaign/version | List-detail workspace containing edit, review, monitoring, and evidence surfaces | Pilot; P1 Campaign #74 |
|
| `/campaigns/:campaignId/*` | `govoplan-campaign` `CampaignResourceRoute` and `CampaignWorkspace` | `campaigns:campaign:read`, plus resource probe | Configure, review, send, and inspect one campaign/version | List-detail workspace containing edit, review, monitoring, and evidence surfaces | Pilot; P1 Campaign #74 |
|
||||||
| `/operator` | `govoplan-campaign` `OperatorQueuePage` | `campaigns:campaign:read` and any of queue, control, retry, or reconcile | Monitor and intervene in campaign jobs through authority-specific controls | Monitoring/work queue | Pilot; durable queue controls delivered in [Campaign #78](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/78); #74 audit remains |
|
| `/operator` | `govoplan-campaign` `OperatorQueuePage` | `campaigns:campaign:read` and any of queue, control, retry, or reconcile | Monitor and intervene in campaign jobs through authority-specific controls | Monitoring/work queue | Pilot; durable queue controls delivered in [Campaign #78](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/78); #74 audit remains |
|
||||||
| `/reports` | `govoplan-campaign` `AggregateReportsPage` | `campaigns:report:read` | Compare privacy-protected cross-campaign outcome totals without recipient detail, diagnostics, export, or drill-down | Aggregate reporting | Pilot; aggregate-reader surface delivered in [Campaign #80](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/80); #74 audit remains |
|
| `/reports` | `govoplan-campaign` `AggregateReportsPage` | `campaigns:report:read` | Compare privacy-protected cross-campaign outcome totals without recipient detail, diagnostics, export, or drill-down | Aggregate reporting | Pilot; aggregate-reader surface delivered in [Campaign #80](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/80); #74 audit remains |
|
||||||
| `/templates` | `govoplan-campaign` `TemplatesPage` | No route guard declared in `module.ts` | Browse/manage campaign templates | Directory/list-detail | Pilot audit; permission intent must be verified; P2 |
|
| `/templates` | `govoplan-campaign` `TemplatesPage` | No route guard declared in `module.ts` | Browse/manage campaign templates | Directory/list-detail | Pilot audit; permission intent must be verified; P2 |
|
||||||
| `/dashboard` | `govoplan-dashboard` `DashboardPage` | No route-specific scope | Assemble module-provided actionable widgets | Dashboard | Contributed; unreviewed; P2 |
|
| `/dashboard` | `govoplan-dashboard` `DashboardPage` | No route-specific scope | Assemble module-provided actionable widgets | Dashboard | Contributed; unreviewed; P2 |
|
||||||
| `/docs` | `govoplan-docs` `DocsPage` | Docs read or system/tenant settings read scopes | Read configured, available, and evidence-aware documentation | Documentation directory/reference | Contributed; unreviewed; P1 [Docs #15](https://git.add-ideas.de/add-ideas/govoplan-docs/issues/15) after initial pattern content |
|
| `/docs` | `govoplan-docs` `DocsPage` | Docs read or system/tenant settings read scopes | Read configured, available, and evidence-aware documentation | Documentation directory/reference | Contributed; unreviewed; P1 [Docs #15](https://git.add-ideas.de/GovOPlaN/govoplan-docs/issues/15) after initial pattern content |
|
||||||
| `/files` | `govoplan-files` `FilesPage` | `files:file:read` | Browse folders/files and perform managed-file work | Directory/explorer | Contributed; metadata gap; unreviewed; P2 after Campaign |
|
| `/files` | `govoplan-files` `FilesPage` | `files:file:read` | Browse folders/files and perform managed-file work | Directory/explorer | Contributed; metadata gap; unreviewed; P2 after Campaign |
|
||||||
| `/idm` | `govoplan-idm` `IdmPage` | Any IDM assignment/write or organization function-assign scope | Inspect and govern identity/function assignments | List-detail/configuration | Contributed; unreviewed; P2 |
|
| `/idm` | `govoplan-idm` `IdmPage` | Any IDM assignment/write or organization function-assign scope | Inspect and govern identity/function assignments | List-detail/configuration | Contributed; unreviewed; P2 |
|
||||||
| `/mail` | `govoplan-mail` `MailboxPage` | `mail:mailbox:read` | Browse mailboxes and messages | Directory/list-detail | Contributed; metadata gap; unreviewed; P2 after Campaign |
|
| `/mail` | `govoplan-mail` `MailboxPage` | `mail:mailbox:read` | Browse mailboxes and messages | Directory/list-detail | Contributed; metadata gap; unreviewed; P2 after Campaign |
|
||||||
@@ -138,7 +144,7 @@ above; they are not independent routes.
|
|||||||
|
|
||||||
Campaign is detailed first because it exercises almost every archetype. The
|
Campaign is detailed first because it exercises almost every archetype. The
|
||||||
recipient-data editor is now consolidated into the `recipients` section on
|
recipient-data editor is now consolidated into the `recipients` section on
|
||||||
remote `main`; [Campaign #67](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/67)
|
remote `main`; [Campaign #67](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/67)
|
||||||
records the accepted and verified integration boundary.
|
records the accepted and verified integration boundary.
|
||||||
|
|
||||||
Campaign already consumes core primitives including `ModuleSubnav`, `Card`,
|
Campaign already consumes core primitives including `ModuleSubnav`, `Card`,
|
||||||
@@ -150,25 +156,25 @@ 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/add-ideas/govoplan-campaign/issues/74); guided entry [#35](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/35) |
|
| 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) |
|
||||||
| 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 need real consequence and reversibility wording | #74 remaining audit |
|
||||||
| 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 audit |
|
||||||
| 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/add-ideas/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; attachment-detail [#59](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/59) |
|
||||||
| 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 delivered in #67; #74 remaining audit and guided entry #35 |
|
||||||
| 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/add-ideas/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; stable overlay [#73](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/73) |
|
||||||
| 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; align with Core #225 mail pattern |
|
||||||
| Campaign settings (`GlobalSettingsPage` settings view) | Configure campaign behavior | Adaptive configuration | Can alter validation/build/send behavior | #74 audit |
|
| Campaign settings (`GlobalSettingsPage` settings view) | Configure campaign behavior | Adaptive configuration | Can alter validation/build/send behavior | #74 audit |
|
||||||
| Mail policy (`MailSettingsPage` policy view) | Inspect/override effective mail policy | Effective policy/provenance editor | Inheritance and locks affect allowed delivery | #74; Core #225 policy pattern |
|
| Mail policy (`MailSettingsPage` policy view) | Inspect/override effective mail policy | Effective policy/provenance editor | Inheritance and locks affect allowed delivery | #74; Core #225 policy pattern |
|
||||||
| Campaign policy (`GlobalSettingsPage` policy view) | Inspect/override campaign policy | Effective policy/provenance editor | Inheritance, actor authority, and blocked edits | #74; Core #225 policy pattern |
|
| Campaign policy (`GlobalSettingsPage` policy view) | Inspect/override campaign policy | Effective policy/provenance editor | Inheritance, actor authority, and blocked edits | #74; Core #225 policy pattern |
|
||||||
| Review/send (`ReviewSendPage`) | Validate, build, mock-test, confirm/send, inspect results | Guided review/decision plus durable progress | External communication, bounded synchronous execution, persisted queue mode, partial effects, retries, evidence | Bounded synchronous and explicit/persisted queued modes delivered in [#62](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/62) and [#79](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/79); [#63](https://git.add-ideas.de/add-ideas/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 | 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 |
|
||||||
| 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/add-ideas/govoplan-campaign/issues/59) and [#73](https://git.add-ideas.de/add-ideas/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/add-ideas/govoplan-campaign/issues/65) with the full-result DataGrid contract from [Core #263](https://git.add-ideas.de/add-ideas/govoplan-core/issues/263); excluded semantics in [#66](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/66) |
|
| Campaign report (`CampaignReportPage`) | Filter and inspect delivery outcomes | Reporting/list-detail | Partial, failed, explicitly excluded/skipped, SMTP/IMAP outcomes and retries | Server-owned filtering and counts delivered in [#65](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/65) with the full-result DataGrid contract from [Core #263](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/263); excluded semantics in [#66](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/66) |
|
||||||
| Audit (`CampaignAuditPage`) | Inspect campaign evidence/history | Provenance timeline/report | Actor/action/effect trace | #74 audit |
|
| Audit (`CampaignAuditPage`) | Inspect campaign evidence/history | Provenance timeline/report | Actor/action/effect trace | #74 audit |
|
||||||
| 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 expert representation | Advanced diagnostics/reference | Raw data may contain personal/configuration values; not a primary editor | #74 privacy/redaction audit |
|
||||||
| 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/add-ideas/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 | Alternate guided review/send shells | Guided review | Tracked routes exist; implementation relationship to `ReviewSendPage` must be established, not guessed | #74 inventory decision |
|
||||||
| 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/add-ideas/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 operator controls delivered in [#78](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/78); #74 wording/accessibility audit remains |
|
||||||
| 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/add-ideas/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 |
|
| 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`,
|
||||||
@@ -186,7 +192,7 @@ The following local repositories contain a backend manifest but no
|
|||||||
`govoplan-contracts`, `govoplan-dist-lists`, `govoplan-evaluation`,
|
`govoplan-contracts`, `govoplan-dist-lists`, `govoplan-evaluation`,
|
||||||
`govoplan-facilities`, `govoplan-forms-runtime`, `govoplan-grants`,
|
`govoplan-facilities`, `govoplan-forms-runtime`, `govoplan-grants`,
|
||||||
`govoplan-helpdesk`, `govoplan-identity`, `govoplan-inspections`,
|
`govoplan-helpdesk`, `govoplan-identity`, `govoplan-inspections`,
|
||||||
`govoplan-issue-reporting`, `govoplan-learning`, `govoplan-permits`,
|
`govoplan-tickets`, `govoplan-learning`, `govoplan-permits`,
|
||||||
`govoplan-poll`, `govoplan-procurement`, `govoplan-records`,
|
`govoplan-poll`, `govoplan-procurement`, `govoplan-records`,
|
||||||
`govoplan-resources`, `govoplan-rest`, `govoplan-risk-compliance`,
|
`govoplan-resources`, `govoplan-rest`, `govoplan-risk-compliance`,
|
||||||
`govoplan-soap`, `govoplan-tenancy`, and `govoplan-transparency`.
|
`govoplan-soap`, `govoplan-tenancy`, and `govoplan-transparency`.
|
||||||
@@ -199,7 +205,7 @@ backend-only modules may remain intentionally headless.
|
|||||||
|
|
||||||
| 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/add-ideas/govoplan/issues/11) | Docs links/diff checks; issue/wiki sync after integration | Initial slice in this document |
|
| 0 | Product grammar and route inventory | Doctrine, ledger, layout rules, module contract, current route sources | One reconciled pattern language and evidence inventory | Meta [#11](https://git.add-ideas.de/GovOPlaN/govoplan/issues/11) | Docs links/diff checks; issue/wiki sync after integration | Initial slice in this document |
|
||||||
| 1 | Campaign baseline integration | Recipient-editor WIP and tracker state have been reconciled with remote `main` | Integrated, testable baseline before migration claims | Campaign #67 and tracker cleanup | Backend and focused WebUI suites; issue evidence | Complete 2026-07-22 |
|
| 1 | Campaign baseline integration | Recipient-editor WIP and tracker state have been reconciled with remote `main` | Integrated, testable baseline before migration claims | Campaign #67 and tracker cleanup | Backend and focused WebUI suites; issue evidence | Complete 2026-07-22 |
|
||||||
| 2 | Campaign previews/details | Stable shared dialog with bounded scrolling and fixed responsive preview workspace | Stable header/body/footer, accessible long-content detail | Campaign #59 and #73 | Review-preview and overlay structure tests | Complete 2026-07-22 |
|
| 2 | Campaign previews/details | Stable shared dialog with bounded scrolling and fixed responsive preview workspace | Stable header/body/footer, accessible long-content detail | Campaign #59 and #73 | Review-preview and overlay structure tests | Complete 2026-07-22 |
|
||||||
| 3 | Campaign review/interventions | Five domain-owned stages with unresolved intervention language | Clear stages, outcomes, blockers, next actor/action, reviewed evidence | Campaign #63 | State matrix behavior/accessibility tests and agreed vocabulary | P1 needs product wording decision |
|
| 3 | Campaign review/interventions | Five domain-owned stages 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 |
|
||||||
@@ -215,10 +221,11 @@ backend-only modules may remain intentionally headless.
|
|||||||
| 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 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 |
|
||||||
| 14 | Manifest/runtime alignment | Several executable routes are absent from manifest metadata | Declared alignment or explicit validated exception | Core contract issue to create | Automated manifest/module route check and configured Docs verification | Discovery follow-up |
|
| 14 | Manifest/runtime alignment | Several executable routes are absent from manifest metadata | Declared alignment or explicit validated exception | Core contract issue to create | Automated manifest/module route check and configured Docs verification | Discovery follow-up |
|
||||||
|
|
||||||
Workflow/user-story implementation is postponed. It is not on the critical path
|
Workflow remains outside this rollout matrix because it has its own runtime and
|
||||||
for this rollout matrix. Focused views can be specified, manually selected, and
|
editor workstream, not because it is postponed. Focused views can be specified,
|
||||||
tested through core composition contracts; a later workflow step may become one
|
manually selected, and tested through core composition contracts today.
|
||||||
activation source without changing the proven surface patterns.
|
Workflow steps may activate those views through the same contract without
|
||||||
|
changing the proven surface patterns.
|
||||||
|
|
||||||
## Inventory Maintenance
|
## Inventory Maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,10 @@ For release validation:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
./.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-release.txt --python ./.venv/bin/python
|
./.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-release.txt --python ./.venv/bin/python
|
||||||
|
./.venv/bin/python -m pip install -r requirements-release-tests.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
That install is necessary because the release environment intentionally resolves
|
That install is necessary because the release environment intentionally resolves
|
||||||
tagged package refs, not local editable source trees.
|
tagged package refs, not local editable source trees. The second requirements
|
||||||
|
file contains only the harness needed to execute tests from those immutable
|
||||||
|
source tags; it is not part of the deployable release dependency set.
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Platform Control Plane And Self-Description
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
GovOPlaN should be able to describe its installed structure without becoming a
|
||||||
|
self-modifying application. The platform model is a declarative control plane:
|
||||||
|
module manifests, UI contributions, schemas, policy provenance, runtime
|
||||||
|
capabilities, and generated source evidence describe what can be configured.
|
||||||
|
Ordinary administrators edit validated data through those contracts; they do
|
||||||
|
not edit Python, TypeScript, routes, or database code from the product UI.
|
||||||
|
|
||||||
|
This distinction provides the requested overview while preserving reviewable
|
||||||
|
releases, module boundaries, migrations, and security controls.
|
||||||
|
|
||||||
|
## Canonical Sources
|
||||||
|
|
||||||
|
| Concern | Canonical source |
|
||||||
|
| --- | --- |
|
||||||
|
| Installed modules and dependency graph | Runtime `ModuleManifest` registry |
|
||||||
|
| Backend routes | Registered FastAPI application; Python AST is build-time evidence |
|
||||||
|
| Frontend routes and navigation | `PlatformWebModule` contributions |
|
||||||
|
| View-filterable regions | Versioned `viewSurfaces` declarations |
|
||||||
|
| Admin sections and module settings | `admin.sections`, including `moduleId`, `kind`, scope group, permission guards, and surface ID |
|
||||||
|
| User settings | `settings.sections` and core settings schemas |
|
||||||
|
| Labels and translations | Generated translation catalogs plus source usage |
|
||||||
|
| 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 |
|
||||||
|
| Effective configuration | Owning module data plus Policy provenance |
|
||||||
|
|
||||||
|
Runtime introspection is authoritative for an installed system. Static source
|
||||||
|
inventory is authoritative evidence for a checkout or release candidate. The
|
||||||
|
two should be compared in CI and by Ops, not conflated.
|
||||||
|
|
||||||
|
## Generated Inventory
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan
|
||||||
|
./.venv/bin/python tools/inventory/platform-interface-inventory.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The command writes:
|
||||||
|
|
||||||
|
- `audit-reports/platform-inventory/platform-interface-inventory.json`
|
||||||
|
- `audit-reports/platform-inventory/platform-interface-inventory.md`
|
||||||
|
|
||||||
|
It combines:
|
||||||
|
|
||||||
|
1. loaded module manifests
|
||||||
|
2. TypeScript AST extraction of fields, label attributes, visible text,
|
||||||
|
translations, frontend routes, navigation, capabilities, and API references
|
||||||
|
3. Python AST extraction of FastAPI route decorators and router prefixes
|
||||||
|
|
||||||
|
The JSON includes exact repository, file, and line evidence. A missing-help
|
||||||
|
entry is a review candidate because dynamic parent components may supply help.
|
||||||
|
A backend route without a static frontend reference is also a review candidate:
|
||||||
|
public APIs, workers, callbacks, health checks, connectors, and dynamic URL
|
||||||
|
assembly are valid explanations.
|
||||||
|
|
||||||
|
`--strict` currently enforces only translation-catalog completeness. Endpoint
|
||||||
|
and help classifications need narrow reviewed baselines before they can become
|
||||||
|
release gates.
|
||||||
|
|
||||||
|
## Admin Information Architecture
|
||||||
|
|
||||||
|
The Admin host uses a tree because system, tenant, group, user, and module
|
||||||
|
settings form a hierarchy rather than one flat list. Every contributed section
|
||||||
|
can identify:
|
||||||
|
|
||||||
|
- its owning `moduleId`
|
||||||
|
- whether it is `management` or `settings`
|
||||||
|
- its system/tenant/group/user scope group
|
||||||
|
- an optional future `parentId`
|
||||||
|
- permission and View visibility requirements
|
||||||
|
|
||||||
|
Existing panels remain their own render owners. The tree only changes discovery
|
||||||
|
and grouping. A later embedded-settings contract may add named slots inside an
|
||||||
|
owning page; it must not allow one module to import another module's private
|
||||||
|
component.
|
||||||
|
|
||||||
|
## Navigation And Workflow
|
||||||
|
|
||||||
|
The intended maximum visible navigation stack is:
|
||||||
|
|
||||||
|
1. global shell context
|
||||||
|
2. one task/object navigation surface
|
||||||
|
3. one workflow stage surface when a workflow is active
|
||||||
|
|
||||||
|
Workflow instance pages should reuse the Campaign stage language: clear stage
|
||||||
|
state, optional/skipped/blocked semantics, partial progress, and a stable current
|
||||||
|
step. Workflow definition pages remain graph editors. Views may activate a
|
||||||
|
focused workflow view that suppresses unrelated shell and module surfaces while
|
||||||
|
retaining an explicit way out.
|
||||||
|
|
||||||
|
Nested module submenus should not be added merely because a data hierarchy
|
||||||
|
exists. Prefer a tree inside configuration/directory surfaces, tabs for sibling
|
||||||
|
views, and the workflow stage rail for ordered work.
|
||||||
|
|
||||||
|
## Safe Meta-Configuration
|
||||||
|
|
||||||
|
The platform can eventually render many configuration editors from versioned
|
||||||
|
JSON Schema and UI Schema supplied by modules. Generated editors remain bounded
|
||||||
|
by:
|
||||||
|
|
||||||
|
- explicit typed schemas and migrations
|
||||||
|
- module-owned validation and preview
|
||||||
|
- Policy locks and provenance
|
||||||
|
- permission and View filtering
|
||||||
|
- preflight, consequence, and rollback information
|
||||||
|
- auditable apply operations
|
||||||
|
|
||||||
|
Custom code, new routes, arbitrary SQL, and executable workflow nodes remain
|
||||||
|
release artifacts. Modeling them as ordinary configuration would create an
|
||||||
|
unreviewed code-execution and migration channel.
|
||||||
|
|
||||||
|
## Next Enforcement Slices
|
||||||
|
|
||||||
|
1. Require every WebUI module route and admin/settings contribution to have
|
||||||
|
matching manifest metadata or a reviewed exception.
|
||||||
|
2. Add stable field IDs and optional help-topic IDs to shared field components.
|
||||||
|
3. Classify each statically unreferenced backend endpoint by consumer type.
|
||||||
|
4. Compare a running installation's OpenAPI and module registry against the
|
||||||
|
release inventory.
|
||||||
|
5. Publish the sanitized installed-system structure through Ops/Docs for
|
||||||
|
authorized administrators.
|
||||||
@@ -445,8 +445,9 @@ that first needs them:
|
|||||||
- exact HIS/CampusOnline source endpoints, student-statistics keys and accepted
|
- exact HIS/CampusOnline source endpoints, student-statistics keys and accepted
|
||||||
calculation, freeze/correction policy, privacy profile, and permitted
|
calculation, freeze/correction policy, privacy profile, and permitted
|
||||||
drill-down level;
|
drill-down level;
|
||||||
- whether repeated data-source and dataflow contracts justify separate
|
- datasource provider selection and quality/promotion policy; the architecture
|
||||||
`govoplan-datasources` and `govoplan-dataflow` modules after the Stage 3 proof;
|
now separates `govoplan-datasources` lifecycle from `govoplan-connectors`
|
||||||
|
acquisition and `govoplan-dataflow` transformation;
|
||||||
- first collaborative editor/provider and whether the first UX is concurrent
|
- first collaborative editor/provider and whether the first UX is concurrent
|
||||||
editing, controlled check-out, or both; and
|
editing, controlled check-out, or both; and
|
||||||
- first Records/archive target and approval/signature assurance level.
|
- first Records/archive target and approval/signature assurance level.
|
||||||
|
|||||||
+240
-50
@@ -5,8 +5,9 @@ GovOPlaN releases. It belongs to the `govoplan` meta repository because it works
|
|||||||
across all local checkouts, release scripts, module manifests, migration audits,
|
across all local checkouts, release scripts, module manifests, migration audits,
|
||||||
catalog files, Git state, and signing keys.
|
catalog files, Git state, and signing keys.
|
||||||
|
|
||||||
The current implementation has a read-only dashboard plus guarded local
|
The current implementation has a read-only dashboard, preview-only legacy
|
||||||
candidate/publish actions:
|
controls, and a bounded durable executor for release steps whose inputs and
|
||||||
|
effects can be verified safely:
|
||||||
|
|
||||||
- inspect repositories from `repositories.json`
|
- inspect repositories from `repositories.json`
|
||||||
- show dirty, ahead, behind, missing, no-HEAD, and tag state
|
- show dirty, ahead, behind, missing, no-HEAD, and tag state
|
||||||
@@ -19,10 +20,19 @@ candidate/publish actions:
|
|||||||
- select the repositories that should advance through checkboxes
|
- select the repositories that should advance through checkboxes
|
||||||
- generate dry-run selective release plans for independently versioned packages
|
- generate dry-run selective release plans for independently versioned packages
|
||||||
- freeze a selective plan as a durable, resumable local release run
|
- freeze a selective plan as a durable, resumable local release run
|
||||||
- create annotated source release tags for selected clean, version-aligned
|
- durably preflight a creation-time-bound repository, create its annotated tag,
|
||||||
repositories and publish each branch/tag pair atomically
|
and publish its branch/tag pair atomically
|
||||||
- generate signed catalog candidates for the selected release units
|
- deterministically update recognized package/manifest version declarations and
|
||||||
- preview/apply/push reviewed catalog candidates behind explicit confirmations
|
commit only the receipt-bound metadata paths
|
||||||
|
- order selected module providers before consumers, create module tags before
|
||||||
|
Core, regenerate Core's selected WebUI release lock, and re-run alignment
|
||||||
|
before any remote push
|
||||||
|
- build selected Python wheels and generate a private, signed, receipt-bound
|
||||||
|
catalog candidate
|
||||||
|
- publish that exact candidate through a verified website commit and immutable
|
||||||
|
tag after explicit confirmation
|
||||||
|
- install selected candidate wheels into a private no-network/no-dependency
|
||||||
|
target and verify their installed metadata against the frozen plan
|
||||||
|
|
||||||
Start it from the meta repository:
|
Start it from the meta repository:
|
||||||
|
|
||||||
@@ -30,8 +40,32 @@ Start it from the meta repository:
|
|||||||
./.venv/bin/python tools/release/release-console.py
|
./.venv/bin/python tools/release/release-console.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The server binds to `127.0.0.1` by default and prints a URL containing a local
|
The launcher accepts only a numeric loopback address, binds to `127.0.0.1` by
|
||||||
API token. Open that URL in a browser on the same machine.
|
default, always creates a fresh API token, and prints that token only in the URL
|
||||||
|
fragment. Open that URL in a browser on the same machine. A deliberately
|
||||||
|
tokenless embedded app is read-only: non-GET `/api/` requests fail with `403`.
|
||||||
|
Non-loopback operation needs a separately deployed authenticated TLS boundary;
|
||||||
|
the local launcher will not expose the mutation API that way.
|
||||||
|
|
||||||
|
Run durable release mutation only against an operator-private workspace.
|
||||||
|
Repository roots, every path ancestor, critical and nested Git metadata, and
|
||||||
|
tracked worktree files must be owned by the console process UID (or root where
|
||||||
|
appropriate) and must not be group/world writable. Symlink/special Git
|
||||||
|
metadata, object alternates, and grafts are rejected. The console pins
|
||||||
|
`/usr/bin/git`, `/usr/bin/ssh`, a fixed system `PATH`, disabled hooks, isolated
|
||||||
|
Git configuration, and no replace objects. Shared or `nfsnobody`-owned
|
||||||
|
checkouts remain usable for read-only planning, but every durable executor
|
||||||
|
fails closed there; clone the registered origins into a private workspace
|
||||||
|
before releasing.
|
||||||
|
|
||||||
|
The runtime itself is part of the authority boundary. Durable run creation
|
||||||
|
verifies the meta checkout, release/check tooling, repository registry, Python
|
||||||
|
environment, loaded `govoplan_core` and cryptography packages, and Git/SSH
|
||||||
|
executables. It then binds a clean meta-repository HEAD and named branch that
|
||||||
|
exactly match the registered `origin`. Every execution and reconciliation
|
||||||
|
rechecks that receipt. Permission checks cannot prove that code was safe before
|
||||||
|
it entered a writable tree, so release from a fresh operator-private clone or a
|
||||||
|
separately verified installed console artifact.
|
||||||
|
|
||||||
The web UI starts with a repository table. Each repository can be checked
|
The web UI starts with a repository table. Each repository can be checked
|
||||||
independently and assigned its own target version. Repositories without version
|
independently and assigned its own target version. Repositories without version
|
||||||
@@ -40,15 +74,35 @@ shows the dry-run commands for the selected rows, and `Generate Candidate`
|
|||||||
creates a signed catalog candidate that advances only selected repositories that
|
creates a signed catalog candidate that advances only selected repositories that
|
||||||
already have a catalog entry.
|
already have a catalog entry.
|
||||||
|
|
||||||
|
The full-width **Release Workflow** guide projects the server state into seven
|
||||||
|
operator phases: Inspect, Targets, Validate, Source, Package, Publish, and
|
||||||
|
Verify. It does not maintain a second workflow state. Completed, current,
|
||||||
|
blocked, locked, and unavailable phases are derived from the dashboard,
|
||||||
|
selective plan, and durable run record. The next-action panel opens the exact
|
||||||
|
section or durable step that needs attention. Changing a channel, target
|
||||||
|
version, repository selection, or release gate detaches the browser from the
|
||||||
|
current run and invalidates the draft plan; the persisted run remains available
|
||||||
|
from the saved-run selector. Problems in unselected repositories remain visible
|
||||||
|
as workspace notices but do not lock an unrelated release; the selective plan
|
||||||
|
is the authority for blockers in the selected repository set.
|
||||||
|
|
||||||
|
Installation verification is an explicit durable step after catalog
|
||||||
|
publication. It verifies the exact candidate receipt, installs every selected
|
||||||
|
Python wheel into a temporary target with network and dependency resolution
|
||||||
|
disabled, and compares installed names and versions with the frozen plan.
|
||||||
|
Deployment startup, database upgrades, and module-combination smoke tests remain
|
||||||
|
release-integration CI gates; the console does not represent its local wheel
|
||||||
|
check as a production deployment.
|
||||||
|
|
||||||
`Build Plan` also returns structured release-gate findings for each selected
|
`Build Plan` also returns structured release-gate findings for each selected
|
||||||
repository. The plan names the recommended next action and gives an explicit
|
repository. The plan names the recommended next action and gives an explicit
|
||||||
remediation for source-version, lockfile, Core WebUI composition, Git state, and
|
remediation for source-version, lockfile, Core WebUI composition, Git state, and
|
||||||
worktree findings. A target version that has not yet been applied consistently
|
worktree findings. A target version that differs from internally consistent
|
||||||
to the selected source tree is therefore explained before the source-tag
|
source metadata becomes a bounded `UPDATE` step. Unsupported, missing, or
|
||||||
preflight rather than appearing only as an error after the operator tries to
|
internally inconsistent declarations remain a blocker with exact remediation.
|
||||||
tag. `source_preflight_ready` means that the plan-visible source gates pass; the
|
`source_preflight_ready` means that plan-visible source gates pass or have a
|
||||||
non-mutating `Preview Tag + Publish` remains mandatory for remote, manifest, and
|
bounded deterministic mutation; the non-mutating `Preview Tag + Publish`
|
||||||
immutable-tag checks.
|
remains mandatory for remote, manifest, and immutable-tag checks.
|
||||||
|
|
||||||
## Durable release runs
|
## Durable release runs
|
||||||
|
|
||||||
@@ -57,6 +111,13 @@ into a versioned local run record. The server rebuilds the selective plan and
|
|||||||
requires the plan to resolve exactly the requested repositories and target
|
requires the plan to resolve exactly the requested repositories and target
|
||||||
versions; the browser cannot submit or replace the plan snapshot. The input and
|
versions; the browser cannot submit or replace the plan snapshot. The input and
|
||||||
plan are then immutable and covered by a canonical SHA-256 integrity digest.
|
plan are then immutable and covered by a canonical SHA-256 integrity digest.
|
||||||
|
Every executable repository step also carries its creation-time full HEAD,
|
||||||
|
branch, target tag, a SHA-256 over both the fetch and push URLs of `origin`, and
|
||||||
|
a SHA-256 over bounded dirty-path names and bytes. Both URLs must exactly equal
|
||||||
|
the remote registered in `repositories.json`; a changed HEAD, branch, worktree,
|
||||||
|
remote, push URL, or metadata byte requires a new run or explicit
|
||||||
|
interrupted-step reconciliation rather than silently retargeting the frozen
|
||||||
|
compatibility decision.
|
||||||
The complete record also has a checksum so a valid-looking manual edit to its
|
The complete record also has a checksum so a valid-looking manual edit to its
|
||||||
mutable state fails closed. File permissions remain the authority boundary;
|
mutable state fails closed. File permissions remain the authority boundary;
|
||||||
these digests detect accidental or manual corruption, not an attacker who can
|
these digests detect accidental or manual corruption, not an attacker who can
|
||||||
@@ -66,9 +127,10 @@ channel, or gate input requires a new run.
|
|||||||
Creation requires a caller-generated `request_id`. Its SHA-256 fingerprint is
|
Creation requires a caller-generated `request_id`. Its SHA-256 fingerprint is
|
||||||
the private, workspace-scoped durable mapping to exactly one run; the raw
|
the private, workspace-scoped durable mapping to exactly one run; the raw
|
||||||
identifier is never persisted. Repeating the same identifier with the same
|
identifier is never persisted. Repeating the same identifier with the same
|
||||||
immutable inputs returns that run without rebuilding the plan, even if the
|
immutable inputs returns that run without rebuilding the plan while it remains
|
||||||
live dashboard has since drifted. Reusing it with different inputs fails
|
inside the bounded local retention window, even if the live dashboard has
|
||||||
closed. The browser keeps an uncertain create identifier in session storage,
|
since drifted. Reusing it with different inputs fails closed. The browser keeps
|
||||||
|
an uncertain create identifier in session storage,
|
||||||
replays it after reload, and selects the known run returned by the server. A
|
replays it after reload, and selects the known run returned by the server. A
|
||||||
successful create remains shown as saved if only the subsequent list refresh
|
successful create remains shown as saved if only the subsequent list refresh
|
||||||
fails.
|
fails.
|
||||||
@@ -86,20 +148,30 @@ modes, malformed schemas, unknown fields, invalid state combinations,
|
|||||||
oversized files, and digest mismatches fail closed. A bad record is neither
|
oversized files, and digest mismatches fail closed. A bad record is neither
|
||||||
rewritten nor automatically quarantined.
|
rewritten nor automatically quarantined.
|
||||||
|
|
||||||
|
The store retains at most 512 workspace-scoped run records created through the
|
||||||
|
console. On creation at that limit it removes only the oldest fully completed,
|
||||||
|
integrity-verified record. Running, planned, attention, blocked, foreign, and
|
||||||
|
unreadable records are never deleted implicitly; if no completed record is
|
||||||
|
available, creation fails closed with a retention remediation. Unavailable
|
||||||
|
records still consume the bound and remain visible in cursor-paginated lists
|
||||||
|
so corruption cannot be hidden by normal turnover.
|
||||||
|
|
||||||
The full resolved-workspace SHA-256 is part of both the private storage
|
The full resolved-workspace SHA-256 is part of both the private storage
|
||||||
namespace and immutable input snapshot. Every list, read, and state transition
|
namespace and immutable input snapshot. Every list, read, and state transition
|
||||||
checks it. An alternate workspace therefore cannot list or resume another
|
checks it. An alternate workspace therefore cannot list or resume another
|
||||||
workspace's runs. This remains true for an embedding/test `run_state_root`
|
workspace's runs. This remains true for an embedding/test `run_state_root`
|
||||||
override: the server always appends
|
override: the server always appends
|
||||||
`workspace-<full-sha256>/release-runs/` rather than treating the override as a
|
`workspace-<full-sha256>/release-runs/` rather than treating the override as a
|
||||||
shared record directory. A corrupt record from one workspace therefore cannot
|
shared record directory. Durable candidates use its private sibling
|
||||||
leak even its identifier or an integrity error into another workspace.
|
`release-candidates/` directory, never a checkout-local runtime path. A corrupt
|
||||||
|
record from one workspace therefore cannot leak even its identifier or an
|
||||||
|
integrity error into another workspace.
|
||||||
|
|
||||||
Each frozen plan step has an explicit `pending`, `running`, `succeeded`,
|
Each frozen plan step has an explicit `pending`, `running`, `succeeded`,
|
||||||
`failed`, or `interrupted` state. Plan order remains a prerequisite: a later
|
`failed`, or `interrupted` state. Plan order remains a prerequisite: a later
|
||||||
step is unavailable until earlier steps have succeeded. Exact attempt and
|
step is unavailable until earlier steps have succeeded. Exact attempt and
|
||||||
resume/retry/reconciliation request identifiers are fingerprinted so delayed
|
resume/retry/reconciliation request identifiers are fingerprinted so delayed
|
||||||
repetitions remain idempotent for the run's lifetime. These fingerprints are
|
repetitions remain idempotent for the retained run's lifetime. These fingerprints are
|
||||||
never evicted: the store fails closed before accepting more than 2,048 commands
|
never evicted: the store fails closed before accepting more than 2,048 commands
|
||||||
or 2,048 attempts and asks the operator to create a fresh run. The display
|
or 2,048 attempts and asks the operator to create a fresh run. The display
|
||||||
record keeps at most 256 server-generated state events. Events contain only an
|
record keeps at most 256 server-generated state events. Events contain only an
|
||||||
@@ -131,6 +203,35 @@ effect, and `unresolved` keeps the run blocked. Each outcome emits a bounded,
|
|||||||
code-only state event. A known failed attempt can likewise be prepared for
|
code-only state event. A known failed attempt can likewise be prepared for
|
||||||
retry. The UI keeps unavailable controls visible and disabled.
|
retry. The UI keeps unavailable controls visible and disabled.
|
||||||
|
|
||||||
|
Supported executors durably claim the step before invoking an effect. Exact
|
||||||
|
attempt replays return the recorded outcome and never invoke the executor a
|
||||||
|
second time. Successful repository preflight, version, commit, Core-bundle,
|
||||||
|
tag, and push steps persist a bounded repository-state receipt. Version
|
||||||
|
reconciliation requires aligned declarations in the same commit; commit
|
||||||
|
reconciliation requires the expected single-parent release commit and only
|
||||||
|
recognized metadata paths. Tag reconciliation independently requires an
|
||||||
|
annotated local tag at the frozen HEAD; push reconciliation additionally
|
||||||
|
requires both the remote annotated tag object and remote branch to match.
|
||||||
|
Catalog generation persists
|
||||||
|
only its server-issued opaque candidate ID and canonical catalog SHA-256, then
|
||||||
|
re-resolves and re-hashes that private candidate before publication.
|
||||||
|
|
||||||
|
Repository capabilities are frozen into each plan unit (`python-package`,
|
||||||
|
`webui-package`, `module-manifest`, `database-migrations`, `documentation`,
|
||||||
|
`core-release-bundle`, and the universal `git-source`) and determine which
|
||||||
|
steps appear. Internally aligned version changes are rendered deterministically
|
||||||
|
from recognized TOML, JSON, lockfile, manifest, and package declarations.
|
||||||
|
Pre-existing dirty worktrees remain visible but have no commit executor; the
|
||||||
|
console never absorbs unrelated operator changes.
|
||||||
|
|
||||||
|
For mixed releases, module interface providers are ordered before consumers
|
||||||
|
and Core is tagged last. The durable sequence creates and commits module
|
||||||
|
metadata, creates local module tags, updates Core's selected WebUI references,
|
||||||
|
regenerates the release lock against those local tags, commits/tags Core, and
|
||||||
|
runs a receipt-bound alignment gate before exposing any atomic branch/tag push.
|
||||||
|
A failed step stops later steps while preserving prior receipts for explicit
|
||||||
|
retry or reconciliation.
|
||||||
|
|
||||||
The browser likewise retains the request identifier for an uncertain
|
The browser likewise retains the request identifier for an uncertain
|
||||||
resume/retry/reconciliation response and replays it after reload. A successful
|
resume/retry/reconciliation response and replays it after reload. A successful
|
||||||
replay selects the returned run state. Transport and server failures retain the
|
replay selects the returned run state. Transport and server failures retain the
|
||||||
@@ -142,26 +243,41 @@ other `/api/` route:
|
|||||||
|
|
||||||
- `POST /api/release-runs` requires `request_id`, then idempotently rebuilds and
|
- `POST /api/release-runs` requires `request_id`, then idempotently rebuilds and
|
||||||
freezes a selective plan only when that creation is not already known.
|
freezes a selective plan only when that creation is not already known.
|
||||||
- `GET /api/release-runs` lists bounded summaries ordered by verified
|
- `GET /api/release-runs` lists bounded summaries ordered by immutable
|
||||||
`updated_at`, then `created_at` and run identifier. Unreadable entries are
|
`created_at` and run identifier. `next_cursor` advances a stable descending
|
||||||
appended deterministically when room remains instead of displacing newer
|
traversal even while older runs are updated, without offset duplicates or
|
||||||
verified runs.
|
skips. Unreadable entries
|
||||||
|
remain a deterministic final section and are therefore reachable through
|
||||||
|
pagination instead of displacing newer verified runs.
|
||||||
- `GET /api/release-runs/{run_id}` reads and verifies one exact record.
|
- `GET /api/release-runs/{run_id}` reads and verifies one exact record.
|
||||||
- `POST /api/release-runs/{run_id}/resume` records explicit recovery.
|
- `POST /api/release-runs/{run_id}/resume` records explicit recovery.
|
||||||
- `POST /api/release-runs/{run_id}/steps/{step_id}/retry` prepares a failed or
|
- `POST /api/release-runs/{run_id}/steps/{step_id}/retry` prepares a failed or
|
||||||
read-only interrupted step for another attempt.
|
read-only interrupted step for another attempt.
|
||||||
- `POST /api/release-runs/{run_id}/steps/{step_id}/reconcile` records a
|
- `POST /api/release-runs/{run_id}/steps/{step_id}/reconcile` records a
|
||||||
confirmed observed outcome for an interrupted mutating step.
|
confirmed observed outcome for an interrupted mutating step.
|
||||||
|
- `POST /api/release-runs/{run_id}/steps/{step_id}/execute` claims and invokes
|
||||||
|
only the narrow executor declared by the immutable plan step. Durable release
|
||||||
|
execution accepts only the registered `origin`, never a caller-selected
|
||||||
|
remote.
|
||||||
|
- `POST /api/release-runs/{run_id}/steps/{step_id}/preview` provides the
|
||||||
|
non-mutating preview for receipt-bound catalog publication.
|
||||||
|
|
||||||
Run-storage errors are confined to the Durable Run State card; dashboard and
|
Run-storage errors are confined to the Durable Release Run section; dashboard
|
||||||
release-preview collection continue and show the bounded storage remediation.
|
and release-preview collection continue and the workflow guide points to the
|
||||||
|
bounded storage remediation.
|
||||||
|
|
||||||
This foundation tracks state only. It deliberately does not run a plan step or
|
The run record is execution evidence only for a supported step whose durable
|
||||||
infer success from a button click. Existing preview and executor controls stay
|
claim and bounded result receipt were persisted. The console never infers
|
||||||
separate and continue to require `COMMIT`, `TAG`, `PUBLISH`, `APPLY`, or `PUSH`
|
success from a button click or process exit alone. An executor exception or a
|
||||||
at their existing narrow boundaries. Wiring confirmed executors to claim and
|
lost result write leaves the attempt interrupted and non-retriable until
|
||||||
finish run steps, plus package/install verification, remains follow-up work;
|
explicit recovery. Catalog publication persists the candidate and keyring
|
||||||
until then the console must not present a run as execution evidence.
|
hashes, exact website commit, annotated tag object and peeled commit, branch,
|
||||||
|
tag name, and registered-origin digest. A successful reconciliation revalidates
|
||||||
|
the candidate against the trust anchor in the frozen website parent, requires
|
||||||
|
the exact deterministic catalog/keyring/module blobs and full commit delta, a
|
||||||
|
sole frozen parent, and matching local and remote branch/tag identities. If any
|
||||||
|
part cannot be proved, the run remains interrupted and may only be recorded as
|
||||||
|
`unresolved`.
|
||||||
|
|
||||||
Dashboard collection is also fail closed. Unreadable Core version metadata or a
|
Dashboard collection is also fail closed. Unreadable Core version metadata or a
|
||||||
malformed module contract is returned as a bounded `collection_errors` entry
|
malformed module contract is returned as a bounded `collection_errors` entry
|
||||||
@@ -188,11 +304,12 @@ Plain repository pushes are separate from catalog publication. `Preview Push`
|
|||||||
shows the selected repository push commands. `Push Selected` requires `PUSH` in
|
shows the selected repository push commands. `Push Selected` requires `PUSH` in
|
||||||
the repository push confirmation field.
|
the repository push confirmation field.
|
||||||
|
|
||||||
The source release panel closes the former gap between a release plan and a
|
The source release panel retains `Preview Tag + Publish` as a non-mutating
|
||||||
catalog candidate. `Preview Tag + Publish` is non-mutating. `Create Tags`
|
inspection. Its legacy `Create Tags` and `Publish Tags` controls stay visible
|
||||||
requires `TAG`; `Publish Tags` requires `PUBLISH` and atomically pushes the
|
but disabled; the corresponding mutation endpoint rejects apply requests.
|
||||||
selected branch and annotated tag. The gate requires an aligned target version,
|
Creation and atomic branch/tag publication use the `TAG` and `PUBLISH`
|
||||||
a clean named branch with a HEAD, and a checkout that is not behind. Existing
|
confirmations on the durable run steps. The gate requires an aligned target
|
||||||
|
version, a clean named branch with a HEAD, and a checkout that is not behind. Existing
|
||||||
local or remote tags must resolve to the selected HEAD and are never moved.
|
local or remote tags must resolve to the selected HEAD and are never moved.
|
||||||
The local and remote annotated tag objects must also be identical, not merely
|
The local and remote annotated tag objects must also be identical, not merely
|
||||||
point at the same commit. Before any source tag is created, the console loads
|
point at the same commit. Before any source tag is created, the console loads
|
||||||
@@ -200,13 +317,17 @@ the cross-repository module registry. This release gate also rejects every
|
|||||||
user-facing workflow documentation topic that has no scope condition, or has
|
user-facing workflow documentation topic that has no scope condition, or has
|
||||||
an alternative condition without `required_scopes` or `any_scopes`.
|
an alternative condition without `required_scopes` or `any_scopes`.
|
||||||
|
|
||||||
The catalog workflow panel can also operate on the same selected rows:
|
The catalog workflow panel can also operate on the same selected rows for
|
||||||
|
generation and preview. Its legacy apply/push controls stay disabled; durable
|
||||||
|
publication consumes only the candidate receipt recorded by that run:
|
||||||
|
|
||||||
- `Generate` creates a signed candidate below `runtime/release-candidates/`.
|
- `Generate` creates a signed candidate in the operator's private XDG state
|
||||||
|
directory and records only an opaque candidate ID plus the canonical catalog
|
||||||
|
SHA-256 in durable run state.
|
||||||
- `Preview` validates a candidate and shows what would be copied into the
|
- `Preview` validates a candidate and shows what would be copied into the
|
||||||
website repository.
|
website repository.
|
||||||
- `Apply + Website Tag` requires `APPLY` in the confirmation field.
|
- `Apply + Website Tag` remains visible but disabled outside a durable run.
|
||||||
- `Push Website Release` requires `PUSH` in the confirmation field.
|
- `Push Website Release` remains visible but disabled outside a durable run.
|
||||||
|
|
||||||
Source release tags belong to Core or module repositories. Website catalog
|
Source release tags belong to Core or module repositories. Website catalog
|
||||||
publication creates a separate catalog tag in the website repository; the UI
|
publication creates a separate catalog tag in the website repository; the UI
|
||||||
@@ -231,6 +352,53 @@ cannot be applied or published while any referenced source tag is absent or
|
|||||||
inconsistent; the preview and API response list each repository and missing or
|
inconsistent; the preview and API response list each repository and missing or
|
||||||
invalid ref so the operator can repair the exact source releases first.
|
invalid ref so the operator can repair the exact source releases first.
|
||||||
|
|
||||||
|
Every selected Python release must also supply its exact built wheel. Durable
|
||||||
|
generation first verifies that the receipt-bound annotated tag is the same
|
||||||
|
object locally and on the registered origin. It clones an isolated checkout at
|
||||||
|
the receipt's exact commit and builds from that checkout, never from the mutable
|
||||||
|
live worktree. Every authenticated base-catalog source is likewise cloned from
|
||||||
|
its registered origin at an annotated release tag; only selected repositories
|
||||||
|
contribute synthesized fields. The base catalog and keyring are read as one
|
||||||
|
authenticated, exact private snapshot before synthesis.
|
||||||
|
|
||||||
|
Python wheels are built by a required Bubblewrap worker with no network,
|
||||||
|
private temporary/home directories, a read-only exact source mount, no host
|
||||||
|
home or file keys, cleared environment, fixed system tools, and CPU/address
|
||||||
|
space/process/file-size limits. If a trusted Bubblewrap launcher is unavailable,
|
||||||
|
generation fails closed. The worker must return one bounded regular wheel; the
|
||||||
|
console copies it through no-follow descriptors into a fresh private file,
|
||||||
|
`fsync`s it, then validates its package identity. Generation computes the
|
||||||
|
archive SHA-256 and an install-stable
|
||||||
|
payload identity from one bounded, regular-file descriptor and signs those
|
||||||
|
values into `release.artifacts`. Updating a Python version without a matching
|
||||||
|
wheel removes the stale identity. A source-only preview can still explain the
|
||||||
|
gap, but apply, commit, tag, and push fail closed until every selected Python
|
||||||
|
unit has a matching built-artifact identity. Repositories selected only for a
|
||||||
|
meta/source tag do not need a Python artifact.
|
||||||
|
|
||||||
|
Candidate directories and files are operator-owned `0700`/`0600` state. Before
|
||||||
|
any later release step consumes one, the executor re-resolves the opaque handle
|
||||||
|
below the configured root and re-hashes the signed catalog against its persisted
|
||||||
|
receipt. Shared roots, symlinks, channel path fragments, altered candidates, and
|
||||||
|
unowned files are rejected.
|
||||||
|
|
||||||
|
The current same-host worker is a containment baseline, not the final hostile
|
||||||
|
build-service boundary. `RLIMIT_FSIZE` is per file, and the worker does not yet
|
||||||
|
have a size-limited filesystem/cgroup quota, a fresh kernel keyring, or a
|
||||||
|
seccomp profile denying keyctl/request-key/ptrace/mount operations. A malicious
|
||||||
|
backend could therefore exhaust scratch disk/inodes or target same-UID kernel
|
||||||
|
facilities. Closing that denial-of-service/isolation gap requires a dedicated
|
||||||
|
quota/cgroup worker with a fresh keyring and seccomp policy.
|
||||||
|
|
||||||
|
The server-owned wheel builds remain under the private candidate's `artifacts/`
|
||||||
|
directory. This slice signs their identities but does **not** upload the wheel or
|
||||||
|
add a download URL to the public catalog; the existing Git `python_ref` is source
|
||||||
|
provenance and rebuilding it is not an equivalent artifact. Keep the private
|
||||||
|
candidate until the exact wheels have been transferred through an approved
|
||||||
|
deployment channel, verified against `archive_sha256`, consumed by the installer,
|
||||||
|
and covered by its signed receipt. Public artifact transport and receipt-aware
|
||||||
|
candidate cleanup remain separate release-lifecycle work.
|
||||||
|
|
||||||
Read-only provenance checks derive a same-host HTTPS Git URL from conventional
|
Read-only provenance checks derive a same-host HTTPS Git URL from conventional
|
||||||
SSH remotes when possible, with a non-interactive check of the configured remote
|
SSH remotes when possible, with a non-interactive check of the configured remote
|
||||||
as fallback. Source tag creation and atomic publication always use the explicit
|
as fallback. Source tag creation and atomic publication always use the explicit
|
||||||
@@ -238,7 +406,10 @@ configured Git remote.
|
|||||||
|
|
||||||
The default signing key is
|
The default signing key is
|
||||||
`$HOME/.config/govoplan/release-keys/release-key-1.pem` when no signing key is
|
`$HOME/.config/govoplan/release-keys/release-key-1.pem` when no signing key is
|
||||||
entered in the UI.
|
entered in the UI. Signing-key files must be regular, owned by the operator, and
|
||||||
|
inaccessible to group/other users. The browser sends a configured key path only
|
||||||
|
on the initial execution request; it never persists signing material in session
|
||||||
|
storage, and request-ID recovery replays no key path.
|
||||||
|
|
||||||
Generate a selective release plan from the terminal:
|
Generate a selective release plan from the terminal:
|
||||||
|
|
||||||
@@ -259,18 +430,28 @@ Build a signed selective catalog candidate:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
KEY_DIR="$HOME/.config/govoplan/release-keys"
|
KEY_DIR="$HOME/.config/govoplan/release-keys"
|
||||||
|
ARTIFACT_DIR="$(mktemp -d)"
|
||||||
|
|
||||||
|
../govoplan-files/.venv/bin/python -m pip wheel \
|
||||||
|
--no-deps \
|
||||||
|
--no-build-isolation \
|
||||||
|
--wheel-dir "$ARTIFACT_DIR" \
|
||||||
|
../govoplan-files
|
||||||
|
|
||||||
./.venv/bin/python tools/release/release-catalog.py selective \
|
./.venv/bin/python tools/release/release-catalog.py selective \
|
||||||
--repo-version govoplan-files=0.1.9 \
|
--repo-version govoplan-files=0.1.9 \
|
||||||
|
--python-artifact \
|
||||||
|
"govoplan-files=$ARTIFACT_DIR/govoplan_files-0.1.9-py3-none-any.whl" \
|
||||||
--channel stable \
|
--channel stable \
|
||||||
--catalog-signing-key "release-key-1=$KEY_DIR/release-key-1.pem"
|
--catalog-signing-key "release-key-1=$KEY_DIR/release-key-1.pem"
|
||||||
```
|
```
|
||||||
|
|
||||||
This writes candidate catalog/keyring files below `runtime/release-candidates/`,
|
This writes candidate catalog/keyring files below
|
||||||
generates the browsable module directory below `modules/`, validates the signed
|
`$XDG_STATE_HOME/govoplan/release-candidates/` (or
|
||||||
candidate with the module installer validator, and reports whether the candidate
|
`~/.local/state/govoplan/release-candidates/`), generates the browsable module
|
||||||
catalog/keyring match the currently published channel. It does not publish to
|
directory below `modules/`, validates the signed candidate with the module
|
||||||
the website repository.
|
installer validator, and reports whether the candidate catalog/keyring match the
|
||||||
|
currently published channel. It does not publish to the website repository.
|
||||||
|
|
||||||
Regenerate the browsable module directory from an existing catalog/keyring:
|
Regenerate the browsable module directory from an existing catalog/keyring:
|
||||||
|
|
||||||
@@ -285,8 +466,10 @@ Regenerate the browsable module directory from an existing catalog/keyring:
|
|||||||
Preview publication of a reviewed candidate:
|
Preview publication of a reviewed candidate:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
CANDIDATE_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}/govoplan/release-candidates"
|
||||||
|
|
||||||
./.venv/bin/python tools/release/release-catalog.py publish-candidate \
|
./.venv/bin/python tools/release/release-catalog.py publish-candidate \
|
||||||
--candidate-dir runtime/release-candidates/stable-YYYYMMDD-HHMMSS \
|
--candidate-dir "$CANDIDATE_ROOT/stable-YYYYMMDD-HHMMSS" \
|
||||||
--channel stable
|
--channel stable
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -294,7 +477,7 @@ Apply the reviewed candidate into the website repository without pushing:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
./.venv/bin/python tools/release/release-catalog.py publish-candidate \
|
./.venv/bin/python tools/release/release-catalog.py publish-candidate \
|
||||||
--candidate-dir runtime/release-candidates/stable-YYYYMMDD-HHMMSS \
|
--candidate-dir "$CANDIDATE_ROOT/stable-YYYYMMDD-HHMMSS" \
|
||||||
--channel stable \
|
--channel stable \
|
||||||
--apply \
|
--apply \
|
||||||
--commit \
|
--commit \
|
||||||
@@ -305,7 +488,7 @@ Push is a separate explicit flag:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
./.venv/bin/python tools/release/release-catalog.py publish-candidate \
|
./.venv/bin/python tools/release/release-catalog.py publish-candidate \
|
||||||
--candidate-dir runtime/release-candidates/stable-YYYYMMDD-HHMMSS \
|
--candidate-dir "$CANDIDATE_ROOT/stable-YYYYMMDD-HHMMSS" \
|
||||||
--channel stable \
|
--channel stable \
|
||||||
--apply \
|
--apply \
|
||||||
--commit \
|
--commit \
|
||||||
@@ -313,6 +496,13 @@ Push is a separate explicit flag:
|
|||||||
--push
|
--push
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Publication validates the same in-memory catalog object that it writes; it does
|
||||||
|
not copy a path that can change after validation. On commit, the console reads
|
||||||
|
the catalog, keyring, and complete module directory back from the immutable Git
|
||||||
|
tree and requires byte-for-byte equality with those validated objects. Tags and
|
||||||
|
remote branch updates then reference that exact commit SHA rather than the
|
||||||
|
mutable worktree `HEAD`.
|
||||||
|
|
||||||
Published channels are expected below the public catalog base URL:
|
Published channels are expected below the public catalog base URL:
|
||||||
|
|
||||||
- `https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json`
|
- `https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json`
|
||||||
|
|||||||
+71
-65
@@ -6,81 +6,87 @@ Generated from `repositories.json`. Use that JSON file as the machine-readable s
|
|||||||
|
|
||||||
| Repository | Subtype | Local path | Gitea |
|
| Repository | Subtype | Local path | Gitea |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `govoplan` | `meta` | `../govoplan` | [govoplan](https://git.add-ideas.de/add-ideas/govoplan) |
|
| `govoplan` | `meta` | `../govoplan` | [govoplan](https://git.add-ideas.de/GovOPlaN/govoplan) |
|
||||||
| `govoplan-core` | `kernel` | `../govoplan-core` | [govoplan-core](https://git.add-ideas.de/add-ideas/govoplan-core) |
|
| `govoplan-core` | `kernel` | `../govoplan-core` | [govoplan-core](https://git.add-ideas.de/GovOPlaN/govoplan-core) |
|
||||||
|
|
||||||
## Module
|
## Module
|
||||||
|
|
||||||
| Repository | Subtype | Local path | Gitea |
|
| Repository | Subtype | Local path | Gitea |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `govoplan-access` | `platform` | `../govoplan-access` | [govoplan-access](https://git.add-ideas.de/add-ideas/govoplan-access) |
|
| `govoplan-access` | `platform` | `../govoplan-access` | [govoplan-access](https://git.add-ideas.de/GovOPlaN/govoplan-access) |
|
||||||
| `govoplan-addresses` | `domain` | `../govoplan-addresses` | [govoplan-addresses](https://git.add-ideas.de/add-ideas/govoplan-addresses) |
|
| `govoplan-addresses` | `domain` | `../govoplan-addresses` | [govoplan-addresses](https://git.add-ideas.de/GovOPlaN/govoplan-addresses) |
|
||||||
| `govoplan-admin` | `platform` | `../govoplan-admin` | [govoplan-admin](https://git.add-ideas.de/add-ideas/govoplan-admin) |
|
| `govoplan-admin` | `platform` | `../govoplan-admin` | [govoplan-admin](https://git.add-ideas.de/GovOPlaN/govoplan-admin) |
|
||||||
| `govoplan-appointments` | `domain` | `../govoplan-appointments` | [govoplan-appointments](https://git.add-ideas.de/add-ideas/govoplan-appointments) |
|
| `govoplan-appointments` | `domain` | `../govoplan-appointments` | [govoplan-appointments](https://git.add-ideas.de/GovOPlaN/govoplan-appointments) |
|
||||||
| `govoplan-approvals` | `domain` | `../govoplan-approvals` | [govoplan-approvals](https://git.add-ideas.de/add-ideas/govoplan-approvals) |
|
| `govoplan-approvals` | `domain` | `../govoplan-approvals` | [govoplan-approvals](https://git.add-ideas.de/GovOPlaN/govoplan-approvals) |
|
||||||
| `govoplan-assets` | `domain` | `../govoplan-assets` | [govoplan-assets](https://git.add-ideas.de/add-ideas/govoplan-assets) |
|
| `govoplan-assets` | `domain` | `../govoplan-assets` | [govoplan-assets](https://git.add-ideas.de/GovOPlaN/govoplan-assets) |
|
||||||
| `govoplan-audit` | `platform` | `../govoplan-audit` | [govoplan-audit](https://git.add-ideas.de/add-ideas/govoplan-audit) |
|
| `govoplan-audit` | `platform` | `../govoplan-audit` | [govoplan-audit](https://git.add-ideas.de/GovOPlaN/govoplan-audit) |
|
||||||
| `govoplan-booking` | `domain` | `../govoplan-booking` | [govoplan-booking](https://git.add-ideas.de/add-ideas/govoplan-booking) |
|
| `govoplan-booking` | `domain` | `../govoplan-booking` | [govoplan-booking](https://git.add-ideas.de/GovOPlaN/govoplan-booking) |
|
||||||
| `govoplan-calendar` | `domain` | `../govoplan-calendar` | [govoplan-calendar](https://git.add-ideas.de/add-ideas/govoplan-calendar) |
|
| `govoplan-calendar` | `domain` | `../govoplan-calendar` | [govoplan-calendar](https://git.add-ideas.de/GovOPlaN/govoplan-calendar) |
|
||||||
| `govoplan-campaign` | `domain` | `../govoplan-campaign` | [govoplan-campaign](https://git.add-ideas.de/add-ideas/govoplan-campaign) |
|
| `govoplan-campaign` | `domain` | `../govoplan-campaign` | [govoplan-campaign](https://git.add-ideas.de/GovOPlaN/govoplan-campaign) |
|
||||||
| `govoplan-cases` | `domain` | `../govoplan-cases` | [govoplan-cases](https://git.add-ideas.de/add-ideas/govoplan-cases) |
|
| `govoplan-cases` | `domain` | `../govoplan-cases` | [govoplan-cases](https://git.add-ideas.de/GovOPlaN/govoplan-cases) |
|
||||||
| `govoplan-certificates` | `domain` | `../govoplan-certificates` | [govoplan-certificates](https://git.add-ideas.de/add-ideas/govoplan-certificates) |
|
| `govoplan-certificates` | `domain` | `../govoplan-certificates` | [govoplan-certificates](https://git.add-ideas.de/GovOPlaN/govoplan-certificates) |
|
||||||
| `govoplan-committee` | `domain` | `../govoplan-committee` | [govoplan-committee](https://git.add-ideas.de/add-ideas/govoplan-committee) |
|
| `govoplan-committee` | `domain` | `../govoplan-committee` | [govoplan-committee](https://git.add-ideas.de/GovOPlaN/govoplan-committee) |
|
||||||
| `govoplan-consultation` | `domain` | `../govoplan-consultation` | [govoplan-consultation](https://git.add-ideas.de/add-ideas/govoplan-consultation) |
|
| `govoplan-consultation` | `domain` | `../govoplan-consultation` | [govoplan-consultation](https://git.add-ideas.de/GovOPlaN/govoplan-consultation) |
|
||||||
| `govoplan-contracts` | `domain` | `../govoplan-contracts` | [govoplan-contracts](https://git.add-ideas.de/add-ideas/govoplan-contracts) |
|
| `govoplan-contracts` | `domain` | `../govoplan-contracts` | [govoplan-contracts](https://git.add-ideas.de/GovOPlaN/govoplan-contracts) |
|
||||||
| `govoplan-dashboard` | `platform` | `../govoplan-dashboard` | [govoplan-dashboard](https://git.add-ideas.de/add-ideas/govoplan-dashboard) |
|
| `govoplan-dashboard` | `platform` | `../govoplan-dashboard` | [govoplan-dashboard](https://git.add-ideas.de/GovOPlaN/govoplan-dashboard) |
|
||||||
| `govoplan-dms` | `domain` | `../govoplan-dms` | [govoplan-dms](https://git.add-ideas.de/add-ideas/govoplan-dms) |
|
| `govoplan-dataflow` | `platform` | `../govoplan-dataflow` | [govoplan-dataflow](https://git.add-ideas.de/GovOPlaN/govoplan-dataflow) |
|
||||||
| `govoplan-dist-lists` | `domain` | `../govoplan-dist-lists` | [govoplan-dist-lists](https://git.add-ideas.de/add-ideas/govoplan-dist-lists) |
|
| `govoplan-datasources` | `platform` | `../govoplan-datasources` | [govoplan-datasources](https://git.add-ideas.de/GovOPlaN/govoplan-datasources) |
|
||||||
| `govoplan-docs` | `platform` | `../govoplan-docs` | [govoplan-docs](https://git.add-ideas.de/add-ideas/govoplan-docs) |
|
| `govoplan-dms` | `domain` | `../govoplan-dms` | [govoplan-dms](https://git.add-ideas.de/GovOPlaN/govoplan-dms) |
|
||||||
| `govoplan-erp` | `domain` | `../govoplan-erp` | [govoplan-erp](https://git.add-ideas.de/add-ideas/govoplan-erp) |
|
| `govoplan-dist-lists` | `domain` | `../govoplan-dist-lists` | [govoplan-dist-lists](https://git.add-ideas.de/GovOPlaN/govoplan-dist-lists) |
|
||||||
| `govoplan-evaluation` | `domain` | `../govoplan-evaluation` | [govoplan-evaluation](https://git.add-ideas.de/add-ideas/govoplan-evaluation) |
|
| `govoplan-docs` | `platform` | `../govoplan-docs` | [govoplan-docs](https://git.add-ideas.de/GovOPlaN/govoplan-docs) |
|
||||||
| `govoplan-facilities` | `domain` | `../govoplan-facilities` | [govoplan-facilities](https://git.add-ideas.de/add-ideas/govoplan-facilities) |
|
| `govoplan-encryption` | `platform` | `../govoplan-encryption` | [govoplan-encryption](https://git.add-ideas.de/GovOPlaN/govoplan-encryption) |
|
||||||
| `govoplan-files` | `domain` | `../govoplan-files` | [govoplan-files](https://git.add-ideas.de/add-ideas/govoplan-files) |
|
| `govoplan-erp` | `domain` | `../govoplan-erp` | [govoplan-erp](https://git.add-ideas.de/GovOPlaN/govoplan-erp) |
|
||||||
| `govoplan-forms` | `domain` | `../govoplan-forms` | [govoplan-forms](https://git.add-ideas.de/add-ideas/govoplan-forms) |
|
| `govoplan-evaluation` | `domain` | `../govoplan-evaluation` | [govoplan-evaluation](https://git.add-ideas.de/GovOPlaN/govoplan-evaluation) |
|
||||||
| `govoplan-forms-runtime` | `platform` | `../govoplan-forms-runtime` | [govoplan-forms-runtime](https://git.add-ideas.de/add-ideas/govoplan-forms-runtime) |
|
| `govoplan-facilities` | `domain` | `../govoplan-facilities` | [govoplan-facilities](https://git.add-ideas.de/GovOPlaN/govoplan-facilities) |
|
||||||
| `govoplan-grants` | `domain` | `../govoplan-grants` | [govoplan-grants](https://git.add-ideas.de/add-ideas/govoplan-grants) |
|
| `govoplan-files` | `domain` | `../govoplan-files` | [govoplan-files](https://git.add-ideas.de/GovOPlaN/govoplan-files) |
|
||||||
| `govoplan-helpdesk` | `domain` | `../govoplan-helpdesk` | [govoplan-helpdesk](https://git.add-ideas.de/add-ideas/govoplan-helpdesk) |
|
| `govoplan-forms` | `domain` | `../govoplan-forms` | [govoplan-forms](https://git.add-ideas.de/GovOPlaN/govoplan-forms) |
|
||||||
| `govoplan-identity` | `platform` | `../govoplan-identity` | [govoplan-identity](https://git.add-ideas.de/add-ideas/govoplan-identity) |
|
| `govoplan-forms-runtime` | `platform` | `../govoplan-forms-runtime` | [govoplan-forms-runtime](https://git.add-ideas.de/GovOPlaN/govoplan-forms-runtime) |
|
||||||
| `govoplan-identity-trust` | `platform` | `../govoplan-identity-trust` | [govoplan-identity-trust](https://git.add-ideas.de/add-ideas/govoplan-identity-trust) |
|
| `govoplan-grants` | `domain` | `../govoplan-grants` | [govoplan-grants](https://git.add-ideas.de/GovOPlaN/govoplan-grants) |
|
||||||
| `govoplan-idm` | `platform` | `../govoplan-idm` | [govoplan-idm](https://git.add-ideas.de/add-ideas/govoplan-idm) |
|
| `govoplan-helpdesk` | `domain` | `../govoplan-helpdesk` | [govoplan-helpdesk](https://git.add-ideas.de/GovOPlaN/govoplan-helpdesk) |
|
||||||
| `govoplan-inspections` | `domain` | `../govoplan-inspections` | [govoplan-inspections](https://git.add-ideas.de/add-ideas/govoplan-inspections) |
|
| `govoplan-identity` | `platform` | `../govoplan-identity` | [govoplan-identity](https://git.add-ideas.de/GovOPlaN/govoplan-identity) |
|
||||||
| `govoplan-issue-reporting` | `domain` | `../govoplan-issue-reporting` | [govoplan-issue-reporting](https://git.add-ideas.de/add-ideas/govoplan-issue-reporting) |
|
| `govoplan-identity-trust` | `platform` | `../govoplan-identity-trust` | [govoplan-identity-trust](https://git.add-ideas.de/GovOPlaN/govoplan-identity-trust) |
|
||||||
| `govoplan-learning` | `domain` | `../govoplan-learning` | [govoplan-learning](https://git.add-ideas.de/add-ideas/govoplan-learning) |
|
| `govoplan-idm` | `platform` | `../govoplan-idm` | [govoplan-idm](https://git.add-ideas.de/GovOPlaN/govoplan-idm) |
|
||||||
| `govoplan-ledger` | `domain` | `../govoplan-ledger` | [govoplan-ledger](https://git.add-ideas.de/add-ideas/govoplan-ledger) |
|
| `govoplan-inspections` | `domain` | `../govoplan-inspections` | [govoplan-inspections](https://git.add-ideas.de/GovOPlaN/govoplan-inspections) |
|
||||||
| `govoplan-mail` | `domain` | `../govoplan-mail` | [govoplan-mail](https://git.add-ideas.de/add-ideas/govoplan-mail) |
|
| `govoplan-learning` | `domain` | `../govoplan-learning` | [govoplan-learning](https://git.add-ideas.de/GovOPlaN/govoplan-learning) |
|
||||||
| `govoplan-notifications` | `platform` | `../govoplan-notifications` | [govoplan-notifications](https://git.add-ideas.de/add-ideas/govoplan-notifications) |
|
| `govoplan-ledger` | `domain` | `../govoplan-ledger` | [govoplan-ledger](https://git.add-ideas.de/GovOPlaN/govoplan-ledger) |
|
||||||
| `govoplan-ops` | `platform` | `../govoplan-ops` | [govoplan-ops](https://git.add-ideas.de/add-ideas/govoplan-ops) |
|
| `govoplan-mail` | `domain` | `../govoplan-mail` | [govoplan-mail](https://git.add-ideas.de/GovOPlaN/govoplan-mail) |
|
||||||
| `govoplan-organizations` | `platform` | `../govoplan-organizations` | [govoplan-organizations](https://git.add-ideas.de/add-ideas/govoplan-organizations) |
|
| `govoplan-notifications` | `platform` | `../govoplan-notifications` | [govoplan-notifications](https://git.add-ideas.de/GovOPlaN/govoplan-notifications) |
|
||||||
| `govoplan-payments` | `domain` | `../govoplan-payments` | [govoplan-payments](https://git.add-ideas.de/add-ideas/govoplan-payments) |
|
| `govoplan-ops` | `platform` | `../govoplan-ops` | [govoplan-ops](https://git.add-ideas.de/GovOPlaN/govoplan-ops) |
|
||||||
| `govoplan-permits` | `domain` | `../govoplan-permits` | [govoplan-permits](https://git.add-ideas.de/add-ideas/govoplan-permits) |
|
| `govoplan-organizations` | `platform` | `../govoplan-organizations` | [govoplan-organizations](https://git.add-ideas.de/GovOPlaN/govoplan-organizations) |
|
||||||
| `govoplan-policy` | `platform` | `../govoplan-policy` | [govoplan-policy](https://git.add-ideas.de/add-ideas/govoplan-policy) |
|
| `govoplan-payments` | `domain` | `../govoplan-payments` | [govoplan-payments](https://git.add-ideas.de/GovOPlaN/govoplan-payments) |
|
||||||
| `govoplan-poll` | `domain` | `../govoplan-poll` | [govoplan-poll](https://git.add-ideas.de/add-ideas/govoplan-poll) |
|
| `govoplan-permits` | `domain` | `../govoplan-permits` | [govoplan-permits](https://git.add-ideas.de/GovOPlaN/govoplan-permits) |
|
||||||
| `govoplan-portal` | `domain` | `../govoplan-portal` | [govoplan-portal](https://git.add-ideas.de/add-ideas/govoplan-portal) |
|
| `govoplan-policy` | `platform` | `../govoplan-policy` | [govoplan-policy](https://git.add-ideas.de/GovOPlaN/govoplan-policy) |
|
||||||
| `govoplan-postbox` | `domain` | `../govoplan-postbox` | [govoplan-postbox](https://git.add-ideas.de/add-ideas/govoplan-postbox) |
|
| `govoplan-poll` | `domain` | `../govoplan-poll` | [govoplan-poll](https://git.add-ideas.de/GovOPlaN/govoplan-poll) |
|
||||||
| `govoplan-procurement` | `domain` | `../govoplan-procurement` | [govoplan-procurement](https://git.add-ideas.de/add-ideas/govoplan-procurement) |
|
| `govoplan-portal` | `domain` | `../govoplan-portal` | [govoplan-portal](https://git.add-ideas.de/GovOPlaN/govoplan-portal) |
|
||||||
| `govoplan-records` | `domain` | `../govoplan-records` | [govoplan-records](https://git.add-ideas.de/add-ideas/govoplan-records) |
|
| `govoplan-postbox` | `domain` | `../govoplan-postbox` | [govoplan-postbox](https://git.add-ideas.de/GovOPlaN/govoplan-postbox) |
|
||||||
| `govoplan-reporting` | `domain` | `../govoplan-reporting` | [govoplan-reporting](https://git.add-ideas.de/add-ideas/govoplan-reporting) |
|
| `govoplan-procurement` | `domain` | `../govoplan-procurement` | [govoplan-procurement](https://git.add-ideas.de/GovOPlaN/govoplan-procurement) |
|
||||||
| `govoplan-resources` | `domain` | `../govoplan-resources` | [govoplan-resources](https://git.add-ideas.de/add-ideas/govoplan-resources) |
|
| `govoplan-projects` | `domain` | `../govoplan-projects` | [govoplan-projects](https://git.add-ideas.de/GovOPlaN/govoplan-projects) |
|
||||||
| `govoplan-risk-compliance` | `domain` | `../govoplan-risk-compliance` | [govoplan-risk-compliance](https://git.add-ideas.de/add-ideas/govoplan-risk-compliance) |
|
| `govoplan-records` | `domain` | `../govoplan-records` | [govoplan-records](https://git.add-ideas.de/GovOPlaN/govoplan-records) |
|
||||||
| `govoplan-scheduling` | `domain` | `../govoplan-scheduling` | [govoplan-scheduling](https://git.add-ideas.de/add-ideas/govoplan-scheduling) |
|
| `govoplan-reporting` | `domain` | `../govoplan-reporting` | [govoplan-reporting](https://git.add-ideas.de/GovOPlaN/govoplan-reporting) |
|
||||||
| `govoplan-search` | `platform` | `../govoplan-search` | [govoplan-search](https://git.add-ideas.de/add-ideas/govoplan-search) |
|
| `govoplan-resources` | `domain` | `../govoplan-resources` | [govoplan-resources](https://git.add-ideas.de/GovOPlaN/govoplan-resources) |
|
||||||
| `govoplan-tasks` | `domain` | `../govoplan-tasks` | [govoplan-tasks](https://git.add-ideas.de/add-ideas/govoplan-tasks) |
|
| `govoplan-risk-compliance` | `domain` | `../govoplan-risk-compliance` | [govoplan-risk-compliance](https://git.add-ideas.de/GovOPlaN/govoplan-risk-compliance) |
|
||||||
| `govoplan-templates` | `domain` | `../govoplan-templates` | [govoplan-templates](https://git.add-ideas.de/add-ideas/govoplan-templates) |
|
| `govoplan-scheduling` | `domain` | `../govoplan-scheduling` | [govoplan-scheduling](https://git.add-ideas.de/GovOPlaN/govoplan-scheduling) |
|
||||||
| `govoplan-tenancy` | `platform` | `../govoplan-tenancy` | [govoplan-tenancy](https://git.add-ideas.de/add-ideas/govoplan-tenancy) |
|
| `govoplan-search` | `platform` | `../govoplan-search` | [govoplan-search](https://git.add-ideas.de/GovOPlaN/govoplan-search) |
|
||||||
| `govoplan-transparency` | `domain` | `../govoplan-transparency` | [govoplan-transparency](https://git.add-ideas.de/add-ideas/govoplan-transparency) |
|
| `govoplan-tasks` | `domain` | `../govoplan-tasks` | [govoplan-tasks](https://git.add-ideas.de/GovOPlaN/govoplan-tasks) |
|
||||||
| `govoplan-workflow` | `platform` | `../govoplan-workflow` | [govoplan-workflow](https://git.add-ideas.de/add-ideas/govoplan-workflow) |
|
| `govoplan-templates` | `domain` | `../govoplan-templates` | [govoplan-templates](https://git.add-ideas.de/GovOPlaN/govoplan-templates) |
|
||||||
|
| `govoplan-tenancy` | `platform` | `../govoplan-tenancy` | [govoplan-tenancy](https://git.add-ideas.de/GovOPlaN/govoplan-tenancy) |
|
||||||
|
| `govoplan-tickets` | `domain` | `../govoplan-tickets` | [govoplan-tickets](https://git.add-ideas.de/GovOPlaN/govoplan-tickets) |
|
||||||
|
| `govoplan-transparency` | `domain` | `../govoplan-transparency` | [govoplan-transparency](https://git.add-ideas.de/GovOPlaN/govoplan-transparency) |
|
||||||
|
| `govoplan-views` | `platform` | `../govoplan-views` | [govoplan-views](https://git.add-ideas.de/GovOPlaN/govoplan-views) |
|
||||||
|
| `govoplan-wiki` | `domain` | `../govoplan-wiki` | [govoplan-wiki](https://git.add-ideas.de/GovOPlaN/govoplan-wiki) |
|
||||||
|
| `govoplan-workflow` | `platform` | `../govoplan-workflow` | [govoplan-workflow](https://git.add-ideas.de/GovOPlaN/govoplan-workflow) |
|
||||||
|
|
||||||
## Connector
|
## Connector
|
||||||
|
|
||||||
| Repository | Subtype | Local path | Gitea |
|
| Repository | Subtype | Local path | Gitea |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `govoplan-connectors` | `connector-hub` | `../govoplan-connectors` | [govoplan-connectors](https://git.add-ideas.de/add-ideas/govoplan-connectors) |
|
| `govoplan-connectors` | `connector-hub` | `../govoplan-connectors` | [govoplan-connectors](https://git.add-ideas.de/GovOPlaN/govoplan-connectors) |
|
||||||
| `govoplan-fit-connect` | `standard` | `../govoplan-fit-connect` | [govoplan-fit-connect](https://git.add-ideas.de/add-ideas/govoplan-fit-connect) |
|
| `govoplan-fit-connect` | `standard` | `../govoplan-fit-connect` | [govoplan-fit-connect](https://git.add-ideas.de/GovOPlaN/govoplan-fit-connect) |
|
||||||
| `govoplan-rest` | `protocol` | `../govoplan-rest` | [govoplan-rest](https://git.add-ideas.de/add-ideas/govoplan-rest) |
|
| `govoplan-rest` | `protocol` | `../govoplan-rest` | [govoplan-rest](https://git.add-ideas.de/GovOPlaN/govoplan-rest) |
|
||||||
| `govoplan-soap` | `protocol` | `../govoplan-soap` | [govoplan-soap](https://git.add-ideas.de/add-ideas/govoplan-soap) |
|
| `govoplan-soap` | `protocol` | `../govoplan-soap` | [govoplan-soap](https://git.add-ideas.de/GovOPlaN/govoplan-soap) |
|
||||||
| `govoplan-xoev` | `standard` | `../govoplan-xoev` | [govoplan-xoev](https://git.add-ideas.de/add-ideas/govoplan-xoev) |
|
| `govoplan-xoev` | `standard` | `../govoplan-xoev` | [govoplan-xoev](https://git.add-ideas.de/GovOPlaN/govoplan-xoev) |
|
||||||
| `govoplan-xrechnung` | `standard` | `../govoplan-xrechnung` | [govoplan-xrechnung](https://git.add-ideas.de/add-ideas/govoplan-xrechnung) |
|
| `govoplan-xrechnung` | `standard` | `../govoplan-xrechnung` | [govoplan-xrechnung](https://git.add-ideas.de/GovOPlaN/govoplan-xrechnung) |
|
||||||
| `govoplan-xta-osci` | `standard` | `../govoplan-xta-osci` | [govoplan-xta-osci](https://git.add-ideas.de/add-ideas/govoplan-xta-osci) |
|
| `govoplan-xta-osci` | `standard` | `../govoplan-xta-osci` | [govoplan-xta-osci](https://git.add-ideas.de/GovOPlaN/govoplan-xta-osci) |
|
||||||
|
|
||||||
## Website
|
## Website
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Scaling And Multi-Host Deployment
|
||||||
|
|
||||||
|
## What Is Implemented
|
||||||
|
|
||||||
|
The default installer now supports explicit same-host horizontal scaling:
|
||||||
|
|
||||||
|
- one HAProxy container accepts the published HTTP endpoint;
|
||||||
|
- one or more WebUI containers serve assets and proxy API requests;
|
||||||
|
- one or more API containers serve normal domain traffic;
|
||||||
|
- one or more Celery workers consume shared Redis queues;
|
||||||
|
- one migration job runs before replacement;
|
||||||
|
- exactly one Celery scheduler runs;
|
||||||
|
- PostgreSQL, Redis, and file storage are shared by every runtime replica.
|
||||||
|
|
||||||
|
HAProxy discovers Compose replicas through Docker's internal DNS and performs
|
||||||
|
health-checked round-robin WebUI balancing and least-connection API balancing.
|
||||||
|
It does not mount the Docker socket. Replica counts live in
|
||||||
|
`installation.json`, so `configure`, `plan`, `apply`, and the receipt agree on
|
||||||
|
the desired topology.
|
||||||
|
|
||||||
|
This is not multi-host scheduling. Docker Compose's bridge network belongs to
|
||||||
|
one Docker Engine. Adding a second machine requires an orchestrator or
|
||||||
|
deployment manager that can place equivalent role definitions on multiple
|
||||||
|
hosts.
|
||||||
|
|
||||||
|
## Recommended Topologies
|
||||||
|
|
||||||
|
### One Host
|
||||||
|
|
||||||
|
Use the generated Compose bundle:
|
||||||
|
|
||||||
|
```text
|
||||||
|
client
|
||||||
|
-> external TLS proxy, when required
|
||||||
|
-> managed HAProxy
|
||||||
|
-> WebUI replica(s)
|
||||||
|
-> managed HAProxy API listener
|
||||||
|
-> API replica(s)
|
||||||
|
|
||||||
|
API/worker/scheduler
|
||||||
|
-> PostgreSQL
|
||||||
|
-> Redis
|
||||||
|
-> local volume, managed Garage, or external S3
|
||||||
|
```
|
||||||
|
|
||||||
|
This improves concurrency and permits rolling process replacement, but the host
|
||||||
|
and every managed stateful component remain single failure domains. Two API
|
||||||
|
containers on one failed host do not provide host-level availability.
|
||||||
|
|
||||||
|
### Multiple Hosts
|
||||||
|
|
||||||
|
Use Kubernetes, Nomad, Docker Swarm, or another reviewed orchestrator. Do not
|
||||||
|
extend the Compose installer into a proprietary scheduler. The target topology
|
||||||
|
is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
public TLS ingress/load balancer
|
||||||
|
-> WebUI replicas on at least two nodes
|
||||||
|
-> internal API service/load balancer
|
||||||
|
-> API replicas on at least two nodes
|
||||||
|
|
||||||
|
queue-specific worker pools on worker nodes
|
||||||
|
one fenced scheduler
|
||||||
|
one fenced migration/deployment job
|
||||||
|
|
||||||
|
shared PostgreSQL
|
||||||
|
shared Redis
|
||||||
|
multi-node Garage or external S3
|
||||||
|
shared secret/config provider
|
||||||
|
central logs, metrics, and health alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
All API and worker nodes must receive the same immutable software composition,
|
||||||
|
`MASTER_KEY_B64`, database URL, Redis URL, enabled-module graph, and object
|
||||||
|
storage binding. Node-local file storage is not valid in this topology.
|
||||||
|
|
||||||
|
## Adding Capacity
|
||||||
|
|
||||||
|
### API And WebUI
|
||||||
|
|
||||||
|
Increase API replicas for measured request concurrency, CPU saturation, or
|
||||||
|
latency after checking database load. Increase WebUI replicas for static asset
|
||||||
|
and proxy capacity. No sticky session should be required because durable
|
||||||
|
sessions and throttling use shared services, but this must remain covered by
|
||||||
|
multi-replica integration tests.
|
||||||
|
|
||||||
|
Every added API process also adds database connections. Set the application
|
||||||
|
pool size and the total replica ceiling against PostgreSQL's connection budget;
|
||||||
|
adding replicas can otherwise reduce throughput.
|
||||||
|
|
||||||
|
### Workers
|
||||||
|
|
||||||
|
Workers are not placed behind a load balancer. They compete for jobs on shared
|
||||||
|
Redis queues. Add workers by queue and cap each pool according to the external
|
||||||
|
system it calls. SMTP, IMAP, directory, and connector jobs often reach provider
|
||||||
|
rate limits before CPU limits.
|
||||||
|
|
||||||
|
Use distinct pools when load warrants it:
|
||||||
|
|
||||||
|
- mail send and Sent-folder append;
|
||||||
|
- notifications;
|
||||||
|
- calendar and connector synchronization;
|
||||||
|
- dataflow/workflow execution;
|
||||||
|
- reporting and export;
|
||||||
|
- platform events and default work.
|
||||||
|
|
||||||
|
Before stopping a worker, mark it draining, stop new claims, and let or safely
|
||||||
|
requeue active jobs. Worker registration and drain control remain part of the
|
||||||
|
larger platform scale-out story.
|
||||||
|
|
||||||
|
### Stateful Services
|
||||||
|
|
||||||
|
- PostgreSQL needs backups, restore drills, connection limits, and an HA/failover
|
||||||
|
design appropriate to the service level.
|
||||||
|
- Redis needs persistence and, for high availability, a supported failover
|
||||||
|
topology. Queue loss is not equivalent to a harmless cache loss.
|
||||||
|
- Managed Garage from the Compose installer is single-node. For redundant
|
||||||
|
storage, deploy a multi-node Garage cluster separately and select external
|
||||||
|
`s3`, or use another compatible object store.
|
||||||
|
- The scheduler stays at one replica until distributed leader election or a
|
||||||
|
fenced lease is implemented.
|
||||||
|
- Migrations and module lifecycle mutations remain one-at-a-time operations.
|
||||||
|
|
||||||
|
## Promotion Path
|
||||||
|
|
||||||
|
1. Measure concurrent users, request latency, database query time, connection
|
||||||
|
use, queue age, worker saturation, storage latency, and external throttling.
|
||||||
|
2. Move files to managed Garage or external S3 before introducing independent
|
||||||
|
runtime hosts.
|
||||||
|
3. Keep PostgreSQL and Redis external to stateless runtime nodes, or deploy
|
||||||
|
their reviewed HA operators.
|
||||||
|
4. Publish immutable API/WebUI images and one versioned configuration/secret
|
||||||
|
contract.
|
||||||
|
5. Translate the generated role commands, environment allowlists, health
|
||||||
|
checks, and singleton constraints into the chosen orchestrator.
|
||||||
|
6. Put API and WebUI replicas behind health-aware services and a TLS ingress.
|
||||||
|
7. Add queue-specific workers with fixed upper bounds.
|
||||||
|
8. Prove replica loss, rolling replacement, job redelivery, session continuity,
|
||||||
|
migration exclusion, backup restore, and storage-node loss before claiming
|
||||||
|
high availability.
|
||||||
|
|
||||||
|
## Remaining Platform Work
|
||||||
|
|
||||||
|
The one-host installer does not yet provide:
|
||||||
|
|
||||||
|
- a Kubernetes, Nomad, or Swarm deployment export/profile;
|
||||||
|
- distributed deployment locks and fenced scheduler leadership;
|
||||||
|
- worker registration, composition-skew reporting, and drain controls;
|
||||||
|
- managed PostgreSQL, Redis, or multi-node Garage high availability;
|
||||||
|
- autoscaling policy;
|
||||||
|
- managed TLS certificate issuance and renewal;
|
||||||
|
- measured multi-replica and failover integration evidence.
|
||||||
|
|
||||||
|
These are separate production capabilities. The local load balancer and
|
||||||
|
replica model provide the contract they should implement, but they do not by
|
||||||
|
themselves make a cluster highly available.
|
||||||
+21
-4
@@ -50,6 +50,10 @@ accept findings, but scanner execution errors and malformed JSON/SARIF reports
|
|||||||
always fail the run. The manifest lists the expected, present, and missing
|
always fail the run. The manifest lists the expected, present, and missing
|
||||||
reports for that invocation. Validation and checksums use that explicit set, so
|
reports for that invocation. Validation and checksums use that explicit set, so
|
||||||
reusing a report directory cannot make stale output look like part of a new run.
|
reusing a report directory cannot make stale output look like part of a new run.
|
||||||
|
It also contains `coverage_status` and structured `scanner_coverage` entries.
|
||||||
|
Every required scanner is recorded as `no-findings`, `findings`,
|
||||||
|
`scanner-failure`, or `skipped`; this makes an incomplete local run visible
|
||||||
|
without treating it as a clean audit.
|
||||||
|
|
||||||
The wrapper tags the toolbox image by a fingerprint of the Dockerfile,
|
The wrapper tags the toolbox image by a fingerprint of the Dockerfile,
|
||||||
`requirements-audit.txt`, and the Semgrep smoke-test inputs. If those inputs have
|
`requirements-audit.txt`, and the Semgrep smoke-test inputs. If those inputs have
|
||||||
@@ -90,6 +94,12 @@ tools/checks/security-audit/run.sh --mode quick --scope current --update --build
|
|||||||
- `ci`: quick plus Semgrep public registry rulesets, Trivy, pip-audit, npm audit.
|
- `ci`: quick plus Semgrep public registry rulesets, Trivy, pip-audit, npm audit.
|
||||||
- `full`: ci plus OSV-Scanner, jscpd, Radon, and Xenon.
|
- `full`: ci plus OSV-Scanner, jscpd, Radon, and Xenon.
|
||||||
|
|
||||||
|
The Gitea workflow uses `full` mode so its coverage contract includes every
|
||||||
|
scanner above. Missing scanners fail strict runs and all Actions runs, even
|
||||||
|
while actual findings remain report-only. Local report-only runs may finish
|
||||||
|
with missing tools for diagnostics, but their manifest is marked
|
||||||
|
`coverage_status: incomplete`.
|
||||||
|
|
||||||
Semgrep and Trivy are invoked with finding-sensitive exit codes. Their exit 1
|
Semgrep and Trivy are invoked with finding-sensitive exit codes. Their exit 1
|
||||||
is therefore a finding under the wrapper contract; higher exit codes, missing
|
is therefore a finding under the wrapper contract; higher exit codes, missing
|
||||||
output, invalid JSON/SARIF, and scanner error payloads are execution failures.
|
output, invalid JSON/SARIF, and scanner error payloads are execution failures.
|
||||||
@@ -103,7 +113,7 @@ baseline.
|
|||||||
|
|
||||||
## Gating
|
## Gating
|
||||||
|
|
||||||
The initial Gitea workflow runs in report-only mode:
|
The Gitea workflow currently runs findings in report-only mode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SECURITY_AUDIT_FAIL_ON_FINDINGS=0
|
SECURITY_AUDIT_FAIL_ON_FINDINGS=0
|
||||||
@@ -119,13 +129,13 @@ SECURITY_AUDIT_FAIL_ON_FINDINGS=1
|
|||||||
or run locally with:
|
or run locally with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/checks/security-audit/run.sh --mode ci --scope current --strict
|
tools/checks/security-audit/run.sh --mode full --scope govoplan --strict
|
||||||
```
|
```
|
||||||
|
|
||||||
## Audit Burndown Workflow
|
## Audit Burndown Workflow
|
||||||
|
|
||||||
Treat Gitea issues as the active audit state. A full GovOPlaN audit should
|
Treat Gitea issues as the active audit state. A full GovOPlaN audit should
|
||||||
produce one tracker issue in `add-ideas/govoplan` and child issues in the
|
produce one tracker issue in `GovOPlaN/govoplan` and child issues in the
|
||||||
repository that owns each fix.
|
repository that owns each fix.
|
||||||
|
|
||||||
Use the tracker issue for:
|
Use the tracker issue for:
|
||||||
@@ -162,7 +172,14 @@ clusters that cross module ownership or make behavior harder to change safely.
|
|||||||
|
|
||||||
The regular `Security Audit` workflow reuses the fingerprinted toolbox image
|
The regular `Security Audit` workflow reuses the fingerprinted toolbox image
|
||||||
when the Docker daemon is persistent, which is the normal case for the
|
when the Docker daemon is persistent, which is the normal case for the
|
||||||
self-hosted Gitea runner using the host Docker socket. The separate
|
self-hosted Gitea runner using the host Docker socket. Trusted push, schedule,
|
||||||
|
and manual runs scan all registered repositories; authenticated SSH is used
|
||||||
|
only for the private website repository. The wrapper inspects the Actions job
|
||||||
|
mount table and forwards only the narrowest writable mount covering the audit
|
||||||
|
scope; it never inherits the job's Docker socket or unrelated runner mounts.
|
||||||
|
Pull-request audit runs stay disabled while the audit runner exposes its host
|
||||||
|
Docker socket: PR-controlled audit code must run on a disposable or rootless
|
||||||
|
runner without host-socket access. The separate
|
||||||
`Security Audit Toolbox Update` workflow runs weekly with
|
`Security Audit Toolbox Update` workflow runs weekly with
|
||||||
`SECURITY_AUDIT_UPDATE=1`; it pulls current base images and re-resolves the
|
`SECURITY_AUDIT_UPDATE=1`; it pulls current base images and re-resolves the
|
||||||
allowed tool version ranges into a refreshed local image.
|
allowed tool version ranges into a refreshed local image.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ This is a product-level story owned by the GovOPlaN platform rather than by an
|
|||||||
individual domain module. It joins installation, module lifecycle, operations,
|
individual domain module. It joins installation, module lifecycle, operations,
|
||||||
configuration packages, and release provenance into one administrator journey.
|
configuration packages, and release provenance into one administrator journey.
|
||||||
The canonical backlog item is
|
The canonical backlog item is
|
||||||
[GovOPlaN #13](https://git.add-ideas.de/add-ideas/govoplan/issues/13).
|
[GovOPlaN #13](https://git.add-ideas.de/GovOPlaN/govoplan/issues/13).
|
||||||
|
|
||||||
## Terms
|
## Terms
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# GovOPlaN Views Architecture
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
GovOPlaN Views are governed presentation projections for a task,
|
||||||
|
responsibility, or workflow step. A View can reduce the visible modules,
|
||||||
|
navigation entries, routes, page sections, and commands to the interface
|
||||||
|
needed for the current job.
|
||||||
|
|
||||||
|
Views are optional. If `govoplan-views` is not installed or enabled, the normal
|
||||||
|
permission-derived interface remains unchanged.
|
||||||
|
|
||||||
|
## Security Boundary
|
||||||
|
|
||||||
|
A View is not an authorization mechanism.
|
||||||
|
|
||||||
|
- Access, tenant isolation, resource guards, and backend permission checks
|
||||||
|
remain authoritative.
|
||||||
|
- A View may hide an interface surface that the actor is otherwise allowed to
|
||||||
|
use.
|
||||||
|
- A View can never expose a route, action, tenant, or resource that normal
|
||||||
|
authorization denies.
|
||||||
|
- An authorized deep link outside the current View should offer an explicit
|
||||||
|
temporary escape or View switch. It must not be presented as a permission
|
||||||
|
denial.
|
||||||
|
|
||||||
|
This boundary lets Views improve focus without creating a second, weaker RBAC
|
||||||
|
system.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
Core owns the versioned, module-neutral surface contract and WebUI runtime
|
||||||
|
hooks. Modules declare stable surfaces and use shared hooks to respect the
|
||||||
|
effective projection. Modules do not import `govoplan-views`.
|
||||||
|
|
||||||
|
`govoplan-views` owns:
|
||||||
|
|
||||||
|
- draft and immutable published View revisions
|
||||||
|
- system, tenant, group, and user assignments
|
||||||
|
- default, mandatory, and user-selectable Views
|
||||||
|
- active per-user View state
|
||||||
|
- effective projection resolution and provenance
|
||||||
|
- the View editor, preview, validation, and stale-surface diagnostics
|
||||||
|
|
||||||
|
Policy optionally owns inherited ceilings and explainable decisions. Workflow
|
||||||
|
optionally references a pinned View revision for an instance or step and may
|
||||||
|
narrow it further.
|
||||||
|
|
||||||
|
## Surface Contract
|
||||||
|
|
||||||
|
Modules announce only useful, semantic surfaces:
|
||||||
|
|
||||||
|
- module
|
||||||
|
- navigation item
|
||||||
|
- route or workspace
|
||||||
|
- section or panel
|
||||||
|
- command or action
|
||||||
|
|
||||||
|
Each descriptor has a stable namespaced id, parent id, kind, label, default
|
||||||
|
visibility, ordering, and dependency metadata where needed. Surface ids are
|
||||||
|
public module contracts, not CSS selectors, component paths, or arbitrary DOM
|
||||||
|
fragments.
|
||||||
|
|
||||||
|
The first release supports visible or hidden. Read-only states, layout
|
||||||
|
replacement, visual emphasis, and arbitrary styling are separate concerns and
|
||||||
|
are deferred.
|
||||||
|
|
||||||
|
## Effective Resolution
|
||||||
|
|
||||||
|
The effective interface is the intersection of:
|
||||||
|
|
||||||
|
1. installed and enabled modules
|
||||||
|
2. actor permissions and resource access
|
||||||
|
3. administrator and Policy ceilings
|
||||||
|
4. an assigned or user-selected View
|
||||||
|
5. an optional workflow instance or step overlay
|
||||||
|
|
||||||
|
Lower scopes and workflow overlays may narrow inherited visibility but cannot
|
||||||
|
broaden it. Every inherited, locked, hidden, unavailable, or stale choice
|
||||||
|
should carry provenance that the editor and runtime can explain.
|
||||||
|
|
||||||
|
Published View revisions are immutable. Active workflow instances pin the
|
||||||
|
revision they use. Unknown or retired surface ids produce diagnostics rather
|
||||||
|
than breaking startup. If no valid effective View can be resolved, the system
|
||||||
|
uses the last valid projection or the normal authorized interface and reports
|
||||||
|
the configuration problem to administrators.
|
||||||
|
|
||||||
|
## Workflow Behavior
|
||||||
|
|
||||||
|
A workflow definition may reference a View for the whole instance or a
|
||||||
|
particular step. Starting, resuming, or advancing the workflow activates the
|
||||||
|
appropriate projection. Users can intentionally leave focused mode and return
|
||||||
|
from an open-work widget or notification without losing workflow state.
|
||||||
|
|
||||||
|
Module handoffs carry the workflow and View context through Core contracts.
|
||||||
|
Workflow does not import the target module or the Views implementation.
|
||||||
|
|
||||||
|
## Delivery Order
|
||||||
|
|
||||||
|
1. Define the Core surface registry and runtime hooks.
|
||||||
|
2. Initialize `govoplan-views` and persist versioned definitions.
|
||||||
|
3. Add assignment, selection, resolution, provenance, and the editor.
|
||||||
|
4. Add Policy inheritance and administrator ceilings.
|
||||||
|
5. Add Workflow instance and step activation.
|
||||||
|
6. Adopt semantic section/action descriptors module by module.
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
Implemented in the initial Views slice:
|
||||||
|
|
||||||
|
- Core contract version `1`, stable module/navigation/route identifiers, custom
|
||||||
|
section/action descriptors, manifest validation, and platform API metadata
|
||||||
|
- shell navigation, route-boundary, settings, administration, dashboard-widget,
|
||||||
|
embedded-capability, and organization-action filtering
|
||||||
|
- `govoplan-views` definitions, immutable revisions, system/tenant/group/user
|
||||||
|
assignments, user selection, provenance, and stale-surface recovery
|
||||||
|
- a system and tenant administration editor with unsaved-change protection,
|
||||||
|
publish/archive controls, assignment management, and server-enforced lockout
|
||||||
|
prevention
|
||||||
|
- surface declarations for every currently installed module that contributes a
|
||||||
|
WebUI, including finer-grained shared administration and settings surfaces
|
||||||
|
|
||||||
|
Still intentionally separate:
|
||||||
|
|
||||||
|
- Policy-owned inherited ceilings and policy decision provenance
|
||||||
|
- workflow-instance and workflow-step activation of pinned View revisions
|
||||||
|
- read-only and layout-replacement projections beyond the version `1`
|
||||||
|
visible/hidden contract
|
||||||
|
|
||||||
|
## Gitea Work Packages
|
||||||
|
|
||||||
|
- `govoplan#17`: task-focused Views user story
|
||||||
|
- `govoplan#16`: initialize and implement `govoplan-views`
|
||||||
|
- `govoplan-core#271`: versioned surface and runtime contracts
|
||||||
|
- `govoplan-policy#9`: inheritance, ceilings, and provenance
|
||||||
|
- `govoplan-workflow#7`: workflow instance and step activation
|
||||||
|
- `govoplan-workflow#3`: focused workflow mode user story
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/capability-fit-boundary-evidence.schema.json",
|
"$id": "https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/capability-fit-boundary-evidence.schema.json",
|
||||||
"title": "GovOPlaN externally issued capability-fit boundary evidence",
|
"title": "GovOPlaN externally issued capability-fit boundary evidence",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
@@ -507,7 +507,7 @@
|
|||||||
{
|
{
|
||||||
"kind": "issue",
|
"kind": "issue",
|
||||||
"scope": "documented_model",
|
"scope": "documented_model",
|
||||||
"locator": "https://git.add-ideas.de/add-ideas/govoplan/issues/12"
|
"locator": "https://git.add-ideas.de/GovOPlaN/govoplan/issues/12"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"conditions": [],
|
"conditions": [],
|
||||||
@@ -793,7 +793,7 @@
|
|||||||
{
|
{
|
||||||
"kind": "issue",
|
"kind": "issue",
|
||||||
"scope": "documented_model",
|
"scope": "documented_model",
|
||||||
"locator": "https://git.add-ideas.de/add-ideas/govoplan-core/issues/29"
|
"locator": "https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/29"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"conditions": [],
|
"conditions": [],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/capability-fit-proof-authority-keyring.schema.json",
|
"$id": "https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/capability-fit-proof-authority-keyring.schema.json",
|
||||||
"title": "GovOPlaN capability-fit proof authority keyring",
|
"title": "GovOPlaN capability-fit proof authority keyring",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/capability-fit.schema.json",
|
"$id": "https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/capability-fit.schema.json",
|
||||||
"title": "GovOPlaN capability and infrastructure fit assessment",
|
"title": "GovOPlaN capability and infrastructure fit assessment",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
@@ -149,12 +149,36 @@
|
|||||||
"description": "GovOPlaN core runner, shared primitives, shell, or extension points.",
|
"description": "GovOPlaN core runner, shared primitives, shell, or extension points.",
|
||||||
"exclusive": false
|
"exclusive": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "module/dashboard",
|
||||||
|
"color": "1d76db",
|
||||||
|
"description": "GovOPlaN Dashboard module behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "module/dataflow",
|
||||||
|
"color": "1d76db",
|
||||||
|
"description": "GovOPlaN Dataflow module behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "module/datasources",
|
||||||
|
"color": "006b75",
|
||||||
|
"description": "GovOPlaN governed datasource contracts, catalogs, and integrations.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module/dms",
|
"name": "module/dms",
|
||||||
"color": "c5def5",
|
"color": "c5def5",
|
||||||
"description": "GovOPlaN Dms module behavior or integration.",
|
"description": "GovOPlaN Dms module behavior or integration.",
|
||||||
"exclusive": false
|
"exclusive": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "module/docs",
|
||||||
|
"color": "c5def5",
|
||||||
|
"description": "GovOPlaN Docs module behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module/dist-lists",
|
"name": "module/dist-lists",
|
||||||
"color": "0e8a16",
|
"color": "0e8a16",
|
||||||
@@ -167,6 +191,12 @@
|
|||||||
"description": "GovOPlaN Erp module behavior or integration.",
|
"description": "GovOPlaN Erp module behavior or integration.",
|
||||||
"exclusive": false
|
"exclusive": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "module/encryption",
|
||||||
|
"color": "b60205",
|
||||||
|
"description": "GovOPlaN Encryption key custody, cryptographic policy, and E2EE integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module/evaluation",
|
"name": "module/evaluation",
|
||||||
"color": "bfdadc",
|
"color": "bfdadc",
|
||||||
@@ -191,6 +221,12 @@
|
|||||||
"description": "GovOPlaN Forms module behavior or integration.",
|
"description": "GovOPlaN Forms module behavior or integration.",
|
||||||
"exclusive": false
|
"exclusive": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "module/helpdesk",
|
||||||
|
"color": "c2e0c6",
|
||||||
|
"description": "GovOPlaN Helpdesk module behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module/identity-trust",
|
"name": "module/identity-trust",
|
||||||
"color": "d4c5f9",
|
"color": "d4c5f9",
|
||||||
@@ -275,12 +311,24 @@
|
|||||||
"description": "GovOPlaN Postbox module behavior or integration.",
|
"description": "GovOPlaN Postbox module behavior or integration.",
|
||||||
"exclusive": false
|
"exclusive": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "module/projects",
|
||||||
|
"color": "5319e7",
|
||||||
|
"description": "GovOPlaN Projects module behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module/reporting",
|
"name": "module/reporting",
|
||||||
"color": "c2e0c6",
|
"color": "c2e0c6",
|
||||||
"description": "GovOPlaN Reporting module behavior or integration.",
|
"description": "GovOPlaN Reporting module behavior or integration.",
|
||||||
"exclusive": false
|
"exclusive": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "module/risk-compliance",
|
||||||
|
"color": "b60205",
|
||||||
|
"description": "GovOPlaN Risk Compliance module behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module/search",
|
"name": "module/search",
|
||||||
"color": "bfdadc",
|
"color": "bfdadc",
|
||||||
@@ -311,6 +359,24 @@
|
|||||||
"description": "GovOPlaN Tenancy module behavior or integration.",
|
"description": "GovOPlaN Tenancy module behavior or integration.",
|
||||||
"exclusive": false
|
"exclusive": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "module/tickets",
|
||||||
|
"color": "0e8a16",
|
||||||
|
"description": "GovOPlaN Tickets module behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "module/views",
|
||||||
|
"color": "c5def5",
|
||||||
|
"description": "GovOPlaN governed task views, interface projections, and workflow view integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "module/wiki",
|
||||||
|
"color": "006b75",
|
||||||
|
"description": "GovOPlaN Wiki module behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module/workflow",
|
"name": "module/workflow",
|
||||||
"color": "f9d0c4",
|
"color": "f9d0c4",
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://govoplan.add-ideas.de/schemas/installation-spec-v1.json",
|
||||||
|
"title": "GovOPlaN installation specification",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"installation_id",
|
||||||
|
"profile",
|
||||||
|
"public_url",
|
||||||
|
"listen",
|
||||||
|
"network_subnet",
|
||||||
|
"release",
|
||||||
|
"components",
|
||||||
|
"enabled_modules"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"installation_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{1,47}$"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"enum": [
|
||||||
|
"evaluation",
|
||||||
|
"self-hosted"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public_url": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri",
|
||||||
|
"pattern": "^https?://"
|
||||||
|
},
|
||||||
|
"listen": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"address",
|
||||||
|
"port"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"address": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 65535
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network_subnet": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"channel",
|
||||||
|
"version",
|
||||||
|
"manifest_url",
|
||||||
|
"manifest_sha256",
|
||||||
|
"api_image",
|
||||||
|
"web_image"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"channel": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{1,31}$"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 80
|
||||||
|
},
|
||||||
|
"manifest_url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"manifest_sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^$|^[0-9a-f]{64}$"
|
||||||
|
},
|
||||||
|
"api_image": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 300
|
||||||
|
},
|
||||||
|
"web_image": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"postgres",
|
||||||
|
"redis",
|
||||||
|
"mail",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"postgres": {
|
||||||
|
"$ref": "#/$defs/postgres"
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"$ref": "#/$defs/redis"
|
||||||
|
},
|
||||||
|
"mail": {
|
||||||
|
"$ref": "#/$defs/mail"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"mode"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"enum": [
|
||||||
|
"local",
|
||||||
|
"garage",
|
||||||
|
"s3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"load_balancer": {
|
||||||
|
"$ref": "#/$defs/load_balancer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replicas": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"api",
|
||||||
|
"web",
|
||||||
|
"worker"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"api": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 64
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 64
|
||||||
|
},
|
||||||
|
"worker": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 128
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enabled_modules": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9_]{1,63}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"service": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"mode",
|
||||||
|
"image",
|
||||||
|
"url_env"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"url_env": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postgres": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/service"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"enum": [
|
||||||
|
"managed",
|
||||||
|
"external"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url_env": {
|
||||||
|
"const": "DATABASE_URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/service"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"enum": [
|
||||||
|
"managed",
|
||||||
|
"external",
|
||||||
|
"disabled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url_env": {
|
||||||
|
"const": "REDIS_URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mail": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/service"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"enum": [
|
||||||
|
"disabled",
|
||||||
|
"external-relay",
|
||||||
|
"test-mail"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url_env": {
|
||||||
|
"const": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"load_balancer": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/service"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"const": "managed"
|
||||||
|
},
|
||||||
|
"url_env": {
|
||||||
|
"const": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"profile": {
|
||||||
|
"const": "self-hosted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"public_url": {
|
||||||
|
"pattern": "^https://"
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"properties": {
|
||||||
|
"redis": {
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"enum": [
|
||||||
|
"managed",
|
||||||
|
"external"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mail": {
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"enum": [
|
||||||
|
"disabled",
|
||||||
|
"external-relay"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/installed-composition-evidence.schema.json",
|
"$id": "https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/installed-composition-evidence.schema.json",
|
||||||
"title": "GovOPlaN installed composition evidence",
|
"title": "GovOPlaN installed composition evidence",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"format": "uri-reference"
|
"format": "uri-reference"
|
||||||
},
|
},
|
||||||
"schema_version": {
|
"schema_version": {
|
||||||
"const": "0.3.0"
|
"const": "0.4.0"
|
||||||
},
|
},
|
||||||
"evidence_kind": {
|
"evidence_kind": {
|
||||||
"const": "govoplan.installed-composition"
|
"const": "govoplan.installed-composition"
|
||||||
@@ -182,7 +182,9 @@
|
|||||||
"generated_unhashed_file_count",
|
"generated_unhashed_file_count",
|
||||||
"unverifiable_file_count",
|
"unverifiable_file_count",
|
||||||
"missing_file_count",
|
"missing_file_count",
|
||||||
"mismatched_file_count"
|
"mismatched_file_count",
|
||||||
|
"artifact_payload_identity",
|
||||||
|
"installed_payload_identity"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"status": {
|
"status": {
|
||||||
@@ -211,6 +213,18 @@
|
|||||||
},
|
},
|
||||||
"mismatched_file_count": {
|
"mismatched_file_count": {
|
||||||
"$ref": "#/$defs/count"
|
"$ref": "#/$defs/count"
|
||||||
|
},
|
||||||
|
"artifact_payload_identity": {
|
||||||
|
"oneOf": [
|
||||||
|
{ "$ref": "#/$defs/artifact_payload_identity" },
|
||||||
|
{ "type": "null" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"installed_payload_identity": {
|
||||||
|
"oneOf": [
|
||||||
|
{ "$ref": "#/$defs/installed_payload_identity" },
|
||||||
|
{ "type": "null" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allOf": [
|
"allOf": [
|
||||||
@@ -222,7 +236,9 @@
|
|||||||
"permitted_unhashed_file_count": { "minimum": 1 },
|
"permitted_unhashed_file_count": { "minimum": 1 },
|
||||||
"unverifiable_file_count": { "const": 0 },
|
"unverifiable_file_count": { "const": 0 },
|
||||||
"missing_file_count": { "const": 0 },
|
"missing_file_count": { "const": 0 },
|
||||||
"mismatched_file_count": { "const": 0 }
|
"mismatched_file_count": { "const": 0 },
|
||||||
|
"artifact_payload_identity": { "$ref": "#/$defs/artifact_payload_identity" },
|
||||||
|
"installed_payload_identity": { "$ref": "#/$defs/installed_payload_identity" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -278,6 +294,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"artifact_payload_identity": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["algorithm", "sha256", "file_count"],
|
||||||
|
"properties": {
|
||||||
|
"algorithm": { "const": "govoplan-wheel-declared-payload-v1" },
|
||||||
|
"sha256": { "$ref": "#/$defs/sha256" },
|
||||||
|
"file_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 10000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"installed_payload_identity": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["algorithm", "sha256", "file_count"],
|
||||||
|
"properties": {
|
||||||
|
"algorithm": { "const": "govoplan-installed-record-payload-v1" },
|
||||||
|
"sha256": { "$ref": "#/$defs/sha256" },
|
||||||
|
"file_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 10000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"count": {
|
"count": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/installer-receipt-authority-keyring.schema.json",
|
||||||
|
"title": "GovOPlaN installer receipt authority keyring",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["schema_version", "purpose", "keys"],
|
||||||
|
"properties": {
|
||||||
|
"$schema": { "type": "string", "format": "uri-reference" },
|
||||||
|
"schema_version": { "const": "0.1.0" },
|
||||||
|
"purpose": { "const": "govoplan.installer-receipt-authorities" },
|
||||||
|
"keys": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 64,
|
||||||
|
"items": { "$ref": "#/$defs/key" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"opaque_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 160,
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["key_id", "status", "public_key", "allowed_scopes"],
|
||||||
|
"properties": {
|
||||||
|
"key_id": { "$ref": "#/$defs/opaque_id" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["active", "next", "revoked", "disabled", "retired"]
|
||||||
|
},
|
||||||
|
"public_key": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 256,
|
||||||
|
"contentEncoding": "base64"
|
||||||
|
},
|
||||||
|
"allowed_scopes": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "const": "installed_release_origin" }
|
||||||
|
},
|
||||||
|
"not_before": { "type": "string", "format": "date-time" },
|
||||||
|
"not_after": { "type": "string", "format": "date-time" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/installer-receipt.schema.json",
|
||||||
|
"title": "GovOPlaN signed installer receipt",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"evidence_kind",
|
||||||
|
"receipt_id",
|
||||||
|
"assessment_id",
|
||||||
|
"assessment_release",
|
||||||
|
"installed_evidence_sha256",
|
||||||
|
"catalog",
|
||||||
|
"issued_at",
|
||||||
|
"artifacts",
|
||||||
|
"signatures"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"$schema": { "type": "string", "format": "uri-reference" },
|
||||||
|
"schema_version": { "const": "0.1.0" },
|
||||||
|
"evidence_kind": { "const": "govoplan.installer-receipt" },
|
||||||
|
"receipt_id": { "$ref": "#/$defs/opaque_id" },
|
||||||
|
"assessment_id": { "$ref": "#/$defs/opaque_id" },
|
||||||
|
"assessment_release": { "$ref": "#/$defs/opaque_id" },
|
||||||
|
"installed_evidence_sha256": { "$ref": "#/$defs/sha256" },
|
||||||
|
"catalog": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["channel", "sequence", "sha256"],
|
||||||
|
"properties": {
|
||||||
|
"channel": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9_-]{0,63}$"
|
||||||
|
},
|
||||||
|
"sequence": { "type": "integer", "minimum": 1 },
|
||||||
|
"sha256": { "$ref": "#/$defs/sha256" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"issued_at": { "type": "string", "format": "date-time" },
|
||||||
|
"artifacts": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 256,
|
||||||
|
"items": { "$ref": "#/$defs/artifact" }
|
||||||
|
},
|
||||||
|
"signatures": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 16,
|
||||||
|
"items": { "$ref": "#/$defs/signature" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"opaque_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 160,
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||||
|
},
|
||||||
|
"package_name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128,
|
||||||
|
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128,
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._+!-]*$"
|
||||||
|
},
|
||||||
|
"artifact": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"package_name",
|
||||||
|
"package_version",
|
||||||
|
"catalog_archive_sha256",
|
||||||
|
"installed_payload"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"package_name": { "$ref": "#/$defs/package_name" },
|
||||||
|
"package_version": { "$ref": "#/$defs/version" },
|
||||||
|
"catalog_archive_sha256": { "$ref": "#/$defs/sha256" },
|
||||||
|
"installed_payload": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["algorithm", "sha256", "file_count"],
|
||||||
|
"properties": {
|
||||||
|
"algorithm": { "const": "govoplan-installed-record-payload-v1" },
|
||||||
|
"sha256": { "$ref": "#/$defs/sha256" },
|
||||||
|
"file_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 10000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["algorithm", "key_id", "value"],
|
||||||
|
"properties": {
|
||||||
|
"algorithm": { "const": "ed25519" },
|
||||||
|
"key_id": { "$ref": "#/$defs/opaque_id" },
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 256,
|
||||||
|
"contentEncoding": "base64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9a-f]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
-67
@@ -1,73 +1,79 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"organization": "add-ideas",
|
"organization": "GovOPlaN",
|
||||||
"default_parent": "/mnt/DATA/git",
|
"default_parent": "/mnt/DATA/git",
|
||||||
"repositories": [
|
"repositories": [
|
||||||
{"name": "govoplan", "category": "system", "subtype": "meta", "remote": "git@git.add-ideas.de:add-ideas/govoplan.git", "path": "govoplan"},
|
{"name": "govoplan", "category": "system", "subtype": "meta", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan.git", "path": "govoplan"},
|
||||||
{"name": "govoplan-core", "category": "system", "subtype": "kernel", "remote": "git@git.add-ideas.de:add-ideas/govoplan-core.git", "path": "govoplan-core"},
|
{"name": "govoplan-core", "category": "system", "subtype": "kernel", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-core.git", "path": "govoplan-core"},
|
||||||
{"name": "govoplan-access", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-access.git", "path": "govoplan-access"},
|
{"name": "govoplan-access", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-access.git", "path": "govoplan-access"},
|
||||||
{"name": "govoplan-addresses", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-addresses.git", "path": "govoplan-addresses"},
|
{"name": "govoplan-addresses", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-addresses.git", "path": "govoplan-addresses"},
|
||||||
{"name": "govoplan-admin", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-admin.git", "path": "govoplan-admin"},
|
{"name": "govoplan-admin", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-admin.git", "path": "govoplan-admin"},
|
||||||
{"name": "govoplan-appointments", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-appointments.git", "path": "govoplan-appointments"},
|
{"name": "govoplan-appointments", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-appointments.git", "path": "govoplan-appointments"},
|
||||||
{"name": "govoplan-approvals", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-approvals.git", "path": "govoplan-approvals"},
|
{"name": "govoplan-approvals", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-approvals.git", "path": "govoplan-approvals"},
|
||||||
{"name": "govoplan-assets", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-assets.git", "path": "govoplan-assets"},
|
{"name": "govoplan-assets", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-assets.git", "path": "govoplan-assets"},
|
||||||
{"name": "govoplan-audit", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-audit.git", "path": "govoplan-audit"},
|
{"name": "govoplan-audit", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-audit.git", "path": "govoplan-audit"},
|
||||||
{"name": "govoplan-booking", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-booking.git", "path": "govoplan-booking"},
|
{"name": "govoplan-booking", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-booking.git", "path": "govoplan-booking"},
|
||||||
{"name": "govoplan-calendar", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-calendar.git", "path": "govoplan-calendar"},
|
{"name": "govoplan-calendar", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-calendar.git", "path": "govoplan-calendar"},
|
||||||
{"name": "govoplan-campaign", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-campaign.git", "path": "govoplan-campaign"},
|
{"name": "govoplan-campaign", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-campaign.git", "path": "govoplan-campaign"},
|
||||||
{"name": "govoplan-cases", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-cases.git", "path": "govoplan-cases"},
|
{"name": "govoplan-cases", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-cases.git", "path": "govoplan-cases"},
|
||||||
{"name": "govoplan-certificates", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-certificates.git", "path": "govoplan-certificates"},
|
{"name": "govoplan-certificates", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-certificates.git", "path": "govoplan-certificates"},
|
||||||
{"name": "govoplan-committee", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-committee.git", "path": "govoplan-committee"},
|
{"name": "govoplan-committee", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-committee.git", "path": "govoplan-committee"},
|
||||||
{"name": "govoplan-connectors", "category": "connector", "subtype": "connector-hub", "remote": "git@git.add-ideas.de:add-ideas/govoplan-connectors.git", "path": "govoplan-connectors"},
|
{"name": "govoplan-connectors", "category": "connector", "subtype": "connector-hub", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-connectors.git", "path": "govoplan-connectors"},
|
||||||
{"name": "govoplan-consultation", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-consultation.git", "path": "govoplan-consultation"},
|
{"name": "govoplan-consultation", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-consultation.git", "path": "govoplan-consultation"},
|
||||||
{"name": "govoplan-contracts", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-contracts.git", "path": "govoplan-contracts"},
|
{"name": "govoplan-contracts", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-contracts.git", "path": "govoplan-contracts"},
|
||||||
{"name": "govoplan-dashboard", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-dashboard.git", "path": "govoplan-dashboard"},
|
{"name": "govoplan-dashboard", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-dashboard.git", "path": "govoplan-dashboard"},
|
||||||
{"name": "govoplan-dms", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-dms.git", "path": "govoplan-dms"},
|
{"name": "govoplan-dataflow", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-dataflow.git", "path": "govoplan-dataflow"},
|
||||||
{"name": "govoplan-dist-lists", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-dist-lists.git", "path": "govoplan-dist-lists"},
|
{"name": "govoplan-datasources", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-datasources.git", "path": "govoplan-datasources"},
|
||||||
{"name": "govoplan-docs", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-docs.git", "path": "govoplan-docs"},
|
{"name": "govoplan-dms", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-dms.git", "path": "govoplan-dms"},
|
||||||
{"name": "govoplan-erp", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-erp.git", "path": "govoplan-erp"},
|
{"name": "govoplan-dist-lists", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-dist-lists.git", "path": "govoplan-dist-lists"},
|
||||||
{"name": "govoplan-evaluation", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-evaluation.git", "path": "govoplan-evaluation"},
|
{"name": "govoplan-docs", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-docs.git", "path": "govoplan-docs"},
|
||||||
{"name": "govoplan-facilities", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-facilities.git", "path": "govoplan-facilities"},
|
{"name": "govoplan-encryption", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-encryption.git", "path": "govoplan-encryption"},
|
||||||
{"name": "govoplan-files", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-files.git", "path": "govoplan-files"},
|
{"name": "govoplan-erp", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-erp.git", "path": "govoplan-erp"},
|
||||||
{"name": "govoplan-fit-connect", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:add-ideas/govoplan-fit-connect.git", "path": "govoplan-fit-connect"},
|
{"name": "govoplan-evaluation", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-evaluation.git", "path": "govoplan-evaluation"},
|
||||||
{"name": "govoplan-forms", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-forms.git", "path": "govoplan-forms"},
|
{"name": "govoplan-facilities", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-facilities.git", "path": "govoplan-facilities"},
|
||||||
{"name": "govoplan-forms-runtime", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-forms-runtime.git", "path": "govoplan-forms-runtime"},
|
{"name": "govoplan-files", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-files.git", "path": "govoplan-files"},
|
||||||
{"name": "govoplan-grants", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-grants.git", "path": "govoplan-grants"},
|
{"name": "govoplan-fit-connect", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-fit-connect.git", "path": "govoplan-fit-connect"},
|
||||||
{"name": "govoplan-helpdesk", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-helpdesk.git", "path": "govoplan-helpdesk"},
|
{"name": "govoplan-forms", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-forms.git", "path": "govoplan-forms"},
|
||||||
{"name": "govoplan-identity", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-identity.git", "path": "govoplan-identity"},
|
{"name": "govoplan-forms-runtime", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-forms-runtime.git", "path": "govoplan-forms-runtime"},
|
||||||
{"name": "govoplan-identity-trust", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-identity-trust.git", "path": "govoplan-identity-trust"},
|
{"name": "govoplan-grants", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-grants.git", "path": "govoplan-grants"},
|
||||||
{"name": "govoplan-idm", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-idm.git", "path": "govoplan-idm"},
|
{"name": "govoplan-helpdesk", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-helpdesk.git", "path": "govoplan-helpdesk"},
|
||||||
{"name": "govoplan-inspections", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-inspections.git", "path": "govoplan-inspections"},
|
{"name": "govoplan-identity", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-identity.git", "path": "govoplan-identity"},
|
||||||
{"name": "govoplan-issue-reporting", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-issue-reporting.git", "path": "govoplan-issue-reporting"},
|
{"name": "govoplan-identity-trust", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-identity-trust.git", "path": "govoplan-identity-trust"},
|
||||||
{"name": "govoplan-learning", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-learning.git", "path": "govoplan-learning"},
|
{"name": "govoplan-idm", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-idm.git", "path": "govoplan-idm"},
|
||||||
{"name": "govoplan-ledger", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-ledger.git", "path": "govoplan-ledger"},
|
{"name": "govoplan-inspections", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-inspections.git", "path": "govoplan-inspections"},
|
||||||
{"name": "govoplan-mail", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-mail.git", "path": "govoplan-mail"},
|
{"name": "govoplan-learning", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-learning.git", "path": "govoplan-learning"},
|
||||||
{"name": "govoplan-notifications", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-notifications.git", "path": "govoplan-notifications"},
|
{"name": "govoplan-ledger", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-ledger.git", "path": "govoplan-ledger"},
|
||||||
{"name": "govoplan-ops", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-ops.git", "path": "govoplan-ops"},
|
{"name": "govoplan-mail", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-mail.git", "path": "govoplan-mail"},
|
||||||
{"name": "govoplan-organizations", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-organizations.git", "path": "govoplan-organizations"},
|
{"name": "govoplan-notifications", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-notifications.git", "path": "govoplan-notifications"},
|
||||||
{"name": "govoplan-payments", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-payments.git", "path": "govoplan-payments"},
|
{"name": "govoplan-ops", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-ops.git", "path": "govoplan-ops"},
|
||||||
{"name": "govoplan-permits", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-permits.git", "path": "govoplan-permits"},
|
{"name": "govoplan-organizations", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-organizations.git", "path": "govoplan-organizations"},
|
||||||
{"name": "govoplan-policy", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-policy.git", "path": "govoplan-policy"},
|
{"name": "govoplan-payments", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-payments.git", "path": "govoplan-payments"},
|
||||||
{"name": "govoplan-poll", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-poll.git", "path": "govoplan-poll"},
|
{"name": "govoplan-permits", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-permits.git", "path": "govoplan-permits"},
|
||||||
{"name": "govoplan-portal", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-portal.git", "path": "govoplan-portal"},
|
{"name": "govoplan-policy", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-policy.git", "path": "govoplan-policy"},
|
||||||
{"name": "govoplan-postbox", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-postbox.git", "path": "govoplan-postbox"},
|
{"name": "govoplan-poll", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-poll.git", "path": "govoplan-poll"},
|
||||||
{"name": "govoplan-procurement", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-procurement.git", "path": "govoplan-procurement"},
|
{"name": "govoplan-portal", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-portal.git", "path": "govoplan-portal"},
|
||||||
{"name": "govoplan-records", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-records.git", "path": "govoplan-records"},
|
{"name": "govoplan-postbox", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-postbox.git", "path": "govoplan-postbox"},
|
||||||
{"name": "govoplan-reporting", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-reporting.git", "path": "govoplan-reporting"},
|
{"name": "govoplan-procurement", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-procurement.git", "path": "govoplan-procurement"},
|
||||||
{"name": "govoplan-resources", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-resources.git", "path": "govoplan-resources"},
|
{"name": "govoplan-projects", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-projects.git", "path": "govoplan-projects"},
|
||||||
{"name": "govoplan-rest", "category": "connector", "subtype": "protocol", "remote": "git@git.add-ideas.de:add-ideas/govoplan-rest.git", "path": "govoplan-rest"},
|
{"name": "govoplan-records", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-records.git", "path": "govoplan-records"},
|
||||||
{"name": "govoplan-risk-compliance", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-risk-compliance.git", "path": "govoplan-risk-compliance"},
|
{"name": "govoplan-reporting", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-reporting.git", "path": "govoplan-reporting"},
|
||||||
{"name": "govoplan-scheduling", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-scheduling.git", "path": "govoplan-scheduling"},
|
{"name": "govoplan-resources", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-resources.git", "path": "govoplan-resources"},
|
||||||
{"name": "govoplan-search", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-search.git", "path": "govoplan-search"},
|
{"name": "govoplan-rest", "category": "connector", "subtype": "protocol", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-rest.git", "path": "govoplan-rest"},
|
||||||
{"name": "govoplan-soap", "category": "connector", "subtype": "protocol", "remote": "git@git.add-ideas.de:add-ideas/govoplan-soap.git", "path": "govoplan-soap"},
|
{"name": "govoplan-risk-compliance", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-risk-compliance.git", "path": "govoplan-risk-compliance"},
|
||||||
{"name": "govoplan-tasks", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-tasks.git", "path": "govoplan-tasks"},
|
{"name": "govoplan-scheduling", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-scheduling.git", "path": "govoplan-scheduling"},
|
||||||
{"name": "govoplan-templates", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-templates.git", "path": "govoplan-templates"},
|
{"name": "govoplan-search", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-search.git", "path": "govoplan-search"},
|
||||||
{"name": "govoplan-tenancy", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-tenancy.git", "path": "govoplan-tenancy"},
|
{"name": "govoplan-soap", "category": "connector", "subtype": "protocol", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-soap.git", "path": "govoplan-soap"},
|
||||||
{"name": "govoplan-transparency", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-transparency.git", "path": "govoplan-transparency"},
|
{"name": "govoplan-tasks", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-tasks.git", "path": "govoplan-tasks"},
|
||||||
{"name": "addideas-govoplan-website", "category": "website", "subtype": "public-site", "remote": "git@git.add-ideas.de:add-ideas/addideas-govoplan-website.git", "path": "addideas-govoplan-website"},
|
{"name": "govoplan-templates", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-templates.git", "path": "govoplan-templates"},
|
||||||
{"name": "govoplan-workflow", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-workflow.git", "path": "govoplan-workflow"},
|
{"name": "govoplan-tenancy", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git", "path": "govoplan-tenancy"},
|
||||||
{"name": "govoplan-xoev", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:add-ideas/govoplan-xoev.git", "path": "govoplan-xoev"},
|
{"name": "govoplan-tickets", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-tickets.git", "path": "govoplan-tickets"},
|
||||||
{"name": "govoplan-xrechnung", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:add-ideas/govoplan-xrechnung.git", "path": "govoplan-xrechnung"},
|
{"name": "govoplan-transparency", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-transparency.git", "path": "govoplan-transparency"},
|
||||||
{"name": "govoplan-xta-osci", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:add-ideas/govoplan-xta-osci.git", "path": "govoplan-xta-osci"}
|
{"name": "govoplan-views", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-views.git", "path": "govoplan-views"},
|
||||||
|
{"name": "govoplan-wiki", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-wiki.git", "path": "govoplan-wiki"},
|
||||||
|
{"name": "addideas-govoplan-website", "category": "website", "subtype": "public-site", "remote": "git@git.add-ideas.de:add-ideas/addideas-govoplan-website.git", "path": "addideas-govoplan-website", "bootstrap_transport": "registered"},
|
||||||
|
{"name": "govoplan-workflow", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-workflow.git", "path": "govoplan-workflow"},
|
||||||
|
{"name": "govoplan-xoev", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-xoev.git", "path": "govoplan-xoev"},
|
||||||
|
{"name": "govoplan-xrechnung", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-xrechnung.git", "path": "govoplan-xrechnung"},
|
||||||
|
{"name": "govoplan-xta-osci", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-xta-osci.git", "path": "govoplan-xta-osci"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,20 @@
|
|||||||
-e ../govoplan-mail
|
-e ../govoplan-mail
|
||||||
-e ../govoplan-campaign
|
-e ../govoplan-campaign
|
||||||
-e ../govoplan-calendar
|
-e ../govoplan-calendar
|
||||||
|
-e ../govoplan-connectors
|
||||||
|
-e ../govoplan-datasources
|
||||||
|
-e ../govoplan-dataflow
|
||||||
|
-e ../govoplan-workflow
|
||||||
|
-e ../govoplan-views
|
||||||
|
-e ../govoplan-search
|
||||||
|
-e ../govoplan-risk-compliance
|
||||||
|
-e ../govoplan-postbox
|
||||||
-e ../govoplan-poll
|
-e ../govoplan-poll
|
||||||
-e ../govoplan-scheduling
|
-e ../govoplan-scheduling
|
||||||
-e ../govoplan-notifications
|
-e ../govoplan-notifications
|
||||||
-e ../govoplan-evaluation
|
-e ../govoplan-evaluation
|
||||||
-e ../govoplan-docs
|
-e ../govoplan-docs
|
||||||
|
-e ../govoplan-encryption
|
||||||
-e ../govoplan-ops
|
-e ../govoplan-ops
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
httpx2>=2.5,<3
|
httpx2>=2.5,<3
|
||||||
@@ -32,5 +41,7 @@ idna>=3.15
|
|||||||
jsonschema>=4,<5
|
jsonschema>=4,<5
|
||||||
pip>=26.1.2
|
pip>=26.1.2
|
||||||
pip-audit>=2.9,<3
|
pip-audit>=2.9,<3
|
||||||
|
pytest>=9.0.3,<10
|
||||||
|
pygments>=2.20,<3
|
||||||
python-multipart>=0.0.31
|
python-multipart>=0.0.31
|
||||||
ruff>=0.14,<1
|
ruff>=0.14,<1
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Test-harness dependencies used against immutable release source tags.
|
||||||
|
# Keep these separate from requirements-release.txt so they are not part of the
|
||||||
|
# deployable product dependency set.
|
||||||
|
pytest>=9.0.3,<10
|
||||||
|
pygments>=2.20,<3
|
||||||
+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/add-ideas/govoplan-tenancy.git@v0.1.8
|
govoplan-tenancy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git@v0.1.8
|
||||||
govoplan-organizations @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git@v0.1.8
|
govoplan-organizations @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git@v0.1.8
|
||||||
govoplan-identity @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-identity.git@v0.1.8
|
govoplan-identity @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity.git@v0.1.8
|
||||||
govoplan-idm @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git@v0.1.8
|
govoplan-idm @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git@v0.1.8
|
||||||
govoplan-access @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git@v0.1.8
|
govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.8
|
||||||
govoplan-admin @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git@v0.1.8
|
govoplan-admin @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git@v0.1.8
|
||||||
govoplan-policy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git@v0.1.8
|
govoplan-policy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git@v0.1.8
|
||||||
govoplan-audit @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git@v0.1.8
|
govoplan-audit @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git@v0.1.8
|
||||||
govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.8
|
govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.8
|
||||||
govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.8
|
govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.8
|
||||||
govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.8
|
govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.10
|
||||||
govoplan-campaign @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git@v0.1.10
|
govoplan-campaign @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git@v0.1.11
|
||||||
govoplan-calendar @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git@v0.1.8
|
govoplan-calendar @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git@v0.1.8
|
||||||
govoplan-docs @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git@v0.1.8
|
govoplan-docs @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git@v0.1.8
|
||||||
govoplan-ops @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git@v0.1.8
|
govoplan-ops @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git@v0.1.8
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ from govoplan_assessment.evidence import ( # noqa: E402
|
|||||||
review_installed_composition,
|
review_installed_composition,
|
||||||
validate_payload,
|
validate_payload,
|
||||||
)
|
)
|
||||||
|
from govoplan_assessment.installer_receipt import ( # noqa: E402
|
||||||
|
issue_installer_receipt,
|
||||||
|
)
|
||||||
|
from govoplan_release.artifact_identity import inspect_python_wheel # noqa: E402
|
||||||
from tests.test_capability_fit_review import signed_catalog # noqa: E402
|
from tests.test_capability_fit_review import signed_catalog # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@@ -89,6 +93,202 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
|||||||
report["proof_scope"]["installed_artifacts"]["evidence_sha256"],
|
report["proof_scope"]["installed_artifacts"]["evidence_sha256"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_signed_catalog_artifact_identities_bind_installed_release_origin(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
evidence = matching_installed_evidence(self.assessment)
|
||||||
|
catalog, keyring = signed_catalog(
|
||||||
|
self.assessment,
|
||||||
|
release_artifacts=catalog_artifacts_for(evidence),
|
||||||
|
)
|
||||||
|
|
||||||
|
report = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
origin = report["proof_scope"]["installed_release_origin"]
|
||||||
|
self.assertEqual("current", report["status"])
|
||||||
|
self.assertTrue(origin["checked"])
|
||||||
|
self.assertTrue(origin["valid"])
|
||||||
|
self.assertTrue(origin["release_origin_bound"])
|
||||||
|
self.assertEqual(len(evidence["artifacts"]), origin["anchored_artifact_digest_count"])
|
||||||
|
self.assertEqual(0, origin["signed_installer_receipt_count"])
|
||||||
|
|
||||||
|
def test_role_scoped_installer_receipt_admits_transformed_artifacts_and_boundary(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
evidence = matching_installed_evidence(self.assessment)
|
||||||
|
artifact_rows = catalog_artifacts_for(evidence, requires_receipt=True)
|
||||||
|
catalog, keyring = signed_catalog(
|
||||||
|
self.assessment,
|
||||||
|
release_artifacts=artifact_rows,
|
||||||
|
)
|
||||||
|
receipt, installer_keys = signed_installer_receipt(
|
||||||
|
assessment=self.assessment,
|
||||||
|
installed=evidence,
|
||||||
|
catalog=catalog,
|
||||||
|
catalog_artifacts=artifact_rows,
|
||||||
|
)
|
||||||
|
proof, proof_keys = signed_boundary_evidence(
|
||||||
|
assessment=self.assessment,
|
||||||
|
installed=evidence,
|
||||||
|
claims=[boundary_claim("target_environment", "passed")],
|
||||||
|
allowed_scopes=["target_environment"],
|
||||||
|
)
|
||||||
|
|
||||||
|
report = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
installer_receipt=receipt,
|
||||||
|
installer_authority_keyring=installer_keys,
|
||||||
|
boundary_evidence=proof,
|
||||||
|
authority_keyring=proof_keys,
|
||||||
|
installed_evidence_mode="imported_unsigned",
|
||||||
|
)
|
||||||
|
|
||||||
|
origin = report["proof_scope"]["installed_release_origin"]
|
||||||
|
self.assertTrue(origin["valid"])
|
||||||
|
self.assertEqual(0, origin["anchored_artifact_digest_count"])
|
||||||
|
self.assertEqual(len(evidence["artifacts"]), origin["signed_installer_receipt_count"])
|
||||||
|
self.assertTrue(report["proof_scope"]["target_environment"]["checked"])
|
||||||
|
self.assertTrue(report["proof_scope"]["target_environment"]["valid"])
|
||||||
|
self.assertTrue(
|
||||||
|
report["proof_scope"]["installed_evidence_observation"][
|
||||||
|
"receipt_authenticated"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"installed_evidence_unsigned_import",
|
||||||
|
{item["code"] for item in report["findings"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_installer_receipt_cannot_reuse_release_key_or_cover_forged_payload(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
evidence = matching_installed_evidence(self.assessment)
|
||||||
|
artifact_rows = catalog_artifacts_for(evidence, requires_receipt=True)
|
||||||
|
catalog, keyring = signed_catalog(
|
||||||
|
self.assessment,
|
||||||
|
release_artifacts=artifact_rows,
|
||||||
|
)
|
||||||
|
receipt, installer_keys = signed_installer_receipt(
|
||||||
|
assessment=self.assessment,
|
||||||
|
installed=evidence,
|
||||||
|
catalog=catalog,
|
||||||
|
catalog_artifacts=artifact_rows,
|
||||||
|
)
|
||||||
|
forged = deepcopy(evidence)
|
||||||
|
forged["artifacts"][0]["record_integrity"]["installed_payload_identity"][
|
||||||
|
"sha256"
|
||||||
|
] = "f" * 64
|
||||||
|
reused = deepcopy(installer_keys)
|
||||||
|
reused["keys"][0]["public_key"] = keyring["keys"][0]["public_key"]
|
||||||
|
|
||||||
|
forged_report = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=forged,
|
||||||
|
installer_receipt=receipt,
|
||||||
|
installer_authority_keyring=installer_keys,
|
||||||
|
)
|
||||||
|
reused_report = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
installer_receipt=receipt,
|
||||||
|
installer_authority_keyring=reused,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"installer_receipt_evidence_mismatch",
|
||||||
|
{item["code"] for item in forged_report["findings"]},
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"installer_authority_untrusted",
|
||||||
|
{item["code"] for item in reused_report["findings"]},
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
forged_report["proof_scope"]["installed_release_origin"]["valid"]
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
reused_report["proof_scope"]["installed_release_origin"]["valid"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_receipt_issuer_refuses_file_like_or_unverified_observations(self) -> None:
|
||||||
|
evidence = matching_installed_evidence(self.assessment)
|
||||||
|
artifacts = catalog_artifacts_for(evidence, requires_receipt=True)
|
||||||
|
catalog, _ = signed_catalog(
|
||||||
|
self.assessment,
|
||||||
|
release_artifacts=artifacts,
|
||||||
|
)
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "same-process"):
|
||||||
|
issue_installer_receipt(
|
||||||
|
assessment=self.assessment,
|
||||||
|
catalog=catalog,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
receipt_id="install:test",
|
||||||
|
key_id="installer:test",
|
||||||
|
private_key=private_key,
|
||||||
|
)
|
||||||
|
evidence["artifacts"][0]["record_integrity"]["status"] = "partial"
|
||||||
|
evidence["artifacts"][0]["record_integrity"]["unverifiable_file_count"] = 1
|
||||||
|
with self.assertRaisesRegex(ValueError, "not a verified match"):
|
||||||
|
issue_installer_receipt(
|
||||||
|
assessment=self.assessment,
|
||||||
|
catalog=catalog,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
receipt_id="install:test",
|
||||||
|
key_id="installer:test",
|
||||||
|
private_key=private_key,
|
||||||
|
same_process_observation=True,
|
||||||
|
consumed_artifacts={
|
||||||
|
str(item["package_name"]): Path("unused.whl")
|
||||||
|
for item in evidence["artifacts"]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_installer_receipt_and_observation_must_be_current_together(self) -> None:
|
||||||
|
evidence = matching_installed_evidence(self.assessment)
|
||||||
|
artifact_rows = catalog_artifacts_for(evidence, requires_receipt=True)
|
||||||
|
catalog, keyring = signed_catalog(
|
||||||
|
self.assessment,
|
||||||
|
release_artifacts=artifact_rows,
|
||||||
|
)
|
||||||
|
receipt, installer_keys = signed_installer_receipt(
|
||||||
|
assessment=self.assessment,
|
||||||
|
installed=evidence,
|
||||||
|
catalog=catalog,
|
||||||
|
catalog_artifacts=artifact_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
report = self.review(
|
||||||
|
catalog=catalog,
|
||||||
|
keyring=keyring,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
installer_receipt=receipt,
|
||||||
|
installer_authority_keyring=installer_keys,
|
||||||
|
installed_evidence_mode="imported_unsigned",
|
||||||
|
verification_time=datetime(2026, 7, 23, 12, 5, 1, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"installer_receipt_time_invalid",
|
||||||
|
{item["code"] for item in report["findings"]},
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
report["proof_scope"]["installed_release_origin"]["valid"]
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
report["proof_scope"]["installed_evidence_observation"][
|
||||||
|
"receipt_authenticated"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def test_catalog_trust_blocker_invalidates_dependent_evidence_scopes(
|
def test_catalog_trust_blocker_invalidates_dependent_evidence_scopes(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -523,6 +723,7 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
|||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
wheel = next(wheels.glob("govoplan_core-*.whl"))
|
wheel = next(wheels.glob("govoplan_core-*.whl"))
|
||||||
|
built_identity = inspect_python_wheel(wheel)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
(
|
(
|
||||||
str(build_python),
|
str(build_python),
|
||||||
@@ -568,6 +769,15 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
|||||||
self.assertGreater(record["permitted_unhashed_file_count"], 0)
|
self.assertGreater(record["permitted_unhashed_file_count"], 0)
|
||||||
self.assertGreater(record["generated_unhashed_file_count"], 0)
|
self.assertGreater(record["generated_unhashed_file_count"], 0)
|
||||||
self.assertEqual(0, record["unverifiable_file_count"])
|
self.assertEqual(0, record["unverifiable_file_count"])
|
||||||
|
self.assertEqual(
|
||||||
|
built_identity.payload_sha256,
|
||||||
|
record["artifact_payload_identity"]["sha256"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
built_identity.payload_file_count,
|
||||||
|
record["artifact_payload_identity"]["file_count"],
|
||||||
|
)
|
||||||
|
self.assertTrue(built_identity.requires_installer_receipt)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
(), validate_payload(payload=evidence, schema=self.installed_schema)
|
(), validate_payload(payload=evidence, schema=self.installed_schema)
|
||||||
)
|
)
|
||||||
@@ -1162,6 +1372,8 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
|||||||
catalog: dict[str, object],
|
catalog: dict[str, object],
|
||||||
keyring: dict[str, object],
|
keyring: dict[str, object],
|
||||||
installed_evidence: dict[str, object] | None = None,
|
installed_evidence: dict[str, object] | None = None,
|
||||||
|
installer_receipt: dict[str, object] | None = None,
|
||||||
|
installer_authority_keyring: dict[str, object] | None = None,
|
||||||
boundary_evidence: dict[str, object] | None = None,
|
boundary_evidence: dict[str, object] | None = None,
|
||||||
authority_keyring: dict[str, object] | None = None,
|
authority_keyring: dict[str, object] | None = None,
|
||||||
installed_evidence_mode: str = "direct_local",
|
installed_evidence_mode: str = "direct_local",
|
||||||
@@ -1179,6 +1391,16 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
|||||||
installed_evidence_schema=(installed_schema or self.installed_schema)
|
installed_evidence_schema=(installed_schema or self.installed_schema)
|
||||||
if installed_evidence is not None
|
if installed_evidence is not None
|
||||||
else None,
|
else None,
|
||||||
|
installer_receipt=installer_receipt,
|
||||||
|
installer_receipt_schema=load_json("docs/installer-receipt.schema.json")
|
||||||
|
if installer_receipt is not None
|
||||||
|
else None,
|
||||||
|
installer_authority_keyring=installer_authority_keyring,
|
||||||
|
installer_authority_keyring_schema=load_json(
|
||||||
|
"docs/installer-receipt-authority-keyring.schema.json"
|
||||||
|
)
|
||||||
|
if installer_receipt is not None
|
||||||
|
else None,
|
||||||
boundary_evidence=boundary_evidence,
|
boundary_evidence=boundary_evidence,
|
||||||
boundary_evidence_schema=self.boundary_schema
|
boundary_evidence_schema=self.boundary_schema
|
||||||
if boundary_evidence is not None
|
if boundary_evidence is not None
|
||||||
@@ -1196,7 +1418,7 @@ class CapabilityFitEvidenceTests(unittest.TestCase):
|
|||||||
def matching_installed_evidence(assessment: dict[str, object]) -> dict[str, object]:
|
def matching_installed_evidence(assessment: dict[str, object]) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"$schema": "./installed-composition-evidence.schema.json",
|
"$schema": "./installed-composition-evidence.schema.json",
|
||||||
"schema_version": "0.3.0",
|
"schema_version": "0.4.0",
|
||||||
"evidence_kind": "govoplan.installed-composition",
|
"evidence_kind": "govoplan.installed-composition",
|
||||||
"assessment_id": assessment["assessment_id"],
|
"assessment_id": assessment["assessment_id"],
|
||||||
"assessment_release": assessment["release"]["ref"],
|
"assessment_release": assessment["release"]["ref"],
|
||||||
@@ -1238,6 +1460,20 @@ def artifact(
|
|||||||
"unverifiable_file_count": 0,
|
"unverifiable_file_count": 0,
|
||||||
"missing_file_count": 0,
|
"missing_file_count": 0,
|
||||||
"mismatched_file_count": 0,
|
"mismatched_file_count": 0,
|
||||||
|
"artifact_payload_identity": {
|
||||||
|
"algorithm": "govoplan-wheel-declared-payload-v1",
|
||||||
|
"sha256": hashlib.sha256(
|
||||||
|
f"artifact:{package_name}:{version}".encode()
|
||||||
|
).hexdigest(),
|
||||||
|
"file_count": 1,
|
||||||
|
},
|
||||||
|
"installed_payload_identity": {
|
||||||
|
"algorithm": "govoplan-installed-record-payload-v1",
|
||||||
|
"sha256": hashlib.sha256(
|
||||||
|
f"installed:{package_name}:{version}".encode()
|
||||||
|
).hexdigest(),
|
||||||
|
"file_count": 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1323,6 +1559,107 @@ def signed_boundary_evidence(
|
|||||||
return proof, keyring
|
return proof, keyring
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_artifacts_for(
|
||||||
|
installed: dict[str, object], *, requires_receipt: bool = False
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
for item in installed["artifacts"]:
|
||||||
|
record = item["record_integrity"]
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"artifact_kind": "python-wheel",
|
||||||
|
"package_name": item["package_name"],
|
||||||
|
"package_version": item["package_version"],
|
||||||
|
"archive_sha256": hashlib.sha256(
|
||||||
|
f"wheel:{item['package_name']}:{item['package_version']}".encode()
|
||||||
|
).hexdigest(),
|
||||||
|
"archive_size": 1024,
|
||||||
|
"installed_payload": deepcopy(record["artifact_payload_identity"]),
|
||||||
|
"requires_installer_receipt": requires_receipt,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def signed_installer_receipt(
|
||||||
|
*,
|
||||||
|
assessment: dict[str, object],
|
||||||
|
installed: dict[str, object],
|
||||||
|
catalog: dict[str, object],
|
||||||
|
catalog_artifacts: list[dict[str, object]],
|
||||||
|
) -> tuple[dict[str, object], dict[str, object]]:
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
public_key = base64.b64encode(
|
||||||
|
private_key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw,
|
||||||
|
format=serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
).decode("ascii")
|
||||||
|
catalog_by_package = {
|
||||||
|
str(item["package_name"]): item for item in catalog_artifacts
|
||||||
|
}
|
||||||
|
receipt: dict[str, object] = {
|
||||||
|
"$schema": "./installer-receipt.schema.json",
|
||||||
|
"schema_version": "0.1.0",
|
||||||
|
"evidence_kind": "govoplan.installer-receipt",
|
||||||
|
"receipt_id": "install:test",
|
||||||
|
"assessment_id": assessment["assessment_id"],
|
||||||
|
"assessment_release": assessment["release"]["ref"],
|
||||||
|
"installed_evidence_sha256": canonical_sha256(installed),
|
||||||
|
"catalog": {
|
||||||
|
"channel": catalog["channel"],
|
||||||
|
"sequence": catalog["sequence"],
|
||||||
|
"sha256": hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
catalog,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest(),
|
||||||
|
},
|
||||||
|
"issued_at": "2026-07-23T11:59:00Z",
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"package_name": item["package_name"],
|
||||||
|
"package_version": item["package_version"],
|
||||||
|
"catalog_archive_sha256": catalog_by_package[
|
||||||
|
str(item["package_name"])
|
||||||
|
]["archive_sha256"],
|
||||||
|
"installed_payload": deepcopy(
|
||||||
|
item["record_integrity"]["installed_payload_identity"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for item in installed["artifacts"]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
receipt["signatures"] = [
|
||||||
|
{
|
||||||
|
"algorithm": "ed25519",
|
||||||
|
"key_id": "installer:test",
|
||||||
|
"value": base64.b64encode(
|
||||||
|
private_key.sign(canonical_bytes(receipt))
|
||||||
|
).decode("ascii"),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
keyring = {
|
||||||
|
"$schema": "./installer-receipt-authority-keyring.schema.json",
|
||||||
|
"schema_version": "0.1.0",
|
||||||
|
"purpose": "govoplan.installer-receipt-authorities",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"key_id": "installer:test",
|
||||||
|
"status": "active",
|
||||||
|
"public_key": public_key,
|
||||||
|
"allowed_scopes": ["installed_release_origin"],
|
||||||
|
"not_before": "2026-07-01T00:00:00Z",
|
||||||
|
"not_after": "2026-08-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return receipt, keyring
|
||||||
|
|
||||||
|
|
||||||
def create_distribution(
|
def create_distribution(
|
||||||
root: Path,
|
root: Path,
|
||||||
) -> tuple[metadata.PathDistribution, Path]:
|
) -> tuple[metadata.PathDistribution, Path]:
|
||||||
|
|||||||
@@ -606,6 +606,7 @@ def signed_catalog(
|
|||||||
sequence: int = 202607220843,
|
sequence: int = 202607220843,
|
||||||
selected_units: list[dict[str, object]] | None = None,
|
selected_units: list[dict[str, object]] | None = None,
|
||||||
include_selected_units: bool = True,
|
include_selected_units: bool = True,
|
||||||
|
release_artifacts: list[dict[str, object]] | None = None,
|
||||||
) -> tuple[dict[str, object], dict[str, object]]:
|
) -> tuple[dict[str, object], dict[str, object]]:
|
||||||
versions = versions or {}
|
versions = versions or {}
|
||||||
private_key = Ed25519PrivateKey.generate()
|
private_key = Ed25519PrivateKey.generate()
|
||||||
@@ -661,6 +662,8 @@ def signed_catalog(
|
|||||||
release: dict[str, object] = {"keyring_sha256": canonical_hash(keyring)}
|
release: dict[str, object] = {"keyring_sha256": canonical_hash(keyring)}
|
||||||
if include_selected_units:
|
if include_selected_units:
|
||||||
release["selected_units"] = selected_units
|
release["selected_units"] = selected_units
|
||||||
|
if release_artifacts is not None:
|
||||||
|
release["artifacts"] = release_artifacts
|
||||||
catalog: dict[str, object] = {
|
catalog: dict[str, object] = {
|
||||||
"catalog_version": "1",
|
"catalog_version": "1",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
|
|||||||
@@ -0,0 +1,782 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import redirect_stderr, 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, patch
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DEPLOYMENT_TOOLS = META_ROOT / "tools" / "deployment"
|
||||||
|
if str(DEPLOYMENT_TOOLS) not in sys.path:
|
||||||
|
sys.path.insert(0, str(DEPLOYMENT_TOOLS))
|
||||||
|
|
||||||
|
from govoplan_deploy.bundle import ( # noqa: E402
|
||||||
|
atomic_write,
|
||||||
|
bundle_paths,
|
||||||
|
canonical_json,
|
||||||
|
environment_fingerprint,
|
||||||
|
initial_secrets,
|
||||||
|
read_env,
|
||||||
|
reconcile_runtime_environment,
|
||||||
|
render_compose,
|
||||||
|
render_load_balancer_config,
|
||||||
|
write_env,
|
||||||
|
)
|
||||||
|
from govoplan_deploy.cli import _receipt_uses_direct_web_port, main # noqa: E402
|
||||||
|
import govoplan_deploy.cli as deployment_cli # noqa: E402
|
||||||
|
from govoplan_deploy.model import ( # noqa: E402
|
||||||
|
SpecError,
|
||||||
|
default_spec,
|
||||||
|
parse_spec,
|
||||||
|
)
|
||||||
|
from govoplan_deploy.planning import _endpoint_check, build_plan # noqa: E402
|
||||||
|
import govoplan_deploy.planning as deployment_planning # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def run_cli(arguments: list[str]) -> tuple[int, str, str]:
|
||||||
|
stdout = io.StringIO()
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||||
|
result = main(arguments)
|
||||||
|
return result, stdout.getvalue(), stderr.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class DeploymentInstallerTests(unittest.TestCase):
|
||||||
|
def test_default_bundle_has_base_modules_and_managed_dependencies(self) -> None:
|
||||||
|
spec = default_spec()
|
||||||
|
compose = render_compose(spec)
|
||||||
|
|
||||||
|
self.assertEqual("evaluation", spec.profile)
|
||||||
|
self.assertIn("access", spec.enabled_modules)
|
||||||
|
self.assertIn("postgres", compose["services"])
|
||||||
|
self.assertIn("redis", compose["services"])
|
||||||
|
self.assertIn("worker", compose["services"])
|
||||||
|
self.assertIn("load-balancer", compose["services"])
|
||||||
|
self.assertNotIn("test-mail", compose["services"])
|
||||||
|
self.assertEqual(
|
||||||
|
["127.0.0.1:8080:8080"],
|
||||||
|
compose["services"]["load-balancer"]["ports"],
|
||||||
|
)
|
||||||
|
self.assertNotIn("ports", compose["services"]["web"])
|
||||||
|
self.assertEqual(1, compose["services"]["api"]["scale"])
|
||||||
|
self.assertEqual(1, compose["services"]["web"]["scale"])
|
||||||
|
|
||||||
|
def test_disabled_redis_removes_workers_and_sets_single_process_acknowledgement(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
spec = default_spec(redis_mode="disabled")
|
||||||
|
values = initial_secrets(spec)
|
||||||
|
compose = render_compose(spec)
|
||||||
|
|
||||||
|
self.assertNotIn("redis", compose["services"])
|
||||||
|
self.assertNotIn("worker", compose["services"])
|
||||||
|
self.assertNotIn("scheduler", compose["services"])
|
||||||
|
self.assertEqual("false", values["CELERY_ENABLED"])
|
||||||
|
self.assertEqual("true", values["GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE"])
|
||||||
|
|
||||||
|
def test_self_hosted_rejects_insecure_or_test_only_choices(self) -> None:
|
||||||
|
with self.assertRaisesRegex(SpecError, "must use HTTPS"):
|
||||||
|
default_spec(profile="self-hosted")
|
||||||
|
with self.assertRaisesRegex(SpecError, "require managed or external Redis"):
|
||||||
|
default_spec(
|
||||||
|
profile="self-hosted",
|
||||||
|
public_url="https://govoplan.example.test",
|
||||||
|
redis_mode="disabled",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(SpecError, "test-mail"):
|
||||||
|
default_spec(
|
||||||
|
profile="self-hosted",
|
||||||
|
public_url="https://govoplan.example.test",
|
||||||
|
mail_mode="test-mail",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_spec_rejects_unknown_fields_and_duplicate_modules(self) -> None:
|
||||||
|
raw = default_spec().to_dict()
|
||||||
|
raw["unexpected"] = True
|
||||||
|
with self.assertRaisesRegex(SpecError, "unknown fields"):
|
||||||
|
parse_spec(raw)
|
||||||
|
|
||||||
|
raw = default_spec().to_dict()
|
||||||
|
raw["enabled_modules"].append(raw["enabled_modules"][0])
|
||||||
|
with self.assertRaisesRegex(SpecError, "duplicate module"):
|
||||||
|
parse_spec(raw)
|
||||||
|
|
||||||
|
raw = default_spec().to_dict()
|
||||||
|
raw["network_subnet"] = "127.0.0.0/24"
|
||||||
|
with self.assertRaisesRegex(SpecError, "RFC1918"):
|
||||||
|
parse_spec(raw)
|
||||||
|
|
||||||
|
def test_generated_secrets_are_stable_across_reconfiguration(self) -> None:
|
||||||
|
first = default_spec()
|
||||||
|
values = initial_secrets(first)
|
||||||
|
master_key = values["MASTER_KEY_B64"]
|
||||||
|
postgres_password = values["POSTGRES_PASSWORD"]
|
||||||
|
redis_password = values["REDIS_PASSWORD"]
|
||||||
|
|
||||||
|
second = default_spec(mail_mode="test-mail", module_set="full")
|
||||||
|
reconciled = reconcile_runtime_environment(second, values)
|
||||||
|
|
||||||
|
self.assertEqual(master_key, reconciled["MASTER_KEY_B64"])
|
||||||
|
self.assertEqual(postgres_password, reconciled["POSTGRES_PASSWORD"])
|
||||||
|
self.assertEqual(redis_password, reconciled["REDIS_PASSWORD"])
|
||||||
|
self.assertEqual(
|
||||||
|
",".join(second.enabled_modules), reconciled["ENABLED_MODULES"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_external_services_require_explicit_valid_urls(self) -> None:
|
||||||
|
spec = default_spec(postgres_mode="external", redis_mode="external")
|
||||||
|
with self.assertRaisesRegex(ValueError, "external PostgreSQL"):
|
||||||
|
initial_secrets(spec)
|
||||||
|
with self.assertRaisesRegex(ValueError, "external Redis"):
|
||||||
|
initial_secrets(
|
||||||
|
spec,
|
||||||
|
supplied={
|
||||||
|
"DATABASE_URL": (
|
||||||
|
"postgresql+psycopg://user:secret@db.example.test/govoplan"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
values = initial_secrets(
|
||||||
|
spec,
|
||||||
|
supplied={
|
||||||
|
"DATABASE_URL": (
|
||||||
|
"postgresql+psycopg://user:secret@db.example.test/govoplan"
|
||||||
|
),
|
||||||
|
"REDIS_URL": "rediss://:secret@redis.example.test/0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("rediss://:secret@redis.example.test/0", values["REDIS_URL"])
|
||||||
|
|
||||||
|
def test_s3_requires_complete_private_configuration_and_https_in_production(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
evaluation = default_spec(storage_mode="s3")
|
||||||
|
with self.assertRaisesRegex(ValueError, "S3 storage requires"):
|
||||||
|
initial_secrets(evaluation)
|
||||||
|
|
||||||
|
supplied = {
|
||||||
|
"FILE_STORAGE_S3_ENDPOINT_URL": "http://s3.example.test",
|
||||||
|
"FILE_STORAGE_S3_REGION": "eu-test-1",
|
||||||
|
"FILE_STORAGE_S3_ACCESS_KEY_ID": "key",
|
||||||
|
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "secret",
|
||||||
|
"FILE_STORAGE_S3_BUCKET": "govoplan",
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
"s3",
|
||||||
|
initial_secrets(evaluation, supplied=supplied)["FILE_STORAGE_BACKEND"],
|
||||||
|
)
|
||||||
|
production = default_spec(
|
||||||
|
profile="self-hosted",
|
||||||
|
public_url="https://govoplan.example.test",
|
||||||
|
storage_mode="s3",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "must use HTTPS"):
|
||||||
|
initial_secrets(production, supplied=supplied)
|
||||||
|
|
||||||
|
def test_managed_garage_bootstraps_private_s3_storage(self) -> None:
|
||||||
|
spec = default_spec(storage_mode="garage")
|
||||||
|
values = initial_secrets(spec)
|
||||||
|
compose = render_compose(spec)
|
||||||
|
|
||||||
|
self.assertEqual("s3", values["FILE_STORAGE_BACKEND"])
|
||||||
|
self.assertEqual("true", values["FILE_STORAGE_S3_DEPLOYMENT_MANAGED"])
|
||||||
|
self.assertEqual(
|
||||||
|
"http://garage:3900",
|
||||||
|
values["FILE_STORAGE_S3_ENDPOINT_URL"],
|
||||||
|
)
|
||||||
|
self.assertEqual("garage", values["FILE_STORAGE_S3_REGION"])
|
||||||
|
self.assertEqual(
|
||||||
|
values["GARAGE_DEFAULT_ACCESS_KEY"],
|
||||||
|
values["FILE_STORAGE_S3_ACCESS_KEY_ID"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
values["GARAGE_DEFAULT_SECRET_KEY"],
|
||||||
|
values["FILE_STORAGE_S3_SECRET_ACCESS_KEY"],
|
||||||
|
)
|
||||||
|
self.assertTrue(values["GARAGE_DEFAULT_ACCESS_KEY"].startswith("GK"))
|
||||||
|
self.assertEqual(34, len(values["GARAGE_DEFAULT_ACCESS_KEY"]))
|
||||||
|
self.assertEqual(64, len(values["GARAGE_DEFAULT_SECRET_KEY"]))
|
||||||
|
self.assertIn("garage", compose["services"])
|
||||||
|
self.assertIn("garage-meta", compose["volumes"])
|
||||||
|
self.assertIn("garage-data", compose["volumes"])
|
||||||
|
self.assertEqual(
|
||||||
|
["/garage", "server", "--single-node", "--default-bucket"],
|
||||||
|
compose["services"]["garage"]["command"],
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
values["GARAGE_DEFAULT_SECRET_KEY"],
|
||||||
|
json.dumps(compose),
|
||||||
|
)
|
||||||
|
|
||||||
|
reconciled = reconcile_runtime_environment(spec, values)
|
||||||
|
self.assertEqual(
|
||||||
|
values["GARAGE_DEFAULT_ACCESS_KEY"],
|
||||||
|
reconciled["GARAGE_DEFAULT_ACCESS_KEY"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
values["GARAGE_RPC_SECRET"],
|
||||||
|
reconciled["GARAGE_RPC_SECRET"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_replica_counts_drive_compose_and_load_balancer_discovery(self) -> None:
|
||||||
|
spec = default_spec(
|
||||||
|
storage_mode="garage",
|
||||||
|
api_replicas=3,
|
||||||
|
web_replicas=2,
|
||||||
|
worker_replicas=4,
|
||||||
|
)
|
||||||
|
compose = render_compose(spec)
|
||||||
|
config = render_load_balancer_config(spec)
|
||||||
|
|
||||||
|
self.assertEqual(3, compose["services"]["api"]["scale"])
|
||||||
|
self.assertEqual(2, compose["services"]["web"]["scale"])
|
||||||
|
self.assertEqual(4, compose["services"]["worker"]["scale"])
|
||||||
|
self.assertEqual(
|
||||||
|
"http://load-balancer:8000",
|
||||||
|
compose["services"]["web"]["environment"]["GOVOPLAN_API_UPSTREAM"],
|
||||||
|
)
|
||||||
|
self.assertIn("server-template web- 2 web:8080", config)
|
||||||
|
self.assertIn("server-template api- 3 api:8000", config)
|
||||||
|
|
||||||
|
def test_multi_replica_runtime_requires_shared_redis(self) -> None:
|
||||||
|
with self.assertRaisesRegex(SpecError, "multiple API replicas"):
|
||||||
|
default_spec(redis_mode="disabled", api_replicas=2)
|
||||||
|
with self.assertRaisesRegex(SpecError, "worker must be 0"):
|
||||||
|
default_spec(redis_mode="disabled", worker_replicas=1)
|
||||||
|
with self.assertRaisesRegex(SpecError, "at least 1"):
|
||||||
|
default_spec(redis_mode="managed", worker_replicas=0)
|
||||||
|
|
||||||
|
def test_legacy_spec_defaults_new_topology_fields(self) -> None:
|
||||||
|
raw = default_spec().to_dict()
|
||||||
|
raw.pop("replicas")
|
||||||
|
raw["components"].pop("load_balancer")
|
||||||
|
raw["components"]["storage"].pop("image")
|
||||||
|
|
||||||
|
parsed = parse_spec(raw)
|
||||||
|
|
||||||
|
self.assertEqual(1, parsed.replicas.api)
|
||||||
|
self.assertEqual(1, parsed.replicas.web)
|
||||||
|
self.assertEqual(1, parsed.replicas.worker)
|
||||||
|
self.assertEqual("managed", parsed.components.load_balancer.mode)
|
||||||
|
|
||||||
|
def test_compose_contains_no_secret_values(self) -> None:
|
||||||
|
spec = default_spec()
|
||||||
|
values = initial_secrets(spec)
|
||||||
|
rendered = json.dumps(render_compose(spec), sort_keys=True)
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
"MASTER_KEY_B64",
|
||||||
|
"POSTGRES_PASSWORD",
|
||||||
|
"REDIS_PASSWORD",
|
||||||
|
):
|
||||||
|
self.assertNotIn(values[key], rendered)
|
||||||
|
self.assertNotIn("secrets.env", rendered)
|
||||||
|
self.assertNotIn("env_file", rendered)
|
||||||
|
self.assertNotIn("./:/etc/govoplan", rendered)
|
||||||
|
self.assertNotIn("installer", render_compose(spec)["services"])
|
||||||
|
postgres_environment = render_compose(spec)["services"]["postgres"][
|
||||||
|
"environment"
|
||||||
|
]
|
||||||
|
redis_environment = render_compose(spec)["services"]["redis"]["environment"]
|
||||||
|
self.assertEqual(
|
||||||
|
{"POSTGRES_DB", "POSTGRES_USER", "POSTGRES_PASSWORD"},
|
||||||
|
set(postgres_environment),
|
||||||
|
)
|
||||||
|
self.assertEqual({"REDIS_PASSWORD"}, set(redis_environment))
|
||||||
|
self.assertNotIn("MASTER_KEY_B64", postgres_environment)
|
||||||
|
self.assertNotIn("MASTER_KEY_B64", redis_environment)
|
||||||
|
|
||||||
|
def test_secret_environment_round_trips_literal_special_characters(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
|
path = Path(directory) / "secrets.env"
|
||||||
|
values = {
|
||||||
|
"PASSWORD": r"dollar$ quote' slash\\ hash# space ",
|
||||||
|
"EMPTY_VALUE": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
write_env(path, values)
|
||||||
|
|
||||||
|
self.assertEqual(values, read_env(path))
|
||||||
|
|
||||||
|
def test_first_plan_creates_services_and_applied_receipt_becomes_noop(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
|
paths = bundle_paths(Path(directory))
|
||||||
|
paths.root.chmod(0o700)
|
||||||
|
spec = default_spec()
|
||||||
|
write_env(paths.env, initial_secrets(spec))
|
||||||
|
first = build_plan(spec, paths, include_host_checks=False)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
("create", "installation"),
|
||||||
|
{(action.action, action.target) for action in first.actions},
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
("start", "api"),
|
||||||
|
{(action.action, action.target) for action in first.actions},
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt = {
|
||||||
|
"spec_sha256": first.desired_spec_sha256,
|
||||||
|
"compose_sha256": first.desired_compose_sha256,
|
||||||
|
"environment_fingerprint": (first.desired_environment_fingerprint),
|
||||||
|
"services": list(render_compose(spec)["services"]),
|
||||||
|
}
|
||||||
|
atomic_write(paths.receipt, canonical_json(receipt), mode=0o600)
|
||||||
|
second = build_plan(spec, paths, include_host_checks=False)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[("noop", "installation")],
|
||||||
|
[(action.action, action.target) for action in second.actions],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reconfiguration_plans_removed_component_containers(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
|
paths = bundle_paths(Path(directory))
|
||||||
|
paths.root.chmod(0o700)
|
||||||
|
first_spec = default_spec(mail_mode="test-mail")
|
||||||
|
first_environment = initial_secrets(first_spec)
|
||||||
|
write_env(paths.env, first_environment)
|
||||||
|
first_plan = build_plan(first_spec, paths, include_host_checks=False)
|
||||||
|
atomic_write(
|
||||||
|
paths.receipt,
|
||||||
|
canonical_json(
|
||||||
|
{
|
||||||
|
"spec_sha256": first_plan.desired_spec_sha256,
|
||||||
|
"compose_sha256": first_plan.desired_compose_sha256,
|
||||||
|
"environment_fingerprint": (
|
||||||
|
first_plan.desired_environment_fingerprint
|
||||||
|
),
|
||||||
|
"services": list(render_compose(first_spec)["services"]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
mode=0o600,
|
||||||
|
)
|
||||||
|
|
||||||
|
second_spec = default_spec(redis_mode="disabled", mail_mode="disabled")
|
||||||
|
write_env(
|
||||||
|
paths.env,
|
||||||
|
reconcile_runtime_environment(second_spec, first_environment),
|
||||||
|
)
|
||||||
|
second_plan = build_plan(second_spec, paths, include_host_checks=False)
|
||||||
|
removed = {
|
||||||
|
action.target
|
||||||
|
for action in second_plan.actions
|
||||||
|
if action.action == "remove"
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{"redis", "worker", "scheduler", "test-mail"},
|
||||||
|
removed,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_secret_change_is_planned_without_exposing_secret_values(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
|
paths = bundle_paths(Path(directory))
|
||||||
|
paths.root.chmod(0o700)
|
||||||
|
spec = default_spec()
|
||||||
|
values = initial_secrets(spec)
|
||||||
|
write_env(paths.env, values)
|
||||||
|
first = build_plan(spec, paths, include_host_checks=False)
|
||||||
|
atomic_write(
|
||||||
|
paths.receipt,
|
||||||
|
canonical_json(
|
||||||
|
{
|
||||||
|
"spec_sha256": first.desired_spec_sha256,
|
||||||
|
"compose_sha256": first.desired_compose_sha256,
|
||||||
|
"environment_fingerprint": (
|
||||||
|
first.desired_environment_fingerprint
|
||||||
|
),
|
||||||
|
"services": list(render_compose(spec)["services"]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
mode=0o600,
|
||||||
|
)
|
||||||
|
rotated = dict(values)
|
||||||
|
rotated["REDIS_PASSWORD"] = "rotated-high-entropy-value"
|
||||||
|
write_env(paths.env, rotated)
|
||||||
|
|
||||||
|
second = build_plan(spec, paths, include_host_checks=False)
|
||||||
|
plan_json = json.dumps(second.to_dict())
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
("reconfigure", "environment"),
|
||||||
|
{(action.action, action.target) for action in second.actions},
|
||||||
|
)
|
||||||
|
self.assertNotIn(rotated["REDIS_PASSWORD"], plan_json)
|
||||||
|
self.assertEqual(
|
||||||
|
environment_fingerprint(rotated),
|
||||||
|
second.desired_environment_fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_external_endpoint_preflight_reports_reachability(self) -> None:
|
||||||
|
connection = MagicMock()
|
||||||
|
connection.__enter__.return_value = connection
|
||||||
|
with patch.object(
|
||||||
|
deployment_planning.socket,
|
||||||
|
"create_connection",
|
||||||
|
return_value=connection,
|
||||||
|
) as connect:
|
||||||
|
check = _endpoint_check(
|
||||||
|
"external.redis",
|
||||||
|
"External Redis",
|
||||||
|
"rediss://redis.example.test/0",
|
||||||
|
default_ports={"redis": 6379, "rediss": 6380},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("ok", check.level)
|
||||||
|
connect.assert_called_once_with(
|
||||||
|
("redis.example.test", 6380),
|
||||||
|
timeout=1.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cli_init_writes_private_idempotent_bundle(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
|
root = Path(directory) / "installation"
|
||||||
|
result, _stdout, _stderr = run_cli(
|
||||||
|
[
|
||||||
|
"init",
|
||||||
|
"--non-interactive",
|
||||||
|
"--directory",
|
||||||
|
str(root),
|
||||||
|
"--redis",
|
||||||
|
"disabled",
|
||||||
|
"--mail",
|
||||||
|
"test-mail",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result)
|
||||||
|
self.assertEqual(0o700, stat.S_IMODE(root.stat().st_mode))
|
||||||
|
for name in (
|
||||||
|
"installation.json",
|
||||||
|
"secrets.env",
|
||||||
|
"compose.json",
|
||||||
|
"plan.json",
|
||||||
|
):
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE((root / name).stat().st_mode))
|
||||||
|
for name in ("garage.toml", "load-balancer.cfg"):
|
||||||
|
self.assertEqual(
|
||||||
|
0o644,
|
||||||
|
stat.S_IMODE((root / name).stat().st_mode),
|
||||||
|
)
|
||||||
|
values = read_env(root / "secrets.env")
|
||||||
|
self.assertTrue(values["MASTER_KEY_B64"])
|
||||||
|
self.assertNotIn(
|
||||||
|
values["POSTGRES_PASSWORD"],
|
||||||
|
(root / "compose.json").read_text(encoding="utf-8"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
run_cli(
|
||||||
|
[
|
||||||
|
"init",
|
||||||
|
"--non-interactive",
|
||||||
|
"--directory",
|
||||||
|
str(root),
|
||||||
|
]
|
||||||
|
)[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cli_requires_external_url_when_switching_from_managed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
|
root = Path(directory) / "installation"
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
run_cli(
|
||||||
|
[
|
||||||
|
"init",
|
||||||
|
"--non-interactive",
|
||||||
|
"--directory",
|
||||||
|
str(root),
|
||||||
|
]
|
||||||
|
)[0],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
run_cli(
|
||||||
|
[
|
||||||
|
"configure",
|
||||||
|
"--directory",
|
||||||
|
str(root),
|
||||||
|
"--postgres",
|
||||||
|
"external",
|
||||||
|
]
|
||||||
|
)[0],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"managed",
|
||||||
|
json.loads((root / "installation.json").read_text(encoding="utf-8"))[
|
||||||
|
"components"
|
||||||
|
]["postgres"]["mode"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
run_cli(
|
||||||
|
[
|
||||||
|
"configure",
|
||||||
|
"--directory",
|
||||||
|
str(root),
|
||||||
|
"--installation-id",
|
||||||
|
"renamed-installation",
|
||||||
|
]
|
||||||
|
)[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cli_requires_explicit_external_s3_values_after_garage(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
|
root = Path(directory) / "installation"
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
run_cli(
|
||||||
|
[
|
||||||
|
"init",
|
||||||
|
"--non-interactive",
|
||||||
|
"--directory",
|
||||||
|
str(root),
|
||||||
|
"--storage",
|
||||||
|
"garage",
|
||||||
|
]
|
||||||
|
)[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
result, _stdout, stderr = run_cli(
|
||||||
|
[
|
||||||
|
"configure",
|
||||||
|
"--directory",
|
||||||
|
str(root),
|
||||||
|
"--storage",
|
||||||
|
"s3",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, result)
|
||||||
|
self.assertIn("switching to external S3 requires", stderr)
|
||||||
|
self.assertEqual(
|
||||||
|
"garage",
|
||||||
|
json.loads((root / "installation.json").read_text(encoding="utf-8"))[
|
||||||
|
"components"
|
||||||
|
]["storage"]["mode"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_deployer_builds_and_runs_as_one_zipapp(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
|
output = Path(directory) / "govoplan-deploy.pyz"
|
||||||
|
build = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(DEPLOYMENT_TOOLS / "build-deployer-zipapp.py"),
|
||||||
|
"--output",
|
||||||
|
str(output),
|
||||||
|
],
|
||||||
|
cwd=META_ROOT,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
help_result = subprocess.run(
|
||||||
|
[sys.executable, str(output), "--help"],
|
||||||
|
cwd=META_ROOT,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
install_root = Path(directory) / "install"
|
||||||
|
init_result = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(output),
|
||||||
|
"init",
|
||||||
|
"--non-interactive",
|
||||||
|
"--directory",
|
||||||
|
str(install_root),
|
||||||
|
],
|
||||||
|
cwd=META_ROOT,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
render_result = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(output),
|
||||||
|
"render",
|
||||||
|
"--directory",
|
||||||
|
str(install_root),
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
cwd=META_ROOT,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, build.returncode, build.stderr)
|
||||||
|
self.assertTrue(output.exists())
|
||||||
|
self.assertEqual(0o755, stat.S_IMODE(output.stat().st_mode))
|
||||||
|
self.assertEqual(0, help_result.returncode, help_result.stderr)
|
||||||
|
self.assertIn("govoplan-deploy", help_result.stdout)
|
||||||
|
self.assertEqual(0, init_result.returncode, init_result.stderr)
|
||||||
|
self.assertEqual(1, render_result.returncode, render_result.stderr)
|
||||||
|
self.assertTrue(json.loads(render_result.stdout)["blocked"])
|
||||||
|
|
||||||
|
def test_generated_environment_passes_core_startup_validation_when_available(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
from govoplan_core.core.install_config import (
|
||||||
|
validate_runtime_configuration,
|
||||||
|
)
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
self.skipTest("govoplan-core is not installed in this test environment")
|
||||||
|
|
||||||
|
for profile, public_url in (
|
||||||
|
("evaluation", "http://127.0.0.1:8080"),
|
||||||
|
("self-hosted", "https://govoplan.example.test"),
|
||||||
|
):
|
||||||
|
with self.subTest(profile=profile):
|
||||||
|
environment = initial_secrets(
|
||||||
|
default_spec(profile=profile, public_url=public_url)
|
||||||
|
)
|
||||||
|
validation = validate_runtime_configuration(environment)
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
validation.errors,
|
||||||
|
validation.to_text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_apply_orders_dependencies_migration_and_runtime_before_receipt(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
|
root = Path(directory) / "installation"
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
run_cli(
|
||||||
|
[
|
||||||
|
"init",
|
||||||
|
"--non-interactive",
|
||||||
|
"--directory",
|
||||||
|
str(root),
|
||||||
|
"--api-image",
|
||||||
|
"local/govoplan-api:test",
|
||||||
|
"--web-image",
|
||||||
|
"local/govoplan-web:test",
|
||||||
|
]
|
||||||
|
)[0],
|
||||||
|
)
|
||||||
|
commands: list[list[str]] = []
|
||||||
|
|
||||||
|
def record_command(argv, *, cwd):
|
||||||
|
self.assertEqual(root, cwd)
|
||||||
|
commands.append(list(argv))
|
||||||
|
|
||||||
|
compose_version = subprocess.CompletedProcess(
|
||||||
|
args=["docker", "compose", "version"],
|
||||||
|
returncode=0,
|
||||||
|
stdout="2.30.0\n",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
deployment_cli.shutil,
|
||||||
|
"which",
|
||||||
|
return_value="/usr/bin/docker",
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
deployment_planning.shutil,
|
||||||
|
"which",
|
||||||
|
return_value="/usr/bin/docker",
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
deployment_planning,
|
||||||
|
"_run_command",
|
||||||
|
return_value=compose_version,
|
||||||
|
),
|
||||||
|
patch.object(deployment_cli, "_run", side_effect=record_command),
|
||||||
|
patch.object(deployment_cli, "_wait_for_health") as wait_for_health,
|
||||||
|
):
|
||||||
|
result, _stdout, _stderr = run_cli(
|
||||||
|
[
|
||||||
|
"apply",
|
||||||
|
"--directory",
|
||||||
|
str(root),
|
||||||
|
"--skip-pull",
|
||||||
|
"--allow-unverified-images",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result)
|
||||||
|
migrate_index = next(
|
||||||
|
index
|
||||||
|
for index, command in enumerate(commands)
|
||||||
|
if command[-3:] == ["run", "--rm", "migrate"]
|
||||||
|
)
|
||||||
|
runtime_index = next(
|
||||||
|
index
|
||||||
|
for index, command in enumerate(commands)
|
||||||
|
if "--remove-orphans" in command
|
||||||
|
)
|
||||||
|
self.assertLess(migrate_index, runtime_index)
|
||||||
|
self.assertIn("postgres", commands[0])
|
||||||
|
self.assertIn("redis", commands[0])
|
||||||
|
wait_for_health.assert_called_once_with(
|
||||||
|
"http://127.0.0.1:8080/health",
|
||||||
|
timeout_seconds=120.0,
|
||||||
|
)
|
||||||
|
receipt = json.loads((root / "receipt.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual("govoplan-local", receipt["installation_id"])
|
||||||
|
self.assertEqual(
|
||||||
|
{"address": "127.0.0.1", "port": 8080},
|
||||||
|
receipt["listen"],
|
||||||
|
)
|
||||||
|
self.assertNotIn("installer", receipt["services"])
|
||||||
|
|
||||||
|
def test_installation_root_symlink_is_rejected(self) -> None:
|
||||||
|
if not hasattr(Path, "symlink_to"):
|
||||||
|
self.skipTest("symbolic links are unavailable")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
|
base = Path(directory)
|
||||||
|
target = base / "real"
|
||||||
|
target.mkdir()
|
||||||
|
link = base / "linked"
|
||||||
|
link.symlink_to(target, target_is_directory=True)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "symbolic link"):
|
||||||
|
bundle_paths(link)
|
||||||
|
|
||||||
|
def test_legacy_receipt_requests_direct_web_port_handoff(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
|
receipt = Path(directory) / "receipt.json"
|
||||||
|
receipt.write_text(
|
||||||
|
json.dumps({"services": ["api", "web", "worker"]}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
self.assertTrue(_receipt_uses_direct_web_port(receipt))
|
||||||
|
|
||||||
|
receipt.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"services": [
|
||||||
|
"api",
|
||||||
|
"web",
|
||||||
|
"load-balancer",
|
||||||
|
"worker",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
self.assertFalse(_receipt_uses_direct_web_port(receipt))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPT = ROOT / "tools" / "gitea" / "gitea-sync-wiki.py"
|
||||||
|
TOOLS = SCRIPT.parent
|
||||||
|
if str(TOOLS) not in sys.path:
|
||||||
|
sys.path.insert(0, str(TOOLS))
|
||||||
|
SPEC = importlib.util.spec_from_file_location("gitea_sync_wiki", SCRIPT)
|
||||||
|
assert SPEC and SPEC.loader
|
||||||
|
wiki_sync = importlib.util.module_from_spec(SPEC)
|
||||||
|
sys.modules[SPEC.name] = wiki_sync
|
||||||
|
SPEC.loader.exec_module(wiki_sync)
|
||||||
|
|
||||||
|
|
||||||
|
class WikiSourceDiscoveryTests(unittest.TestCase):
|
||||||
|
def test_generated_and_incidental_govoplan_files_are_not_docs(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = pathlib.Path(temporary)
|
||||||
|
paths = {
|
||||||
|
"readme": root / "README.md",
|
||||||
|
"architecture": root / "docs" / "DATASOURCE_ARCHITECTURE.md",
|
||||||
|
"plan": root / "workflow-plan.md",
|
||||||
|
"audit": root / "audit-reports" / "full" / "manifest.json",
|
||||||
|
"runtime": root / "runtime" / "release" / "manifest.json",
|
||||||
|
"incidental": root / "tools" / "semgrep" / "govoplan.yml",
|
||||||
|
"manifest": root / "manifest.json",
|
||||||
|
}
|
||||||
|
for path in paths.values():
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("content\n", encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertTrue(wiki_sync.is_repo_doc(root, paths["readme"]))
|
||||||
|
self.assertTrue(wiki_sync.is_repo_doc(root, paths["architecture"]))
|
||||||
|
self.assertTrue(wiki_sync.is_repo_doc(root, paths["plan"]))
|
||||||
|
self.assertFalse(wiki_sync.is_repo_doc(root, paths["audit"]))
|
||||||
|
self.assertFalse(wiki_sync.is_repo_doc(root, paths["runtime"]))
|
||||||
|
self.assertFalse(wiki_sync.is_repo_doc(root, paths["incidental"]))
|
||||||
|
self.assertFalse(wiki_sync.is_repo_doc(root, paths["manifest"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "tools"
|
||||||
|
/ "inventory"
|
||||||
|
/ "platform-interface-inventory.py"
|
||||||
|
)
|
||||||
|
SPEC = importlib.util.spec_from_file_location("platform_interface_inventory", SCRIPT)
|
||||||
|
assert SPEC is not None and SPEC.loader is not None
|
||||||
|
inventory = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(inventory)
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformInterfaceInventoryTests(unittest.TestCase):
|
||||||
|
def test_canonical_api_path_normalizes_versions_and_parameters(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
inventory.canonical_api_path(
|
||||||
|
"http://localhost/api/v1/campaigns/${campaignId}?limit=10"
|
||||||
|
),
|
||||||
|
"/campaigns/{}",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
inventory.canonical_api_path("/api/v2/campaigns/{campaign_id}"),
|
||||||
|
"/campaigns/{}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fastapi_route_scanner_includes_router_prefix(self) -> None:
|
||||||
|
tree = ast.parse(
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
router = APIRouter(prefix="/api/v1/items")
|
||||||
|
|
||||||
|
@router.get("/{item_id}")
|
||||||
|
def read_item(item_id: str):
|
||||||
|
return item_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
prefixes = inventory._router_prefixes(tree)
|
||||||
|
function = next(
|
||||||
|
node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)
|
||||||
|
)
|
||||||
|
|
||||||
|
route = inventory._endpoint_from_decorator(
|
||||||
|
function.decorator_list[0],
|
||||||
|
prefixes=prefixes,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
route,
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/items/{item_id}",
|
||||||
|
"router": "router",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPT = META_ROOT / "tools" / "checks" / "postgres-integration-check.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_script():
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"postgres_integration_check",
|
||||||
|
SCRIPT,
|
||||||
|
)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError(f"Could not load {SCRIPT}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresIntegrationCheckTests(unittest.TestCase):
|
||||||
|
def test_staging_check_has_explicit_runtime_safety_settings(self) -> None:
|
||||||
|
module = _load_script()
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
env = module._check_env(
|
||||||
|
database_url="postgresql+psycopg://user:password@postgres/db",
|
||||||
|
modules="tenancy,access,search",
|
||||||
|
app_env="staging",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
env["CORS_ORIGINS"],
|
||||||
|
"http://127.0.0.1:5173,http://localhost:5173",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
env["GOVOPLAN_TRUSTED_HOSTS"],
|
||||||
|
"127.0.0.1,localhost,testserver",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
env["GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS"],
|
||||||
|
"false",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
env["GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE"],
|
||||||
|
"true",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_existing_runtime_safety_settings_are_preserved(self) -> None:
|
||||||
|
module = _load_script()
|
||||||
|
configured = {
|
||||||
|
"CORS_ORIGINS": "https://govoplan.example.test",
|
||||||
|
"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.test",
|
||||||
|
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true",
|
||||||
|
"GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict(os.environ, configured, clear=True):
|
||||||
|
env = module._check_env(
|
||||||
|
database_url="postgresql+psycopg://user:password@postgres/db",
|
||||||
|
modules="tenancy,access,search",
|
||||||
|
app_env="staging",
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, value in configured.items():
|
||||||
|
self.assertEqual(env[key], value)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -77,6 +77,76 @@ class PythonEnvironmentSyncTests(unittest.TestCase):
|
|||||||
self.assertIn(str(root / "govoplan-core"), command)
|
self.assertIn(str(root / "govoplan-core"), command)
|
||||||
self.assertIn(str(root / "govoplan-access"), command)
|
self.assertIn(str(root / "govoplan-access"), command)
|
||||||
|
|
||||||
|
def test_missing_editable_distribution_is_not_hidden_by_current_stamp(self) -> None:
|
||||||
|
sync = load_sync_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-python-sync-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
project_root = root / "govoplan-probe-missing"
|
||||||
|
project_root.mkdir()
|
||||||
|
(project_root / "pyproject.toml").write_text(
|
||||||
|
"\n".join(
|
||||||
|
(
|
||||||
|
"[project]",
|
||||||
|
'name = "govoplan-probe-definitely-not-installed"',
|
||||||
|
'version = "0.1.0"',
|
||||||
|
"",
|
||||||
|
'[project.entry-points."govoplan.modules"]',
|
||||||
|
'probe_missing = "govoplan_probe.backend.manifest:get_manifest"',
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
requirements = root / "requirements-dev.txt"
|
||||||
|
requirements.write_text("-e ./govoplan-probe-missing\n", encoding="utf-8")
|
||||||
|
entries = sync.local_requirement_entries(requirements)
|
||||||
|
|
||||||
|
validation = sync.validate_local_installations(sys.executable, entries)
|
||||||
|
plan = sync.build_environment_repair_plan(
|
||||||
|
python=sys.executable,
|
||||||
|
stale_requirements=validation.stale_requirements,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(validation.current)
|
||||||
|
self.assertEqual(validation.stale_requirements, entries)
|
||||||
|
self.assertIn("distribution is not installed", validation.issues[0])
|
||||||
|
self.assertEqual(plan.mode, "Selective Python environment repair")
|
||||||
|
self.assertEqual(plan.commands[0][-2:], ("-e", str(project_root)))
|
||||||
|
|
||||||
|
def test_declared_module_entry_points_are_part_of_environment_validation(self) -> None:
|
||||||
|
sync = load_sync_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-python-sync-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
project_root = root / "govoplan-probe"
|
||||||
|
project_root.mkdir()
|
||||||
|
(project_root / "pyproject.toml").write_text(
|
||||||
|
"\n".join(
|
||||||
|
(
|
||||||
|
"[project]",
|
||||||
|
'name = "govoplan-probe"',
|
||||||
|
'version = "0.1.0"',
|
||||||
|
"",
|
||||||
|
'[project.entry-points."govoplan.modules"]',
|
||||||
|
'probe = "govoplan_probe.backend.manifest:get_manifest"',
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
requirements = root / "requirements-dev.txt"
|
||||||
|
requirements.write_text("-e ./govoplan-probe\n", encoding="utf-8")
|
||||||
|
|
||||||
|
expectations = sync.local_installation_expectations(
|
||||||
|
sync.local_requirement_entries(requirements)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(expectations), 1)
|
||||||
|
self.assertEqual(expectations[0].distribution, "govoplan-probe")
|
||||||
|
self.assertEqual(
|
||||||
|
expectations[0].entry_points,
|
||||||
|
(("govoplan.modules", "probe", "govoplan_probe.backend.manifest:get_manifest"),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.artifact_identity import ( # noqa: E402
|
||||||
|
ArtifactIdentityError,
|
||||||
|
inspect_python_wheel,
|
||||||
|
selected_artifact_identity_issues,
|
||||||
|
)
|
||||||
|
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||||
|
apply_python_artifact_identities,
|
||||||
|
)
|
||||||
|
ASSESSMENT_TOOLS_ROOT = META_ROOT / "tools" / "assessments"
|
||||||
|
if str(ASSESSMENT_TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ASSESSMENT_TOOLS_ROOT))
|
||||||
|
from govoplan_assessment.installer_receipt import issue_installer_receipt # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseArtifactIdentityTests(unittest.TestCase):
|
||||||
|
def test_built_wheel_bytes_become_catalog_identity(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
wheel = self._wheel(Path(temp_dir), console_script=True)
|
||||||
|
payload = self._catalog_payload()
|
||||||
|
|
||||||
|
changes = apply_python_artifact_identities(
|
||||||
|
payload,
|
||||||
|
repo_versions={"govoplan-demo": "1.2.3"},
|
||||||
|
python_artifacts={"govoplan-demo": wheel},
|
||||||
|
)
|
||||||
|
|
||||||
|
identity = payload["release"]["artifacts"][0]
|
||||||
|
self.assertEqual("govoplan-demo", identity["package_name"])
|
||||||
|
self.assertEqual("1.2.3", identity["package_version"])
|
||||||
|
self.assertRegex(identity["archive_sha256"], r"^[0-9a-f]{64}$")
|
||||||
|
self.assertTrue(identity["requires_installer_receipt"])
|
||||||
|
self.assertEqual("release.artifact", changes[0].field)
|
||||||
|
self.assertEqual((), selected_artifact_identity_issues(payload))
|
||||||
|
|
||||||
|
def test_version_update_without_wheel_removes_stale_identity_and_blocks_publish(self) -> None:
|
||||||
|
payload = self._catalog_payload()
|
||||||
|
payload["release"]["artifacts"] = [
|
||||||
|
{
|
||||||
|
"artifact_kind": "python-wheel",
|
||||||
|
"package_name": "govoplan-demo",
|
||||||
|
"package_version": "1.2.2",
|
||||||
|
"archive_sha256": "a" * 64,
|
||||||
|
"archive_size": 10,
|
||||||
|
"installed_payload": {
|
||||||
|
"algorithm": "govoplan-wheel-declared-payload-v1",
|
||||||
|
"sha256": "b" * 64,
|
||||||
|
"file_count": 1,
|
||||||
|
},
|
||||||
|
"requires_installer_receipt": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
apply_python_artifact_identities(
|
||||||
|
payload,
|
||||||
|
repo_versions={"govoplan-demo": "1.2.3"},
|
||||||
|
python_artifacts={},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotIn("artifacts", payload["release"])
|
||||||
|
self.assertIn(
|
||||||
|
"no built artifact identity",
|
||||||
|
" ".join(selected_artifact_identity_issues(payload)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tag_only_selection_needs_no_python_artifact_mapping(self) -> None:
|
||||||
|
payload = self._catalog_payload()
|
||||||
|
payload["release"]["selected_units"].append(
|
||||||
|
{"repo": "govoplan", "version": "1.2.3"}
|
||||||
|
)
|
||||||
|
|
||||||
|
apply_python_artifact_identities(
|
||||||
|
payload,
|
||||||
|
repo_versions={"govoplan": "1.2.3"},
|
||||||
|
python_artifacts={},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("selected_units", payload["release"])
|
||||||
|
|
||||||
|
def test_unsafe_or_mismatched_wheel_fails_closed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
unsafe = root / "govoplan_demo-1.2.3-py3-none-any.whl"
|
||||||
|
with zipfile.ZipFile(unsafe, "w") as archive:
|
||||||
|
archive.writestr("../escape", b"bad")
|
||||||
|
archive.writestr(
|
||||||
|
"govoplan_demo-1.2.3.dist-info/METADATA",
|
||||||
|
"Metadata-Version: 2.1\nName: govoplan-demo\nVersion: 1.2.3\n",
|
||||||
|
)
|
||||||
|
wrong = self._wheel(root, version="9.9.9")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ArtifactIdentityError, "unsafe"):
|
||||||
|
inspect_python_wheel(unsafe)
|
||||||
|
with self.assertRaisesRegex(ValueError, "expected govoplan-demo 1.2.3"):
|
||||||
|
apply_python_artifact_identities(
|
||||||
|
self._catalog_payload(),
|
||||||
|
repo_versions={"govoplan-demo": "1.2.3"},
|
||||||
|
python_artifacts={"govoplan-demo": wrong},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_receipt_issuer_hashes_exact_consumed_wheel(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
original = self._wheel(root / "original", value=b"VALUE = 1\n")
|
||||||
|
different = self._wheel(root / "different", value=b"VALUE = 2\n")
|
||||||
|
identity = inspect_python_wheel(original)
|
||||||
|
catalog = self._catalog_payload()
|
||||||
|
catalog["channel"] = "stable"
|
||||||
|
catalog["sequence"] = 1
|
||||||
|
catalog["release"]["artifacts"] = [identity.catalog_payload()]
|
||||||
|
assessment = {
|
||||||
|
"assessment_id": "assessment:test",
|
||||||
|
"release": {"ref": "stable-catalog-1"},
|
||||||
|
"composition": [{"module_id": "demo", "enabled": True}],
|
||||||
|
}
|
||||||
|
installed_payload = {
|
||||||
|
"algorithm": "govoplan-installed-record-payload-v1",
|
||||||
|
"sha256": "c" * 64,
|
||||||
|
"file_count": identity.payload_file_count,
|
||||||
|
}
|
||||||
|
evidence = {
|
||||||
|
"assessment_id": "assessment:test",
|
||||||
|
"assessment_release": "stable-catalog-1",
|
||||||
|
"collected_at": "2026-07-22T12:00:00Z",
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"package_name": "govoplan-demo",
|
||||||
|
"package_version": "1.2.3",
|
||||||
|
"record_integrity": {
|
||||||
|
"status": "verified",
|
||||||
|
"hashed_file_count": identity.payload_file_count,
|
||||||
|
"permitted_unhashed_file_count": 1,
|
||||||
|
"generated_unhashed_file_count": 0,
|
||||||
|
"unverifiable_file_count": 0,
|
||||||
|
"missing_file_count": 0,
|
||||||
|
"mismatched_file_count": 0,
|
||||||
|
"artifact_payload_identity": catalog["release"]["artifacts"][0]["installed_payload"],
|
||||||
|
"installed_payload_identity": installed_payload,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
key = Ed25519PrivateKey.generate()
|
||||||
|
|
||||||
|
receipt = issue_installer_receipt(
|
||||||
|
assessment=assessment,
|
||||||
|
catalog=catalog,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
receipt_id="install:test",
|
||||||
|
key_id="installer:test",
|
||||||
|
private_key=key,
|
||||||
|
consumed_artifacts={"govoplan-demo": original},
|
||||||
|
issued_at=datetime(2026, 7, 22, 12, 1, tzinfo=UTC),
|
||||||
|
same_process_observation=True,
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "differs from the signed"):
|
||||||
|
issue_installer_receipt(
|
||||||
|
assessment=assessment,
|
||||||
|
catalog=catalog,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
receipt_id="install:test",
|
||||||
|
key_id="installer:test",
|
||||||
|
private_key=key,
|
||||||
|
consumed_artifacts={"govoplan-demo": different},
|
||||||
|
issued_at=datetime(2026, 7, 22, 12, 1, tzinfo=UTC),
|
||||||
|
same_process_observation=True,
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "within five minutes"):
|
||||||
|
issue_installer_receipt(
|
||||||
|
assessment=assessment,
|
||||||
|
catalog=catalog,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
receipt_id="install:test",
|
||||||
|
key_id="installer:test",
|
||||||
|
private_key=key,
|
||||||
|
consumed_artifacts={"govoplan-demo": original},
|
||||||
|
issued_at=datetime(2026, 7, 22, 12, 6, tzinfo=UTC),
|
||||||
|
same_process_observation=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(identity.archive_sha256, receipt["artifacts"][0]["catalog_archive_sha256"])
|
||||||
|
|
||||||
|
def test_path_replacement_during_one_descriptor_inspection_fails_closed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
target = self._wheel(root / "target", value=b"VALUE = 1\n")
|
||||||
|
replacement = self._wheel(root / "replacement", value=b"VALUE = 2\n")
|
||||||
|
real_zip = zipfile.ZipFile
|
||||||
|
|
||||||
|
def swap_path(file_or_path, *args, **kwargs):
|
||||||
|
target.unlink()
|
||||||
|
replacement.rename(target)
|
||||||
|
return real_zip(file_or_path, *args, **kwargs)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"govoplan_release.artifact_identity.zipfile.ZipFile",
|
||||||
|
side_effect=swap_path,
|
||||||
|
),
|
||||||
|
self.assertRaisesRegex(ArtifactIdentityError, "changed"),
|
||||||
|
):
|
||||||
|
inspect_python_wheel(target)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _catalog_payload() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"core_release": {},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"module_id": "demo",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"python_package": "govoplan-demo",
|
||||||
|
"python_ref": "govoplan-demo @ git+ssh://git@example.test/acme/govoplan-demo.git@v1.2.3",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"release": {
|
||||||
|
"selected_units": [
|
||||||
|
{"repo": "govoplan-demo", "version": "1.2.3"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wheel(
|
||||||
|
root: Path,
|
||||||
|
*,
|
||||||
|
version: str = "1.2.3",
|
||||||
|
console_script: bool = False,
|
||||||
|
value: bytes = b"VALUE = 1\n",
|
||||||
|
) -> Path:
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
wheel = root / f"govoplan_demo-{version}-py3-none-any.whl"
|
||||||
|
dist_info = f"govoplan_demo-{version}.dist-info"
|
||||||
|
files: dict[str, bytes] = {
|
||||||
|
"govoplan_demo.py": value,
|
||||||
|
f"{dist_info}/METADATA": (
|
||||||
|
f"Metadata-Version: 2.1\nName: govoplan-demo\nVersion: {version}\n"
|
||||||
|
).encode(),
|
||||||
|
f"{dist_info}/WHEEL": b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||||
|
}
|
||||||
|
if console_script:
|
||||||
|
files[f"{dist_info}/entry_points.txt"] = (
|
||||||
|
b"[console_scripts]\ngovoplan-demo = govoplan_demo:main\n"
|
||||||
|
)
|
||||||
|
record_buffer = io.StringIO()
|
||||||
|
writer = csv.writer(record_buffer, lineterminator="\n")
|
||||||
|
for name, encoded in files.items():
|
||||||
|
writer.writerow((name, "", len(encoded)))
|
||||||
|
writer.writerow((f"{dist_info}/RECORD", "", ""))
|
||||||
|
files[f"{dist_info}/RECORD"] = record_buffer.getvalue().encode()
|
||||||
|
with zipfile.ZipFile(wheel, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for name, encoded in files.items():
|
||||||
|
archive.writestr(name, encoded)
|
||||||
|
return wheel
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.catalog import canonical_hash # noqa: E402
|
||||||
|
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||||
|
candidate_keyring_from_authenticated_base,
|
||||||
|
load_authenticated_catalog_base,
|
||||||
|
public_key_base64,
|
||||||
|
signature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseBaseCatalogTrustTests(unittest.TestCase):
|
||||||
|
def test_exact_signed_base_pair_is_loaded_once_and_carried_in_memory(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
catalog, keyring = self._write_pair(root, private_key=private_key)
|
||||||
|
|
||||||
|
authenticated = load_authenticated_catalog_base(
|
||||||
|
base_catalog=catalog,
|
||||||
|
base_keyring=keyring,
|
||||||
|
web_root=root,
|
||||||
|
channel="stable",
|
||||||
|
public_base_url="https://invalid.example",
|
||||||
|
signer_public_keys={"release-key": public_key_base64(private_key)},
|
||||||
|
)
|
||||||
|
|
||||||
|
keyring.unlink()
|
||||||
|
|
||||||
|
self.assertEqual("release-key", authenticated.keyring["keys"][0]["key_id"])
|
||||||
|
self.assertEqual(
|
||||||
|
canonical_hash(authenticated.keyring),
|
||||||
|
authenticated.catalog["release"]["keyring_sha256"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_deliberate_old_to_new_signer_rotation_is_carried_forward(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
new_key = Ed25519PrivateKey.generate()
|
||||||
|
catalog, keyring = self._write_pair(root, private_key=private_key)
|
||||||
|
configured = {
|
||||||
|
"release-key": public_key_base64(private_key),
|
||||||
|
"release-key-next": public_key_base64(new_key),
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticated = load_authenticated_catalog_base(
|
||||||
|
base_catalog=catalog,
|
||||||
|
base_keyring=keyring,
|
||||||
|
web_root=root,
|
||||||
|
channel="stable",
|
||||||
|
public_base_url="https://invalid.example",
|
||||||
|
signer_public_keys=configured,
|
||||||
|
)
|
||||||
|
candidate = candidate_keyring_from_authenticated_base(
|
||||||
|
authenticated.keyring,
|
||||||
|
signer_public_keys=configured,
|
||||||
|
generated_at=datetime(2026, 7, 22, 12, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{"release-key", "release-key-next"},
|
||||||
|
{item["key_id"] for item in candidate["keys"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_injected_extra_key_without_catalog_authorization_is_rejected(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
extra_key = Ed25519PrivateKey.generate()
|
||||||
|
catalog, keyring = self._write_pair(root, private_key=private_key)
|
||||||
|
payload = json.loads(keyring.read_text(encoding="utf-8"))
|
||||||
|
payload["keys"].append(
|
||||||
|
{
|
||||||
|
"key_id": "injected-key",
|
||||||
|
"status": "active",
|
||||||
|
"public_key": public_key_base64(extra_key),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
keyring.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "does not pin"):
|
||||||
|
load_authenticated_catalog_base(
|
||||||
|
base_catalog=catalog,
|
||||||
|
base_keyring=keyring,
|
||||||
|
web_root=root,
|
||||||
|
channel="stable",
|
||||||
|
public_base_url="https://invalid.example",
|
||||||
|
signer_public_keys={
|
||||||
|
"release-key": public_key_base64(private_key)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unpinned_or_symlinked_base_keyring_fails_closed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
catalog, keyring = self._write_pair(root, private_key=private_key)
|
||||||
|
payload = json.loads(keyring.read_text(encoding="utf-8"))
|
||||||
|
payload["generated_at"] = "changed"
|
||||||
|
keyring.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "does not pin"):
|
||||||
|
load_authenticated_catalog_base(
|
||||||
|
base_catalog=catalog,
|
||||||
|
base_keyring=keyring,
|
||||||
|
web_root=root,
|
||||||
|
channel="stable",
|
||||||
|
public_base_url="https://invalid.example",
|
||||||
|
signer_public_keys={
|
||||||
|
"release-key": public_key_base64(private_key)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
keyring.unlink()
|
||||||
|
outside = root / "outside.json"
|
||||||
|
outside.write_text("{}", encoding="utf-8")
|
||||||
|
keyring.symlink_to(outside)
|
||||||
|
with self.assertRaisesRegex(ValueError, "opened safely"):
|
||||||
|
load_authenticated_catalog_base(
|
||||||
|
base_catalog=catalog,
|
||||||
|
base_keyring=keyring,
|
||||||
|
web_root=root,
|
||||||
|
channel="stable",
|
||||||
|
public_base_url="https://invalid.example",
|
||||||
|
signer_public_keys={
|
||||||
|
"release-key": public_key_base64(private_key)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_pair(
|
||||||
|
root: Path, *, private_key: Ed25519PrivateKey
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
keys = [
|
||||||
|
{
|
||||||
|
"key_id": "release-key",
|
||||||
|
"status": "active",
|
||||||
|
"public_key": public_key_base64(private_key),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
keyring_payload = {
|
||||||
|
"keyring_version": "1",
|
||||||
|
"purpose": "govoplan module package catalog signatures",
|
||||||
|
"keys": keys,
|
||||||
|
}
|
||||||
|
catalog_payload = {
|
||||||
|
"channel": "stable",
|
||||||
|
"sequence": 1,
|
||||||
|
"core_release": {"version": "1.0.0"},
|
||||||
|
"modules": [],
|
||||||
|
"release": {"keyring_sha256": canonical_hash(keyring_payload)},
|
||||||
|
}
|
||||||
|
catalog_payload["signatures"] = [
|
||||||
|
signature(
|
||||||
|
catalog_payload,
|
||||||
|
key_id="release-key",
|
||||||
|
private_key=private_key,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
catalog = root / "stable.json"
|
||||||
|
keyring = root / "keyring.json"
|
||||||
|
catalog.write_text(json.dumps(catalog_payload), encoding="utf-8")
|
||||||
|
keyring.write_text(json.dumps(keyring_payload), encoding="utf-8")
|
||||||
|
return catalog, keyring
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.candidate_artifact import ( # noqa: E402
|
||||||
|
CandidateArtifactError,
|
||||||
|
candidate_output_path,
|
||||||
|
issue_candidate_id,
|
||||||
|
issue_candidate_receipt,
|
||||||
|
verify_candidate_receipt,
|
||||||
|
validate_release_channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateArtifactTests(unittest.TestCase):
|
||||||
|
def test_channel_is_one_canonical_basename(self) -> None:
|
||||||
|
self.assertEqual("stable_1", validate_release_channel("stable_1"))
|
||||||
|
for value in ("../stable", "/stable", ".", "..", "Stable", "stable/name"):
|
||||||
|
with self.subTest(value=value), self.assertRaises(CandidateArtifactError):
|
||||||
|
validate_release_channel(value)
|
||||||
|
|
||||||
|
def test_handle_is_deterministic_and_can_precede_output(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir) / "not-created" / "release-candidates"
|
||||||
|
candidate_id = issue_candidate_id("rr-123", "attempt-456")
|
||||||
|
|
||||||
|
first = candidate_output_path(root, candidate_id)
|
||||||
|
second = candidate_output_path(root, candidate_id)
|
||||||
|
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertEqual(candidate_id, first.name)
|
||||||
|
self.assertRegex(candidate_id, r"^candidate-[0-9a-f]{32}$")
|
||||||
|
|
||||||
|
def test_receipt_rejects_catalog_drift(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir) / "release-candidates"
|
||||||
|
candidate_id = issue_candidate_id("run", "attempt")
|
||||||
|
catalog = self._write_candidate(root / candidate_id)
|
||||||
|
receipt = issue_candidate_receipt(
|
||||||
|
root=root, candidate_id=candidate_id, channel="stable"
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved = verify_candidate_receipt(
|
||||||
|
root=root,
|
||||||
|
candidate_id=receipt.candidate_id,
|
||||||
|
catalog_sha256=receipt.catalog_sha256,
|
||||||
|
channel="stable",
|
||||||
|
)
|
||||||
|
catalog.write_text(
|
||||||
|
json.dumps({"channel": "stable", "sequence": 2, "signatures": [{}]}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(root / candidate_id, resolved)
|
||||||
|
with self.assertRaisesRegex(CandidateArtifactError, "no longer matches"):
|
||||||
|
verify_candidate_receipt(
|
||||||
|
root=root,
|
||||||
|
candidate_id=receipt.candidate_id,
|
||||||
|
catalog_sha256=receipt.catalog_sha256,
|
||||||
|
channel="stable",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unissued_ids_traversal_and_symlinks_fail_closed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir) / "release-candidates"
|
||||||
|
root.mkdir(mode=0o700)
|
||||||
|
candidate_id = issue_candidate_id("run", "attempt")
|
||||||
|
outside = Path(temp_dir) / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
(root / candidate_id).symlink_to(outside, target_is_directory=True)
|
||||||
|
|
||||||
|
with self.assertRaises(CandidateArtifactError):
|
||||||
|
candidate_output_path(root, "../candidate-" + "0" * 32)
|
||||||
|
with self.assertRaisesRegex(CandidateArtifactError, "real directory"):
|
||||||
|
candidate_output_path(root, candidate_id)
|
||||||
|
|
||||||
|
def test_catalog_and_channel_components_must_not_be_symlinks(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir) / "release-candidates"
|
||||||
|
candidate_id = issue_candidate_id("run", "attempt")
|
||||||
|
candidate = root / candidate_id
|
||||||
|
target = Path(temp_dir) / "target"
|
||||||
|
target.mkdir()
|
||||||
|
(target / "stable.json").write_text(
|
||||||
|
json.dumps({"signatures": [{}]}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
root.mkdir(mode=0o700)
|
||||||
|
candidate.mkdir(mode=0o700)
|
||||||
|
(candidate / "channels").symlink_to(target, target_is_directory=True)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(CandidateArtifactError, "real directory"):
|
||||||
|
issue_candidate_receipt(
|
||||||
|
root=root, candidate_id=candidate_id, channel="stable"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_shared_candidate_roots_and_catalog_files_fail_closed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir) / "release-candidates"
|
||||||
|
root.mkdir(mode=0o755)
|
||||||
|
candidate_id = issue_candidate_id("run", "attempt")
|
||||||
|
with self.assertRaisesRegex(CandidateArtifactError, "another user"):
|
||||||
|
candidate_output_path(root, candidate_id)
|
||||||
|
|
||||||
|
root.chmod(0o700)
|
||||||
|
catalog = self._write_candidate(root / candidate_id)
|
||||||
|
catalog.chmod(0o640)
|
||||||
|
with self.assertRaisesRegex(CandidateArtifactError, "another user"):
|
||||||
|
issue_candidate_receipt(
|
||||||
|
root=root, candidate_id=candidate_id, channel="stable"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_receipt_rejects_catalog_with_inconsistent_channel(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir) / "release-candidates"
|
||||||
|
candidate_id = issue_candidate_id("run", "attempt")
|
||||||
|
catalog = self._write_candidate(root / candidate_id)
|
||||||
|
catalog.write_text(
|
||||||
|
json.dumps({"channel": "next", "signatures": [{}]}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(CandidateArtifactError, "does not match"):
|
||||||
|
issue_candidate_receipt(
|
||||||
|
root=root, candidate_id=candidate_id, channel="stable"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_candidate(candidate: Path) -> Path:
|
||||||
|
candidate.parent.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
candidate.mkdir(mode=0o700)
|
||||||
|
channels = candidate / "channels"
|
||||||
|
channels.mkdir(mode=0o700)
|
||||||
|
catalog = channels / "stable.json"
|
||||||
|
catalog.write_text(
|
||||||
|
json.dumps({"channel": "stable", "sequence": 1, "signatures": [{}]}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
catalog.chmod(0o600)
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -24,7 +24,7 @@ class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
|||||||
"module_id": "access",
|
"module_id": "access",
|
||||||
"version": "0.1.11",
|
"version": "0.1.11",
|
||||||
"python_package": "govoplan-access",
|
"python_package": "govoplan-access",
|
||||||
"python_ref": "govoplan-access @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git@v0.1.11",
|
"python_ref": "govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.11",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
|||||||
"govoplan-scheduling": "0.1.11",
|
"govoplan-scheduling": "0.1.11",
|
||||||
},
|
},
|
||||||
repo_contracts={},
|
repo_contracts={},
|
||||||
repository_base="git+ssh://git@git.add-ideas.de/add-ideas",
|
repository_base="git+ssh://git@git.add-ideas.de/GovOPlaN",
|
||||||
workspace=META_ROOT.parent,
|
workspace=META_ROOT.parent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -13,10 +16,691 @@ RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
|||||||
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||||
|
|
||||||
from govoplan_release.publisher import publish_catalog_candidate # noqa: E402
|
from govoplan_release.publisher import ( # noqa: E402
|
||||||
|
FrozenPublicationRemote,
|
||||||
|
bind_publication_remote,
|
||||||
|
commit_publication_tree,
|
||||||
|
publication_mutation_trust_issues,
|
||||||
|
publish_catalog_candidate,
|
||||||
|
remote_publication_identity,
|
||||||
|
run_checked,
|
||||||
|
verify_committed_publication,
|
||||||
|
verify_remote_branch_head,
|
||||||
|
verify_remote_publication,
|
||||||
|
_git_bytes,
|
||||||
|
_sanitized_git_environment,
|
||||||
|
_seal_git_metadata_file,
|
||||||
|
_trusted_npm_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReleaseCatalogPublicationTests(unittest.TestCase):
|
class ReleaseCatalogPublicationTests(unittest.TestCase):
|
||||||
|
def test_publication_git_writes_ignore_permissive_operator_umask(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
repository = Path(temp_dir) / "website"
|
||||||
|
repository.mkdir()
|
||||||
|
self._git(repository, "init", "--quiet")
|
||||||
|
payload_number = 0
|
||||||
|
while True:
|
||||||
|
payload = f"private publication object {payload_number}\n".encode()
|
||||||
|
header = f"blob {len(payload)}\0".encode()
|
||||||
|
expected_object = hashlib.sha1(
|
||||||
|
header + payload,
|
||||||
|
usedforsecurity=False,
|
||||||
|
).hexdigest()
|
||||||
|
object_parent = repository / ".git" / "objects" / expected_object[:2]
|
||||||
|
if not object_parent.exists():
|
||||||
|
break
|
||||||
|
payload_number += 1
|
||||||
|
|
||||||
|
previous_umask = os.umask(0o002)
|
||||||
|
try:
|
||||||
|
object_id = (
|
||||||
|
_git_bytes(
|
||||||
|
repository,
|
||||||
|
"hash-object",
|
||||||
|
"-w",
|
||||||
|
"--stdin",
|
||||||
|
input_bytes=payload,
|
||||||
|
)
|
||||||
|
.decode("ascii")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.umask(previous_umask)
|
||||||
|
|
||||||
|
object_path = object_parent / expected_object[2:]
|
||||||
|
self.assertEqual(expected_object, object_id)
|
||||||
|
self.assertEqual(os.geteuid(), object_parent.stat().st_uid)
|
||||||
|
self.assertEqual(0o700, object_parent.stat().st_mode & 0o777)
|
||||||
|
self.assertEqual(os.geteuid(), object_path.stat().st_uid)
|
||||||
|
self.assertEqual(0o400, object_path.stat().st_mode & 0o777)
|
||||||
|
|
||||||
|
def test_publication_git_metadata_is_sealed_after_git_writes(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
metadata = Path(temp_dir) / "index"
|
||||||
|
metadata.write_bytes(b"index")
|
||||||
|
metadata.chmod(0o664)
|
||||||
|
|
||||||
|
_seal_git_metadata_file(metadata, label="test index")
|
||||||
|
|
||||||
|
self.assertEqual(0o600, metadata.stat().st_mode & 0o777)
|
||||||
|
|
||||||
|
def test_publication_git_identity_is_fixed_and_non_personal(self) -> None:
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GIT_AUTHOR_NAME": "Attacker",
|
||||||
|
"GIT_AUTHOR_EMAIL": "attacker@example.test",
|
||||||
|
"GIT_COMMITTER_NAME": "Attacker",
|
||||||
|
"GIT_COMMITTER_EMAIL": "attacker@example.test",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
environment = _sanitized_git_environment()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
"GovOPlaN Release Automation",
|
||||||
|
environment["GIT_AUTHOR_NAME"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"release@govoplan.invalid",
|
||||||
|
environment["GIT_AUTHOR_EMAIL"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
environment["GIT_AUTHOR_NAME"],
|
||||||
|
environment["GIT_COMMITTER_NAME"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
environment["GIT_AUTHOR_EMAIL"],
|
||||||
|
environment["GIT_COMMITTER_EMAIL"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_build_command_uses_its_pinned_node_directory(self) -> None:
|
||||||
|
completed = subprocess.CompletedProcess(
|
||||||
|
["/trusted/node/bin/npm"],
|
||||||
|
0,
|
||||||
|
stdout=b"",
|
||||||
|
stderr=b"",
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.publisher.subprocess.run",
|
||||||
|
return_value=completed,
|
||||||
|
) as runner:
|
||||||
|
run_checked(
|
||||||
|
["/trusted/node/bin/npm", "run", "build"],
|
||||||
|
cwd=Path("/trusted/website"),
|
||||||
|
)
|
||||||
|
|
||||||
|
environment = runner.call_args.kwargs["env"]
|
||||||
|
self.assertEqual(
|
||||||
|
"/trusted/node/bin:/usr/bin:/bin",
|
||||||
|
environment["PATH"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_npm_command_requires_trusted_npm_and_sibling_node(self) -> None:
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_release.publisher.shutil.which",
|
||||||
|
return_value="/trusted/node/bin/npm",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.publisher._trusted_runtime_executable_issue",
|
||||||
|
side_effect=(None, None),
|
||||||
|
) as trust_check,
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
"/trusted/node/bin/npm",
|
||||||
|
_trusted_npm_command("npm"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Path("/trusted/node/bin/npm"), trust_check.call_args_list[0].args[0])
|
||||||
|
self.assertEqual(Path("/trusted/node/bin/node"), trust_check.call_args_list[1].args[0])
|
||||||
|
|
||||||
|
def test_npm_command_rejects_caller_relative_path(self) -> None:
|
||||||
|
with (
|
||||||
|
patch("govoplan_release.publisher.shutil.which") as which,
|
||||||
|
self.assertRaisesRegex(RuntimeError, "absolute path or the bare name"),
|
||||||
|
):
|
||||||
|
_trusted_npm_command("./npm")
|
||||||
|
which.assert_not_called()
|
||||||
|
|
||||||
|
def test_committed_publication_must_match_validated_blobs_exactly(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
web_root = Path(tmp) / "website"
|
||||||
|
module_root = web_root / "public" / "catalogs" / "v1" / "modules"
|
||||||
|
module_root.mkdir(parents=True)
|
||||||
|
catalog = (
|
||||||
|
web_root / "public" / "catalogs" / "v1" / "channels" / "stable.json"
|
||||||
|
)
|
||||||
|
keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
||||||
|
catalog.parent.mkdir(parents=True)
|
||||||
|
expected = {
|
||||||
|
catalog.relative_to(web_root).as_posix(): b'{"catalog":true}\n',
|
||||||
|
keyring.relative_to(web_root).as_posix(): b'{"keys":[]}\n',
|
||||||
|
(module_root / "index.json")
|
||||||
|
.relative_to(web_root)
|
||||||
|
.as_posix(): b'{"modules":[]}\n',
|
||||||
|
}
|
||||||
|
for relative, encoded in expected.items():
|
||||||
|
target = web_root / relative
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(encoded)
|
||||||
|
self._git(web_root, "init", "--quiet")
|
||||||
|
self._git(web_root, "config", "user.email", "test@example.test")
|
||||||
|
self._git(web_root, "config", "user.name", "Test")
|
||||||
|
self._git(web_root, "add", ".")
|
||||||
|
self._git(web_root, "commit", "--quiet", "-m", "publication")
|
||||||
|
commit_sha = self._git(web_root, "rev-parse", "HEAD").strip()
|
||||||
|
|
||||||
|
verify_committed_publication(
|
||||||
|
web_root=web_root,
|
||||||
|
commit_sha=commit_sha,
|
||||||
|
expected_blobs=expected,
|
||||||
|
module_root=module_root,
|
||||||
|
)
|
||||||
|
changed = dict(expected)
|
||||||
|
changed[catalog.relative_to(web_root).as_posix()] = b'{"catalog":false}\n'
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "differs"):
|
||||||
|
verify_committed_publication(
|
||||||
|
web_root=web_root,
|
||||||
|
commit_sha=commit_sha,
|
||||||
|
expected_blobs=changed,
|
||||||
|
module_root=module_root,
|
||||||
|
)
|
||||||
|
catalog_path = catalog.relative_to(web_root).as_posix()
|
||||||
|
self._git(web_root, "update-index", "--chmod=+x", catalog_path)
|
||||||
|
self._git(web_root, "commit", "--quiet", "-m", "unsafe mode")
|
||||||
|
executable_commit = self._git(web_root, "rev-parse", "HEAD").strip()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "regular blob"):
|
||||||
|
verify_committed_publication(
|
||||||
|
web_root=web_root,
|
||||||
|
commit_sha=executable_commit,
|
||||||
|
expected_blobs=expected,
|
||||||
|
module_root=module_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_private_index_commit_has_one_parent_and_only_computed_delta(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
web_root = root / "website"
|
||||||
|
module_root = web_root / "public" / "catalogs" / "v1" / "modules"
|
||||||
|
channel = (
|
||||||
|
web_root / "public" / "catalogs" / "v1" / "channels" / "stable.json"
|
||||||
|
)
|
||||||
|
keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
||||||
|
old_module = module_root / "obsolete.json"
|
||||||
|
unrelated = web_root / "unrelated.txt"
|
||||||
|
for path, encoded in (
|
||||||
|
(channel, b'{"old":true}\n'),
|
||||||
|
(keyring, b'{"keys":[]}\n'),
|
||||||
|
(old_module, b'{"obsolete":true}\n'),
|
||||||
|
(unrelated, b"keep\n"),
|
||||||
|
):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(encoded)
|
||||||
|
self._git(web_root, "init", "--quiet")
|
||||||
|
self._git(web_root, "config", "user.email", "test@example.test")
|
||||||
|
self._git(web_root, "config", "user.name", "Test")
|
||||||
|
self._git(web_root, "add", ".")
|
||||||
|
self._git(web_root, "commit", "--quiet", "-m", "base")
|
||||||
|
frozen_head = self._git(web_root, "rev-parse", "HEAD").strip()
|
||||||
|
branch = self._git(web_root, "branch", "--show-current").strip()
|
||||||
|
|
||||||
|
malicious = web_root / "not-in-publication.txt"
|
||||||
|
malicious.write_text("staged but excluded\n", encoding="utf-8")
|
||||||
|
self._git(web_root, "add", malicious.name)
|
||||||
|
marker = root / "reference-hook-ran"
|
||||||
|
hook = web_root / ".git" / "hooks" / "reference-transaction"
|
||||||
|
hook.write_text(f"#!/bin/sh\ntouch {marker}\n", encoding="utf-8")
|
||||||
|
hook.chmod(0o755)
|
||||||
|
expected = {
|
||||||
|
channel.relative_to(web_root).as_posix(): b'{"new":true}\n',
|
||||||
|
keyring.relative_to(web_root).as_posix(): b'{"keys":["release"]}\n',
|
||||||
|
(module_root / "index.json")
|
||||||
|
.relative_to(web_root)
|
||||||
|
.as_posix(): b'{"modules":[]}\n',
|
||||||
|
}
|
||||||
|
redirected = root / "attacker-index"
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GIT_DIR": str(root / "attacker-git-dir"),
|
||||||
|
"GIT_INDEX_FILE": str(redirected),
|
||||||
|
"GIT_OBJECT_DIRECTORY": str(root / "attacker-objects"),
|
||||||
|
"GIT_CONFIG_GLOBAL": str(root / "attacker-config"),
|
||||||
|
},
|
||||||
|
):
|
||||||
|
commit_sha = commit_publication_tree(
|
||||||
|
web_root=web_root,
|
||||||
|
frozen_head=frozen_head,
|
||||||
|
branch=branch,
|
||||||
|
expected_blobs=expected,
|
||||||
|
module_root=module_root,
|
||||||
|
message="Exact publication",
|
||||||
|
)
|
||||||
|
|
||||||
|
parents = self._git(
|
||||||
|
web_root, "rev-list", "--parents", "-n", "1", commit_sha
|
||||||
|
).split()
|
||||||
|
changed = set(
|
||||||
|
filter(
|
||||||
|
None,
|
||||||
|
self._git(
|
||||||
|
web_root,
|
||||||
|
"diff-tree",
|
||||||
|
"--no-commit-id",
|
||||||
|
"--name-only",
|
||||||
|
"-r",
|
||||||
|
frozen_head,
|
||||||
|
commit_sha,
|
||||||
|
).splitlines(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
hook_ran = marker.exists()
|
||||||
|
inherited_index_used = redirected.exists()
|
||||||
|
commit_paths = self._git(
|
||||||
|
web_root, "ls-tree", "-r", "--name-only", commit_sha
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([commit_sha, frozen_head], parents)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"public/catalogs/v1/channels/stable.json",
|
||||||
|
"public/catalogs/v1/keyring.json",
|
||||||
|
"public/catalogs/v1/modules/index.json",
|
||||||
|
"public/catalogs/v1/modules/obsolete.json",
|
||||||
|
},
|
||||||
|
changed,
|
||||||
|
)
|
||||||
|
self.assertFalse(hook_ran)
|
||||||
|
self.assertFalse(inherited_index_used)
|
||||||
|
self.assertNotIn("not-in-publication.txt", commit_paths)
|
||||||
|
|
||||||
|
def test_remote_binding_and_annotated_publication_identity_are_exact(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
remote_root = root / "website.git"
|
||||||
|
remote_root.mkdir()
|
||||||
|
self._git(remote_root, "init", "--bare", "--quiet")
|
||||||
|
web_root = root / "website"
|
||||||
|
web_root.mkdir()
|
||||||
|
self._git(web_root, "init", "--quiet")
|
||||||
|
self._git(web_root, "config", "user.email", "test@example.test")
|
||||||
|
self._git(web_root, "config", "user.name", "Test")
|
||||||
|
self._git(web_root, "branch", "-M", "main")
|
||||||
|
web_root.joinpath("base.txt").write_text("base\n", encoding="utf-8")
|
||||||
|
self._git(web_root, "add", ".")
|
||||||
|
self._git(web_root, "commit", "--quiet", "-m", "base")
|
||||||
|
self._git(web_root, "remote", "add", "origin", str(remote_root))
|
||||||
|
self._git(web_root, "push", "--quiet", "-u", "origin", "main")
|
||||||
|
base_commit = self._git(web_root, "rev-parse", "HEAD").strip()
|
||||||
|
|
||||||
|
frozen = bind_publication_remote(
|
||||||
|
web_root=web_root,
|
||||||
|
remote="origin",
|
||||||
|
registered_remote=str(remote_root),
|
||||||
|
)
|
||||||
|
self.assertIsInstance(frozen, FrozenPublicationRemote)
|
||||||
|
verify_remote_branch_head(
|
||||||
|
web_root=web_root,
|
||||||
|
remote_url=frozen.url,
|
||||||
|
branch="main",
|
||||||
|
expected_commit=base_commit,
|
||||||
|
)
|
||||||
|
web_root.joinpath("catalog.json").write_text("{}\n", encoding="utf-8")
|
||||||
|
self._git(web_root, "add", "catalog.json")
|
||||||
|
self._git(web_root, "commit", "--quiet", "-m", "publication")
|
||||||
|
commit_sha = self._git(web_root, "rev-parse", "HEAD").strip()
|
||||||
|
tag_name = "catalog-test"
|
||||||
|
self._git(web_root, "tag", "-a", tag_name, "-m", "publication")
|
||||||
|
tag_object = self._git(
|
||||||
|
web_root, "rev-parse", f"refs/tags/{tag_name}"
|
||||||
|
).strip()
|
||||||
|
self._git(
|
||||||
|
web_root,
|
||||||
|
"push",
|
||||||
|
"--quiet",
|
||||||
|
"--atomic",
|
||||||
|
"origin",
|
||||||
|
f"{commit_sha}:refs/heads/main",
|
||||||
|
f"{tag_object}:refs/tags/{tag_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
identity = remote_publication_identity(
|
||||||
|
web_root=web_root,
|
||||||
|
remote_url=frozen.url,
|
||||||
|
branch="main",
|
||||||
|
tag_name=tag_name,
|
||||||
|
)
|
||||||
|
verified = verify_remote_publication(
|
||||||
|
web_root=web_root,
|
||||||
|
remote_url=frozen.url,
|
||||||
|
branch="main",
|
||||||
|
tag_name=tag_name,
|
||||||
|
expected_commit=commit_sha,
|
||||||
|
expected_tag_object=tag_object,
|
||||||
|
)
|
||||||
|
self._git(
|
||||||
|
web_root,
|
||||||
|
"remote",
|
||||||
|
"set-url",
|
||||||
|
"--add",
|
||||||
|
"--push",
|
||||||
|
"origin",
|
||||||
|
str(root / "other.git"),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "do not match"):
|
||||||
|
bind_publication_remote(
|
||||||
|
web_root=web_root,
|
||||||
|
remote="origin",
|
||||||
|
registered_remote=str(remote_root),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(identity, verified)
|
||||||
|
self.assertEqual(commit_sha, identity["publication_commit_sha"])
|
||||||
|
self.assertEqual(tag_object, identity["publication_tag_object_sha"])
|
||||||
|
self.assertEqual(commit_sha, identity["publication_tag_commit_sha"])
|
||||||
|
|
||||||
|
def test_mutation_rejects_writable_website_and_git_configuration(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
web_root = root / "website"
|
||||||
|
web_root.mkdir()
|
||||||
|
self._git(web_root, "init", "--quiet")
|
||||||
|
candidate = self._candidate(root, key="trusted-key")
|
||||||
|
target_root = web_root / "public" / "catalogs" / "v1"
|
||||||
|
target_catalog = target_root / "channels" / "stable.json"
|
||||||
|
target_keyring = target_root / "keyring.json"
|
||||||
|
target_keyring.parent.mkdir(parents=True)
|
||||||
|
target_keyring.write_text("{}\n", encoding="utf-8")
|
||||||
|
|
||||||
|
web_root.chmod(0o777)
|
||||||
|
root_issues = publication_mutation_trust_issues(
|
||||||
|
web_root=web_root,
|
||||||
|
candidate_catalog=candidate / "channels" / "stable.json",
|
||||||
|
candidate_keyring=candidate / "keyring.json",
|
||||||
|
target_catalog=target_catalog,
|
||||||
|
target_keyring=target_keyring,
|
||||||
|
target_modules=target_root / "modules",
|
||||||
|
)
|
||||||
|
web_root.chmod(0o755)
|
||||||
|
config = web_root / ".git" / "config"
|
||||||
|
config.chmod(0o666)
|
||||||
|
config_issues = publication_mutation_trust_issues(
|
||||||
|
web_root=web_root,
|
||||||
|
candidate_catalog=candidate / "channels" / "stable.json",
|
||||||
|
candidate_keyring=candidate / "keyring.json",
|
||||||
|
target_catalog=target_catalog,
|
||||||
|
target_keyring=target_keyring,
|
||||||
|
target_modules=target_root / "modules",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("website root is writable by another user", root_issues)
|
||||||
|
self.assertIn("website Git config is writable by another user", config_issues)
|
||||||
|
|
||||||
|
def test_push_returns_only_independently_verified_publication_receipt(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
candidate = self._candidate(root, key="trusted-key")
|
||||||
|
catalog_path = candidate / "channels" / "stable.json"
|
||||||
|
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||||
|
catalog["sequence"] = 7
|
||||||
|
catalog["core_release"]["python_package"] = "govoplan-core"
|
||||||
|
catalog["release"] = {
|
||||||
|
"selected_units": [
|
||||||
|
{
|
||||||
|
"repo": "govoplan-core",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"commit_sha": "a" * 40,
|
||||||
|
"tag_object_sha": "b" * 40,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"artifact_kind": "python-wheel",
|
||||||
|
"package_name": "govoplan-core",
|
||||||
|
"package_version": "1.2.3",
|
||||||
|
"archive_sha256": "c" * 64,
|
||||||
|
"archive_size": 100,
|
||||||
|
"installed_payload": {
|
||||||
|
"algorithm": "govoplan-wheel-declared-payload-v1",
|
||||||
|
"sha256": "d" * 64,
|
||||||
|
"file_count": 1,
|
||||||
|
},
|
||||||
|
"requires_installer_receipt": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
catalog["signatures"] = [{}]
|
||||||
|
catalog_path.write_text(json.dumps(catalog), encoding="utf-8")
|
||||||
|
|
||||||
|
remote_root = root / "website.git"
|
||||||
|
remote_root.mkdir()
|
||||||
|
self._git(remote_root, "init", "--bare", "--quiet")
|
||||||
|
web_root = root / "website"
|
||||||
|
target_keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
||||||
|
target_keyring.parent.mkdir(parents=True)
|
||||||
|
target_keyring.write_text(json.dumps(self._keyring("trusted-key")))
|
||||||
|
self._git(web_root, "init", "--quiet")
|
||||||
|
self._git(web_root, "config", "user.email", "test@example.test")
|
||||||
|
self._git(web_root, "config", "user.name", "Test")
|
||||||
|
self._git(web_root, "branch", "-M", "main")
|
||||||
|
self._git(web_root, "remote", "add", "origin", str(remote_root))
|
||||||
|
self._git(web_root, "add", ".")
|
||||||
|
self._git(web_root, "commit", "--quiet", "-m", "base")
|
||||||
|
self._git(web_root, "push", "--quiet", "-u", "origin", "main")
|
||||||
|
frozen_head = self._git(web_root, "rev-parse", "HEAD").strip()
|
||||||
|
frozen_remote = bind_publication_remote(
|
||||||
|
web_root=web_root,
|
||||||
|
remote="origin",
|
||||||
|
registered_remote=str(remote_root),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_release.publisher.validate_module_package_catalog",
|
||||||
|
return_value={"valid": True, "warnings": [], "error": None},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.publisher.source_tag_provenance_issues",
|
||||||
|
return_value=(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.publisher.publication_runtime_trust_issues",
|
||||||
|
return_value=(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.publisher.registered_website_remote",
|
||||||
|
return_value=str(remote_root),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = publish_catalog_candidate(
|
||||||
|
candidate_dir=candidate,
|
||||||
|
web_root=web_root,
|
||||||
|
workspace_root=root,
|
||||||
|
apply=True,
|
||||||
|
push=True,
|
||||||
|
branch="main",
|
||||||
|
tag_name="catalog-test",
|
||||||
|
expected_website_head=frozen_head,
|
||||||
|
expected_website_branch="main",
|
||||||
|
expected_remote_sha256=frozen_remote.sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
remote_identity = remote_publication_identity(
|
||||||
|
web_root=web_root,
|
||||||
|
remote_url=str(remote_root),
|
||||||
|
branch="main",
|
||||||
|
tag_name="catalog-test",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("published", result.status)
|
||||||
|
self.assertEqual("origin", result.remote)
|
||||||
|
self.assertEqual(
|
||||||
|
remote_identity["publication_commit_sha"], result.publication_commit_sha
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
remote_identity["publication_tag_object_sha"],
|
||||||
|
result.publication_tag_object_sha,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
remote_identity["publication_tag_commit_sha"],
|
||||||
|
result.publication_tag_commit_sha,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_apply_writes_validated_objects_even_if_candidate_path_changes(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
candidate = self._candidate(root, key="trusted-key")
|
||||||
|
catalog_path = candidate / "channels" / "stable.json"
|
||||||
|
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||||
|
catalog.update({"channel": "stable", "sequence": 1})
|
||||||
|
catalog["core_release"]["python_package"] = "govoplan-core"
|
||||||
|
catalog["release"] = {
|
||||||
|
"selected_units": [
|
||||||
|
{
|
||||||
|
"repo": "govoplan-core",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"commit_sha": "a" * 40,
|
||||||
|
"tag_object_sha": "b" * 40,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"artifact_kind": "python-wheel",
|
||||||
|
"package_name": "govoplan-core",
|
||||||
|
"package_version": "1.2.3",
|
||||||
|
"archive_sha256": "c" * 64,
|
||||||
|
"archive_size": 100,
|
||||||
|
"installed_payload": {
|
||||||
|
"algorithm": "govoplan-wheel-declared-payload-v1",
|
||||||
|
"sha256": "d" * 64,
|
||||||
|
"file_count": 1,
|
||||||
|
},
|
||||||
|
"requires_installer_receipt": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
catalog["signatures"] = [{}]
|
||||||
|
catalog_path.write_text(json.dumps(catalog), encoding="utf-8")
|
||||||
|
web_root = root / "website"
|
||||||
|
target_keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
||||||
|
target_keyring.parent.mkdir(parents=True)
|
||||||
|
target_keyring.write_text(json.dumps(self._keyring("trusted-key")))
|
||||||
|
registered_remote = "ssh://example.test/release/website.git"
|
||||||
|
self._git(web_root, "init", "--quiet")
|
||||||
|
self._git(web_root, "config", "user.email", "test@example.test")
|
||||||
|
self._git(web_root, "config", "user.name", "Test")
|
||||||
|
self._git(web_root, "remote", "add", "origin", registered_remote)
|
||||||
|
self._git(web_root, "add", ".")
|
||||||
|
self._git(web_root, "commit", "--quiet", "-m", "base")
|
||||||
|
|
||||||
|
def validate_and_swap(*args, **kwargs):
|
||||||
|
del args, kwargs
|
||||||
|
catalog_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"channel": "stable",
|
||||||
|
"sequence": 999,
|
||||||
|
"malicious": True,
|
||||||
|
"signatures": [{}],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return {"valid": True, "warnings": [], "error": None}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_release.publisher.validate_module_package_catalog",
|
||||||
|
side_effect=validate_and_swap,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.publisher.source_tag_provenance_issues",
|
||||||
|
return_value=(),
|
||||||
|
),
|
||||||
|
patch("govoplan_release.publisher.website_dirty", return_value=False),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.publisher.publication_runtime_trust_issues",
|
||||||
|
return_value=(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.publisher.registered_website_remote",
|
||||||
|
return_value=registered_remote,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = publish_catalog_candidate(
|
||||||
|
candidate_dir=candidate,
|
||||||
|
web_root=web_root,
|
||||||
|
workspace_root=root,
|
||||||
|
apply=True,
|
||||||
|
allow_dirty_website=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
published = json.loads(
|
||||||
|
(
|
||||||
|
web_root / "public" / "catalogs" / "v1" / "channels" / "stable.json"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("applied", result.status)
|
||||||
|
self.assertEqual(1, published["sequence"])
|
||||||
|
self.assertNotIn("malicious", published)
|
||||||
|
|
||||||
|
def test_apply_blocks_selected_python_release_without_built_identity(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
candidate = self._candidate(root, key="trusted-key")
|
||||||
|
catalog_path = candidate / "channels" / "stable.json"
|
||||||
|
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||||
|
catalog["core_release"]["python_package"] = "govoplan-core"
|
||||||
|
catalog["release"] = {
|
||||||
|
"selected_units": [
|
||||||
|
{
|
||||||
|
"repo": "govoplan-core",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"commit_sha": "a" * 40,
|
||||||
|
"tag_object_sha": "b" * 40,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
catalog_path.write_text(json.dumps(catalog), encoding="utf-8")
|
||||||
|
web_root = root / "website"
|
||||||
|
target_keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
||||||
|
target_keyring.parent.mkdir(parents=True)
|
||||||
|
target_keyring.write_text(json.dumps(self._keyring("trusted-key")))
|
||||||
|
(web_root / ".git").mkdir()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_release.publisher.validate_module_package_catalog",
|
||||||
|
return_value={"valid": True, "warnings": [], "error": None},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.publisher.source_tag_provenance_issues",
|
||||||
|
return_value=(),
|
||||||
|
),
|
||||||
|
patch("govoplan_release.publisher.website_dirty", return_value=False),
|
||||||
|
):
|
||||||
|
result = publish_catalog_candidate(
|
||||||
|
candidate_dir=candidate,
|
||||||
|
web_root=web_root,
|
||||||
|
workspace_root=root,
|
||||||
|
apply=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", result.status)
|
||||||
|
self.assertIn(
|
||||||
|
"selected Python repository govoplan-core has no built artifact identity",
|
||||||
|
" ".join(result.notes),
|
||||||
|
)
|
||||||
|
|
||||||
def test_publication_requires_an_existing_trust_anchor(self) -> None:
|
def test_publication_requires_an_existing_trust_anchor(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
@@ -79,7 +763,9 @@ class ReleaseCatalogPublicationTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
candidate.joinpath("keyring.json").write_text(json.dumps(ReleaseCatalogPublicationTests._keyring(key)))
|
candidate.joinpath("keyring.json").write_text(
|
||||||
|
json.dumps(ReleaseCatalogPublicationTests._keyring(key))
|
||||||
|
)
|
||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -94,6 +780,17 @@ class ReleaseCatalogPublicationTests(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _git(root: Path, *args: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=root,
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -36,6 +37,30 @@ class ReleaseConsoleSecurityTests(unittest.TestCase):
|
|||||||
self.assertIn("window.location.hash.slice(1)", webui)
|
self.assertIn("window.location.hash.slice(1)", webui)
|
||||||
self.assertIn("history.replaceState", webui)
|
self.assertIn("history.replaceState", webui)
|
||||||
|
|
||||||
|
def test_tokenless_embedding_is_read_only(self) -> None:
|
||||||
|
with TestClient(create_app(workspace_root=META_ROOT)) as client:
|
||||||
|
read_response = client.get("/api/health")
|
||||||
|
write_response = client.post("/api/selective-plan", json={})
|
||||||
|
|
||||||
|
self.assertEqual(200, read_response.status_code)
|
||||||
|
self.assertEqual(403, write_response.status_code)
|
||||||
|
self.assertIn("read-only", write_response.json()["detail"])
|
||||||
|
|
||||||
|
def test_launcher_rejects_non_loopback_and_tokenless_modes(self) -> None:
|
||||||
|
launcher = RELEASE_ROOT / "release-console.py"
|
||||||
|
for arguments in (("--host", "0.0.0.0"), ("--no-token",)):
|
||||||
|
with self.subTest(arguments=arguments):
|
||||||
|
result = subprocess.run(
|
||||||
|
(sys.executable, str(launcher), *arguments),
|
||||||
|
cwd=META_ROOT,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
self.assertEqual(2, result.returncode)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ class ReleaseEntrypointGateTests(unittest.TestCase):
|
|||||||
self.assertIn("trusted_keys_from_keyring(\n target_keyring_payload", publisher)
|
self.assertIn("trusted_keys_from_keyring(\n target_keyring_payload", publisher)
|
||||||
self.assertIn("trusted_keys=publication_trust", publisher)
|
self.assertIn("trusted_keys=publication_trust", publisher)
|
||||||
self.assertNotIn("trusted_keys=candidate_keys", publisher)
|
self.assertNotIn("trusted_keys=candidate_keys", publisher)
|
||||||
self.assertIn("write_module_directory(", publisher)
|
self.assertIn("module_directory_payloads(", publisher)
|
||||||
|
self.assertIn("verify_committed_publication(", publisher)
|
||||||
self.assertNotIn("shutil.copytree(candidate_modules", publisher)
|
self.assertNotIn("shutil.copytree(candidate_modules", publisher)
|
||||||
|
|
||||||
def test_release_integration_honors_independent_module_versions(self) -> None:
|
def test_release_integration_honors_independent_module_versions(self) -> None:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
|
||||||
|
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release import git_state # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseGitStateTests(unittest.TestCase):
|
||||||
|
def test_git_trusts_only_the_resolved_repository_for_each_command(self) -> None:
|
||||||
|
repository = Path("/workspace/../workspace/govoplan-core")
|
||||||
|
completed = subprocess.CompletedProcess([], 0, "", "")
|
||||||
|
|
||||||
|
with patch.object(git_state.subprocess, "run", return_value=completed) as run:
|
||||||
|
result = git_state.git(repository, "status", "--porcelain")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0)
|
||||||
|
command = run.call_args.args[0]
|
||||||
|
self.assertIn(f"safe.directory={repository.resolve()}", command)
|
||||||
|
self.assertNotIn("safe.directory=*", command)
|
||||||
|
self.assertEqual(run.call_args.kwargs["cwd"], repository)
|
||||||
|
self.assertEqual(
|
||||||
|
run.call_args.kwargs["env"]["GIT_CONFIG_GLOBAL"],
|
||||||
|
"/dev/null",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from tools.checks.release_integration import (
|
from tools.checks.release_integration import (
|
||||||
SOURCE_COUPLED_CORE_TESTS,
|
SOURCE_COUPLED_CORE_TESTS,
|
||||||
@@ -10,6 +12,7 @@ from tools.checks.release_integration import (
|
|||||||
artifact_contract_issues,
|
artifact_contract_issues,
|
||||||
filter_test_suite,
|
filter_test_suite,
|
||||||
release_package_names,
|
release_package_names,
|
||||||
|
run_module_release_tests,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -32,9 +35,9 @@ class ReleaseIntegrationTests(unittest.TestCase):
|
|||||||
"\n".join(
|
"\n".join(
|
||||||
(
|
(
|
||||||
"../govoplan-core[server]",
|
"../govoplan-core[server]",
|
||||||
"govoplan-idm @ git+ssh://git@example.test/add-ideas/govoplan-idm.git@v0.1.8",
|
"govoplan-idm @ git+ssh://git@example.test/GovOPlaN/govoplan-idm.git@v0.1.8",
|
||||||
"govoplan-campaign @ git+ssh://git@example.test/add-ideas/govoplan-campaign.git@v0.1.8",
|
"govoplan-campaign @ git+ssh://git@example.test/GovOPlaN/govoplan-campaign.git@v0.1.8",
|
||||||
"govoplan-idm @ git+ssh://git@example.test/add-ideas/govoplan-idm.git@v0.1.8",
|
"govoplan-idm @ git+ssh://git@example.test/GovOPlaN/govoplan-idm.git@v0.1.8",
|
||||||
"other-package==1.0",
|
"other-package==1.0",
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -77,7 +80,7 @@ class ReleaseIntegrationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(set(removed), set(SOURCE_COUPLED_CORE_TESTS))
|
self.assertEqual(set(removed), set(SOURCE_COUPLED_CORE_TESTS))
|
||||||
self.assertEqual([test.id() for test in filtered], [retained_id])
|
self.assertEqual([test.id() for test in filtered], [retained_id])
|
||||||
self.assertEqual(len(SOURCE_COUPLED_CORE_TESTS), 4)
|
self.assertEqual(len(SOURCE_COUPLED_CORE_TESTS), 5)
|
||||||
|
|
||||||
def test_release_entrypoint_uses_artifact_checks_and_filtered_core_suite(self) -> None:
|
def test_release_entrypoint_uses_artifact_checks_and_filtered_core_suite(self) -> None:
|
||||||
meta_root = Path(__file__).resolve().parents[1]
|
meta_root = Path(__file__).resolve().parents[1]
|
||||||
@@ -89,8 +92,76 @@ class ReleaseIntegrationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertLess(artifact_check, core_tests)
|
self.assertLess(artifact_check, core_tests)
|
||||||
self.assertLess(core_tests, module_tests)
|
self.assertLess(core_tests, module_tests)
|
||||||
|
self.assertNotIn("unittest discover", script)
|
||||||
self.assertNotIn('"$PYTHON" -m unittest \\\n tests.test_module_system', script)
|
self.assertNotIn('"$PYTHON" -m unittest \\\n tests.test_module_system', script)
|
||||||
|
|
||||||
|
def test_tagged_module_runner_executes_pytest_functions_from_module_root(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-release-module-tests-") as temp_dir:
|
||||||
|
module_root = Path(temp_dir)
|
||||||
|
tests_root = module_root / "tests"
|
||||||
|
tests_root.mkdir()
|
||||||
|
marker = module_root / "pytest-function-ran"
|
||||||
|
(tests_root / "test_probe.py").write_text(
|
||||||
|
"""from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def test_pytest_style_function():
|
||||||
|
expected_root = Path(os.environ[\"GOVOPLAN_TEST_MODULE_ROOT\"])
|
||||||
|
assert Path.cwd() == expected_root
|
||||||
|
Path(os.environ[\"GOVOPLAN_TEST_MARKER\"]).write_text(\"ran\", encoding=\"utf-8\")
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GOVOPLAN_TEST_MODULE_ROOT": str(module_root),
|
||||||
|
"GOVOPLAN_TEST_MARKER": str(marker),
|
||||||
|
},
|
||||||
|
):
|
||||||
|
status = run_module_release_tests(module_root)
|
||||||
|
|
||||||
|
self.assertEqual(status, 0)
|
||||||
|
self.assertEqual(marker.read_text(encoding="utf-8"), "ran")
|
||||||
|
|
||||||
|
def test_tagged_module_runner_skips_missing_test_tree(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-release-module-tests-") as temp_dir:
|
||||||
|
self.assertEqual(run_module_release_tests(Path(temp_dir)), 0)
|
||||||
|
|
||||||
|
def test_module_matrix_uses_artifact_aware_core_suite(self) -> None:
|
||||||
|
meta_root = Path(__file__).resolve().parents[1]
|
||||||
|
script = (meta_root / "tools" / "checks" / "check-module-matrix.sh").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
artifact_check = script.index('"$META_ROOT/tools/checks/release_integration.py" artifacts')
|
||||||
|
core_tests = script.index('"$META_ROOT/tools/checks/release_integration.py" core-tests')
|
||||||
|
|
||||||
|
self.assertLess(artifact_check, core_tests)
|
||||||
|
self.assertIn('--requirements "$META_ROOT/requirements-release.txt"', script)
|
||||||
|
self.assertIn('--core-root "$ROOT"', script)
|
||||||
|
self.assertNotIn('"$PYTHON" -m unittest tests.test_module_system', script)
|
||||||
|
|
||||||
|
def test_module_matrix_installs_search_before_postgres_validation(self) -> None:
|
||||||
|
meta_root = Path(__file__).resolve().parents[1]
|
||||||
|
workflow = (meta_root / ".gitea" / "workflows" / "module-matrix.yml").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
install = workflow.index("pip install --no-deps ../govoplan-search")
|
||||||
|
validation = workflow.index("Validate Search against PostgreSQL")
|
||||||
|
|
||||||
|
self.assertLess(install, validation)
|
||||||
|
|
||||||
|
def test_release_workflow_installs_tagged_source_test_harness(self) -> None:
|
||||||
|
meta_root = Path(__file__).resolve().parents[1]
|
||||||
|
workflow = (meta_root / ".gitea" / "workflows" / "release-integration.yml").read_text(encoding="utf-8")
|
||||||
|
requirements = (meta_root / "requirements-release-tests.txt").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
install = workflow.index("pip install -r requirements-release-tests.txt")
|
||||||
|
checks = workflow.index("bash tools/checks/check-release-integration.sh")
|
||||||
|
|
||||||
|
self.assertLess(install, checks)
|
||||||
|
self.assertIn("pytest>=9.0.3,<10", requirements)
|
||||||
|
self.assertNotIn("requirements-release-tests.txt", (meta_root / "requirements-release.txt").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -47,6 +47,38 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
self.assertEqual(migration.depends_on, ("core",))
|
self.assertEqual(migration.depends_on, ("core",))
|
||||||
self.assertEqual(migration.branch_labels, ())
|
self.assertEqual(migration.branch_labels, ())
|
||||||
|
|
||||||
|
def test_development_wrapper_uses_release_revision_metadata(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
versions = root / "versions"
|
||||||
|
development = root / "dev_versions"
|
||||||
|
versions.mkdir()
|
||||||
|
development.mkdir()
|
||||||
|
release = versions / "1234_example.py"
|
||||||
|
release.write_text(
|
||||||
|
'revision = "1234"\n'
|
||||||
|
'down_revision = "base"\n'
|
||||||
|
'depends_on = "core"\n'
|
||||||
|
"branch_labels = None\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
wrapper = development / release.name
|
||||||
|
wrapper.write_text(
|
||||||
|
"revision = _migration.revision\n"
|
||||||
|
"down_revision = _migration.down_revision\n"
|
||||||
|
"depends_on = _migration.depends_on\n"
|
||||||
|
"branch_labels = _migration.branch_labels\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
migration = audit.parse_migration_file("govoplan-core", wrapper)
|
||||||
|
|
||||||
|
self.assertIsNotNone(migration)
|
||||||
|
self.assertEqual("1234", migration.revision)
|
||||||
|
self.assertEqual(("base",), migration.down_revisions)
|
||||||
|
self.assertEqual(("core",), migration.depends_on)
|
||||||
|
|
||||||
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
|
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
|
||||||
audit = load_audit_module()
|
audit = load_audit_module()
|
||||||
migrations = [
|
migrations = [
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.module_directory import ( # noqa: E402
|
||||||
|
module_directory_payloads,
|
||||||
|
safe_path_part,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseModuleDirectoryTests(unittest.TestCase):
|
||||||
|
def test_signed_catalog_timestamp_makes_derived_files_reproducible(self) -> None:
|
||||||
|
catalog = {
|
||||||
|
"generated_at": "2026-07-22T12:00:00Z",
|
||||||
|
"sequence": 7,
|
||||||
|
"modules": [],
|
||||||
|
}
|
||||||
|
first = module_directory_payloads(
|
||||||
|
catalog_payload=catalog,
|
||||||
|
keyring_payload={},
|
||||||
|
channel="stable",
|
||||||
|
)
|
||||||
|
second = module_directory_payloads(
|
||||||
|
catalog_payload=catalog,
|
||||||
|
keyring_payload={},
|
||||||
|
channel="stable",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertEqual(
|
||||||
|
"2026-07-22T12:00:00Z",
|
||||||
|
first[-1][1]["generated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dot_segments_and_noncanonical_ids_never_become_paths(self) -> None:
|
||||||
|
for value in (".", "..", "../escape", "/absolute", "module/child"):
|
||||||
|
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||||
|
safe_path_part(value)
|
||||||
|
|
||||||
|
for module_id in ("..", "../escape", "UPPER", "bad.id"):
|
||||||
|
with self.subTest(module_id=module_id), mock.patch(
|
||||||
|
"govoplan_release.module_directory.module_rows",
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"module_id": module_id,
|
||||||
|
"version": "1.2.3",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
module_directory_payloads(
|
||||||
|
catalog_payload={},
|
||||||
|
keyring_payload={},
|
||||||
|
channel="stable",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_noncanonical_version_and_channel_fail_before_paths(self) -> None:
|
||||||
|
with mock.patch(
|
||||||
|
"govoplan_release.module_directory.module_rows",
|
||||||
|
return_value=[{"module_id": "demo", "version": "../1.2.3"}],
|
||||||
|
):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
module_directory_payloads(
|
||||||
|
catalog_payload={}, keyring_payload={}, channel="stable"
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
module_directory_payloads(
|
||||||
|
catalog_payload={}, keyring_payload={}, channel="../stable"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -23,7 +23,7 @@ from govoplan_release.selective_planner import build_selective_release_plan # n
|
|||||||
|
|
||||||
|
|
||||||
class ReleasePlanGuidanceTests(unittest.TestCase):
|
class ReleasePlanGuidanceTests(unittest.TestCase):
|
||||||
def test_unprepared_target_has_structured_remediation_and_recommendation(
|
def test_unprepared_target_gets_bounded_version_and_commit_steps(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
@@ -40,20 +40,25 @@ class ReleasePlanGuidanceTests(unittest.TestCase):
|
|||||||
repo_versions={"govoplan-files": "1.2.4"},
|
repo_versions={"govoplan-files": "1.2.4"},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual("blocked", plan.status)
|
self.assertEqual("attention", plan.status)
|
||||||
self.assertFalse(plan.source_preflight_ready)
|
self.assertTrue(plan.source_preflight_ready)
|
||||||
finding = next(
|
self.assertFalse(
|
||||||
item
|
any(
|
||||||
|
item.code == "repository_version_alignment"
|
||||||
for item in plan.gate_findings
|
for item in plan.gate_findings
|
||||||
if item.code == "repository_version_alignment"
|
|
||||||
)
|
)
|
||||||
self.assertEqual("govoplan-files", finding.repo)
|
)
|
||||||
self.assertEqual("pyproject.toml", finding.source)
|
steps = {step.id: step for step in plan.dry_run_steps}
|
||||||
self.assertEqual("1.2.4", finding.expected)
|
self.assertEqual("planned", steps["govoplan-files:version"].status)
|
||||||
self.assertEqual("1.2.3", finding.actual)
|
self.assertEqual("planned", steps["govoplan-files:commit"].status)
|
||||||
self.assertIn("Regenerate lockfiles", finding.remediation)
|
self.assertIn("pyproject.toml", steps["govoplan-files:version"].detail)
|
||||||
self.assertEqual("resolve_release_gate", plan.recommended_action.id)
|
self.assertTrue(
|
||||||
self.assertEqual("govoplan-files", plan.recommended_action.repo)
|
any(
|
||||||
|
"version metadata will be updated" in warning
|
||||||
|
for warning in plan.units[0].warnings
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn("python-package", plan.units[0].capabilities)
|
||||||
|
|
||||||
def test_aligned_target_recommends_non_mutating_tag_preview(self) -> None:
|
def test_aligned_target_recommends_non_mutating_tag_preview(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
@@ -74,6 +79,91 @@ class ReleasePlanGuidanceTests(unittest.TestCase):
|
|||||||
self.assertEqual("preview_source_release", plan.recommended_action.id)
|
self.assertEqual("preview_source_release", plan.recommended_action.id)
|
||||||
self.assertIn("Preview Tag + Publish", plan.recommended_action.remediation)
|
self.assertIn("Preview Tag + Publish", plan.recommended_action.remediation)
|
||||||
|
|
||||||
|
def test_mixed_plan_tags_modules_before_core_and_aligns_before_push(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
for repo_name in ("govoplan-core", "govoplan-files"):
|
||||||
|
repo_path = workspace / repo_name
|
||||||
|
repo_path.mkdir()
|
||||||
|
(repo_path / "pyproject.toml").write_text(
|
||||||
|
f'[project]\nname = "{repo_name}"\nversion = "1.2.3"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
repositories = tuple(
|
||||||
|
RepositorySnapshot(
|
||||||
|
spec=RepositorySpec(
|
||||||
|
name=repo_name,
|
||||||
|
category="core" if repo_name == "govoplan-core" else "module",
|
||||||
|
subtype="platform" if repo_name == "govoplan-core" else "infrastructure",
|
||||||
|
remote=f"git@example.test:GovOPlaN/{repo_name}.git",
|
||||||
|
path=repo_name,
|
||||||
|
),
|
||||||
|
absolute_path=str(workspace / repo_name),
|
||||||
|
exists=True,
|
||||||
|
is_git=True,
|
||||||
|
has_head=True,
|
||||||
|
branch="main",
|
||||||
|
versions=VersionSnapshot(pyproject="1.2.3"),
|
||||||
|
local_target_tag_exists=False,
|
||||||
|
)
|
||||||
|
for repo_name in ("govoplan-core", "govoplan-files")
|
||||||
|
)
|
||||||
|
base = dashboard(workspace=workspace, version="1.2.3")
|
||||||
|
mixed = ReleaseDashboard(
|
||||||
|
generated_at=base.generated_at,
|
||||||
|
meta_root=base.meta_root,
|
||||||
|
workspace_root=base.workspace_root,
|
||||||
|
target_version=None,
|
||||||
|
target_tag=None,
|
||||||
|
online=False,
|
||||||
|
include_migrations=False,
|
||||||
|
summary=DashboardSummary(
|
||||||
|
repository_count=2,
|
||||||
|
missing_count=0,
|
||||||
|
dirty_count=0,
|
||||||
|
ahead_count=0,
|
||||||
|
behind_count=0,
|
||||||
|
no_head_count=0,
|
||||||
|
error_count=0,
|
||||||
|
safe_directory_count=0,
|
||||||
|
local_target_tag_missing_count=0,
|
||||||
|
status="ready",
|
||||||
|
),
|
||||||
|
repositories=repositories,
|
||||||
|
catalog=base.catalog,
|
||||||
|
)
|
||||||
|
plan = build_selective_release_plan(
|
||||||
|
mixed,
|
||||||
|
selected_repos=("govoplan-core", "govoplan-files"),
|
||||||
|
repo_versions={
|
||||||
|
"govoplan-core": "1.2.3",
|
||||||
|
"govoplan-files": "1.2.3",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["govoplan-files", "govoplan-core"],
|
||||||
|
[unit.repo for unit in plan.units],
|
||||||
|
)
|
||||||
|
step_ids = [step.id for step in plan.dry_run_steps]
|
||||||
|
self.assertLess(
|
||||||
|
step_ids.index("govoplan-files:tag"),
|
||||||
|
step_ids.index("govoplan-core:tag"),
|
||||||
|
)
|
||||||
|
self.assertLess(
|
||||||
|
step_ids.index("govoplan-core:tag"),
|
||||||
|
step_ids.index("release:alignment"),
|
||||||
|
)
|
||||||
|
self.assertLess(
|
||||||
|
step_ids.index("release:alignment"),
|
||||||
|
step_ids.index("govoplan-files:push"),
|
||||||
|
)
|
||||||
|
self.assertEqual("release:install-verify", step_ids[-1])
|
||||||
|
core = next(unit for unit in plan.units if unit.repo == "govoplan-core")
|
||||||
|
self.assertIn("core-release-bundle", core.capabilities)
|
||||||
|
|
||||||
def test_malformed_version_metadata_is_a_structured_blocker(self) -> None:
|
def test_malformed_version_metadata_is_a_structured_blocker(self) -> None:
|
||||||
cases = (
|
cases = (
|
||||||
("package.json", "{not-json\n", "JSONDecodeError"),
|
("package.json", "{not-json\n", "JSONDecodeError"),
|
||||||
@@ -149,6 +239,29 @@ class ReleasePlanGuidanceTests(unittest.TestCase):
|
|||||||
self.assertIn('pill("recommended next", recommendationKind)', webui)
|
self.assertIn('pill("recommended next", recommendationKind)', webui)
|
||||||
self.assertIn("<strong>Remediation:</strong>", webui)
|
self.assertIn("<strong>Remediation:</strong>", webui)
|
||||||
|
|
||||||
|
def test_webui_projects_release_state_into_a_guided_workflow(self) -> None:
|
||||||
|
webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
for phase in (
|
||||||
|
"Inspect",
|
||||||
|
"Targets",
|
||||||
|
"Validate",
|
||||||
|
"Source",
|
||||||
|
"Package",
|
||||||
|
"Publish",
|
||||||
|
"Verify",
|
||||||
|
):
|
||||||
|
self.assertIn(f'label: "{phase}"', webui)
|
||||||
|
self.assertIn("releaseWorkflowPhases", webui)
|
||||||
|
self.assertIn("workflowPrimaryAction", webui)
|
||||||
|
self.assertIn("data-run-step-id", webui)
|
||||||
|
self.assertIn("invalidateReleaseDraft", webui)
|
||||||
|
self.assertIn("Installation Verification", webui)
|
||||||
|
self.assertIn("receipt-bound local gate", webui)
|
||||||
|
self.assertIn('step.id === "release:install-verify"', webui)
|
||||||
|
self.assertIn('const inspectionNotices = (summary.missing_count || 0)', webui)
|
||||||
|
self.assertIn('(summary.repository_count || 0) === 0', webui)
|
||||||
|
|
||||||
|
|
||||||
def dashboard(*, workspace: Path, version: str) -> ReleaseDashboard:
|
def dashboard(*, workspace: Path, version: str) -> ReleaseDashboard:
|
||||||
repo = RepositorySnapshot(
|
repo = RepositorySnapshot(
|
||||||
@@ -156,7 +269,7 @@ def dashboard(*, workspace: Path, version: str) -> ReleaseDashboard:
|
|||||||
name="govoplan-files",
|
name="govoplan-files",
|
||||||
category="module",
|
category="module",
|
||||||
subtype="infrastructure",
|
subtype="infrastructure",
|
||||||
remote="git@example.test:add-ideas/govoplan-files.git",
|
remote="git@example.test:GovOPlaN/govoplan-files.git",
|
||||||
path="govoplan-files",
|
path="govoplan-files",
|
||||||
),
|
),
|
||||||
absolute_path=str(workspace / "govoplan-files"),
|
absolute_path=str(workspace / "govoplan-files"),
|
||||||
|
|||||||
@@ -230,10 +230,14 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
|||||||
for repository in (self.repo, self.remote, campaign, campaign_remote):
|
for repository in (self.repo, self.remote, campaign, campaign_remote):
|
||||||
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
def test_api_requires_explicit_confirmation_and_ui_exposes_source_release(self) -> None:
|
def test_api_keeps_legacy_source_release_preview_only(self) -> None:
|
||||||
with TestClient(create_app(workspace_root=self.workspace)) as client:
|
with TestClient(
|
||||||
|
create_app(workspace_root=self.workspace, token="token")
|
||||||
|
) as client:
|
||||||
|
headers = {"X-Release-Console-Token": "token"}
|
||||||
rejected = client.post(
|
rejected = client.post(
|
||||||
"/api/repositories/tag",
|
"/api/repositories/tag",
|
||||||
|
headers=headers,
|
||||||
json={
|
json={
|
||||||
"repos": ["govoplan-core"],
|
"repos": ["govoplan-core"],
|
||||||
"repo_versions": {"govoplan-core": "0.1.10"},
|
"repo_versions": {"govoplan-core": "0.1.10"},
|
||||||
@@ -244,6 +248,7 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
preview = client.post(
|
preview = client.post(
|
||||||
"/api/repositories/tag",
|
"/api/repositories/tag",
|
||||||
|
headers=headers,
|
||||||
json={
|
json={
|
||||||
"repos": ["govoplan-core"],
|
"repos": ["govoplan-core"],
|
||||||
"repo_versions": {"govoplan-core": "0.1.10"},
|
"repo_versions": {"govoplan-core": "0.1.10"},
|
||||||
@@ -251,14 +256,47 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
|||||||
"push": True,
|
"push": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
legacy_push = client.post(
|
||||||
|
"/api/repositories/push",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"repos": ["govoplan-core"],
|
||||||
|
"apply": True,
|
||||||
|
"confirm": "PUSH",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
legacy_sync = client.post(
|
||||||
|
"/api/repositories/sync",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"repos": ["govoplan-core"],
|
||||||
|
"apply": True,
|
||||||
|
"confirm": "SYNC",
|
||||||
|
},
|
||||||
|
)
|
||||||
ui = client.get("/")
|
ui = client.get("/")
|
||||||
|
|
||||||
self.assertEqual(rejected.status_code, 400)
|
self.assertEqual(rejected.status_code, 409)
|
||||||
|
self.assertIn("durable release run", rejected.json()["detail"])
|
||||||
self.assertEqual(preview.status_code, 200)
|
self.assertEqual(preview.status_code, 200)
|
||||||
|
self.assertEqual(409, legacy_push.status_code)
|
||||||
|
self.assertEqual(409, legacy_sync.status_code)
|
||||||
|
self.assertIn("durable", legacy_push.json()["detail"])
|
||||||
|
self.assertIn("durable", legacy_sync.json()["detail"])
|
||||||
self.assertEqual(preview.json()["repositories"][0]["repo"], "govoplan-core")
|
self.assertEqual(preview.json()["repositories"][0]["repo"], "govoplan-core")
|
||||||
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
||||||
self.assertIn('id="publishReleaseTags"', ui.text)
|
self.assertIn('id="publishReleaseTags"', ui.text)
|
||||||
|
self.assertIn(
|
||||||
|
'id="publishReleaseTags" data-release-disabled="true" disabled',
|
||||||
|
ui.text,
|
||||||
|
)
|
||||||
self.assertIn('postJson("/api/repositories/tag"', ui.text)
|
self.assertIn('postJson("/api/repositories/tag"', ui.text)
|
||||||
|
self.assertIn(
|
||||||
|
'id="syncRepos" disabled data-release-disabled="true"', ui.text
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'id="pushRepos" disabled data-release-disabled="true"', ui.text
|
||||||
|
)
|
||||||
self.assertIn("Signed Website Catalog", ui.text)
|
self.assertIn("Signed Website Catalog", ui.text)
|
||||||
self.assertIn("Apply + Website Tag", ui.text)
|
self.assertIn("Apply + Website Tag", ui.text)
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ from govoplan_release.release_run import ( # noqa: E402
|
|||||||
ReleaseRunStore,
|
ReleaseRunStore,
|
||||||
_record_digest,
|
_record_digest,
|
||||||
)
|
)
|
||||||
|
from govoplan_release.candidate_artifact import ( # noqa: E402
|
||||||
|
CandidateArtifactReceipt,
|
||||||
|
candidate_output_path,
|
||||||
|
harden_private_candidate_tree,
|
||||||
|
issue_candidate_receipt,
|
||||||
|
)
|
||||||
from server.app import ( # noqa: E402
|
from server.app import ( # noqa: E402
|
||||||
create_app,
|
create_app,
|
||||||
default_release_run_root,
|
default_release_run_root,
|
||||||
@@ -51,8 +57,24 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
self.root,
|
self.root,
|
||||||
expected_workspace_fingerprint=WORKSPACE_FINGERPRINT,
|
expected_workspace_fingerprint=WORKSPACE_FINGERPRINT,
|
||||||
)
|
)
|
||||||
|
self.runtime_trust = patch(
|
||||||
|
"server.app.require_trusted_release_runtime"
|
||||||
|
)
|
||||||
|
self.runtime_binding = patch(
|
||||||
|
"server.app.verify_release_runtime_binding"
|
||||||
|
)
|
||||||
|
self.source_bindings = patch(
|
||||||
|
"server.app.bind_plan_source_states",
|
||||||
|
side_effect=bind_test_plan_source_states,
|
||||||
|
)
|
||||||
|
self.runtime_trust.start()
|
||||||
|
self.runtime_binding.start()
|
||||||
|
self.source_bindings.start()
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
|
self.source_bindings.stop()
|
||||||
|
self.runtime_binding.stop()
|
||||||
|
self.runtime_trust.stop()
|
||||||
self.temporary.cleanup()
|
self.temporary.cleanup()
|
||||||
|
|
||||||
def test_create_persists_private_immutable_plan_and_ordered_steps(self) -> None:
|
def test_create_persists_private_immutable_plan_and_ordered_steps(self) -> None:
|
||||||
@@ -68,7 +90,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
record_path = self.root / f"{run['run_id']}.json"
|
record_path = self.root / f"{run['run_id']}.json"
|
||||||
|
|
||||||
self.assertEqual("govoplan.release-run", reopened["schema"])
|
self.assertEqual("govoplan.release-run", reopened["schema"])
|
||||||
self.assertEqual(3, reopened["schema_version"])
|
self.assertEqual(4, reopened["schema_version"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{"govoplan-core": "1.2.3", "govoplan-files": "1.2.4"},
|
{"govoplan-core": "1.2.3", "govoplan-files": "1.2.4"},
|
||||||
reopened["immutable"]["input"]["repo_versions"],
|
reopened["immutable"]["input"]["repo_versions"],
|
||||||
@@ -82,6 +104,119 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
self.assertNotIn("processed_requests", reopened["state"])
|
self.assertNotIn("processed_requests", reopened["state"])
|
||||||
self.assertNotIn("attempt_fingerprint", reopened["state"]["steps"][0])
|
self.assertNotIn("attempt_fingerprint", reopened["state"]["steps"][0])
|
||||||
|
|
||||||
|
def test_catalog_receipt_is_bounded_persisted_and_idempotent(self) -> None:
|
||||||
|
run_id = create_run(
|
||||||
|
self.store,
|
||||||
|
plan_snapshot=mutating_first_plan(),
|
||||||
|
request_id="receipt-create-request-0001",
|
||||||
|
)["run_id"]
|
||||||
|
attempt_id = "receipt-attempt-request-0001"
|
||||||
|
self.store.start_step(run_id, "core:tag", attempt_id=attempt_id)
|
||||||
|
receipt = {
|
||||||
|
"kind": "catalog_candidate",
|
||||||
|
"candidate_id": "candidate-" + "a" * 32,
|
||||||
|
"catalog_sha256": "b" * 64,
|
||||||
|
}
|
||||||
|
|
||||||
|
finished = self.store.finish_step(
|
||||||
|
run_id,
|
||||||
|
"core:tag",
|
||||||
|
attempt_id=attempt_id,
|
||||||
|
succeeded=True,
|
||||||
|
result_code="catalog_candidate_ready",
|
||||||
|
result_receipt=receipt,
|
||||||
|
)
|
||||||
|
replayed = self.store.finish_step(
|
||||||
|
run_id,
|
||||||
|
"core:tag",
|
||||||
|
attempt_id=attempt_id,
|
||||||
|
succeeded=True,
|
||||||
|
result_code="catalog_candidate_ready",
|
||||||
|
result_receipt=receipt,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(receipt, finished["state"]["steps"][0]["result_receipt"])
|
||||||
|
self.assertEqual(finished, replayed)
|
||||||
|
with self.assertRaisesRegex(ReleaseRunConflict, "different recorded outcome"):
|
||||||
|
self.store.finish_step(
|
||||||
|
run_id,
|
||||||
|
"core:tag",
|
||||||
|
attempt_id=attempt_id,
|
||||||
|
succeeded=True,
|
||||||
|
result_code="catalog_candidate_ready",
|
||||||
|
result_receipt=receipt | {"catalog_sha256": "c" * 64},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_catalog_publication_receipt_binds_candidate_origin_commit_and_tag(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
run_id = create_run(
|
||||||
|
self.store,
|
||||||
|
plan_snapshot=mutating_first_plan(),
|
||||||
|
request_id="publication-receipt-create-0001",
|
||||||
|
)["run_id"]
|
||||||
|
attempt_id = "publication-receipt-attempt-0001"
|
||||||
|
self.store.start_step(run_id, "core:tag", attempt_id=attempt_id)
|
||||||
|
receipt = {
|
||||||
|
"kind": "catalog_publication",
|
||||||
|
"candidate_id": "candidate-" + "a" * 32,
|
||||||
|
"catalog_sha256": "b" * 64,
|
||||||
|
"keyring_sha256": "c" * 64,
|
||||||
|
"publication_commit_sha": "d" * 40,
|
||||||
|
"publication_tag_object_sha": "e" * 40,
|
||||||
|
"publication_tag_commit_sha": "d" * 40,
|
||||||
|
"branch": "main",
|
||||||
|
"tag_name": "catalog-stable-1",
|
||||||
|
"remote": "origin",
|
||||||
|
"remote_sha256": "f" * 64,
|
||||||
|
}
|
||||||
|
|
||||||
|
finished = self.store.finish_step(
|
||||||
|
run_id,
|
||||||
|
"core:tag",
|
||||||
|
attempt_id=attempt_id,
|
||||||
|
succeeded=True,
|
||||||
|
result_code="catalog_published",
|
||||||
|
result_receipt=receipt,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(receipt, finished["state"]["steps"][0]["result_receipt"])
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ReleaseRunConflict, "publication receipt identity"
|
||||||
|
):
|
||||||
|
self.store.finish_step(
|
||||||
|
run_id,
|
||||||
|
"core:tag",
|
||||||
|
attempt_id=attempt_id,
|
||||||
|
succeeded=True,
|
||||||
|
result_code="catalog_published",
|
||||||
|
result_receipt=receipt
|
||||||
|
| {"publication_tag_commit_sha": "0" * 40},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schema_three_record_is_verified_then_migrated_privately(self) -> None:
|
||||||
|
run_id = create_run(
|
||||||
|
self.store,
|
||||||
|
request_id="legacy-schema-create-request-0001",
|
||||||
|
)["run_id"]
|
||||||
|
path = self.root / f"{run_id}.json"
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
payload["schema_version"] = 3
|
||||||
|
for step in payload["state"]["steps"]:
|
||||||
|
step.pop("result_receipt")
|
||||||
|
payload["record_digest"] = _record_digest(payload)
|
||||||
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
migrated = self.store.get(run_id)
|
||||||
|
persisted = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
self.assertEqual(4, migrated["schema_version"])
|
||||||
|
self.assertEqual(4, persisted["schema_version"])
|
||||||
|
self.assertTrue(
|
||||||
|
all(step["result_receipt"] is None for step in persisted["state"]["steps"])
|
||||||
|
)
|
||||||
|
|
||||||
def test_zero_step_plan_is_rejected(self) -> None:
|
def test_zero_step_plan_is_rejected(self) -> None:
|
||||||
plan = release_plan()
|
plan = release_plan()
|
||||||
plan["dry_run_steps"] = []
|
plan["dry_run_steps"] = []
|
||||||
@@ -293,7 +428,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
self.assertEqual(0, untouched["state"]["steps"][0]["attempt_count"])
|
self.assertEqual(0, untouched["state"]["steps"][0]["attempt_count"])
|
||||||
|
|
||||||
accepted_plan = mutating_first_plan()
|
accepted_plan = mutating_first_plan()
|
||||||
accepted_plan["notes"] = ["x" * (MAX_RECORD_BYTES - 3_000)]
|
accepted_plan["notes"] = ["x" * (MAX_RECORD_BYTES - 4_300)]
|
||||||
accepted_run_id = create_run(
|
accepted_run_id = create_run(
|
||||||
self.store,
|
self.store,
|
||||||
request_id="byte-capacity-accepted-create-0001",
|
request_id="byte-capacity-accepted-create-0001",
|
||||||
@@ -334,7 +469,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
) -> None:
|
) -> None:
|
||||||
plan = mutating_first_plan()
|
plan = mutating_first_plan()
|
||||||
plan["dry_run_steps"][0]["mutating"] = False # type: ignore[index]
|
plan["dry_run_steps"][0]["mutating"] = False # type: ignore[index]
|
||||||
plan["notes"] = ["x" * (MAX_RECORD_BYTES - 3_000)]
|
plan["notes"] = ["x" * (MAX_RECORD_BYTES - 5_500)]
|
||||||
run_id = create_run(
|
run_id = create_run(
|
||||||
self.store,
|
self.store,
|
||||||
request_id="byte-capacity-read-only-create-0001",
|
request_id="byte-capacity-read-only-create-0001",
|
||||||
@@ -747,7 +882,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
listed = self.store.list(limit=1)
|
listed = self.store.list(limit=1)
|
||||||
self.assertEqual([local_run["run_id"]], [item["run_id"] for item in listed])
|
self.assertEqual([local_run["run_id"]], [item["run_id"] for item in listed])
|
||||||
|
|
||||||
def test_list_orders_verified_runs_by_updated_timestamp_before_unavailable(
|
def test_list_orders_verified_runs_by_creation_timestamp_before_unavailable(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
with patch(
|
with patch(
|
||||||
@@ -786,11 +921,139 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
|
|
||||||
listed = self.store.list(limit=3)
|
listed = self.store.list(limit=3)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[older["run_id"], newer["run_id"], corrupt["run_id"]],
|
[newer["run_id"], older["run_id"], corrupt["run_id"]],
|
||||||
[item["run_id"] for item in listed],
|
[item["run_id"] for item in listed],
|
||||||
)
|
)
|
||||||
self.assertEqual("unavailable", listed[-1]["status"])
|
self.assertEqual("unavailable", listed[-1]["status"])
|
||||||
self.assertEqual(older["run_id"], self.store.list(limit=1)[0]["run_id"])
|
self.assertEqual(newer["run_id"], self.store.list(limit=1)[0]["run_id"])
|
||||||
|
|
||||||
|
def test_cursor_pagination_is_stable_and_keeps_unavailable_records_visible(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
created: list[dict[str, object]] = []
|
||||||
|
for index in range(3):
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_run._timestamp",
|
||||||
|
return_value=f"2026-07-22T0{index}:00:00Z",
|
||||||
|
):
|
||||||
|
created.append(
|
||||||
|
create_run(
|
||||||
|
self.store,
|
||||||
|
request_id=f"cursor-page-create-request-{index:04d}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
corrupt_path = self.root / f"{created[0]['run_id']}.json"
|
||||||
|
corrupt_path.write_text("{}", encoding="utf-8")
|
||||||
|
corrupt_path.chmod(0o600)
|
||||||
|
|
||||||
|
first = self.store.list_page(limit=1)
|
||||||
|
second = self.store.list_page(limit=1, cursor=first["next_cursor"])
|
||||||
|
third = self.store.list_page(limit=1, cursor=second["next_cursor"])
|
||||||
|
|
||||||
|
self.assertEqual(created[2]["run_id"], first["runs"][0]["run_id"])
|
||||||
|
self.assertEqual(created[1]["run_id"], second["runs"][0]["run_id"])
|
||||||
|
self.assertEqual("unavailable", third["runs"][0]["status"])
|
||||||
|
self.assertIsNone(third["next_cursor"])
|
||||||
|
with self.assertRaisesRegex(ReleaseRunConflict, "cursor is invalid"):
|
||||||
|
self.store.list_page(limit=1, cursor=f"{first['next_cursor']}tampered")
|
||||||
|
|
||||||
|
def test_cursor_does_not_skip_or_repeat_when_an_older_run_is_updated(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
created: list[dict[str, object]] = []
|
||||||
|
for index in range(3):
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_run._timestamp",
|
||||||
|
return_value=f"2026-07-22T0{index}:00:00Z",
|
||||||
|
):
|
||||||
|
created.append(
|
||||||
|
create_run(
|
||||||
|
self.store,
|
||||||
|
request_id=f"cursor-update-create-request-{index:04d}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
first = self.store.list_page(limit=1)
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_run._timestamp",
|
||||||
|
return_value="2026-07-22T12:00:00Z",
|
||||||
|
):
|
||||||
|
self.store.start_step(
|
||||||
|
created[0]["run_id"],
|
||||||
|
"core:preflight",
|
||||||
|
attempt_id="cursor-update-attempt-request-0001",
|
||||||
|
)
|
||||||
|
second = self.store.list_page(limit=1, cursor=first["next_cursor"])
|
||||||
|
third = self.store.list_page(limit=1, cursor=second["next_cursor"])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[item["run_id"] for item in reversed(created)],
|
||||||
|
[
|
||||||
|
first["runs"][0]["run_id"],
|
||||||
|
second["runs"][0]["run_id"],
|
||||||
|
third["runs"][0]["run_id"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_retention_prunes_only_oldest_completed_records_and_fails_closed(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with patch("govoplan_release.release_run.MAX_RUN_RECORDS", 3):
|
||||||
|
completed: list[dict[str, object]] = []
|
||||||
|
for index in range(2):
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_run._timestamp",
|
||||||
|
return_value=f"2026-07-22T0{index}:00:00Z",
|
||||||
|
):
|
||||||
|
run = create_run(
|
||||||
|
self.store,
|
||||||
|
request_id=f"retention-completed-create-{index:04d}",
|
||||||
|
plan_snapshot=mutating_first_plan(),
|
||||||
|
)
|
||||||
|
attempt_id = f"retention-completed-attempt-{index:04d}"
|
||||||
|
self.store.start_step(
|
||||||
|
run["run_id"], "core:tag", attempt_id=attempt_id
|
||||||
|
)
|
||||||
|
self.store.finish_step(
|
||||||
|
run["run_id"],
|
||||||
|
"core:tag",
|
||||||
|
attempt_id=attempt_id,
|
||||||
|
succeeded=True,
|
||||||
|
result_code="tag_created",
|
||||||
|
)
|
||||||
|
completed.append(run)
|
||||||
|
active = create_run(
|
||||||
|
self.store,
|
||||||
|
request_id="retention-active-create-0001",
|
||||||
|
)
|
||||||
|
replacement = create_run(
|
||||||
|
self.store,
|
||||||
|
request_id="retention-replacement-create-0001",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
(self.root / f"{completed[0]['run_id']}.json").exists()
|
||||||
|
)
|
||||||
|
self.assertTrue((self.root / f"{completed[1]['run_id']}.json").exists())
|
||||||
|
self.assertTrue((self.root / f"{active['run_id']}.json").exists())
|
||||||
|
self.assertTrue((self.root / f"{replacement['run_id']}.json").exists())
|
||||||
|
|
||||||
|
corrupt_path = self.root / f"{completed[1]['run_id']}.json"
|
||||||
|
corrupt_path.write_text("{}", encoding="utf-8")
|
||||||
|
corrupt_path.chmod(0o600)
|
||||||
|
with self.assertRaisesRegex(ReleaseRunConflict, "retention limit"):
|
||||||
|
create_run(
|
||||||
|
self.store,
|
||||||
|
request_id="retention-refused-create-0001",
|
||||||
|
)
|
||||||
|
self.assertEqual(3, len(list(self.root.glob("*.json"))))
|
||||||
|
self.assertIn(
|
||||||
|
completed[1]["run_id"],
|
||||||
|
{
|
||||||
|
item["run_id"]
|
||||||
|
for item in self.store.list_page(limit=3)["runs"]
|
||||||
|
if item["status"] == "unavailable"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_list_enumeration_oserror_is_normalized(self) -> None:
|
def test_list_enumeration_oserror_is_normalized(self) -> None:
|
||||||
self.store.list()
|
self.store.list()
|
||||||
@@ -872,6 +1135,399 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ReleaseRunApiTests(unittest.TestCase):
|
class ReleaseRunApiTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.runtime_trust = patch("server.app.require_trusted_release_runtime")
|
||||||
|
self.runtime_binding = patch("server.app.verify_release_runtime_binding")
|
||||||
|
self.source_bindings = patch(
|
||||||
|
"server.app.bind_plan_source_states",
|
||||||
|
side_effect=bind_test_plan_source_states,
|
||||||
|
)
|
||||||
|
self.runtime_trust.start()
|
||||||
|
self.runtime_binding.start()
|
||||||
|
self.source_bindings.start()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.source_bindings.stop()
|
||||||
|
self.runtime_binding.stop()
|
||||||
|
self.runtime_trust.stop()
|
||||||
|
|
||||||
|
def test_api_exposes_and_validates_cursor_pagination(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
app = create_app(
|
||||||
|
workspace_root=Path(temp_dir),
|
||||||
|
run_state_root=Path(temp_dir) / "runs",
|
||||||
|
token="token",
|
||||||
|
)
|
||||||
|
headers = {"X-Release-Console-Token": "token"}
|
||||||
|
with (
|
||||||
|
patch("server.app.build_dashboard", return_value=object()),
|
||||||
|
patch(
|
||||||
|
"server.app.build_selective_release_plan",
|
||||||
|
return_value=release_plan(),
|
||||||
|
),
|
||||||
|
TestClient(app) as client,
|
||||||
|
):
|
||||||
|
for index in range(2):
|
||||||
|
response = client.post(
|
||||||
|
"/api/release-runs",
|
||||||
|
headers=headers,
|
||||||
|
json=api_run_input(
|
||||||
|
request_id=f"api-page-create-request-{index:04d}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
first = client.get(
|
||||||
|
"/api/release-runs?limit=1", headers=headers
|
||||||
|
).json()
|
||||||
|
second = client.get(
|
||||||
|
"/api/release-runs",
|
||||||
|
headers=headers,
|
||||||
|
params={"limit": 1, "cursor": first["next_cursor"]},
|
||||||
|
).json()
|
||||||
|
invalid = client.get(
|
||||||
|
"/api/release-runs?cursor=not-a-cursor", headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(first["runs"]))
|
||||||
|
self.assertEqual(1, len(second["runs"]))
|
||||||
|
self.assertNotEqual(
|
||||||
|
first["runs"][0]["run_id"], second["runs"][0]["run_id"]
|
||||||
|
)
|
||||||
|
self.assertIsNone(second["next_cursor"])
|
||||||
|
self.assertEqual(409, invalid.status_code)
|
||||||
|
|
||||||
|
def test_executor_claims_before_effect_and_replays_lost_success_response(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
app = create_app(
|
||||||
|
workspace_root=Path(temp_dir),
|
||||||
|
run_state_root=Path(temp_dir) / "runs",
|
||||||
|
candidate_root=Path(temp_dir) / "candidates",
|
||||||
|
token="token",
|
||||||
|
)
|
||||||
|
headers = {"X-Release-Console-Token": "token"}
|
||||||
|
with (
|
||||||
|
patch("server.app.build_dashboard", return_value=object()),
|
||||||
|
patch(
|
||||||
|
"server.app.build_selective_release_plan",
|
||||||
|
return_value=release_plan(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.bind_plan_source_states",
|
||||||
|
side_effect=bind_test_plan_source_states,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.verify_repository_preflight_binding",
|
||||||
|
return_value=repository_receipt(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.execute_repository_step",
|
||||||
|
return_value=({"status": "inspected"}, repository_receipt()),
|
||||||
|
) as executor,
|
||||||
|
TestClient(app) as client,
|
||||||
|
):
|
||||||
|
created = client.post(
|
||||||
|
"/api/release-runs",
|
||||||
|
headers=headers,
|
||||||
|
json=api_run_input(request_id="api-executor-create-0001"),
|
||||||
|
).json()
|
||||||
|
run_id = created["run_id"]
|
||||||
|
self.assertTrue(created["state"]["steps"][0]["executor"]["available"])
|
||||||
|
self.assertEqual(
|
||||||
|
"preflight", created["state"]["steps"][0]["executor"]["kind"]
|
||||||
|
)
|
||||||
|
payload = {"request_id": "api-executor-attempt-0001"}
|
||||||
|
executed = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/core:preflight/execute",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
replayed = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/core:preflight/execute",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, executed.status_code)
|
||||||
|
self.assertEqual("succeeded", executed.json()["state"]["steps"][0]["state"])
|
||||||
|
self.assertEqual("replayed", replayed.json()["execution_result"]["status"])
|
||||||
|
executor.assert_called_once()
|
||||||
|
|
||||||
|
def test_lost_finish_write_interrupts_without_reexecuting_effect(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
app = create_app(
|
||||||
|
workspace_root=Path(temp_dir),
|
||||||
|
run_state_root=Path(temp_dir) / "runs",
|
||||||
|
token="token",
|
||||||
|
)
|
||||||
|
headers = {"X-Release-Console-Token": "token"}
|
||||||
|
with (
|
||||||
|
patch("server.app.build_dashboard", return_value=object()),
|
||||||
|
patch(
|
||||||
|
"server.app.build_selective_release_plan",
|
||||||
|
return_value=release_plan(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.bind_plan_source_states",
|
||||||
|
side_effect=bind_test_plan_source_states,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.verify_repository_preflight_binding",
|
||||||
|
return_value=repository_receipt(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.execute_repository_step",
|
||||||
|
return_value=({"status": "inspected"}, repository_receipt()),
|
||||||
|
) as executor,
|
||||||
|
TestClient(app) as client,
|
||||||
|
):
|
||||||
|
run_id = client.post(
|
||||||
|
"/api/release-runs",
|
||||||
|
headers=headers,
|
||||||
|
json=api_run_input(request_id="api-lost-finish-create-0001"),
|
||||||
|
).json()["run_id"]
|
||||||
|
with patch.object(
|
||||||
|
app.state.release_runs,
|
||||||
|
"finish_step",
|
||||||
|
side_effect=ReleaseRunCorrupt("simulated durable write failure"),
|
||||||
|
):
|
||||||
|
first = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/core:preflight/execute",
|
||||||
|
headers=headers,
|
||||||
|
json={"request_id": "api-lost-finish-attempt-0001"},
|
||||||
|
)
|
||||||
|
replay = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/core:preflight/execute",
|
||||||
|
headers=headers,
|
||||||
|
json={"request_id": "api-lost-finish-attempt-0001"},
|
||||||
|
)
|
||||||
|
loaded = client.get(
|
||||||
|
f"/api/release-runs/{run_id}", headers=headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
self.assertEqual(409, first.status_code)
|
||||||
|
self.assertEqual(409, replay.status_code)
|
||||||
|
self.assertEqual("interrupted", loaded["state"]["steps"][0]["state"])
|
||||||
|
executor.assert_called_once()
|
||||||
|
|
||||||
|
def test_executor_exception_is_interrupted_and_never_retried_implicitly(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
app = create_app(
|
||||||
|
workspace_root=Path(temp_dir),
|
||||||
|
run_state_root=Path(temp_dir) / "runs",
|
||||||
|
token="token",
|
||||||
|
)
|
||||||
|
headers = {"X-Release-Console-Token": "token"}
|
||||||
|
with (
|
||||||
|
patch("server.app.build_dashboard", return_value=object()),
|
||||||
|
patch(
|
||||||
|
"server.app.build_selective_release_plan",
|
||||||
|
return_value=mutating_first_plan(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.bind_plan_source_states",
|
||||||
|
side_effect=bind_test_plan_source_states,
|
||||||
|
),
|
||||||
|
patch("server.app.verify_repository_step_precondition"),
|
||||||
|
patch(
|
||||||
|
"server.app.execute_repository_step",
|
||||||
|
side_effect=RuntimeError("connection lost after push"),
|
||||||
|
) as executor,
|
||||||
|
TestClient(app) as client,
|
||||||
|
):
|
||||||
|
run_id = client.post(
|
||||||
|
"/api/release-runs",
|
||||||
|
headers=headers,
|
||||||
|
json=api_run_input(request_id="api-ambiguous-create-0001"),
|
||||||
|
).json()["run_id"]
|
||||||
|
payload = {
|
||||||
|
"request_id": "api-ambiguous-attempt-0001",
|
||||||
|
"confirm": "TAG",
|
||||||
|
}
|
||||||
|
first = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/core:tag/execute",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
second = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/core:tag/execute",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
loaded = client.get(
|
||||||
|
f"/api/release-runs/{run_id}", headers=headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
self.assertEqual(409, first.status_code)
|
||||||
|
self.assertEqual(409, second.status_code)
|
||||||
|
self.assertEqual("interrupted", loaded["state"]["steps"][0]["state"])
|
||||||
|
self.assertEqual(
|
||||||
|
"executor_outcome_unknown",
|
||||||
|
loaded["state"]["steps"][0]["result_code"],
|
||||||
|
)
|
||||||
|
executor.assert_called_once()
|
||||||
|
|
||||||
|
def test_catalog_execution_persists_and_consumes_only_issued_receipt(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
candidate_root = Path(temp_dir) / "candidates"
|
||||||
|
app = create_app(
|
||||||
|
workspace_root=Path(temp_dir),
|
||||||
|
run_state_root=Path(temp_dir) / "runs",
|
||||||
|
candidate_root=candidate_root,
|
||||||
|
token="token",
|
||||||
|
)
|
||||||
|
headers = {"X-Release-Console-Token": "token"}
|
||||||
|
|
||||||
|
def generated(**kwargs: object) -> tuple[dict[str, object], CandidateArtifactReceipt]:
|
||||||
|
candidate_id = str(kwargs["candidate_id"])
|
||||||
|
candidate = candidate_output_path(candidate_root, candidate_id)
|
||||||
|
channels = candidate / "channels"
|
||||||
|
channels.mkdir(parents=True)
|
||||||
|
(channels / "stable.json").write_text(
|
||||||
|
json.dumps({"channel": "stable", "signatures": [{}]}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
harden_private_candidate_tree(candidate)
|
||||||
|
return (
|
||||||
|
{"status": "ready"},
|
||||||
|
issue_candidate_receipt(
|
||||||
|
root=candidate_root,
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
channel="stable",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("server.app.build_dashboard", return_value=object()),
|
||||||
|
patch(
|
||||||
|
"server.app.build_selective_release_plan",
|
||||||
|
return_value=catalog_release_plan(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.bind_plan_source_states",
|
||||||
|
side_effect=bind_test_plan_source_states,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.verify_catalog_publication_precondition",
|
||||||
|
return_value=repository_receipt(
|
||||||
|
repo="addideas-govoplan-website", target_tag=""
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch("server.app.default_signing_keys", return_value=("key=/private",)),
|
||||||
|
patch(
|
||||||
|
"server.app.generate_catalog_candidate", side_effect=generated
|
||||||
|
) as generator,
|
||||||
|
patch(
|
||||||
|
"server.app.publish_received_candidate",
|
||||||
|
side_effect=published_candidate_result,
|
||||||
|
) as publisher,
|
||||||
|
TestClient(app) as client,
|
||||||
|
):
|
||||||
|
run_id = client.post(
|
||||||
|
"/api/release-runs",
|
||||||
|
headers=headers,
|
||||||
|
json=api_run_input(request_id="api-candidate-create-0001"),
|
||||||
|
).json()["run_id"]
|
||||||
|
generated_response = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/catalog:selective-generator/execute",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"request_id": "api-candidate-generate-0001",
|
||||||
|
"confirm": "GENERATE",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with patch("server.app.default_signing_keys", return_value=()):
|
||||||
|
replayed_generation = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/catalog:selective-generator/execute",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"request_id": "api-candidate-generate-0001",
|
||||||
|
"confirm": "GENERATE",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
published = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/catalog:validate-sign-publish/execute",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"request_id": "api-candidate-publish-0001",
|
||||||
|
"confirm": "PUSH",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt = generated_response.json()["state"]["steps"][0]["result_receipt"]
|
||||||
|
self.assertRegex(receipt["candidate_id"], r"^candidate-[0-9a-f]{32}$")
|
||||||
|
self.assertEqual(64, len(receipt["catalog_sha256"]))
|
||||||
|
self.assertEqual(200, replayed_generation.status_code)
|
||||||
|
self.assertEqual(
|
||||||
|
"replayed",
|
||||||
|
replayed_generation.json()["execution_result"]["status"],
|
||||||
|
)
|
||||||
|
generator.assert_called_once()
|
||||||
|
self.assertEqual(200, published.status_code)
|
||||||
|
self.assertEqual("completed", published.json()["state"]["status"])
|
||||||
|
publisher.assert_called_once_with(
|
||||||
|
candidate_path=candidate_root / receipt["candidate_id"],
|
||||||
|
candidate_receipt=receipt,
|
||||||
|
channel="stable",
|
||||||
|
workspace_root=Path(temp_dir),
|
||||||
|
remote="origin",
|
||||||
|
expected_website_receipt=repository_receipt(
|
||||||
|
repo="addideas-govoplan-website", target_tag=""
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_catalog_endpoint_is_preview_only(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
app = create_app(workspace_root=Path(temp_dir), token="token")
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/catalog-candidates/publish",
|
||||||
|
headers={"X-Release-Console-Token": "token"},
|
||||||
|
json={
|
||||||
|
"candidate_dir": "/tmp/untrusted",
|
||||||
|
"apply": True,
|
||||||
|
"push": True,
|
||||||
|
"confirm": "PUSH",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, response.status_code)
|
||||||
|
self.assertIn("durable release run", response.json()["detail"])
|
||||||
|
|
||||||
|
def test_release_channels_and_durable_remote_are_strictly_validated(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
app = create_app(workspace_root=Path(temp_dir), token="token")
|
||||||
|
headers = {"X-Release-Console-Token": "token"}
|
||||||
|
with (
|
||||||
|
patch("server.app.build_dashboard") as dashboard,
|
||||||
|
TestClient(app) as client,
|
||||||
|
):
|
||||||
|
query = client.get(
|
||||||
|
"/api/dashboard", headers=headers, params={"channel": "../stable"}
|
||||||
|
)
|
||||||
|
created = client.post(
|
||||||
|
"/api/release-runs",
|
||||||
|
headers=headers,
|
||||||
|
json=api_run_input(
|
||||||
|
request_id="api-invalid-channel-create-0001",
|
||||||
|
channel="/absolute",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
execute = client.post(
|
||||||
|
"/api/release-runs/not-a-run/steps/core:tag/execute",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"request_id": "api-invalid-remote-attempt-0001",
|
||||||
|
"confirm": "TAG",
|
||||||
|
"remote": "upstream",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(422, query.status_code)
|
||||||
|
self.assertEqual(422, created.status_code)
|
||||||
|
self.assertEqual(422, execute.status_code)
|
||||||
|
dashboard.assert_not_called()
|
||||||
|
|
||||||
def test_token_guarded_create_list_read_resume_and_retry(self) -> None:
|
def test_token_guarded_create_list_read_resume_and_retry(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
root = Path(temp_dir) / "runs"
|
root = Path(temp_dir) / "runs"
|
||||||
@@ -1069,6 +1725,10 @@ class ReleaseRunApiTests(unittest.TestCase):
|
|||||||
"server.app.build_selective_release_plan",
|
"server.app.build_selective_release_plan",
|
||||||
return_value=mutating_first_plan(),
|
return_value=mutating_first_plan(),
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.reconciled_repository_receipt",
|
||||||
|
return_value=repository_receipt(),
|
||||||
|
),
|
||||||
TestClient(app) as client,
|
TestClient(app) as client,
|
||||||
):
|
):
|
||||||
created = client.post(
|
created = client.post(
|
||||||
@@ -1236,12 +1896,16 @@ class ReleaseRunApiTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn("Durable Run State", webui)
|
self.assertIn("Durable Run State", webui)
|
||||||
self.assertIn("State tracking only.", webui)
|
self.assertIn("Durable execution.", webui)
|
||||||
self.assertIn("Create Run from Selection", webui)
|
self.assertIn("Create Run from Selection", webui)
|
||||||
self.assertIn("Prepare Retry", webui)
|
self.assertIn("Prepare Retry", webui)
|
||||||
self.assertIn("step.retry_available", webui)
|
self.assertIn("step.retry_available", webui)
|
||||||
self.assertIn("Record Reconciliation", webui)
|
self.assertIn("Record Reconciliation", webui)
|
||||||
self.assertIn("effect_succeeded", webui)
|
self.assertIn("effect_succeeded", webui)
|
||||||
|
self.assertNotIn(
|
||||||
|
'step.id === "catalog:validate-sign-publish" ? "disabled"',
|
||||||
|
webui,
|
||||||
|
)
|
||||||
self.assertIn("renderReleaseRunStoreUnavailable", webui)
|
self.assertIn("renderReleaseRunStoreUnavailable", webui)
|
||||||
self.assertIn('const runsRequest = api("/api/release-runs")', webui)
|
self.assertIn('const runsRequest = api("/api/release-runs")', webui)
|
||||||
self.assertIn("pendingReleaseRunPayload", webui)
|
self.assertIn("pendingReleaseRunPayload", webui)
|
||||||
@@ -1249,13 +1913,30 @@ class ReleaseRunApiTests(unittest.TestCase):
|
|||||||
self.assertIn("recoverPendingRunCommands", webui)
|
self.assertIn("recoverPendingRunCommands", webui)
|
||||||
self.assertIn("inFlightRunCommandId", webui)
|
self.assertIn("inFlightRunCommandId", webui)
|
||||||
self.assertIn("Run saved; list refresh unavailable", webui)
|
self.assertIn("Run saved; list refresh unavailable", webui)
|
||||||
self.assertIn("This foundation tracks state only.", runbook)
|
self.assertIn("The run record is execution evidence only", runbook)
|
||||||
|
self.assertIn("isolated checkout", runbook)
|
||||||
self.assertIn("effect_absent", runbook)
|
self.assertIn("effect_absent", runbook)
|
||||||
self.assertIn("$XDG_STATE_HOME", runbook)
|
self.assertIn("$XDG_STATE_HOME", runbook)
|
||||||
self.assertIn("same-directory", runbook)
|
self.assertIn("same-directory", runbook)
|
||||||
self.assertIn("caller-generated `request_id`", runbook)
|
self.assertIn("caller-generated `request_id`", runbook)
|
||||||
self.assertIn("reserves the two command-ledger", runbook)
|
self.assertIn("reserves the two command-ledger", runbook)
|
||||||
|
|
||||||
|
def test_entire_release_console_script_is_valid_javascript(self) -> None:
|
||||||
|
node = shutil.which("node")
|
||||||
|
if node is None:
|
||||||
|
self.skipTest("Node.js is required for the Release Console UI check")
|
||||||
|
webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||||
|
match = re.search(r"<script>(?P<script>.*?)</script>", webui, re.DOTALL)
|
||||||
|
self.assertIsNotNone(match, "release console script element is missing")
|
||||||
|
result = subprocess.run( # noqa: S603
|
||||||
|
[node, "--check", "-"],
|
||||||
|
input=match.group("script") if match is not None else "",
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
|
||||||
def test_ui_reuses_uncertain_ids_and_separates_saved_run_list_failure(
|
def test_ui_reuses_uncertain_ids_and_separates_saved_run_list_failure(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1357,6 +2038,16 @@ async function api(_path) {
|
|||||||
const reconcileRequest = pendingRunCommandRequest("reconcile:rr-known:core:tag:effect_succeeded", "reconcile-request");
|
const reconcileRequest = pendingRunCommandRequest("reconcile:rr-known:core:tag:effect_succeeded", "reconcile-request");
|
||||||
assert(reconcileRequest.path.endsWith("/steps/core%3Atag/reconcile"), "reconciliation recovery path was not executable");
|
assert(reconcileRequest.path.endsWith("/steps/core%3Atag/reconcile"), "reconciliation recovery path was not executable");
|
||||||
assert(reconcileRequest.body.confirm === "RECONCILE" && reconcileRequest.body.outcome === "effect_succeeded", "reconciliation recovery body changed");
|
assert(reconcileRequest.body.confirm === "RECONCILE" && reconcileRequest.body.outcome === "effect_succeeded", "reconciliation recovery body changed");
|
||||||
|
const executeKey = "execute:rr-known:catalog:selective-generator:GENERATE";
|
||||||
|
const executeId = inFlightRunCommandId(executeKey, "execute", { signing_keys: ["release-key=/private/key"] });
|
||||||
|
assert(!values.get(pendingRunCommandsKey).includes("signing_keys"), "signing key path was persisted in session storage");
|
||||||
|
const executeRequest = pendingRunCommandRequest(executeKey, executeId);
|
||||||
|
assert(!Object.hasOwn(executeRequest.body, "signing_keys"), "execute replay reconstructed signing material");
|
||||||
|
values.set(pendingRunCommandsKey, JSON.stringify({ [commandKey]: commandOne, [executeKey]: { request_id: executeId, body: { signing_keys: ["legacy-secret"] } } }));
|
||||||
|
const migratedCommands = readInFlightRunCommands();
|
||||||
|
assert(migratedCommands[executeKey] === executeId, "legacy pending execute request ID was lost");
|
||||||
|
assert(!values.get(pendingRunCommandsKey).includes("legacy-secret"), "legacy persisted signing material was not purged");
|
||||||
|
clearInFlightRunCommand(executeKey, executeId);
|
||||||
|
|
||||||
failCreate = true;
|
failCreate = true;
|
||||||
const uncertainCommand = await recoverPendingRunCommands();
|
const uncertainCommand = await recoverPendingRunCommands();
|
||||||
@@ -1455,6 +2146,64 @@ def run_input(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def repository_receipt(
|
||||||
|
*, repo: str = "govoplan-core", target_tag: str = "v1.2.3"
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"kind": "repository_state",
|
||||||
|
"repo": repo,
|
||||||
|
"head": "a" * 40,
|
||||||
|
"branch": "main",
|
||||||
|
"remote": "origin",
|
||||||
|
"remote_sha256": "b" * 64,
|
||||||
|
"worktree_clean": True,
|
||||||
|
"target_tag": target_tag,
|
||||||
|
"tag_object": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def published_candidate_result(
|
||||||
|
**kwargs: object,
|
||||||
|
) -> tuple[dict[str, object], dict[str, object]]:
|
||||||
|
candidate = kwargs["candidate_receipt"]
|
||||||
|
website = kwargs["expected_website_receipt"]
|
||||||
|
if not isinstance(candidate, dict) or not isinstance(website, dict):
|
||||||
|
raise AssertionError("publication test receipts are unavailable")
|
||||||
|
receipt = {
|
||||||
|
"kind": "catalog_publication",
|
||||||
|
"candidate_id": candidate["candidate_id"],
|
||||||
|
"catalog_sha256": candidate["catalog_sha256"],
|
||||||
|
"keyring_sha256": "c" * 64,
|
||||||
|
"publication_commit_sha": "d" * 40,
|
||||||
|
"publication_tag_object_sha": "e" * 40,
|
||||||
|
"publication_tag_commit_sha": "d" * 40,
|
||||||
|
"branch": website["branch"],
|
||||||
|
"tag_name": "catalog-stable-7",
|
||||||
|
"remote": "origin",
|
||||||
|
"remote_sha256": website["remote_sha256"],
|
||||||
|
}
|
||||||
|
return {"status": "published"}, receipt
|
||||||
|
|
||||||
|
|
||||||
|
def bind_test_plan_source_states(**kwargs: object) -> dict[str, object]:
|
||||||
|
plan = kwargs["plan"]
|
||||||
|
if not isinstance(plan, dict):
|
||||||
|
raise AssertionError("test plan is not an object")
|
||||||
|
steps = plan.get("dry_run_steps")
|
||||||
|
if not isinstance(steps, list):
|
||||||
|
raise AssertionError("test plan has no steps")
|
||||||
|
for step in steps:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
if isinstance(step.get("repo"), str):
|
||||||
|
step["source_binding"] = repository_receipt()
|
||||||
|
elif step.get("id") == "catalog:validate-sign-publish":
|
||||||
|
step["source_binding"] = repository_receipt(
|
||||||
|
repo="addideas-govoplan-website", target_tag=""
|
||||||
|
)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
def release_plan() -> dict[str, object]:
|
def release_plan() -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"generated_at": "2026-07-22T00:00:00Z",
|
"generated_at": "2026-07-22T00:00:00Z",
|
||||||
@@ -1505,5 +2254,32 @@ def mutating_first_plan() -> dict[str, object]:
|
|||||||
return plan
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_release_plan() -> dict[str, object]:
|
||||||
|
plan = release_plan()
|
||||||
|
plan["dry_run_steps"] = [
|
||||||
|
{
|
||||||
|
"id": "catalog:selective-generator",
|
||||||
|
"title": "Generate candidate",
|
||||||
|
"detail": "Build artifacts and sign a candidate.",
|
||||||
|
"command": "release-catalog selective",
|
||||||
|
"cwd": "/workspace/govoplan",
|
||||||
|
"mutating": True,
|
||||||
|
"repo": None,
|
||||||
|
"status": "planned",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "catalog:validate-sign-publish",
|
||||||
|
"title": "Publish candidate",
|
||||||
|
"detail": "Publish only the receipt-bound candidate.",
|
||||||
|
"command": "release-catalog publish-candidate",
|
||||||
|
"cwd": "/workspace/govoplan",
|
||||||
|
"mutating": True,
|
||||||
|
"repo": None,
|
||||||
|
"status": "planned",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -19,9 +19,13 @@ if str(RELEASE_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(RELEASE_ROOT))
|
sys.path.insert(0, str(RELEASE_ROOT))
|
||||||
|
|
||||||
from govoplan_release.publisher import publish_catalog_candidate # noqa: E402
|
from govoplan_release.publisher import publish_catalog_candidate # noqa: E402
|
||||||
|
from govoplan_release.catalog import canonical_hash # noqa: E402
|
||||||
from govoplan_release.selective_catalog import ( # noqa: E402
|
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||||
build_selective_catalog_candidate,
|
build_selective_catalog_candidate,
|
||||||
enforce_selected_source_provenance,
|
enforce_selected_source_provenance,
|
||||||
|
parse_signing_key,
|
||||||
|
public_key_base64,
|
||||||
|
signature,
|
||||||
)
|
)
|
||||||
from govoplan_release.source_provenance import ( # noqa: E402
|
from govoplan_release.source_provenance import ( # noqa: E402
|
||||||
catalog_source_selection,
|
catalog_source_selection,
|
||||||
@@ -117,10 +121,20 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
|||||||
core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||||
git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
||||||
git(core, "push", "origin", "refs/tags/v1.2.3")
|
git(core, "push", "origin", "refs/tags/v1.2.3")
|
||||||
base_catalog = self.root / "base.json"
|
private_key = Ed25519PrivateKey.generate()
|
||||||
base_catalog.write_text(
|
keyring = {
|
||||||
json.dumps(
|
"keys": [
|
||||||
{
|
{
|
||||||
|
"key_id": "test-key",
|
||||||
|
"status": "active",
|
||||||
|
"public_key": public_key_base64(private_key),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
base_keyring = self.root / "base-keyring.json"
|
||||||
|
base_keyring.write_text(json.dumps(keyring), encoding="utf-8")
|
||||||
|
base_catalog = self.root / "base.json"
|
||||||
|
base_payload = {
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"sequence": 1,
|
"sequence": 1,
|
||||||
"core_release": {
|
"core_release": {
|
||||||
@@ -128,12 +142,16 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
|||||||
"python_ref": "govoplan-core @ git+ssh://git@example.test/acme/govoplan-core.git@v1.2.2",
|
"python_ref": "govoplan-core @ git+ssh://git@example.test/acme/govoplan-core.git@v1.2.2",
|
||||||
},
|
},
|
||||||
"modules": [],
|
"modules": [],
|
||||||
"release": {"version": "1.2.2", "tag": "v1.2.2"},
|
"release": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"tag": "v1.2.2",
|
||||||
|
"keyring_sha256": canonical_hash(keyring),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
),
|
base_payload["signatures"] = [
|
||||||
encoding="utf-8",
|
signature(base_payload, key_id="test-key", private_key=private_key)
|
||||||
)
|
]
|
||||||
private_key = Ed25519PrivateKey.generate()
|
base_catalog.write_text(json.dumps(base_payload), encoding="utf-8")
|
||||||
key_path = self.root / "release.pem"
|
key_path = self.root / "release.pem"
|
||||||
key_path.write_bytes(
|
key_path.write_bytes(
|
||||||
private_key.private_bytes(
|
private_key.private_bytes(
|
||||||
@@ -142,6 +160,7 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
|||||||
encryption_algorithm=serialization.NoEncryption(),
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
key_path.chmod(0o600)
|
||||||
output = self.root / "candidate"
|
output = self.root / "candidate"
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
@@ -153,6 +172,7 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
|||||||
workspace_root=self.workspace,
|
workspace_root=self.workspace,
|
||||||
output_dir=output,
|
output_dir=output,
|
||||||
base_catalog=base_catalog,
|
base_catalog=base_catalog,
|
||||||
|
base_keyring=base_keyring,
|
||||||
signing_keys=(f"test-key={key_path}",),
|
signing_keys=(f"test-key={key_path}",),
|
||||||
check_public=False,
|
check_public=False,
|
||||||
)
|
)
|
||||||
@@ -163,6 +183,25 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
|||||||
self.assertEqual(git_text(core, "rev-parse", "refs/tags/v1.2.3"), selected["tag_object_sha"])
|
self.assertEqual(git_text(core, "rev-parse", "refs/tags/v1.2.3"), selected["tag_object_sha"])
|
||||||
self.assertEqual("test-key", payload["signatures"][0]["key_id"])
|
self.assertEqual("test-key", payload["signatures"][0]["key_id"])
|
||||||
|
|
||||||
|
def test_catalog_signing_key_must_be_operator_private(self) -> None:
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
key_path = self.root / "release.pem"
|
||||||
|
key_path.write_bytes(
|
||||||
|
private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
key_path.chmod(0o644)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "inaccessible to other users"):
|
||||||
|
parse_signing_key(f"test-key={key_path}")
|
||||||
|
|
||||||
|
key_path.chmod(0o600)
|
||||||
|
key_id, _parsed = parse_signing_key(f"test-key={key_path}")
|
||||||
|
self.assertEqual("test-key", key_id)
|
||||||
|
|
||||||
def test_catalog_publication_checks_every_preserved_source_ref(self) -> None:
|
def test_catalog_publication_checks_every_preserved_source_ref(self) -> None:
|
||||||
core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||||
git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
||||||
@@ -217,14 +256,13 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
|||||||
self.assertIn("Array.isArray(result.notes)", ui)
|
self.assertIn("Array.isArray(result.notes)", ui)
|
||||||
self.assertIn("Validation and provenance details", ui)
|
self.assertIn("Validation and provenance details", ui)
|
||||||
|
|
||||||
def test_console_returns_actionable_conflict_for_candidate_provenance_gate(self) -> None:
|
def test_console_disables_unclaimed_legacy_candidate_signing(self) -> None:
|
||||||
with patch(
|
with TestClient(
|
||||||
"server.app.build_selective_catalog_candidate",
|
create_app(workspace_root=self.workspace, token="token")
|
||||||
side_effect=ValueError("Complete catalog source provenance gate failed: govoplan-core v1.2.3: missing"),
|
) as client:
|
||||||
):
|
|
||||||
with TestClient(create_app(workspace_root=self.workspace)) as client:
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/catalog-candidates",
|
"/api/catalog-candidates",
|
||||||
|
headers={"X-Release-Console-Token": "token"},
|
||||||
json={
|
json={
|
||||||
"repo_versions": {"govoplan-core": "1.2.3"},
|
"repo_versions": {"govoplan-core": "1.2.3"},
|
||||||
"signing_keys": ["test=/unused/key.pem"],
|
"signing_keys": ["test=/unused/key.pem"],
|
||||||
@@ -232,7 +270,7 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(409, response.status_code)
|
self.assertEqual(409, response.status_code)
|
||||||
self.assertIn("govoplan-core v1.2.3: missing", response.json()["detail"])
|
self.assertIn("durable release run", response.json()["detail"])
|
||||||
|
|
||||||
def test_read_only_ref_checks_can_reuse_the_same_gitea_https_endpoint(self) -> None:
|
def test_read_only_ref_checks_can_reuse_the_same_gitea_https_endpoint(self) -> None:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.version_metadata import ( # noqa: E402
|
||||||
|
apply_version_metadata_mutations,
|
||||||
|
version_metadata_mutations,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseVersionMetadataTests(unittest.TestCase):
|
||||||
|
def test_updates_recognized_metadata_without_changing_interface_versions(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
package = root / "src" / "govoplan_example"
|
||||||
|
backend = package / "backend"
|
||||||
|
webui = root / "webui"
|
||||||
|
backend.mkdir(parents=True)
|
||||||
|
webui.mkdir()
|
||||||
|
(root / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "govoplan-example"\nversion = "1.2.3"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(root / "package.json").write_text(
|
||||||
|
'{"name":"root","version":"1.2.3"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(webui / "package.json").write_text(
|
||||||
|
'{"name":"@govoplan/example-webui","version":"1.2.3"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(webui / "package-lock.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "@govoplan/example-webui",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@govoplan/example-webui",
|
||||||
|
"version": "1.2.3",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(backend / "manifest.py").write_text(
|
||||||
|
"def get_manifest():\n"
|
||||||
|
" return ModuleManifest(\n"
|
||||||
|
' id="example",\n'
|
||||||
|
' version="1.2.3",\n'
|
||||||
|
" provides_interfaces=(\n"
|
||||||
|
' ModuleInterfaceProvider(name="example.api", version="4.0"),\n'
|
||||||
|
" ),\n"
|
||||||
|
" )\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(package / "__init__.py").write_text(
|
||||||
|
'__version__ = "1.2.3"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
preview = version_metadata_mutations(
|
||||||
|
root,
|
||||||
|
target_version="1.2.4",
|
||||||
|
)
|
||||||
|
changed = apply_version_metadata_mutations(
|
||||||
|
root,
|
||||||
|
target_version="1.2.4",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{mutation.path for mutation in preview},
|
||||||
|
set(changed),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"1.2.4",
|
||||||
|
json.loads((webui / "package-lock.json").read_text())[
|
||||||
|
"packages"
|
||||||
|
][""]["version"],
|
||||||
|
)
|
||||||
|
manifest = (backend / "manifest.py").read_text(encoding="utf-8")
|
||||||
|
self.assertIn('version="1.2.4"', manifest)
|
||||||
|
self.assertIn('version="4.0"', manifest)
|
||||||
|
self.assertEqual(
|
||||||
|
'__version__ = "1.2.4"\n',
|
||||||
|
(package / "__init__.py").read_text(encoding="utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPT = META_ROOT / "tools" / "repo" / "bootstrap-repositories.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_bootstrap_module():
|
||||||
|
spec = importlib.util.spec_from_file_location("bootstrap_repositories", SCRIPT)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError(f"Could not load {SCRIPT}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryBootstrapTests(unittest.TestCase):
|
||||||
|
def test_public_https_transport_rewrites_registered_gitea_remotes(self) -> None:
|
||||||
|
bootstrap = load_bootstrap_module()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||||
|
bootstrap.clone_remote(
|
||||||
|
"git@git.add-ideas.de:GovOPlaN/govoplan-core.git",
|
||||||
|
transport=bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||||
|
bootstrap.clone_remote(
|
||||||
|
"ssh://git@git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||||
|
transport=bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||||
|
bootstrap.clone_remote(
|
||||||
|
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||||
|
transport=bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_registered_transport_preserves_the_manifest_remote(self) -> None:
|
||||||
|
bootstrap = load_bootstrap_module()
|
||||||
|
remote = "git@example.test:private/repository.git"
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
remote,
|
||||||
|
bootstrap.clone_remote(
|
||||||
|
remote,
|
||||||
|
transport=bootstrap.REGISTERED_TRANSPORT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_public_https_transport_fails_closed_for_other_hosts(self) -> None:
|
||||||
|
bootstrap = load_bootstrap_module()
|
||||||
|
|
||||||
|
unsafe_remotes = (
|
||||||
|
"git@example.test:GovOPlaN/govoplan-core.git",
|
||||||
|
"https://token@git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||||
|
"https://git.add-ideas.de:443/GovOPlaN/govoplan-core.git",
|
||||||
|
"https://git.add-ideas.de/add-ideas/../govoplan-core.git",
|
||||||
|
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git?ref=main",
|
||||||
|
"ssh://root@git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||||
|
)
|
||||||
|
for remote in unsafe_remotes:
|
||||||
|
with self.subTest(remote=remote), self.assertRaises(ValueError):
|
||||||
|
bootstrap.clone_remote(
|
||||||
|
remote,
|
||||||
|
transport=bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_main_clones_missing_repositories_over_public_https(self) -> None:
|
||||||
|
bootstrap = load_bootstrap_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
parent = root / "checkouts"
|
||||||
|
parent.mkdir()
|
||||||
|
root.joinpath("repositories.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"default_parent": str(parent),
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "govoplan-core",
|
||||||
|
"path": "govoplan-core",
|
||||||
|
"remote": (
|
||||||
|
"git@git.add-ideas.de:"
|
||||||
|
"GovOPlaN/govoplan-core.git"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(bootstrap, "ROOT", root),
|
||||||
|
patch.object(bootstrap.subprocess, "run") as runner,
|
||||||
|
):
|
||||||
|
status = bootstrap.main(
|
||||||
|
[
|
||||||
|
"--parent",
|
||||||
|
str(parent),
|
||||||
|
"--transport",
|
||||||
|
bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, status)
|
||||||
|
runner.assert_called_once_with(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"credential.helper=",
|
||||||
|
"clone",
|
||||||
|
"--",
|
||||||
|
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||||
|
str(parent / "govoplan-core"),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
env=runner.call_args.kwargs["env"],
|
||||||
|
)
|
||||||
|
environment = runner.call_args.kwargs["env"]
|
||||||
|
self.assertEqual("/bin/false", environment["GIT_ASKPASS"])
|
||||||
|
self.assertEqual("0", environment["GIT_TERMINAL_PROMPT"])
|
||||||
|
|
||||||
|
def test_main_validates_every_remote_before_cloning(self) -> None:
|
||||||
|
bootstrap = load_bootstrap_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
parent = root / "checkouts"
|
||||||
|
parent.mkdir()
|
||||||
|
root.joinpath("repositories.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"default_parent": str(parent),
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "govoplan-core",
|
||||||
|
"path": "govoplan-core",
|
||||||
|
"remote": (
|
||||||
|
"git@git.add-ideas.de:"
|
||||||
|
"GovOPlaN/govoplan-core.git"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unsafe",
|
||||||
|
"path": "unsafe",
|
||||||
|
"remote": "git@example.test:private/unsafe.git",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(bootstrap, "ROOT", root),
|
||||||
|
patch.object(bootstrap.subprocess, "run") as runner,
|
||||||
|
self.assertRaises(ValueError),
|
||||||
|
):
|
||||||
|
bootstrap.main(
|
||||||
|
[
|
||||||
|
"--parent",
|
||||||
|
str(parent),
|
||||||
|
"--transport",
|
||||||
|
bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
runner.assert_not_called()
|
||||||
|
|
||||||
|
def test_main_preserves_an_explicit_private_repository_transport(self) -> None:
|
||||||
|
bootstrap = load_bootstrap_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
parent = root / "checkouts"
|
||||||
|
parent.mkdir()
|
||||||
|
root.joinpath("repositories.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"default_parent": str(parent),
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "website",
|
||||||
|
"path": "website",
|
||||||
|
"remote": (
|
||||||
|
"git@git.add-ideas.de:"
|
||||||
|
"add-ideas/addideas-govoplan-website.git"
|
||||||
|
),
|
||||||
|
"bootstrap_transport": (
|
||||||
|
bootstrap.REGISTERED_TRANSPORT
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(bootstrap, "ROOT", root),
|
||||||
|
patch.object(bootstrap.subprocess, "run") as runner,
|
||||||
|
):
|
||||||
|
status = bootstrap.main(
|
||||||
|
[
|
||||||
|
"--parent",
|
||||||
|
str(parent),
|
||||||
|
"--transport",
|
||||||
|
bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, status)
|
||||||
|
command = runner.call_args.args[0]
|
||||||
|
self.assertEqual(
|
||||||
|
"git@git.add-ideas.de:add-ideas/addideas-govoplan-website.git",
|
||||||
|
command[-2],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_main_limits_bootstrap_to_selected_repositories(self) -> None:
|
||||||
|
bootstrap = load_bootstrap_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
parent = root / "checkouts"
|
||||||
|
parent.mkdir()
|
||||||
|
root.joinpath("repositories.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"default_parent": str(parent),
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"path": name,
|
||||||
|
"remote": (
|
||||||
|
"git@git.add-ideas.de:GovOPlaN/"
|
||||||
|
f"{name}.git"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for name in ("govoplan-core", "govoplan-poll")
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
for repository_filter in (
|
||||||
|
["--repo", "govoplan-core"],
|
||||||
|
["--exclude-repo", "govoplan-poll"],
|
||||||
|
):
|
||||||
|
with (
|
||||||
|
self.subTest(repository_filter=repository_filter),
|
||||||
|
patch.object(bootstrap, "ROOT", root),
|
||||||
|
patch.object(bootstrap.subprocess, "run") as runner,
|
||||||
|
):
|
||||||
|
status = bootstrap.main(
|
||||||
|
[
|
||||||
|
"--parent",
|
||||||
|
str(parent),
|
||||||
|
"--transport",
|
||||||
|
bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||||
|
*repository_filter,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertEqual(0, status)
|
||||||
|
runner.assert_called_once()
|
||||||
|
self.assertEqual(
|
||||||
|
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||||
|
runner.call_args.args[0][-2],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_main_rejects_unknown_or_conflicting_repository_filters(self) -> None:
|
||||||
|
bootstrap = load_bootstrap_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
parent = root / "checkouts"
|
||||||
|
parent.mkdir()
|
||||||
|
root.joinpath("repositories.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"default_parent": str(parent),
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "govoplan-core",
|
||||||
|
"path": "govoplan-core",
|
||||||
|
"remote": (
|
||||||
|
"git@git.add-ideas.de:"
|
||||||
|
"GovOPlaN/govoplan-core.git"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(bootstrap, "ROOT", root),
|
||||||
|
patch.object(bootstrap.subprocess, "run") as runner,
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(ValueError, "unknown registered"):
|
||||||
|
bootstrap.main(["--repo", "govoplan-missing"])
|
||||||
|
with self.assertRaisesRegex(ValueError, "selected and excluded"):
|
||||||
|
bootstrap.main(
|
||||||
|
[
|
||||||
|
"--repo",
|
||||||
|
"govoplan-core",
|
||||||
|
"--exclude-repo",
|
||||||
|
"govoplan-core",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
runner.assert_not_called()
|
||||||
|
|
||||||
|
def test_check_reports_missing_without_cloning(self) -> None:
|
||||||
|
bootstrap = load_bootstrap_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
parent = root / "checkouts"
|
||||||
|
parent.mkdir()
|
||||||
|
root.joinpath("repositories.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"default_parent": str(parent),
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "govoplan-core",
|
||||||
|
"path": "govoplan-core",
|
||||||
|
"remote": (
|
||||||
|
"git@git.add-ideas.de:"
|
||||||
|
"GovOPlaN/govoplan-core.git"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(bootstrap, "ROOT", root),
|
||||||
|
patch.object(bootstrap.subprocess, "run") as runner,
|
||||||
|
):
|
||||||
|
status = bootstrap.main(
|
||||||
|
[
|
||||||
|
"--check",
|
||||||
|
"--parent",
|
||||||
|
str(parent),
|
||||||
|
"--transport",
|
||||||
|
bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, status)
|
||||||
|
runner.assert_not_called()
|
||||||
|
|
||||||
|
def test_anonymous_ci_bootstrap_excludes_registered_transport_repositories(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
manifest = json.loads(
|
||||||
|
(META_ROOT / "repositories.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
registered_only = {
|
||||||
|
entry["name"]
|
||||||
|
for entry in manifest["repositories"]
|
||||||
|
if entry.get("bootstrap_transport") == "registered"
|
||||||
|
}
|
||||||
|
self.assertTrue(registered_only)
|
||||||
|
|
||||||
|
for workflow in sorted(
|
||||||
|
(META_ROOT / ".gitea" / "workflows").glob("*.yml")
|
||||||
|
):
|
||||||
|
contents = workflow.read_text(encoding="utf-8")
|
||||||
|
if (
|
||||||
|
"bootstrap-repositories.py" not in contents
|
||||||
|
or "--transport public-https" not in contents
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
with self.subTest(workflow=workflow.name):
|
||||||
|
for repository in registered_only:
|
||||||
|
self.assertIn(
|
||||||
|
f"--exclude-repo {repository}",
|
||||||
|
contents,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RESOLVER = (
|
||||||
|
META_ROOT / "tools" / "checks" / "security-audit" / "resolve_workspace_mount.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_resolver_module():
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"security_audit_mount_resolver",
|
||||||
|
RESOLVER,
|
||||||
|
)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError(f"Could not load {RESOLVER}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityAuditMountResolverTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.resolver = load_resolver_module()
|
||||||
|
self.root = "/workspace/GovOPlaN/govoplan/govoplan"
|
||||||
|
self.workspace = "/workspace/GovOPlaN/govoplan"
|
||||||
|
|
||||||
|
def test_resolves_only_the_named_workspace_volume_for_both_scopes(self) -> None:
|
||||||
|
mounts = [
|
||||||
|
{
|
||||||
|
"Type": "bind",
|
||||||
|
"Source": "/var/run/docker.sock",
|
||||||
|
"Destination": "/var/run/docker.sock",
|
||||||
|
"RW": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Type": "volume",
|
||||||
|
"Name": "actions-workspace",
|
||||||
|
"Source": "/var/lib/docker/volumes/actions-workspace/_data",
|
||||||
|
"Destination": self.workspace,
|
||||||
|
"RW": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Type": "volume",
|
||||||
|
"Name": "actions-environment",
|
||||||
|
"Source": "/var/lib/docker/volumes/actions-environment/_data",
|
||||||
|
"Destination": "/var/run/act",
|
||||||
|
"RW": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for scope in ("current", "govoplan"):
|
||||||
|
with self.subTest(scope=scope):
|
||||||
|
self.assertEqual(
|
||||||
|
(f"type=volume,source=actions-workspace,target={self.workspace}"),
|
||||||
|
self.resolver.resolve_workspace_mount(
|
||||||
|
mounts,
|
||||||
|
root=self.root,
|
||||||
|
scope=scope,
|
||||||
|
reports_dir="audit-reports",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolves_a_workspace_bind_without_other_mounts(self) -> None:
|
||||||
|
mounts = [
|
||||||
|
{
|
||||||
|
"Type": "bind",
|
||||||
|
"Source": "/srv/gitea/actions/task-123",
|
||||||
|
"Destination": self.workspace,
|
||||||
|
"RW": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Type": "bind",
|
||||||
|
"Source": "/var/run/docker.sock",
|
||||||
|
"Destination": "/var/run/docker.sock",
|
||||||
|
"RW": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
(f"type=bind,source=/srv/gitea/actions/task-123,target={self.workspace}"),
|
||||||
|
self.resolver.resolve_workspace_mount(
|
||||||
|
mounts,
|
||||||
|
root=self.root,
|
||||||
|
scope="govoplan",
|
||||||
|
reports_dir="audit-reports",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nested_repository_mount_is_valid_only_for_current_scope(self) -> None:
|
||||||
|
mounts = [
|
||||||
|
{
|
||||||
|
"Type": "volume",
|
||||||
|
"Name": "all-repositories",
|
||||||
|
"Destination": self.workspace,
|
||||||
|
"RW": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Type": "volume",
|
||||||
|
"Name": "current-repository",
|
||||||
|
"Destination": self.root,
|
||||||
|
"RW": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"source=current-repository",
|
||||||
|
self.resolver.resolve_workspace_mount(
|
||||||
|
mounts,
|
||||||
|
root=self.root,
|
||||||
|
scope="current",
|
||||||
|
reports_dir="audit-reports",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
self.resolver.MountResolutionError,
|
||||||
|
"nested job-container mounts",
|
||||||
|
):
|
||||||
|
self.resolver.resolve_workspace_mount(
|
||||||
|
mounts,
|
||||||
|
root=self.root,
|
||||||
|
scope="govoplan",
|
||||||
|
reports_dir="audit-reports",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_unsafe_or_unusable_mount_layouts(self) -> None:
|
||||||
|
cases = {
|
||||||
|
"missing": [],
|
||||||
|
"path-boundary": [
|
||||||
|
{
|
||||||
|
"Type": "volume",
|
||||||
|
"Name": "wrong-workspace",
|
||||||
|
"Destination": "/workspace/GovOPlaN/govoplan-other",
|
||||||
|
"RW": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"read-only": [
|
||||||
|
{
|
||||||
|
"Type": "volume",
|
||||||
|
"Name": "actions-workspace",
|
||||||
|
"Destination": self.workspace,
|
||||||
|
"RW": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ambiguous": [
|
||||||
|
{
|
||||||
|
"Type": "volume",
|
||||||
|
"Name": name,
|
||||||
|
"Destination": self.workspace,
|
||||||
|
"RW": True,
|
||||||
|
}
|
||||||
|
for name in ("workspace-one", "workspace-two")
|
||||||
|
],
|
||||||
|
"scope-too-narrow": [
|
||||||
|
{
|
||||||
|
"Type": "volume",
|
||||||
|
"Name": "repository-only",
|
||||||
|
"Destination": self.root,
|
||||||
|
"RW": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, mounts in cases.items():
|
||||||
|
with (
|
||||||
|
self.subTest(name=name),
|
||||||
|
self.assertRaises(self.resolver.MountResolutionError),
|
||||||
|
):
|
||||||
|
self.resolver.resolve_workspace_mount(
|
||||||
|
mounts,
|
||||||
|
root=self.root,
|
||||||
|
scope="govoplan",
|
||||||
|
reports_dir="audit-reports",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_reports_outside_the_selected_workspace_mount(self) -> None:
|
||||||
|
mounts = [
|
||||||
|
{
|
||||||
|
"Type": "volume",
|
||||||
|
"Name": "actions-workspace",
|
||||||
|
"Destination": self.workspace,
|
||||||
|
"RW": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
self.resolver.MountResolutionError,
|
||||||
|
"reports path .* outside",
|
||||||
|
):
|
||||||
|
self.resolver.resolve_workspace_mount(
|
||||||
|
mounts,
|
||||||
|
root=self.root,
|
||||||
|
scope="current",
|
||||||
|
reports_dir="/tmp/audit-reports",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_broad_or_sensitive_workspace_bind_sources(self) -> None:
|
||||||
|
for source in (
|
||||||
|
"/",
|
||||||
|
"/home",
|
||||||
|
"/var/run/docker.sock",
|
||||||
|
"/srv/../etc/shadow",
|
||||||
|
):
|
||||||
|
with (
|
||||||
|
self.subTest(source=source),
|
||||||
|
self.assertRaisesRegex(
|
||||||
|
self.resolver.MountResolutionError,
|
||||||
|
"too broad or sensitive",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
self.resolver.resolve_workspace_mount(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Type": "bind",
|
||||||
|
"Source": source,
|
||||||
|
"Destination": self.workspace,
|
||||||
|
"RW": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
root=self.root,
|
||||||
|
scope="govoplan",
|
||||||
|
reports_dir="audit-reports",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(self.resolver.MountResolutionError):
|
||||||
|
self.resolver.resolve_workspace_mount(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Type": "bind",
|
||||||
|
"Source": "//var/lib/workspace",
|
||||||
|
"Destination": self.workspace,
|
||||||
|
"RW": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
root=self.root,
|
||||||
|
scope="govoplan",
|
||||||
|
reports_dir="audit-reports",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -12,6 +12,7 @@ import unittest
|
|||||||
|
|
||||||
META_ROOT = Path(__file__).resolve().parents[1]
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
AUDIT_SCRIPT = META_ROOT / "tools" / "checks" / "check-security-audit.sh"
|
AUDIT_SCRIPT = META_ROOT / "tools" / "checks" / "check-security-audit.sh"
|
||||||
|
CONTAINER_RUNNER = META_ROOT / "tools" / "checks" / "security-audit" / "run.sh"
|
||||||
|
|
||||||
|
|
||||||
class SecurityAuditWrapperTests(unittest.TestCase):
|
class SecurityAuditWrapperTests(unittest.TestCase):
|
||||||
@@ -252,6 +253,12 @@ class SecurityAuditWrapperTests(unittest.TestCase):
|
|||||||
self.assertEqual(0, manifest["overall_status"])
|
self.assertEqual(0, manifest["overall_status"])
|
||||||
self.assertEqual(1, manifest["finding_status"])
|
self.assertEqual(1, manifest["finding_status"])
|
||||||
self.assertEqual(0, manifest["execution_error_status"])
|
self.assertEqual(0, manifest["execution_error_status"])
|
||||||
|
self.assertEqual("complete", manifest["coverage_status"])
|
||||||
|
scanner_results = {
|
||||||
|
result["id"]: result["status"]
|
||||||
|
for result in manifest["scanner_coverage"]["results"]
|
||||||
|
}
|
||||||
|
self.assertEqual("findings", scanner_results["semgrep"])
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"Semgrep SAST\t1\tfindings", (reports / "step-status.tsv").read_text()
|
"Semgrep SAST\t1\tfindings", (reports / "step-status.tsv").read_text()
|
||||||
)
|
)
|
||||||
@@ -311,6 +318,86 @@ class SecurityAuditWrapperTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(1, self._manifest(invalid_reports)["execution_error_status"])
|
self.assertEqual(1, self._manifest(invalid_reports)["execution_error_status"])
|
||||||
|
|
||||||
|
def test_missing_tool_is_machine_readable_and_strictly_enforced(self) -> None:
|
||||||
|
gitleaks_stub = self.stub_bin / "gitleaks"
|
||||||
|
disabled_stub = self.stub_bin / "gitleaks.disabled"
|
||||||
|
gitleaks_stub.rename(disabled_stub)
|
||||||
|
try:
|
||||||
|
result, reports = self._run(
|
||||||
|
"--report-only",
|
||||||
|
SECURITY_AUDIT_REQUIRE_TOOLS="0",
|
||||||
|
CI="false",
|
||||||
|
GITEA_ACTIONS="false",
|
||||||
|
)
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
manifest = self._manifest(reports)
|
||||||
|
self.assertEqual("incomplete", manifest["coverage_status"])
|
||||||
|
self.assertEqual(["gitleaks"], manifest["scanner_coverage"]["incomplete"])
|
||||||
|
scanner_results = {
|
||||||
|
item["id"]: item for item in manifest["scanner_coverage"]["results"]
|
||||||
|
}
|
||||||
|
self.assertEqual("skipped", scanner_results["gitleaks"]["status"])
|
||||||
|
self.assertEqual(127, scanner_results["gitleaks"]["exit_code"])
|
||||||
|
self.assertIn(
|
||||||
|
"gitleaks\tmissing",
|
||||||
|
(reports / "tool-versions.txt").read_text(encoding="utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
strict_result, strict_reports = self._run(
|
||||||
|
"--strict",
|
||||||
|
SECURITY_AUDIT_REQUIRE_TOOLS="0",
|
||||||
|
CI="false",
|
||||||
|
GITEA_ACTIONS="false",
|
||||||
|
)
|
||||||
|
self.assertEqual(1, strict_result.returncode)
|
||||||
|
self.assertEqual(
|
||||||
|
"incomplete",
|
||||||
|
self._manifest(strict_reports)["coverage_status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
ci_result, ci_reports = self._run(
|
||||||
|
"--report-only",
|
||||||
|
SECURITY_AUDIT_REQUIRE_TOOLS="0",
|
||||||
|
CI="true",
|
||||||
|
GITEA_ACTIONS="false",
|
||||||
|
)
|
||||||
|
self.assertEqual(1, ci_result.returncode)
|
||||||
|
self.assertEqual(
|
||||||
|
"incomplete",
|
||||||
|
self._manifest(ci_reports)["coverage_status"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
disabled_stub.rename(gitleaks_stub)
|
||||||
|
|
||||||
|
def test_full_mode_records_every_required_scanner(self) -> None:
|
||||||
|
self._install_full_mode_stubs()
|
||||||
|
|
||||||
|
result, reports = self._run("--report-only", mode="full")
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
manifest = self._manifest(reports)
|
||||||
|
self.assertEqual("complete", manifest["coverage_status"])
|
||||||
|
required = manifest["scanner_coverage"]["required"]
|
||||||
|
results = manifest["scanner_coverage"]["results"]
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"semgrep",
|
||||||
|
"bandit",
|
||||||
|
"ruff-security",
|
||||||
|
"gitleaks",
|
||||||
|
"trivy",
|
||||||
|
"pip-audit",
|
||||||
|
"npm-audit",
|
||||||
|
"osv-scanner",
|
||||||
|
"jscpd",
|
||||||
|
"radon",
|
||||||
|
"xenon",
|
||||||
|
},
|
||||||
|
set(required),
|
||||||
|
)
|
||||||
|
self.assertEqual(set(required), {item["id"] for item in results})
|
||||||
|
self.assertTrue(all(item["status"] == "no-findings" for item in results))
|
||||||
|
|
||||||
def test_workspace_mutation_invalidates_the_audit(self) -> None:
|
def test_workspace_mutation_invalidates_the_audit(self) -> None:
|
||||||
result, reports = self._run(
|
result, reports = self._run(
|
||||||
"--report-only",
|
"--report-only",
|
||||||
@@ -354,5 +441,226 @@ class SecurityAuditWrapperTests(unittest.TestCase):
|
|||||||
self.assertEqual(set(manifest["reports"]), checksummed_paths)
|
self.assertEqual(set(manifest["reports"]), checksummed_paths)
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityAuditContainerRunnerTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._temporary_directory = tempfile.TemporaryDirectory(
|
||||||
|
prefix="govoplan-audit-runner-"
|
||||||
|
)
|
||||||
|
root = Path(self._temporary_directory.name)
|
||||||
|
self.stub_bin = root / "bin"
|
||||||
|
self.stub_bin.mkdir()
|
||||||
|
self.docker_log = root / "docker.jsonl"
|
||||||
|
docker_stub = self.stub_bin / "docker"
|
||||||
|
docker_stub.write_text(
|
||||||
|
textwrap.dedent(
|
||||||
|
"""\
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
arguments = sys.argv[1:]
|
||||||
|
with Path(os.environ["DOCKER_STUB_LOG"]).open(
|
||||||
|
"a", encoding="utf-8"
|
||||||
|
) as handle:
|
||||||
|
handle.write(json.dumps(arguments) + "\\n")
|
||||||
|
if arguments and arguments[0] == "version":
|
||||||
|
print("26.1.0")
|
||||||
|
if arguments[:2] == ["container", "inspect"]:
|
||||||
|
print(os.environ["DOCKER_STUB_MOUNTS"])
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
docker_stub.chmod(0o755)
|
||||||
|
self.environment = os.environ.copy()
|
||||||
|
self.environment.update(
|
||||||
|
{
|
||||||
|
"DOCKER_STUB_LOG": str(self.docker_log),
|
||||||
|
"DOCKER_STUB_MOUNTS": "[]",
|
||||||
|
"PATH": f"{self.stub_bin}:{self.environment['PATH']}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._temporary_directory.cleanup()
|
||||||
|
|
||||||
|
def _run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
scope: str,
|
||||||
|
actions_mounts: object | None = None,
|
||||||
|
**environment_overrides: str,
|
||||||
|
) -> tuple[subprocess.CompletedProcess[str], list[list[str]]]:
|
||||||
|
self.docker_log.write_text("", encoding="utf-8")
|
||||||
|
environment = self.environment.copy()
|
||||||
|
environment.update(environment_overrides)
|
||||||
|
if actions_mounts is None:
|
||||||
|
environment.pop("GITEA_ACTIONS", None)
|
||||||
|
else:
|
||||||
|
environment["GITEA_ACTIONS"] = "true"
|
||||||
|
environment["DOCKER_STUB_MOUNTS"] = json.dumps(actions_mounts)
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"bash",
|
||||||
|
str(CONTAINER_RUNNER),
|
||||||
|
"--mode",
|
||||||
|
"quick",
|
||||||
|
"--scope",
|
||||||
|
scope,
|
||||||
|
],
|
||||||
|
cwd=META_ROOT,
|
||||||
|
env=environment,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
commands = [
|
||||||
|
json.loads(line)
|
||||||
|
for line in self.docker_log.read_text(encoding="utf-8").splitlines()
|
||||||
|
]
|
||||||
|
return result, commands
|
||||||
|
|
||||||
|
def test_gitea_job_shares_only_its_workspace_mount(self) -> None:
|
||||||
|
mounts = [
|
||||||
|
{
|
||||||
|
"Type": "bind",
|
||||||
|
"Source": "/var/run/docker.sock",
|
||||||
|
"Destination": "/var/run/docker.sock",
|
||||||
|
"RW": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Type": "volume",
|
||||||
|
"Name": "govoplan-actions-workspace",
|
||||||
|
"Destination": str(META_ROOT.parent),
|
||||||
|
"RW": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Type": "volume",
|
||||||
|
"Name": "govoplan-actions-environment",
|
||||||
|
"Destination": "/var/run/act",
|
||||||
|
"RW": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
container_id = subprocess.run(
|
||||||
|
["hostname"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
for scope in ("current", "govoplan"):
|
||||||
|
with self.subTest(scope=scope):
|
||||||
|
result, commands = self._run(scope=scope, actions_mounts=mounts)
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
run_command = next(
|
||||||
|
command for command in commands if command[0] == "run"
|
||||||
|
)
|
||||||
|
inspect_command = next(
|
||||||
|
command
|
||||||
|
for command in commands
|
||||||
|
if command[:2] == ["container", "inspect"]
|
||||||
|
)
|
||||||
|
self.assertEqual(container_id, inspect_command[-1])
|
||||||
|
mount_index = run_command.index("--mount")
|
||||||
|
self.assertEqual(
|
||||||
|
(
|
||||||
|
"type=volume,source=govoplan-actions-workspace,"
|
||||||
|
f"target={META_ROOT.parent}"
|
||||||
|
),
|
||||||
|
run_command[mount_index + 1],
|
||||||
|
)
|
||||||
|
self.assertNotIn("--volumes-from", run_command)
|
||||||
|
self.assertNotIn("-v", run_command)
|
||||||
|
self.assertNotIn("/var/run/docker.sock", " ".join(run_command))
|
||||||
|
self.assertNotIn("/var/run/act", " ".join(run_command))
|
||||||
|
repository_roots = [
|
||||||
|
value
|
||||||
|
for value in run_command
|
||||||
|
if value.startswith("GOVOPLAN_REPOS_ROOT=")
|
||||||
|
]
|
||||||
|
expected_roots = (
|
||||||
|
[f"GOVOPLAN_REPOS_ROOT={META_ROOT.parent}"]
|
||||||
|
if scope == "govoplan"
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
self.assertEqual(expected_roots, repository_roots)
|
||||||
|
self.assertIn("SECURITY_AUDIT_REQUIRE_TOOLS=1", run_command)
|
||||||
|
|
||||||
|
def test_gitea_job_passes_only_public_git_url_rewrites(self) -> None:
|
||||||
|
mounts = [
|
||||||
|
{
|
||||||
|
"Type": "volume",
|
||||||
|
"Name": "govoplan-actions-workspace",
|
||||||
|
"Destination": str(META_ROOT.parent),
|
||||||
|
"RW": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
git_config = {
|
||||||
|
"GIT_CONFIG_COUNT": "2",
|
||||||
|
"GIT_CONFIG_KEY_0": (
|
||||||
|
"url.https://git.add-ideas.de/GovOPlaN/govoplan.insteadOf"
|
||||||
|
),
|
||||||
|
"GIT_CONFIG_VALUE_0": "git@git.add-ideas.de:GovOPlaN/govoplan",
|
||||||
|
"GIT_CONFIG_KEY_1": (
|
||||||
|
"url.https://git.add-ideas.de/GovOPlaN/govoplan.insteadOf"
|
||||||
|
),
|
||||||
|
"GIT_CONFIG_VALUE_1": (
|
||||||
|
"ssh://git@git.add-ideas.de/GovOPlaN/govoplan"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, commands = self._run(
|
||||||
|
scope="govoplan",
|
||||||
|
actions_mounts=mounts,
|
||||||
|
**git_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
run_command = next(command for command in commands if command[0] == "run")
|
||||||
|
for key, value in git_config.items():
|
||||||
|
self.assertIn(f"{key}={value}", run_command)
|
||||||
|
|
||||||
|
def test_container_runner_rejects_non_url_git_configuration(self) -> None:
|
||||||
|
result, _commands = self._run(
|
||||||
|
scope="current",
|
||||||
|
GIT_CONFIG_COUNT="1",
|
||||||
|
GIT_CONFIG_KEY_0="credential.helper",
|
||||||
|
GIT_CONFIG_VALUE_0="!print-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, result.returncode)
|
||||||
|
self.assertIn(
|
||||||
|
"Only credential-free HTTPS Git insteadOf rewrites",
|
||||||
|
result.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_security_workflow_configures_nested_public_git_resolution(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
workflow = (
|
||||||
|
META_ROOT / ".gitea" / "workflows" / "security-audit.yml"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn('GIT_CONFIG_COUNT: "2"', workflow)
|
||||||
|
self.assertIn(
|
||||||
|
"url.https://git.add-ideas.de/GovOPlaN/govoplan.insteadOf",
|
||||||
|
workflow,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_actions_runner_keeps_the_scoped_bind_mount(self) -> None:
|
||||||
|
result, commands = self._run(scope="govoplan")
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
run_command = next(command for command in commands if command[0] == "run")
|
||||||
|
bind_index = run_command.index("-v")
|
||||||
|
self.assertEqual(
|
||||||
|
f"{META_ROOT.parent}:/workspace",
|
||||||
|
run_command[bind_index + 1],
|
||||||
|
)
|
||||||
|
self.assertNotIn("--mount", run_command)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
META_ROOT = Path(__file__).resolve().parents[1]
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -20,6 +21,7 @@ from govoplan_release.version_alignment import ( # noqa: E402
|
|||||||
repository_version_issues,
|
repository_version_issues,
|
||||||
selected_repository_version_issues,
|
selected_repository_version_issues,
|
||||||
)
|
)
|
||||||
|
from govoplan_release.git_state import sanitized_git_environment # noqa: E402
|
||||||
from govoplan_release.model import ( # noqa: E402
|
from govoplan_release.model import ( # noqa: E402
|
||||||
CatalogSnapshot,
|
CatalogSnapshot,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
@@ -398,6 +400,68 @@ class VersionAlignmentTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue(all("must contain" in issue.message for issue in issues))
|
self.assertTrue(all("must contain" in issue.message for issue in issues))
|
||||||
|
|
||||||
|
def test_annotated_release_tags_work_with_sanitized_git_configuration(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
workspace = Path(tmp)
|
||||||
|
meta = workspace / "govoplan"
|
||||||
|
core = workspace / "govoplan-core"
|
||||||
|
module = workspace / "govoplan-example"
|
||||||
|
meta.mkdir()
|
||||||
|
(core / "webui").mkdir(parents=True)
|
||||||
|
(module / "webui").mkdir(parents=True)
|
||||||
|
backend_ref = "git+ssh://git@example.test/acme/govoplan-example.git@v1.2.3"
|
||||||
|
webui_ref = "git+ssh://git@example.test/acme/govoplan-example.git#v1.2.3"
|
||||||
|
(meta / "requirements-release.txt").write_text(f"govoplan-example @ {backend_ref}\n")
|
||||||
|
(module / "pyproject.toml").write_text('[project]\nname="govoplan-example"\nversion="1.2.3"\n')
|
||||||
|
(module / "webui" / "package.json").write_text(
|
||||||
|
'{"name":"@govoplan/example-webui","version":"1.2.3"}\n'
|
||||||
|
)
|
||||||
|
subprocess.run(["git", "init", "-q", str(module)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(module), "config", "user.email", "test@example.test"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(module), "config", "user.name", "Test"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(module), "add", "pyproject.toml", "webui/package.json"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(module), "commit", "-qm", "release"], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(module), "tag", "-a", "v1.2.3", "-m", "Release v1.2.3"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
tagged_commit = subprocess.run(
|
||||||
|
["git", "-C", str(module), "rev-parse", "refs/tags/v1.2.3^{commit}"],
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
).stdout.strip()
|
||||||
|
dependencies = {"@govoplan/example-webui": webui_ref}
|
||||||
|
(core / "pyproject.toml").write_text('[project]\nname="govoplan-core"\nversion="2.0.0"\n')
|
||||||
|
(core / "webui" / "package.release.json").write_text(
|
||||||
|
json.dumps({"version": "2.0.0", "dependencies": dependencies})
|
||||||
|
)
|
||||||
|
(core / "webui" / "package-lock.release.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"packages": {
|
||||||
|
"": {"version": "2.0.0", "dependencies": dependencies},
|
||||||
|
"node_modules/@govoplan/example-webui": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": f"git+ssh://git@example.test/acme/govoplan-example.git#{tagged_commit}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
git_environment = sanitized_git_environment()
|
||||||
|
git_environment["GIT_TEST_ASSUME_DIFFERENT_OWNER"] = "1"
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.version_alignment.sanitized_git_environment",
|
||||||
|
return_value=git_environment,
|
||||||
|
):
|
||||||
|
composition_issues = release_composition_issues(meta, core_root=core)
|
||||||
|
core_issues = repository_version_issues(core)
|
||||||
|
|
||||||
|
self.assertEqual((), composition_issues)
|
||||||
|
self.assertEqual((), core_issues)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -105,6 +105,31 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
type=Path,
|
type=Path,
|
||||||
default=META_ROOT / "docs" / "installed-composition-evidence.schema.json",
|
default=META_ROOT / "docs" / "installed-composition-evidence.schema.json",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--installer-receipt",
|
||||||
|
type=Path,
|
||||||
|
help="Signed installer receipt binding installed payloads to this catalog.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--installer-receipt-schema",
|
||||||
|
type=Path,
|
||||||
|
default=META_ROOT / "docs" / "installer-receipt.schema.json",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--installer-authority-keyring",
|
||||||
|
type=Path,
|
||||||
|
help=(
|
||||||
|
"Independently provisioned, role-scoped trust root for installer "
|
||||||
|
"receipt signatures."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--installer-authority-keyring-schema",
|
||||||
|
type=Path,
|
||||||
|
default=META_ROOT
|
||||||
|
/ "docs"
|
||||||
|
/ "installer-receipt-authority-keyring.schema.json",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--boundary-evidence",
|
"--boundary-evidence",
|
||||||
type=Path,
|
type=Path,
|
||||||
@@ -165,6 +190,18 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"--boundary-evidence and --boundary-authority-keyring are required together"
|
"--boundary-evidence and --boundary-authority-keyring are required together"
|
||||||
)
|
)
|
||||||
|
if (args.installer_receipt is None) != (
|
||||||
|
args.installer_authority_keyring is None
|
||||||
|
):
|
||||||
|
raise SystemExit(
|
||||||
|
"--installer-receipt and --installer-authority-keyring are required together"
|
||||||
|
)
|
||||||
|
if args.installer_receipt is not None and (
|
||||||
|
args.installed_evidence is None and args.collect_installed_evidence is None
|
||||||
|
):
|
||||||
|
raise SystemExit(
|
||||||
|
"--installer-receipt requires --installed-evidence or --collect-installed-evidence"
|
||||||
|
)
|
||||||
if args.boundary_evidence is not None and (
|
if args.boundary_evidence is not None and (
|
||||||
args.installed_evidence is None and args.collect_installed_evidence is None
|
args.installed_evidence is None and args.collect_installed_evidence is None
|
||||||
):
|
):
|
||||||
@@ -286,6 +323,32 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
installer_receipt = None
|
||||||
|
installer_receipt_schema = None
|
||||||
|
installer_authority_keyring = None
|
||||||
|
installer_authority_keyring_schema = None
|
||||||
|
if args.installer_receipt is not None:
|
||||||
|
installer_receipt = read_object(
|
||||||
|
args.installer_receipt,
|
||||||
|
label="installer receipt",
|
||||||
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
|
)
|
||||||
|
installer_receipt_schema = read_object(
|
||||||
|
args.installer_receipt_schema,
|
||||||
|
label="installer receipt schema",
|
||||||
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
|
)
|
||||||
|
installer_authority_keyring = read_object(
|
||||||
|
args.installer_authority_keyring,
|
||||||
|
label="installer authority keyring",
|
||||||
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
|
)
|
||||||
|
installer_authority_keyring_schema = read_object(
|
||||||
|
args.installer_authority_keyring_schema,
|
||||||
|
label="installer authority keyring schema",
|
||||||
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
|
)
|
||||||
|
|
||||||
verification_time = parse_datetime(args.verification_time)
|
verification_time = parse_datetime(args.verification_time)
|
||||||
report = review_capability_fit(
|
report = review_capability_fit(
|
||||||
assessment=assessment,
|
assessment=assessment,
|
||||||
@@ -298,6 +361,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
else args.workspace_root.resolve(),
|
else args.workspace_root.resolve(),
|
||||||
installed_evidence=installed_evidence,
|
installed_evidence=installed_evidence,
|
||||||
installed_evidence_schema=installed_evidence_schema,
|
installed_evidence_schema=installed_evidence_schema,
|
||||||
|
installer_receipt=installer_receipt,
|
||||||
|
installer_receipt_schema=installer_receipt_schema,
|
||||||
|
installer_authority_keyring=installer_authority_keyring,
|
||||||
|
installer_authority_keyring_schema=installer_authority_keyring_schema,
|
||||||
boundary_evidence=boundary_evidence,
|
boundary_evidence=boundary_evidence,
|
||||||
boundary_evidence_schema=boundary_evidence_schema,
|
boundary_evidence_schema=boundary_evidence_schema,
|
||||||
boundary_authority_keyring=boundary_authority_keyring,
|
boundary_authority_keyring=boundary_authority_keyring,
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ def review_capability_fit(
|
|||||||
workspace_root: Path | None = None,
|
workspace_root: Path | None = None,
|
||||||
installed_evidence: dict[str, Any] | None = None,
|
installed_evidence: dict[str, Any] | None = None,
|
||||||
installed_evidence_schema: dict[str, Any] | None = None,
|
installed_evidence_schema: dict[str, Any] | None = None,
|
||||||
|
installer_receipt: dict[str, Any] | None = None,
|
||||||
|
installer_receipt_schema: dict[str, Any] | None = None,
|
||||||
|
installer_authority_keyring: dict[str, Any] | None = None,
|
||||||
|
installer_authority_keyring_schema: dict[str, Any] | None = None,
|
||||||
boundary_evidence: dict[str, Any] | None = None,
|
boundary_evidence: dict[str, Any] | None = None,
|
||||||
boundary_evidence_schema: dict[str, Any] | None = None,
|
boundary_evidence_schema: dict[str, Any] | None = None,
|
||||||
boundary_authority_keyring: dict[str, Any] | None = None,
|
boundary_authority_keyring: dict[str, Any] | None = None,
|
||||||
@@ -377,6 +381,24 @@ def review_capability_fit(
|
|||||||
verification_time=evidence_verification_time,
|
verification_time=evidence_verification_time,
|
||||||
verification_mode=evidence_verification_mode,
|
verification_mode=evidence_verification_mode,
|
||||||
catalog_trusted=catalog_dependency_trusted,
|
catalog_trusted=catalog_dependency_trusted,
|
||||||
|
catalog_artifacts=(
|
||||||
|
catalog.get("release", {}).get("artifacts")
|
||||||
|
if isinstance(catalog.get("release"), dict)
|
||||||
|
and "artifacts" in catalog.get("release", {})
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
catalog_sha256=canonical_hash(catalog),
|
||||||
|
catalog_channel=_text(catalog.get("channel")),
|
||||||
|
catalog_sequence=_integer(catalog.get("sequence")),
|
||||||
|
installer_receipt=installer_receipt,
|
||||||
|
installer_receipt_schema=installer_receipt_schema,
|
||||||
|
installer_authority_keyring=installer_authority_keyring,
|
||||||
|
installer_authority_keyring_schema=installer_authority_keyring_schema,
|
||||||
|
forbidden_installer_public_keys=public_key_material_from_keyrings(
|
||||||
|
published_keyring,
|
||||||
|
trusted_keyring,
|
||||||
|
boundary_authority_keyring or {},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
findings.extend(
|
findings.extend(
|
||||||
Finding(item.severity, item.code, item.message, item.assessment_ids)
|
Finding(item.severity, item.code, item.message, item.assessment_ids)
|
||||||
@@ -398,6 +420,7 @@ def review_capability_fit(
|
|||||||
forbidden_authority_public_keys=public_key_material_from_keyrings(
|
forbidden_authority_public_keys=public_key_material_from_keyrings(
|
||||||
published_keyring,
|
published_keyring,
|
||||||
trusted_keyring,
|
trusted_keyring,
|
||||||
|
installer_authority_keyring or {},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
findings.extend(
|
findings.extend(
|
||||||
@@ -939,7 +962,7 @@ def build_report(
|
|||||||
**boundary_scope,
|
**boundary_scope,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"report_version": "0.4.0",
|
"report_version": "0.5.0",
|
||||||
"status": status,
|
"status": status,
|
||||||
"assessment_id": assessment.get("assessment_id"),
|
"assessment_id": assessment.get("assessment_id"),
|
||||||
"assessment_release": assessment.get("release", {}).get("ref")
|
"assessment_release": assessment.get("release", {}).get("ref")
|
||||||
@@ -977,7 +1000,11 @@ def render_review(report: dict[str, Any]) -> str:
|
|||||||
(
|
(
|
||||||
"Scope: schema, signed release metadata, optional local tag provenance, "
|
"Scope: schema, signed release metadata, optional local tag provenance, "
|
||||||
+ (
|
+ (
|
||||||
"and locally observed installed-composition consistency evidence; release origin remains a separate proof boundary."
|
(
|
||||||
|
"and locally observed installed-composition evidence independently bound to signed release origin."
|
||||||
|
if release_origin_checked
|
||||||
|
else "and locally observed installed-composition consistency evidence; release origin remains a separate proof boundary."
|
||||||
|
)
|
||||||
if installed_checked
|
if installed_checked
|
||||||
else (
|
else (
|
||||||
"and supplied installed-composition evidence that did not establish local proof."
|
"and supplied installed-composition evidence that did not establish local proof."
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|||||||
from jsonschema import Draft202012Validator, FormatChecker
|
from jsonschema import Draft202012Validator, FormatChecker
|
||||||
from jsonschema.exceptions import SchemaError
|
from jsonschema.exceptions import SchemaError
|
||||||
|
|
||||||
|
from govoplan_release.artifact_identity import (
|
||||||
|
INSTALLED_PAYLOAD_ALGORITHM,
|
||||||
|
WHEEL_PAYLOAD_ALGORITHM,
|
||||||
|
payload_identity_sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
MAX_GOVOPLAN_DISTRIBUTIONS = 256
|
MAX_GOVOPLAN_DISTRIBUTIONS = 256
|
||||||
MAX_DISTRIBUTION_FILES = 10_000
|
MAX_DISTRIBUTION_FILES = 10_000
|
||||||
@@ -243,7 +249,7 @@ def collect_installed_composition(
|
|||||||
sorted_issues.sort(key=lambda item: (item["package_name"], item["code"]))
|
sorted_issues.sort(key=lambda item: (item["package_name"], item["code"]))
|
||||||
return {
|
return {
|
||||||
"$schema": "./installed-composition-evidence.schema.json",
|
"$schema": "./installed-composition-evidence.schema.json",
|
||||||
"schema_version": "0.3.0",
|
"schema_version": "0.4.0",
|
||||||
"evidence_kind": "govoplan.installed-composition",
|
"evidence_kind": "govoplan.installed-composition",
|
||||||
"assessment_id": _safe_opaque_id(
|
"assessment_id": _safe_opaque_id(
|
||||||
assessment.get("assessment_id"), fallback="invalid-assessment"
|
assessment.get("assessment_id"), fallback="invalid-assessment"
|
||||||
@@ -277,6 +283,15 @@ def review_installed_composition(
|
|||||||
verification_time: datetime | None = None,
|
verification_time: datetime | None = None,
|
||||||
verification_mode: str = "current",
|
verification_mode: str = "current",
|
||||||
catalog_trusted: bool = False,
|
catalog_trusted: bool = False,
|
||||||
|
catalog_artifacts: object = None,
|
||||||
|
catalog_sha256: str | None = None,
|
||||||
|
catalog_channel: str | None = None,
|
||||||
|
catalog_sequence: int | None = None,
|
||||||
|
installer_receipt: dict[str, Any] | None = None,
|
||||||
|
installer_receipt_schema: dict[str, Any] | None = None,
|
||||||
|
installer_authority_keyring: dict[str, Any] | None = None,
|
||||||
|
installer_authority_keyring_schema: dict[str, Any] | None = None,
|
||||||
|
forbidden_installer_public_keys: frozenset[bytes] = frozenset(),
|
||||||
) -> InstalledEvidenceReview:
|
) -> InstalledEvidenceReview:
|
||||||
if evidence is None:
|
if evidence is None:
|
||||||
return InstalledEvidenceReview(
|
return InstalledEvidenceReview(
|
||||||
@@ -403,14 +418,6 @@ def review_installed_composition(
|
|||||||
observation_trusted = True
|
observation_trusted = True
|
||||||
elif effective_observation_mode == "imported_unsigned":
|
elif effective_observation_mode == "imported_unsigned":
|
||||||
freshness = "unsigned_import"
|
freshness = "unsigned_import"
|
||||||
findings.append(
|
|
||||||
EvidenceFinding(
|
|
||||||
"blocker",
|
|
||||||
"installed_evidence_unsigned_import",
|
|
||||||
"Imported unsigned installed evidence may be compared but cannot establish installed proof.",
|
|
||||||
("assessment.installed_composition",),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if effective_verification_mode == "invalid":
|
if effective_verification_mode == "invalid":
|
||||||
observation_trusted = False
|
observation_trusted = False
|
||||||
if catalog_trusted is not True:
|
if catalog_trusted is not True:
|
||||||
@@ -528,7 +535,7 @@ def review_installed_composition(
|
|||||||
provenance_declared_match = 0
|
provenance_declared_match = 0
|
||||||
provenance_mutable = 0
|
provenance_mutable = 0
|
||||||
provenance_unanchored = 0
|
provenance_unanchored = 0
|
||||||
artifact_valid = binding_valid and not duplicate_packages
|
payload_consistent = binding_valid and not duplicate_packages
|
||||||
|
|
||||||
for package_name, (component, catalog_entry) in sorted(expected_by_package.items()):
|
for package_name, (component, catalog_entry) in sorted(expected_by_package.items()):
|
||||||
module_id = str(component["module_id"])
|
module_id = str(component["module_id"])
|
||||||
@@ -537,7 +544,7 @@ def review_installed_composition(
|
|||||||
record_expected += 1
|
record_expected += 1
|
||||||
provenance_expected += 1
|
provenance_expected += 1
|
||||||
if package_name in duplicate_packages:
|
if package_name in duplicate_packages:
|
||||||
artifact_valid = False
|
payload_consistent = False
|
||||||
_record_change(
|
_record_change(
|
||||||
findings=findings,
|
findings=findings,
|
||||||
changes=changes,
|
changes=changes,
|
||||||
@@ -550,7 +557,7 @@ def review_installed_composition(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if artifact is None:
|
if artifact is None:
|
||||||
artifact_valid = False
|
payload_consistent = False
|
||||||
_record_change(
|
_record_change(
|
||||||
findings=findings,
|
findings=findings,
|
||||||
changes=changes,
|
changes=changes,
|
||||||
@@ -567,7 +574,7 @@ def review_installed_composition(
|
|||||||
package_version = str(artifact.get("package_version") or "")
|
package_version = str(artifact.get("package_version") or "")
|
||||||
catalog_version = str(catalog_entry.get("version") or "")
|
catalog_version = str(catalog_entry.get("version") or "")
|
||||||
if package_version != expected_version or package_version != catalog_version:
|
if package_version != expected_version or package_version != catalog_version:
|
||||||
artifact_valid = False
|
payload_consistent = False
|
||||||
_record_change(
|
_record_change(
|
||||||
findings=findings,
|
findings=findings,
|
||||||
changes=changes,
|
changes=changes,
|
||||||
@@ -606,7 +613,7 @@ def review_installed_composition(
|
|||||||
and str(expected_manifest.get("manifest_version") or "") != expected_version
|
and str(expected_manifest.get("manifest_version") or "") != expected_version
|
||||||
)
|
)
|
||||||
if manifest_mismatch:
|
if manifest_mismatch:
|
||||||
artifact_valid = False
|
payload_consistent = False
|
||||||
_record_change(
|
_record_change(
|
||||||
findings=findings,
|
findings=findings,
|
||||||
changes=changes,
|
changes=changes,
|
||||||
@@ -619,7 +626,7 @@ def review_installed_composition(
|
|||||||
)
|
)
|
||||||
unexpected_modules = sorted(set(module_rows) - {module_id})
|
unexpected_modules = sorted(set(module_rows) - {module_id})
|
||||||
if duplicate_module_ids or unexpected_modules:
|
if duplicate_module_ids or unexpected_modules:
|
||||||
artifact_valid = False
|
payload_consistent = False
|
||||||
_record_change(
|
_record_change(
|
||||||
findings=findings,
|
findings=findings,
|
||||||
changes=changes,
|
changes=changes,
|
||||||
@@ -647,7 +654,7 @@ def review_installed_composition(
|
|||||||
if record_status == "verified" and record_semantics_valid:
|
if record_status == "verified" and record_semantics_valid:
|
||||||
record_verified += 1
|
record_verified += 1
|
||||||
else:
|
else:
|
||||||
artifact_valid = False
|
payload_consistent = False
|
||||||
_record_change(
|
_record_change(
|
||||||
findings=findings,
|
findings=findings,
|
||||||
changes=changes,
|
changes=changes,
|
||||||
@@ -676,7 +683,6 @@ def review_installed_composition(
|
|||||||
str(provenance.get("basis") or "") if isinstance(provenance, dict) else ""
|
str(provenance.get("basis") or "") if isinstance(provenance, dict) else ""
|
||||||
)
|
)
|
||||||
if provenance_basis != "local-pep610-metadata":
|
if provenance_basis != "local-pep610-metadata":
|
||||||
artifact_valid = False
|
|
||||||
provenance_unanchored += 1
|
provenance_unanchored += 1
|
||||||
_record_change(
|
_record_change(
|
||||||
findings=findings,
|
findings=findings,
|
||||||
@@ -703,7 +709,6 @@ def review_installed_composition(
|
|||||||
if commit_matches and selected_matches:
|
if commit_matches and selected_matches:
|
||||||
provenance_declared_match += 1
|
provenance_declared_match += 1
|
||||||
else:
|
else:
|
||||||
artifact_valid = False
|
|
||||||
_record_change(
|
_record_change(
|
||||||
findings=findings,
|
findings=findings,
|
||||||
changes=changes,
|
changes=changes,
|
||||||
@@ -716,7 +721,6 @@ def review_installed_composition(
|
|||||||
)
|
)
|
||||||
elif provenance_kind in {"editable-vcs", "editable-local", "local-directory"}:
|
elif provenance_kind in {"editable-vcs", "editable-local", "local-directory"}:
|
||||||
provenance_mutable += 1
|
provenance_mutable += 1
|
||||||
artifact_valid = False
|
|
||||||
_record_change(
|
_record_change(
|
||||||
findings=findings,
|
findings=findings,
|
||||||
changes=changes,
|
changes=changes,
|
||||||
@@ -729,7 +733,6 @@ def review_installed_composition(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
provenance_unanchored += 1
|
provenance_unanchored += 1
|
||||||
artifact_valid = False
|
|
||||||
_record_change(
|
_record_change(
|
||||||
findings=findings,
|
findings=findings,
|
||||||
changes=changes,
|
changes=changes,
|
||||||
@@ -743,7 +746,7 @@ def review_installed_composition(
|
|||||||
|
|
||||||
expected_packages = set(expected_by_package)
|
expected_packages = set(expected_by_package)
|
||||||
for package_name in sorted(set(artifacts_by_package) - expected_packages):
|
for package_name in sorted(set(artifacts_by_package) - expected_packages):
|
||||||
artifact_valid = False
|
payload_consistent = False
|
||||||
matching_module_id = next(
|
matching_module_id = next(
|
||||||
(
|
(
|
||||||
module_id
|
module_id
|
||||||
@@ -788,7 +791,7 @@ def review_installed_composition(
|
|||||||
continue
|
continue
|
||||||
package_name = str(issue.get("package_name") or "")
|
package_name = str(issue.get("package_name") or "")
|
||||||
code = str(issue.get("code") or "")
|
code = str(issue.get("code") or "")
|
||||||
artifact_valid = False
|
payload_consistent = False
|
||||||
findings.append(
|
findings.append(
|
||||||
EvidenceFinding(
|
EvidenceFinding(
|
||||||
"review",
|
"review",
|
||||||
@@ -801,18 +804,68 @@ def review_installed_composition(
|
|||||||
exact_set = (
|
exact_set = (
|
||||||
set(artifacts_by_package) == expected_packages and not duplicate_packages
|
set(artifacts_by_package) == expected_packages and not duplicate_packages
|
||||||
)
|
)
|
||||||
installed_checked = observation_trusted
|
origin_scope, origin_findings = _review_installed_release_origin(
|
||||||
|
expected_by_package=expected_by_package,
|
||||||
|
installed_artifacts=artifacts_by_package,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
installed_evidence_sha256=digest,
|
||||||
|
installed_composition_valid=(
|
||||||
|
binding_valid
|
||||||
|
and exact_set
|
||||||
|
and payload_consistent
|
||||||
|
and record_verified == record_expected
|
||||||
|
and record_expected > 0
|
||||||
|
),
|
||||||
|
observation_trusted=observation_trusted,
|
||||||
|
catalog_artifacts=catalog_artifacts,
|
||||||
|
catalog_sha256=catalog_sha256,
|
||||||
|
catalog_channel=catalog_channel,
|
||||||
|
catalog_sequence=catalog_sequence,
|
||||||
|
catalog_trusted=catalog_trusted,
|
||||||
|
receipt=installer_receipt,
|
||||||
|
receipt_schema=installer_receipt_schema,
|
||||||
|
authority_keyring=installer_authority_keyring,
|
||||||
|
authority_keyring_schema=installer_authority_keyring_schema,
|
||||||
|
verification_time=now,
|
||||||
|
forbidden_public_keys=forbidden_installer_public_keys,
|
||||||
|
)
|
||||||
|
findings.extend(origin_findings)
|
||||||
|
receipt_authenticated = (
|
||||||
|
origin_scope.get("valid") is True
|
||||||
|
and origin_scope.get("signed_installer_receipt_count") == record_expected
|
||||||
|
and record_expected > 0
|
||||||
|
)
|
||||||
|
effective_observation_trusted = observation_trusted or receipt_authenticated
|
||||||
|
if (
|
||||||
|
effective_observation_mode == "imported_unsigned"
|
||||||
|
and not receipt_authenticated
|
||||||
|
):
|
||||||
|
findings.append(
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installed_evidence_unsigned_import",
|
||||||
|
"Imported installed evidence requires a valid independent installer receipt before it can establish installed proof.",
|
||||||
|
("assessment.installed_composition",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
installed_checked = effective_observation_trusted
|
||||||
record_checked = (
|
record_checked = (
|
||||||
observation_trusted and binding_valid and exact_set and record_expected > 0
|
effective_observation_trusted
|
||||||
|
and binding_valid
|
||||||
|
and exact_set
|
||||||
|
and record_expected > 0
|
||||||
)
|
)
|
||||||
provenance_checked = (
|
provenance_checked = (
|
||||||
observation_trusted and binding_valid and exact_set and provenance_expected > 0
|
effective_observation_trusted
|
||||||
|
and binding_valid
|
||||||
|
and exact_set
|
||||||
|
and provenance_expected > 0
|
||||||
)
|
)
|
||||||
proof_scope = {
|
proof_scope = {
|
||||||
"installed_artifacts": {
|
"installed_artifacts": {
|
||||||
"checked": installed_checked,
|
"checked": installed_checked,
|
||||||
"valid": artifact_valid if installed_checked else None,
|
"valid": payload_consistent if installed_checked else None,
|
||||||
"comparison_valid": artifact_valid,
|
"comparison_valid": payload_consistent,
|
||||||
"evidence_sha256": digest,
|
"evidence_sha256": digest,
|
||||||
"expected_distribution_count": len(expected_packages),
|
"expected_distribution_count": len(expected_packages),
|
||||||
"observed_distribution_count": len(artifact_rows),
|
"observed_distribution_count": len(artifact_rows),
|
||||||
@@ -836,16 +889,7 @@ def review_installed_composition(
|
|||||||
"expected_distribution_count": provenance_expected,
|
"expected_distribution_count": provenance_expected,
|
||||||
"release_origin_bound": False,
|
"release_origin_bound": False,
|
||||||
},
|
},
|
||||||
"installed_release_origin": {
|
"installed_release_origin": origin_scope,
|
||||||
"checked": False,
|
|
||||||
"valid": None,
|
|
||||||
"release_origin_bound": False,
|
|
||||||
"anchored_artifact_digest_count": 0,
|
|
||||||
"expected_distribution_count": provenance_expected,
|
|
||||||
"required_evidence": (
|
|
||||||
"An independently anchored artifact digest or signed installer receipt that binds each installed payload to the signed release catalog.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"runtime_activation": {
|
"runtime_activation": {
|
||||||
"checked": False,
|
"checked": False,
|
||||||
"valid": None,
|
"valid": None,
|
||||||
@@ -861,6 +905,7 @@ def review_installed_composition(
|
|||||||
"evaluated_at": _iso_datetime(now),
|
"evaluated_at": _iso_datetime(now),
|
||||||
"verification_mode": effective_verification_mode,
|
"verification_mode": effective_verification_mode,
|
||||||
"catalog_trusted": catalog_trusted is True,
|
"catalog_trusted": catalog_trusted is True,
|
||||||
|
"receipt_authenticated": receipt_authenticated,
|
||||||
"freshness_window_seconds": int(MAX_LOCAL_OBSERVATION_AGE.total_seconds()),
|
"freshness_window_seconds": int(MAX_LOCAL_OBSERVATION_AGE.total_seconds()),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -874,6 +919,489 @@ def review_installed_composition(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _review_installed_release_origin(
|
||||||
|
*,
|
||||||
|
expected_by_package: Mapping[str, tuple[dict[str, Any], Mapping[str, Any]]],
|
||||||
|
installed_artifacts: Mapping[str, dict[str, Any]],
|
||||||
|
installed_evidence: dict[str, Any],
|
||||||
|
installed_evidence_sha256: str,
|
||||||
|
installed_composition_valid: bool,
|
||||||
|
observation_trusted: bool,
|
||||||
|
catalog_artifacts: object,
|
||||||
|
catalog_sha256: str | None,
|
||||||
|
catalog_channel: str | None,
|
||||||
|
catalog_sequence: int | None,
|
||||||
|
catalog_trusted: bool,
|
||||||
|
receipt: dict[str, Any] | None,
|
||||||
|
receipt_schema: dict[str, Any] | None,
|
||||||
|
authority_keyring: dict[str, Any] | None,
|
||||||
|
authority_keyring_schema: dict[str, Any] | None,
|
||||||
|
verification_time: datetime,
|
||||||
|
forbidden_public_keys: frozenset[bytes],
|
||||||
|
) -> tuple[dict[str, Any], tuple[EvidenceFinding, ...]]:
|
||||||
|
findings: list[EvidenceFinding] = []
|
||||||
|
expected_count = len(expected_by_package)
|
||||||
|
scope: dict[str, Any] = {
|
||||||
|
"checked": False,
|
||||||
|
"valid": None,
|
||||||
|
"release_origin_bound": False,
|
||||||
|
"anchored_artifact_digest_count": 0,
|
||||||
|
"signed_installer_receipt_count": 0,
|
||||||
|
"expected_distribution_count": expected_count,
|
||||||
|
"catalog_sha256": catalog_sha256,
|
||||||
|
"installer_receipt_sha256": None,
|
||||||
|
"required_evidence": (
|
||||||
|
"A signed catalog identity computed from each built immutable artifact, or a role-scoped installer receipt signed by an independently provisioned authority.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
manifest_supplied = catalog_artifacts is not None
|
||||||
|
receipt_supplied = receipt is not None
|
||||||
|
if not manifest_supplied and not receipt_supplied:
|
||||||
|
return scope, ()
|
||||||
|
scope["checked"] = True
|
||||||
|
scope["valid"] = False
|
||||||
|
if not installed_composition_valid or catalog_trusted is not True:
|
||||||
|
findings.append(
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installed_origin_dependency_untrusted",
|
||||||
|
"Installed release-origin proof requires a trusted catalog and valid fresh installed-composition evidence.",
|
||||||
|
("assessment.release", "assessment.installed_composition"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return scope, tuple(findings)
|
||||||
|
|
||||||
|
catalog_by_package, catalog_findings = _catalog_artifact_identities(
|
||||||
|
catalog_artifacts
|
||||||
|
)
|
||||||
|
findings.extend(catalog_findings)
|
||||||
|
if catalog_findings:
|
||||||
|
return scope, tuple(findings)
|
||||||
|
|
||||||
|
direct_bound: set[str] = set()
|
||||||
|
receipt_required: set[str] = set()
|
||||||
|
for package_name, (component, catalog_entry) in sorted(expected_by_package.items()):
|
||||||
|
expected_version = str(catalog_entry.get("version") or "")
|
||||||
|
identity = catalog_by_package.get(package_name)
|
||||||
|
if identity is None or identity.get("package_version") != expected_version:
|
||||||
|
findings.append(
|
||||||
|
EvidenceFinding(
|
||||||
|
"review",
|
||||||
|
"installed_origin_catalog_artifact_missing",
|
||||||
|
f"The signed catalog has no built-artifact identity for {package_name!r} at the selected version.",
|
||||||
|
(f"composition.{component['module_id']}",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if identity.get("requires_installer_receipt") is True:
|
||||||
|
receipt_required.add(package_name)
|
||||||
|
continue
|
||||||
|
if not observation_trusted:
|
||||||
|
continue
|
||||||
|
installed = installed_artifacts.get(package_name)
|
||||||
|
record = installed.get("record_integrity") if isinstance(installed, dict) else None
|
||||||
|
observed = (
|
||||||
|
record.get("artifact_payload_identity") if isinstance(record, dict) else None
|
||||||
|
)
|
||||||
|
expected = identity.get("installed_payload")
|
||||||
|
if observed == expected:
|
||||||
|
direct_bound.add(package_name)
|
||||||
|
else:
|
||||||
|
findings.append(
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installed_origin_artifact_payload_mismatch",
|
||||||
|
f"Installed payload for {package_name!r} does not match the identity independently computed from the signed catalog artifact.",
|
||||||
|
(f"composition.{component['module_id']}",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt_bound: set[str] = set()
|
||||||
|
if receipt_supplied:
|
||||||
|
receipt_bound, receipt_findings, receipt_digest = _verify_installer_receipt(
|
||||||
|
expected_by_package=expected_by_package,
|
||||||
|
installed_artifacts=installed_artifacts,
|
||||||
|
installed_evidence=installed_evidence,
|
||||||
|
installed_evidence_sha256=installed_evidence_sha256,
|
||||||
|
catalog_by_package=catalog_by_package,
|
||||||
|
catalog_sha256=catalog_sha256,
|
||||||
|
catalog_channel=catalog_channel,
|
||||||
|
catalog_sequence=catalog_sequence,
|
||||||
|
receipt=receipt,
|
||||||
|
receipt_schema=receipt_schema,
|
||||||
|
authority_keyring=authority_keyring,
|
||||||
|
authority_keyring_schema=authority_keyring_schema,
|
||||||
|
verification_time=verification_time,
|
||||||
|
forbidden_public_keys=forbidden_public_keys,
|
||||||
|
)
|
||||||
|
findings.extend(receipt_findings)
|
||||||
|
scope["installer_receipt_sha256"] = receipt_digest
|
||||||
|
elif receipt_required:
|
||||||
|
findings.append(
|
||||||
|
EvidenceFinding(
|
||||||
|
"review",
|
||||||
|
"installed_origin_receipt_required",
|
||||||
|
"At least one selected wheel produces installer-transformed files, so its signed installer receipt is required for complete origin proof.",
|
||||||
|
("assessment.installed_composition",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
bound = direct_bound | receipt_bound
|
||||||
|
scope["anchored_artifact_digest_count"] = len(direct_bound)
|
||||||
|
scope["signed_installer_receipt_count"] = len(receipt_bound)
|
||||||
|
valid = len(bound) == expected_count and not any(
|
||||||
|
finding.severity == "blocker" for finding in findings
|
||||||
|
)
|
||||||
|
scope["valid"] = valid
|
||||||
|
scope["release_origin_bound"] = valid
|
||||||
|
return scope, tuple(findings)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_artifact_identities(
|
||||||
|
value: object,
|
||||||
|
) -> tuple[dict[str, dict[str, Any]], tuple[EvidenceFinding, ...]]:
|
||||||
|
if value is None:
|
||||||
|
return {}, ()
|
||||||
|
if not isinstance(value, list) or len(value) > MAX_GOVOPLAN_DISTRIBUTIONS:
|
||||||
|
return {}, (
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"catalog_artifact_manifest_invalid",
|
||||||
|
"Signed catalog release artifacts must be a bounded list.",
|
||||||
|
("assessment.release",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
invalid = False
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, dict) or set(item) != {
|
||||||
|
"artifact_kind",
|
||||||
|
"package_name",
|
||||||
|
"package_version",
|
||||||
|
"archive_sha256",
|
||||||
|
"archive_size",
|
||||||
|
"installed_payload",
|
||||||
|
"requires_installer_receipt",
|
||||||
|
}:
|
||||||
|
invalid = True
|
||||||
|
continue
|
||||||
|
package_name = item.get("package_name")
|
||||||
|
version = item.get("package_version")
|
||||||
|
archive_size = item.get("archive_size")
|
||||||
|
payload = item.get("installed_payload")
|
||||||
|
if (
|
||||||
|
item.get("artifact_kind") != "python-wheel"
|
||||||
|
or not isinstance(package_name, str)
|
||||||
|
or PACKAGE_PATTERN.fullmatch(package_name) is None
|
||||||
|
or not isinstance(version, str)
|
||||||
|
or VERSION_PATTERN.fullmatch(version) is None
|
||||||
|
or not isinstance(item.get("archive_sha256"), str)
|
||||||
|
or SHA256_PATTERN.fullmatch(str(item["archive_sha256"])) is None
|
||||||
|
or not isinstance(archive_size, int)
|
||||||
|
or isinstance(archive_size, bool)
|
||||||
|
or not 0 < archive_size <= MAX_HASHED_DISTRIBUTION_BYTES
|
||||||
|
or not isinstance(item.get("requires_installer_receipt"), bool)
|
||||||
|
or not _payload_identity_semantics_valid(
|
||||||
|
payload,
|
||||||
|
algorithm=WHEEL_PAYLOAD_ALGORITHM,
|
||||||
|
maximum_files=MAX_DISTRIBUTION_FILES,
|
||||||
|
)
|
||||||
|
or package_name in result
|
||||||
|
):
|
||||||
|
invalid = True
|
||||||
|
continue
|
||||||
|
result[package_name] = item
|
||||||
|
if invalid:
|
||||||
|
return {}, (
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"catalog_artifact_manifest_invalid",
|
||||||
|
"Signed catalog release artifacts contain malformed or duplicate package identities.",
|
||||||
|
("assessment.release",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return result, ()
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_installer_receipt(
|
||||||
|
*,
|
||||||
|
expected_by_package: Mapping[str, tuple[dict[str, Any], Mapping[str, Any]]],
|
||||||
|
installed_artifacts: Mapping[str, dict[str, Any]],
|
||||||
|
installed_evidence: dict[str, Any],
|
||||||
|
installed_evidence_sha256: str,
|
||||||
|
catalog_by_package: Mapping[str, dict[str, Any]],
|
||||||
|
catalog_sha256: str | None,
|
||||||
|
catalog_channel: str | None,
|
||||||
|
catalog_sequence: int | None,
|
||||||
|
receipt: dict[str, Any],
|
||||||
|
receipt_schema: dict[str, Any] | None,
|
||||||
|
authority_keyring: dict[str, Any] | None,
|
||||||
|
authority_keyring_schema: dict[str, Any] | None,
|
||||||
|
verification_time: datetime,
|
||||||
|
forbidden_public_keys: frozenset[bytes],
|
||||||
|
) -> tuple[set[str], tuple[EvidenceFinding, ...], str]:
|
||||||
|
receipt_digest = canonical_sha256(receipt)
|
||||||
|
findings: list[EvidenceFinding] = []
|
||||||
|
if receipt_schema is None or authority_keyring_schema is None:
|
||||||
|
findings.append(
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installer_receipt_schema_missing",
|
||||||
|
"Installer receipt proof requires both bounded validation schemas.",
|
||||||
|
("assessment.installed_composition",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return set(), tuple(findings), receipt_digest
|
||||||
|
receipt_errors = validate_payload(payload=receipt, schema=receipt_schema)
|
||||||
|
keyring_errors = (
|
||||||
|
validate_payload(payload=authority_keyring, schema=authority_keyring_schema)
|
||||||
|
if isinstance(authority_keyring, dict)
|
||||||
|
else ("$: an independently provisioned installer authority keyring is required",)
|
||||||
|
)
|
||||||
|
findings.extend(
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installer_receipt_schema",
|
||||||
|
message,
|
||||||
|
("assessment.installed_composition",),
|
||||||
|
)
|
||||||
|
for message in receipt_errors
|
||||||
|
)
|
||||||
|
findings.extend(
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installer_authority_keyring_schema",
|
||||||
|
message,
|
||||||
|
("assessment.installed_composition",),
|
||||||
|
)
|
||||||
|
for message in keyring_errors
|
||||||
|
)
|
||||||
|
if receipt_errors or keyring_errors or not isinstance(authority_keyring, dict):
|
||||||
|
return set(), tuple(findings), receipt_digest
|
||||||
|
|
||||||
|
expected_release = installed_evidence.get("assessment_release")
|
||||||
|
expected_assessment = installed_evidence.get("assessment_id")
|
||||||
|
catalog_binding = receipt.get("catalog")
|
||||||
|
binding_valid = True
|
||||||
|
if (
|
||||||
|
receipt.get("assessment_id") != expected_assessment
|
||||||
|
or receipt.get("assessment_release") != expected_release
|
||||||
|
or receipt.get("installed_evidence_sha256") != installed_evidence_sha256
|
||||||
|
):
|
||||||
|
binding_valid = False
|
||||||
|
findings.append(
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installer_receipt_evidence_mismatch",
|
||||||
|
"Installer receipt is not bound to the supplied assessment and installed-evidence digest.",
|
||||||
|
("assessment.installed_composition",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not isinstance(catalog_binding, dict) or (
|
||||||
|
catalog_binding.get("sha256") != catalog_sha256
|
||||||
|
or catalog_binding.get("channel") != catalog_channel
|
||||||
|
or catalog_binding.get("sequence") != catalog_sequence
|
||||||
|
):
|
||||||
|
binding_valid = False
|
||||||
|
findings.append(
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installer_receipt_catalog_mismatch",
|
||||||
|
"Installer receipt is not bound to the supplied signed catalog identity.",
|
||||||
|
("assessment.release", "assessment.installed_composition"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
issued_at = _datetime(receipt.get("issued_at"))
|
||||||
|
collected_at = _datetime(installed_evidence.get("collected_at"))
|
||||||
|
if (
|
||||||
|
issued_at is None
|
||||||
|
or collected_at is None
|
||||||
|
or issued_at < collected_at
|
||||||
|
or issued_at - collected_at > MAX_LOCAL_OBSERVATION_AGE
|
||||||
|
or issued_at > verification_time + MAX_LOCAL_OBSERVATION_FUTURE_SKEW
|
||||||
|
or verification_time - issued_at > MAX_LOCAL_OBSERVATION_AGE
|
||||||
|
):
|
||||||
|
binding_valid = False
|
||||||
|
findings.append(
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installer_receipt_time_invalid",
|
||||||
|
"Installer receipt issuance must follow the observation within five minutes and remain fresh at the selected verification time.",
|
||||||
|
("assessment.installed_composition",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt_rows = [
|
||||||
|
item for item in receipt.get("artifacts", []) if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
rows_by_package: dict[str, dict[str, Any]] = {}
|
||||||
|
duplicate_packages: set[str] = set()
|
||||||
|
for row in receipt_rows:
|
||||||
|
package_name = str(row.get("package_name") or "")
|
||||||
|
if package_name in rows_by_package:
|
||||||
|
duplicate_packages.add(package_name)
|
||||||
|
else:
|
||||||
|
rows_by_package[package_name] = row
|
||||||
|
expected_packages = set(expected_by_package)
|
||||||
|
if duplicate_packages or set(rows_by_package) != expected_packages:
|
||||||
|
binding_valid = False
|
||||||
|
findings.append(
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installer_receipt_artifact_set_mismatch",
|
||||||
|
"Installer receipt must bind exactly one row for every expected installed distribution.",
|
||||||
|
("assessment.installed_composition",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bound: set[str] = set()
|
||||||
|
for package_name, (_, catalog_entry) in sorted(expected_by_package.items()):
|
||||||
|
row = rows_by_package.get(package_name)
|
||||||
|
installed = installed_artifacts.get(package_name)
|
||||||
|
record = installed.get("record_integrity") if isinstance(installed, dict) else None
|
||||||
|
observed_payload = (
|
||||||
|
record.get("installed_payload_identity") if isinstance(record, dict) else None
|
||||||
|
)
|
||||||
|
catalog_identity = catalog_by_package.get(package_name)
|
||||||
|
if (
|
||||||
|
row is None
|
||||||
|
or catalog_identity is None
|
||||||
|
or row.get("package_version") != catalog_entry.get("version")
|
||||||
|
or row.get("catalog_archive_sha256")
|
||||||
|
!= catalog_identity.get("archive_sha256")
|
||||||
|
or row.get("installed_payload") != observed_payload
|
||||||
|
):
|
||||||
|
binding_valid = False
|
||||||
|
findings.append(
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installer_receipt_artifact_mismatch",
|
||||||
|
f"Installer receipt does not bind the observed {package_name!r} payload to its signed catalog artifact.",
|
||||||
|
("assessment.installed_composition",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bound.add(package_name)
|
||||||
|
|
||||||
|
keys, key_findings = _installer_receipt_keys(
|
||||||
|
authority_keyring=authority_keyring,
|
||||||
|
issued_at=issued_at,
|
||||||
|
forbidden_public_keys=forbidden_public_keys,
|
||||||
|
)
|
||||||
|
findings.extend(key_findings)
|
||||||
|
signature_payload = dict(receipt)
|
||||||
|
signature_payload.pop("signatures", None)
|
||||||
|
signature_bytes = canonical_bytes(signature_payload)
|
||||||
|
signature_valid = False
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
duplicate_ids = False
|
||||||
|
for signature in receipt.get("signatures", []):
|
||||||
|
if not isinstance(signature, dict):
|
||||||
|
continue
|
||||||
|
key_id = str(signature.get("key_id") or "")
|
||||||
|
if key_id in seen_ids:
|
||||||
|
duplicate_ids = True
|
||||||
|
continue
|
||||||
|
seen_ids.add(key_id)
|
||||||
|
public_key = keys.get(key_id)
|
||||||
|
if public_key is None or signature.get("algorithm") != "ed25519":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
encoded = base64.b64decode(str(signature.get("value") or ""), validate=True)
|
||||||
|
Ed25519PublicKey.from_public_bytes(public_key).verify(
|
||||||
|
encoded, signature_bytes
|
||||||
|
)
|
||||||
|
signature_valid = True
|
||||||
|
except (ValueError, InvalidSignature):
|
||||||
|
continue
|
||||||
|
if duplicate_ids or not signature_valid:
|
||||||
|
findings.append(
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installer_receipt_signature_untrusted",
|
||||||
|
"Installer receipt has no unique valid signature from an authorized independent installer key.",
|
||||||
|
("assessment.installed_composition",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not binding_valid or not signature_valid or key_findings:
|
||||||
|
return set(), tuple(findings), receipt_digest
|
||||||
|
return bound, tuple(findings), receipt_digest
|
||||||
|
|
||||||
|
|
||||||
|
def _installer_receipt_keys(
|
||||||
|
*,
|
||||||
|
authority_keyring: dict[str, Any],
|
||||||
|
issued_at: datetime | None,
|
||||||
|
forbidden_public_keys: frozenset[bytes],
|
||||||
|
) -> tuple[dict[str, bytes], tuple[EvidenceFinding, ...]]:
|
||||||
|
findings: list[EvidenceFinding] = []
|
||||||
|
result: dict[str, bytes] = {}
|
||||||
|
raw_keys = authority_keyring.get("keys")
|
||||||
|
if not isinstance(raw_keys, list) or issued_at is None:
|
||||||
|
return {}, (
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installer_authority_untrusted",
|
||||||
|
"Installer receipt authority keyring or issuance time is invalid.",
|
||||||
|
("assessment.installed_composition",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
seen_material: set[bytes] = set()
|
||||||
|
malformed = False
|
||||||
|
for item in raw_keys:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
malformed = True
|
||||||
|
continue
|
||||||
|
key_id = str(item.get("key_id") or "")
|
||||||
|
try:
|
||||||
|
public_key = base64.b64decode(str(item.get("public_key") or ""), validate=True)
|
||||||
|
Ed25519PublicKey.from_public_bytes(public_key)
|
||||||
|
except ValueError:
|
||||||
|
malformed = True
|
||||||
|
continue
|
||||||
|
not_before = _datetime(item.get("not_before"))
|
||||||
|
not_after = _datetime(item.get("not_after"))
|
||||||
|
interval_valid = not (
|
||||||
|
not_before is not None
|
||||||
|
and not_after is not None
|
||||||
|
and not_before >= not_after
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
OPAQUE_ID_PATTERN.fullmatch(key_id) is None
|
||||||
|
or key_id in seen_ids
|
||||||
|
or public_key in seen_material
|
||||||
|
or public_key in forbidden_public_keys
|
||||||
|
or item.get("allowed_scopes") != ["installed_release_origin"]
|
||||||
|
or not interval_valid
|
||||||
|
):
|
||||||
|
malformed = True
|
||||||
|
continue
|
||||||
|
seen_ids.add(key_id)
|
||||||
|
seen_material.add(public_key)
|
||||||
|
status = item.get("status")
|
||||||
|
active_at_issue = (
|
||||||
|
(not_before is None or issued_at >= not_before)
|
||||||
|
and (not_after is None or issued_at < not_after)
|
||||||
|
)
|
||||||
|
historically_retired = status == "retired" and not_after is not None
|
||||||
|
if active_at_issue and (
|
||||||
|
status in {"active", "next"} or historically_retired
|
||||||
|
):
|
||||||
|
result[key_id] = public_key
|
||||||
|
if malformed or not result:
|
||||||
|
findings.append(
|
||||||
|
EvidenceFinding(
|
||||||
|
"blocker",
|
||||||
|
"installer_authority_untrusted",
|
||||||
|
"Installer authority keyring is malformed, reuses another trust-domain key, or has no role-scoped key valid at receipt issuance.",
|
||||||
|
("assessment.installed_composition",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {}, tuple(findings)
|
||||||
|
return result, ()
|
||||||
|
|
||||||
|
|
||||||
def review_boundary_evidence(
|
def review_boundary_evidence(
|
||||||
*,
|
*,
|
||||||
assessment: dict[str, Any],
|
assessment: dict[str, Any],
|
||||||
@@ -1606,13 +2134,17 @@ def _record_integrity(
|
|||||||
"missing_file_count": 0,
|
"missing_file_count": 0,
|
||||||
"mismatched_file_count": 0,
|
"mismatched_file_count": 0,
|
||||||
}
|
}
|
||||||
|
identities = {
|
||||||
|
"artifact_payload_identity": None,
|
||||||
|
"installed_payload_identity": None,
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
distribution_root = Path(distribution.locate_file("")).resolve()
|
distribution_root = Path(distribution.locate_file("")).resolve()
|
||||||
installation_root = Path(sys.prefix).resolve()
|
installation_root = Path(sys.prefix).resolve()
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"status": "unavailable", **counts}
|
return {"status": "unavailable", **counts, **identities}
|
||||||
if not _trusted_distribution_root(distribution_root):
|
if not _trusted_distribution_root(distribution_root):
|
||||||
return {"status": "unavailable", **counts}
|
return {"status": "unavailable", **counts, **identities}
|
||||||
entries, malformed_rows, record_status = _read_record_entries(
|
entries, malformed_rows, record_status = _read_record_entries(
|
||||||
distribution=distribution,
|
distribution=distribution,
|
||||||
distribution_root=distribution_root,
|
distribution_root=distribution_root,
|
||||||
@@ -1620,13 +2152,13 @@ def _record_integrity(
|
|||||||
)
|
)
|
||||||
if record_status == "limit-exceeded":
|
if record_status == "limit-exceeded":
|
||||||
budget.remaining_files = 0
|
budget.remaining_files = 0
|
||||||
return {"status": "limit-exceeded", **counts}
|
return {"status": "limit-exceeded", **counts, **identities}
|
||||||
if record_status != "ok":
|
if record_status != "ok":
|
||||||
counts["unverifiable_file_count"] = malformed_rows
|
counts["unverifiable_file_count"] = malformed_rows
|
||||||
return {"status": "unavailable", **counts}
|
return {"status": "unavailable", **counts, **identities}
|
||||||
record_file_count = len(entries) + malformed_rows
|
record_file_count = len(entries) + malformed_rows
|
||||||
if record_file_count == 0:
|
if record_file_count == 0:
|
||||||
return {"status": "unavailable", **counts}
|
return {"status": "unavailable", **counts, **identities}
|
||||||
budget.remaining_files -= record_file_count
|
budget.remaining_files -= record_file_count
|
||||||
counts["unverifiable_file_count"] = malformed_rows
|
counts["unverifiable_file_count"] = malformed_rows
|
||||||
declared_scripts = _declared_distribution_scripts(distribution)
|
declared_scripts = _declared_distribution_scripts(distribution)
|
||||||
@@ -1666,6 +2198,8 @@ def _record_integrity(
|
|||||||
}
|
}
|
||||||
total_hashed_bytes = 0
|
total_hashed_bytes = 0
|
||||||
limit_exceeded = False
|
limit_exceeded = False
|
||||||
|
artifact_payload_rows: list[dict[str, object]] = []
|
||||||
|
installed_payload_rows: list[dict[str, object]] = []
|
||||||
for entry, resolved_path in resolved_entries:
|
for entry, resolved_path in resolved_entries:
|
||||||
if resolved_path is None:
|
if resolved_path is None:
|
||||||
counts["unverifiable_file_count"] += 1
|
counts["unverifiable_file_count"] += 1
|
||||||
@@ -1731,6 +2265,31 @@ def _record_integrity(
|
|||||||
continue
|
continue
|
||||||
if hmac.compare_digest(actual, expected):
|
if hmac.compare_digest(actual, expected):
|
||||||
counts["hashed_file_count"] += 1
|
counts["hashed_file_count"] += 1
|
||||||
|
installed_path = _installed_payload_path(
|
||||||
|
resolved_path,
|
||||||
|
distribution_root=distribution_root,
|
||||||
|
installation_root=installation_root,
|
||||||
|
declared_scripts=declared_scripts,
|
||||||
|
)
|
||||||
|
if installed_path is None:
|
||||||
|
counts["unverifiable_file_count"] += 1
|
||||||
|
continue
|
||||||
|
row = {
|
||||||
|
"path": installed_path,
|
||||||
|
"sha256": actual.hex(),
|
||||||
|
"size": file_stat.st_size,
|
||||||
|
}
|
||||||
|
installed_payload_rows.append(row)
|
||||||
|
artifact_path = _artifact_payload_path(
|
||||||
|
resolved_path,
|
||||||
|
distribution_root=distribution_root,
|
||||||
|
installation_root=installation_root,
|
||||||
|
declared_scripts=declared_scripts,
|
||||||
|
)
|
||||||
|
if artifact_path is not None and not _installer_generated_metadata(
|
||||||
|
resolved_path, distribution_root=distribution_root
|
||||||
|
):
|
||||||
|
artifact_payload_rows.append({**row, "path": artifact_path})
|
||||||
else:
|
else:
|
||||||
counts["mismatched_file_count"] += 1
|
counts["mismatched_file_count"] += 1
|
||||||
if limit_exceeded:
|
if limit_exceeded:
|
||||||
@@ -1743,7 +2302,85 @@ def _record_integrity(
|
|||||||
status = "partial"
|
status = "partial"
|
||||||
else:
|
else:
|
||||||
status = "verified"
|
status = "verified"
|
||||||
return {"status": status, **counts}
|
if status == "verified":
|
||||||
|
installed_identity = _payload_identity(
|
||||||
|
algorithm=INSTALLED_PAYLOAD_ALGORITHM,
|
||||||
|
rows=installed_payload_rows,
|
||||||
|
)
|
||||||
|
artifact_identity = _payload_identity(
|
||||||
|
algorithm=WHEEL_PAYLOAD_ALGORITHM,
|
||||||
|
rows=artifact_payload_rows,
|
||||||
|
)
|
||||||
|
if installed_identity is None or artifact_identity is None:
|
||||||
|
status = "partial"
|
||||||
|
counts["unverifiable_file_count"] += 1
|
||||||
|
else:
|
||||||
|
identities["installed_payload_identity"] = installed_identity
|
||||||
|
identities["artifact_payload_identity"] = artifact_identity
|
||||||
|
return {"status": status, **counts, **identities}
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_identity(
|
||||||
|
*, algorithm: str, rows: list[dict[str, object]]
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
ordered = sorted(rows, key=lambda item: str(item["path"]))
|
||||||
|
paths = [str(item["path"]) for item in ordered]
|
||||||
|
if not ordered or len(set(paths)) != len(paths):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"algorithm": algorithm,
|
||||||
|
"sha256": payload_identity_sha256(algorithm=algorithm, rows=ordered),
|
||||||
|
"file_count": len(ordered),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _installed_payload_path(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
distribution_root: Path,
|
||||||
|
installation_root: Path,
|
||||||
|
declared_scripts: frozenset[str],
|
||||||
|
) -> str | None:
|
||||||
|
if _is_relative_to(path, distribution_root):
|
||||||
|
return path.relative_to(distribution_root).as_posix()
|
||||||
|
for script_root in (
|
||||||
|
(installation_root / "bin").resolve(),
|
||||||
|
(installation_root / "Scripts").resolve(),
|
||||||
|
):
|
||||||
|
if path.parent == script_root and path.name in declared_scripts:
|
||||||
|
return f"@scripts/{path.name}"
|
||||||
|
if _is_relative_to(path, installation_root):
|
||||||
|
return f"@data/{path.relative_to(installation_root).as_posix()}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact_payload_path(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
distribution_root: Path,
|
||||||
|
installation_root: Path,
|
||||||
|
declared_scripts: frozenset[str],
|
||||||
|
) -> str | None:
|
||||||
|
installed = _installed_payload_path(
|
||||||
|
path,
|
||||||
|
distribution_root=distribution_root,
|
||||||
|
installation_root=installation_root,
|
||||||
|
declared_scripts=declared_scripts,
|
||||||
|
)
|
||||||
|
return None if installed is None or installed.startswith("@scripts/") else installed
|
||||||
|
|
||||||
|
|
||||||
|
def _installer_generated_metadata(
|
||||||
|
path: Path, *, distribution_root: Path
|
||||||
|
) -> bool:
|
||||||
|
if not _is_relative_to(path, distribution_root):
|
||||||
|
return False
|
||||||
|
relative = path.relative_to(distribution_root)
|
||||||
|
return (
|
||||||
|
len(relative.parts) >= 2
|
||||||
|
and relative.parent.name.endswith(".dist-info")
|
||||||
|
and relative.name in {"direct_url.json", "INSTALLER", "REQUESTED"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_record_path(
|
def _resolve_record_path(
|
||||||
@@ -1867,12 +2504,25 @@ def _record_integrity_semantics_valid(record: Mapping[str, Any]) -> bool:
|
|||||||
missing = counts["missing_file_count"]
|
missing = counts["missing_file_count"]
|
||||||
mismatched = counts["mismatched_file_count"]
|
mismatched = counts["mismatched_file_count"]
|
||||||
if status == "verified":
|
if status == "verified":
|
||||||
|
artifact_identity = record.get("artifact_payload_identity")
|
||||||
|
installed_identity = record.get("installed_payload_identity")
|
||||||
return (
|
return (
|
||||||
hashed > 0
|
hashed > 0
|
||||||
and counts["permitted_unhashed_file_count"] > 0
|
and counts["permitted_unhashed_file_count"] > 0
|
||||||
and unverifiable == 0
|
and unverifiable == 0
|
||||||
and missing == 0
|
and missing == 0
|
||||||
and mismatched == 0
|
and mismatched == 0
|
||||||
|
and _payload_identity_semantics_valid(
|
||||||
|
artifact_identity,
|
||||||
|
algorithm=WHEEL_PAYLOAD_ALGORITHM,
|
||||||
|
maximum_files=hashed,
|
||||||
|
)
|
||||||
|
and _payload_identity_semantics_valid(
|
||||||
|
installed_identity,
|
||||||
|
algorithm=INSTALLED_PAYLOAD_ALGORITHM,
|
||||||
|
maximum_files=hashed,
|
||||||
|
exact_files=hashed,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if status == "partial":
|
if status == "partial":
|
||||||
return hashed > 0 and unverifiable > 0 and missing == 0 and mismatched == 0
|
return hashed > 0 and unverifiable > 0 and missing == 0 and mismatched == 0
|
||||||
@@ -1885,6 +2535,31 @@ def _record_integrity_semantics_valid(record: Mapping[str, Any]) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_identity_semantics_valid(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
algorithm: str,
|
||||||
|
maximum_files: int,
|
||||||
|
exact_files: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
if not isinstance(value, Mapping) or set(value) != {
|
||||||
|
"algorithm",
|
||||||
|
"sha256",
|
||||||
|
"file_count",
|
||||||
|
}:
|
||||||
|
return False
|
||||||
|
file_count = value.get("file_count")
|
||||||
|
return (
|
||||||
|
value.get("algorithm") == algorithm
|
||||||
|
and isinstance(value.get("sha256"), str)
|
||||||
|
and SHA256_PATTERN.fullmatch(str(value["sha256"])) is not None
|
||||||
|
and isinstance(file_count, int)
|
||||||
|
and not isinstance(file_count, bool)
|
||||||
|
and 0 < file_count <= maximum_files
|
||||||
|
and (exact_files is None or file_count == exact_files)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _permitted_unhashed_record_path(
|
def _permitted_unhashed_record_path(
|
||||||
path: Path,
|
path: Path,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Issuance of role-scoped receipts from same-process installed observations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
from .capability_fit import canonical_hash
|
||||||
|
from .evidence import (
|
||||||
|
MAX_LOCAL_OBSERVATION_AGE,
|
||||||
|
OPAQUE_ID_PATTERN,
|
||||||
|
_catalog_artifact_identities,
|
||||||
|
_record_integrity_semantics_valid,
|
||||||
|
canonical_bytes,
|
||||||
|
canonical_sha256,
|
||||||
|
normalize_package_name,
|
||||||
|
)
|
||||||
|
from govoplan_release.artifact_identity import inspect_python_wheel
|
||||||
|
|
||||||
|
|
||||||
|
def issue_installer_receipt(
|
||||||
|
*,
|
||||||
|
assessment: Mapping[str, Any],
|
||||||
|
catalog: Mapping[str, Any],
|
||||||
|
installed_evidence: dict[str, Any],
|
||||||
|
receipt_id: str,
|
||||||
|
key_id: str,
|
||||||
|
private_key: Ed25519PrivateKey,
|
||||||
|
consumed_artifacts: Mapping[str, Path | str] | None = None,
|
||||||
|
issued_at: datetime | None = None,
|
||||||
|
same_process_observation: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Sign a receipt only for a fresh observation collected by this process.
|
||||||
|
|
||||||
|
File-based evidence is intentionally not an admitted issuance input. The CLI
|
||||||
|
collector calls this function directly with its in-memory observation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if same_process_observation is not True:
|
||||||
|
raise ValueError("installer receipts require a same-process observation")
|
||||||
|
if OPAQUE_ID_PATTERN.fullmatch(receipt_id) is None:
|
||||||
|
raise ValueError("receipt ID must be a bounded opaque ID")
|
||||||
|
if OPAQUE_ID_PATTERN.fullmatch(key_id) is None:
|
||||||
|
raise ValueError("installer signing key ID must be a bounded opaque ID")
|
||||||
|
if not isinstance(consumed_artifacts, Mapping) or not consumed_artifacts:
|
||||||
|
raise ValueError("installer receipt issuance requires exact consumed wheels")
|
||||||
|
release = catalog.get("release")
|
||||||
|
raw_artifacts = release.get("artifacts") if isinstance(release, Mapping) else None
|
||||||
|
catalog_artifacts, artifact_findings = _catalog_artifact_identities(raw_artifacts)
|
||||||
|
if artifact_findings:
|
||||||
|
raise ValueError("signed catalog artifact manifest is invalid")
|
||||||
|
evidence_rows = installed_evidence.get("artifacts")
|
||||||
|
if not isinstance(evidence_rows, list) or not evidence_rows:
|
||||||
|
raise ValueError("installed evidence has no artifact observations")
|
||||||
|
expected_packages = _expected_packages(assessment=assessment, catalog=catalog)
|
||||||
|
receipt_rows: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for artifact in evidence_rows:
|
||||||
|
if not isinstance(artifact, dict):
|
||||||
|
raise ValueError("installed evidence contains a malformed artifact")
|
||||||
|
package_name = normalize_package_name(str(artifact.get("package_name") or ""))
|
||||||
|
package_version = str(artifact.get("package_version") or "")
|
||||||
|
if package_name in seen:
|
||||||
|
raise ValueError("installed evidence contains duplicate packages")
|
||||||
|
seen.add(package_name)
|
||||||
|
catalog_artifact = catalog_artifacts.get(package_name)
|
||||||
|
record = artifact.get("record_integrity")
|
||||||
|
installed_payload = (
|
||||||
|
record.get("installed_payload_identity")
|
||||||
|
if isinstance(record, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
catalog_artifact is None
|
||||||
|
or catalog_artifact.get("package_version") != package_version
|
||||||
|
or not isinstance(record, Mapping)
|
||||||
|
or record.get("status") != "verified"
|
||||||
|
or not _record_integrity_semantics_valid(record)
|
||||||
|
or not isinstance(installed_payload, Mapping)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"installed payload is not a verified match for a catalog artifact"
|
||||||
|
)
|
||||||
|
artifact_path = consumed_artifacts.get(package_name)
|
||||||
|
if artifact_path is None:
|
||||||
|
raise ValueError("every installed package requires its exact consumed wheel")
|
||||||
|
consumed = inspect_python_wheel(artifact_path)
|
||||||
|
if (
|
||||||
|
consumed.package_name != package_name
|
||||||
|
or consumed.package_version != package_version
|
||||||
|
or consumed.archive_sha256 != catalog_artifact.get("archive_sha256")
|
||||||
|
or consumed.catalog_payload().get("installed_payload")
|
||||||
|
!= catalog_artifact.get("installed_payload")
|
||||||
|
or record.get("artifact_payload_identity")
|
||||||
|
!= catalog_artifact.get("installed_payload")
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"consumed wheel or observed wheel payload differs from the signed catalog identity"
|
||||||
|
)
|
||||||
|
receipt_rows.append(
|
||||||
|
{
|
||||||
|
"package_name": package_name,
|
||||||
|
"package_version": package_version,
|
||||||
|
"catalog_archive_sha256": catalog_artifact["archive_sha256"],
|
||||||
|
"installed_payload": dict(installed_payload),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if seen != expected_packages:
|
||||||
|
raise ValueError(
|
||||||
|
"installed evidence must contain exactly the enabled assessed catalog packages"
|
||||||
|
)
|
||||||
|
if set(consumed_artifacts) != expected_packages:
|
||||||
|
raise ValueError("consumed wheel set must exactly match enabled packages")
|
||||||
|
receipt_rows.sort(key=lambda item: (item["package_name"], item["package_version"]))
|
||||||
|
observed_at = issued_at or datetime.now(tz=UTC)
|
||||||
|
if observed_at.tzinfo is None:
|
||||||
|
raise ValueError("installer receipt issuance time must include a timezone")
|
||||||
|
collected_at = _datetime(installed_evidence.get("collected_at"))
|
||||||
|
if (
|
||||||
|
collected_at is None
|
||||||
|
or observed_at < collected_at
|
||||||
|
or observed_at - collected_at > MAX_LOCAL_OBSERVATION_AGE
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"installer receipt issuance must follow live collection within five minutes"
|
||||||
|
)
|
||||||
|
assessment_release = assessment.get("release")
|
||||||
|
assessment_release_ref = (
|
||||||
|
assessment_release.get("ref")
|
||||||
|
if isinstance(assessment_release, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
receipt: dict[str, Any] = {
|
||||||
|
"$schema": "./installer-receipt.schema.json",
|
||||||
|
"schema_version": "0.1.0",
|
||||||
|
"evidence_kind": "govoplan.installer-receipt",
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
"assessment_id": assessment.get("assessment_id"),
|
||||||
|
"assessment_release": assessment_release_ref,
|
||||||
|
"installed_evidence_sha256": canonical_sha256(installed_evidence),
|
||||||
|
"catalog": {
|
||||||
|
"channel": catalog.get("channel"),
|
||||||
|
"sequence": catalog.get("sequence"),
|
||||||
|
"sha256": canonical_hash(catalog),
|
||||||
|
},
|
||||||
|
"issued_at": observed_at.astimezone(UTC).isoformat().replace("+00:00", "Z"),
|
||||||
|
"artifacts": receipt_rows,
|
||||||
|
}
|
||||||
|
receipt["signatures"] = [
|
||||||
|
{
|
||||||
|
"algorithm": "ed25519",
|
||||||
|
"key_id": key_id,
|
||||||
|
"value": base64.b64encode(
|
||||||
|
private_key.sign(canonical_bytes(receipt))
|
||||||
|
).decode("ascii"),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return receipt
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_packages(
|
||||||
|
*, assessment: Mapping[str, Any], catalog: Mapping[str, Any]
|
||||||
|
) -> set[str]:
|
||||||
|
entries: dict[str, Mapping[str, Any]] = {}
|
||||||
|
core = catalog.get("core_release")
|
||||||
|
if isinstance(core, Mapping):
|
||||||
|
entries["core"] = core
|
||||||
|
modules = catalog.get("modules")
|
||||||
|
if isinstance(modules, list):
|
||||||
|
for entry in modules:
|
||||||
|
if isinstance(entry, Mapping) and isinstance(entry.get("module_id"), str):
|
||||||
|
entries[str(entry["module_id"])] = entry
|
||||||
|
expected: set[str] = set()
|
||||||
|
composition = assessment.get("composition")
|
||||||
|
if not isinstance(composition, list):
|
||||||
|
raise ValueError("assessment composition is unavailable")
|
||||||
|
for component in composition:
|
||||||
|
if not isinstance(component, Mapping) or component.get("enabled") is not True:
|
||||||
|
continue
|
||||||
|
entry = entries.get(str(component.get("module_id") or ""))
|
||||||
|
package = entry.get("python_package") if isinstance(entry, Mapping) else None
|
||||||
|
if not isinstance(package, str):
|
||||||
|
raise ValueError("enabled assessment component has no catalog package")
|
||||||
|
normalized = normalize_package_name(package)
|
||||||
|
if normalized in expected:
|
||||||
|
raise ValueError("enabled assessment packages are ambiguous")
|
||||||
|
expected.add(normalized)
|
||||||
|
if not expected:
|
||||||
|
raise ValueError("assessment has no enabled catalog packages")
|
||||||
|
return expected
|
||||||
|
|
||||||
|
|
||||||
|
def _datetime(value: object) -> datetime | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return parsed.astimezone(UTC) if parsed.tzinfo is not None else None
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Collect live installed payloads and issue an independently signed receipt."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
for tools_root in (META_ROOT / "tools" / "assessments", META_ROOT / "tools" / "release"):
|
||||||
|
if str(tools_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(tools_root))
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization # noqa: E402
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import ( # noqa: E402
|
||||||
|
Ed25519PrivateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
from govoplan_assessment import collect_installed_composition # noqa: E402
|
||||||
|
from govoplan_assessment.atomic_io import ( # noqa: E402
|
||||||
|
AtomicJsonWriteError,
|
||||||
|
atomic_write_json,
|
||||||
|
)
|
||||||
|
from govoplan_assessment.capability_fit import ( # noqa: E402
|
||||||
|
canonical_hash,
|
||||||
|
review_capability_fit,
|
||||||
|
)
|
||||||
|
from govoplan_assessment.evidence import validate_payload # noqa: E402
|
||||||
|
from govoplan_assessment.installer_receipt import ( # noqa: E402
|
||||||
|
issue_installer_receipt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MAX_INPUT_BYTES = 16 * 1024 * 1024
|
||||||
|
MAX_OUTPUT_BYTES = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--assessment", type=Path, required=True)
|
||||||
|
parser.add_argument("--assessment-schema", type=Path, default=META_ROOT / "docs" / "capability-fit.schema.json")
|
||||||
|
parser.add_argument("--catalog", type=Path, required=True)
|
||||||
|
parser.add_argument("--keyring", type=Path, required=True)
|
||||||
|
parser.add_argument("--trusted-keyring", type=Path, required=True)
|
||||||
|
parser.add_argument("--receipt-id", required=True)
|
||||||
|
parser.add_argument("--signing-key", required=True, metavar="KEY_ID=/PATH/PRIVATE.pem")
|
||||||
|
parser.add_argument(
|
||||||
|
"--python-artifact",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
required=True,
|
||||||
|
metavar="PACKAGE=/PATH/TO/CONSUMED.whl",
|
||||||
|
help="Exact wheel consumed by this installer; repeat for every package.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--evidence-output", type=Path, required=True)
|
||||||
|
parser.add_argument("--receipt-output", type=Path, required=True)
|
||||||
|
parser.add_argument("--installed-evidence-schema", type=Path, default=META_ROOT / "docs" / "installed-composition-evidence.schema.json")
|
||||||
|
parser.add_argument("--receipt-schema", type=Path, default=META_ROOT / "docs" / "installer-receipt.schema.json")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if args.evidence_output.resolve() == args.receipt_output.resolve():
|
||||||
|
parser.error("evidence and receipt output paths must differ")
|
||||||
|
|
||||||
|
assessment = read_object(args.assessment, label="assessment")
|
||||||
|
assessment_schema = read_object(args.assessment_schema, label="assessment schema")
|
||||||
|
catalog = read_object(args.catalog, label="catalog")
|
||||||
|
keyring = read_object(args.keyring, label="published keyring")
|
||||||
|
trusted_keyring = read_object(args.trusted_keyring, label="trusted keyring")
|
||||||
|
evidence_schema = read_object(args.installed_evidence_schema, label="installed evidence schema")
|
||||||
|
receipt_schema = read_object(args.receipt_schema, label="installer receipt schema")
|
||||||
|
release = catalog.get("release")
|
||||||
|
if not isinstance(release, dict) or release.get("keyring_sha256") != canonical_hash(keyring):
|
||||||
|
parser.error("catalog does not pin the supplied published keyring")
|
||||||
|
|
||||||
|
evidence = collect_installed_composition(assessment=assessment)
|
||||||
|
if validate_payload(payload=evidence, schema=evidence_schema):
|
||||||
|
parser.error("live installed observation does not satisfy its bounded schema")
|
||||||
|
report = review_capability_fit(
|
||||||
|
assessment=assessment,
|
||||||
|
schema=assessment_schema,
|
||||||
|
catalog=catalog,
|
||||||
|
published_keyring=keyring,
|
||||||
|
trusted_keyring=trusted_keyring,
|
||||||
|
workspace_root=None,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
installed_evidence_schema=evidence_schema,
|
||||||
|
installed_evidence_mode="direct_local",
|
||||||
|
)
|
||||||
|
required_scopes = (
|
||||||
|
"catalog_signature_and_keyring",
|
||||||
|
"catalog_signature_and_trusted_keyring",
|
||||||
|
"published_keyring_hash",
|
||||||
|
"release_metadata",
|
||||||
|
"installed_artifacts",
|
||||||
|
"installed_record_integrity",
|
||||||
|
)
|
||||||
|
if any(report["proof_scope"].get(scope, {}).get("valid") is not True for scope in required_scopes):
|
||||||
|
parser.error("trusted catalog and live installed-composition checks must pass before receipt issuance")
|
||||||
|
|
||||||
|
key_id, private_key = read_signing_key(args.signing_key)
|
||||||
|
consumed_artifacts = parse_package_paths(tuple(args.python_artifact))
|
||||||
|
receipt = issue_installer_receipt(
|
||||||
|
assessment=assessment,
|
||||||
|
catalog=catalog,
|
||||||
|
installed_evidence=evidence,
|
||||||
|
receipt_id=args.receipt_id,
|
||||||
|
key_id=key_id,
|
||||||
|
private_key=private_key,
|
||||||
|
consumed_artifacts=consumed_artifacts,
|
||||||
|
same_process_observation=True,
|
||||||
|
)
|
||||||
|
if validate_payload(payload=receipt, schema=receipt_schema):
|
||||||
|
parser.error("issued installer receipt does not satisfy its bounded schema")
|
||||||
|
write_object(args.evidence_output, evidence, label="installed evidence")
|
||||||
|
write_object(args.receipt_output, receipt, label="installer receipt")
|
||||||
|
print(json.dumps({"receipt_id": args.receipt_id, "evidence_output": str(args.evidence_output), "receipt_output": str(args.receipt_output)}, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def read_object(path: Path, *, label: str) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
if path.stat().st_size > MAX_INPUT_BYTES:
|
||||||
|
raise ValueError("input exceeds size limit")
|
||||||
|
encoded = path.read_bytes()
|
||||||
|
payload = json.loads(encoded.decode("utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
||||||
|
raise SystemExit(f"Could not read {label}") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise SystemExit(f"{label.capitalize()} must be a JSON object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def read_signing_key(value: str) -> tuple[str, Ed25519PrivateKey]:
|
||||||
|
key_id, separator, path_text = value.partition("=")
|
||||||
|
if not separator or not key_id or not path_text:
|
||||||
|
raise SystemExit("--signing-key must use KEY_ID=/path/to/private.pem")
|
||||||
|
try:
|
||||||
|
key = serialization.load_pem_private_key(Path(path_text).expanduser().read_bytes(), password=None)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise SystemExit("Could not read installer signing key") from exc
|
||||||
|
if not isinstance(key, Ed25519PrivateKey):
|
||||||
|
raise SystemExit("Installer signing key must be Ed25519")
|
||||||
|
return key_id, key
|
||||||
|
|
||||||
|
|
||||||
|
def parse_package_paths(values: tuple[str, ...]) -> dict[str, Path]:
|
||||||
|
result: dict[str, Path] = {}
|
||||||
|
for value in values:
|
||||||
|
package, separator, path_text = value.partition("=")
|
||||||
|
package = package.strip()
|
||||||
|
path_text = path_text.strip()
|
||||||
|
if not separator or not package or not path_text or package in result:
|
||||||
|
raise SystemExit(
|
||||||
|
"--python-artifact must uniquely use PACKAGE=/path/to/consumed.whl"
|
||||||
|
)
|
||||||
|
result[package] = Path(path_text).expanduser()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def write_object(path: Path, payload: dict[str, object], *, label: str) -> None:
|
||||||
|
try:
|
||||||
|
atomic_write_json(path, payload, max_bytes=MAX_OUTPUT_BYTES)
|
||||||
|
except AtomicJsonWriteError as exc:
|
||||||
|
raise SystemExit(f"Could not securely write {label}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Exercise connector -> datasource -> dataflow publication capabilities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_connectors.backend.db.models import ConnectorTabularSource
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.dataflows import (
|
||||||
|
DataflowPublicationTarget,
|
||||||
|
DataflowRunRequest,
|
||||||
|
dataflow_run_lifecycle,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.automation import AutomationPrincipalResolution
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.datasources import (
|
||||||
|
DatasourceReadRequest,
|
||||||
|
datasource_catalogue,
|
||||||
|
datasource_lifecycle,
|
||||||
|
datasource_publication,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.tabular_sources import (
|
||||||
|
TabularSnapshotInput,
|
||||||
|
tabular_snapshot_writer,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.server.registry import build_platform_registry
|
||||||
|
from govoplan_dataflow.backend.schemas import (
|
||||||
|
GraphEdge,
|
||||||
|
GraphNode,
|
||||||
|
GraphPosition,
|
||||||
|
PipelineGraph,
|
||||||
|
PipelineCreateRequest,
|
||||||
|
PipelinePreviewRequest,
|
||||||
|
)
|
||||||
|
from govoplan_dataflow.backend.db.models import (
|
||||||
|
DataflowPipeline,
|
||||||
|
DataflowPipelineRevision,
|
||||||
|
DataflowRun,
|
||||||
|
)
|
||||||
|
from govoplan_dataflow.backend.service import create_pipeline, preview_pipeline
|
||||||
|
from govoplan_dataflow.backend.run_worker import SqlDataflowRunWorker
|
||||||
|
from govoplan_datasources.backend.db.models import (
|
||||||
|
DatasourceMaterializationRecord,
|
||||||
|
DatasourcePayloadRecord,
|
||||||
|
DatasourcePayloadRowRecord,
|
||||||
|
DatasourcePublicationRecord,
|
||||||
|
DatasourceRecord,
|
||||||
|
DatasourceStageRecord,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
registry = build_platform_registry(
|
||||||
|
("connectors", "datasources", "dataflow", "workflow")
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=object())
|
||||||
|
)
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
engine,
|
||||||
|
tables=[
|
||||||
|
ConnectorTabularSource.__table__,
|
||||||
|
DatasourceRecord.__table__,
|
||||||
|
DatasourcePayloadRecord.__table__,
|
||||||
|
DatasourcePayloadRowRecord.__table__,
|
||||||
|
DatasourceMaterializationRecord.__table__,
|
||||||
|
DatasourceStageRecord.__table__,
|
||||||
|
DatasourcePublicationRecord.__table__,
|
||||||
|
DataflowPipeline.__table__,
|
||||||
|
DataflowPipelineRevision.__table__,
|
||||||
|
DataflowRun.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
session_factory = sessionmaker(bind=engine)
|
||||||
|
with session_factory() as session:
|
||||||
|
principal = _principal()
|
||||||
|
writer = tabular_snapshot_writer(registry)
|
||||||
|
lifecycle = datasource_lifecycle(registry)
|
||||||
|
catalogue = datasource_catalogue(registry)
|
||||||
|
publisher = datasource_publication(registry)
|
||||||
|
runner = dataflow_run_lifecycle(registry)
|
||||||
|
if (
|
||||||
|
writer is None
|
||||||
|
or lifecycle is None
|
||||||
|
or catalogue is None
|
||||||
|
or publisher is None
|
||||||
|
or runner is None
|
||||||
|
):
|
||||||
|
raise RuntimeError("Datasource composition capabilities are incomplete.")
|
||||||
|
|
||||||
|
origin = writer.create_snapshot(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
snapshot=TabularSnapshotInput(
|
||||||
|
name="Monthly cases",
|
||||||
|
source_name="connector_monthly_cases",
|
||||||
|
rows=(
|
||||||
|
{"id": 1, "amount": 5},
|
||||||
|
{"id": 2, "amount": 15},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
datasource = lifecycle.register_origin(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
origin_ref=origin.ref,
|
||||||
|
name="Monthly cases cache",
|
||||||
|
source_name="monthly_cases",
|
||||||
|
mode="cached",
|
||||||
|
)
|
||||||
|
result = preview_pipeline(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor_id="account-1",
|
||||||
|
payload=PipelinePreviewRequest(
|
||||||
|
graph=_graph(
|
||||||
|
datasource_ref=datasource.ref,
|
||||||
|
fingerprint=datasource.fingerprint,
|
||||||
|
),
|
||||||
|
row_limit=100,
|
||||||
|
),
|
||||||
|
principal=principal,
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
|
expected_rows = [
|
||||||
|
{"id": 1, "amount": 5},
|
||||||
|
{"id": 2, "amount": 15},
|
||||||
|
]
|
||||||
|
if result.status != "succeeded":
|
||||||
|
raise RuntimeError(f"Dataflow preview failed: {result.diagnostics}")
|
||||||
|
if result.rows != expected_rows:
|
||||||
|
raise RuntimeError(f"Unexpected Dataflow rows: {result.rows!r}")
|
||||||
|
if result.source_fingerprints[0]["source_ref"] != datasource.ref:
|
||||||
|
raise RuntimeError("Dataflow lineage did not retain the datasource reference.")
|
||||||
|
pipeline = create_pipeline(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor_id="account-1",
|
||||||
|
payload=PipelineCreateRequest(
|
||||||
|
name="Monthly case output",
|
||||||
|
status="active",
|
||||||
|
graph=_graph(
|
||||||
|
datasource_ref=datasource.ref,
|
||||||
|
fingerprint=datasource.fingerprint,
|
||||||
|
),
|
||||||
|
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(
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
engine.dispose()
|
||||||
|
print(
|
||||||
|
"Connector -> Datasources -> pinned Dataflow publication composition passed."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class _AutomationProvider:
|
||||||
|
def __init__(self, principal: ApiPrincipal) -> None:
|
||||||
|
self.principal = principal
|
||||||
|
|
||||||
|
def resolve_automation_principal(self, _session, *, request):
|
||||||
|
return AutomationPrincipalResolution(
|
||||||
|
allowed=True,
|
||||||
|
principal=self.principal,
|
||||||
|
granted_scopes=request.grant_scopes,
|
||||||
|
provenance={"status": "composition_recheck"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _AutomationRegistry:
|
||||||
|
def __init__(self, registry, principal: ApiPrincipal) -> None:
|
||||||
|
self.registry = registry
|
||||||
|
self.provider = _AutomationProvider(principal)
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return (
|
||||||
|
name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
|
||||||
|
or self.registry.has_capability(name)
|
||||||
|
)
|
||||||
|
|
||||||
|
def capability(self, name: str):
|
||||||
|
if name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER:
|
||||||
|
return self.provider
|
||||||
|
return self.registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _principal() -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset(
|
||||||
|
{
|
||||||
|
"connectors:source:read",
|
||||||
|
"connectors:source:write",
|
||||||
|
"datasources:catalogue:read",
|
||||||
|
"datasources:source:write",
|
||||||
|
"datasources:stage:write",
|
||||||
|
"dataflow:pipeline:run",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _graph(*, datasource_ref: str, fingerprint: str) -> PipelineGraph:
|
||||||
|
return PipelineGraph(
|
||||||
|
nodes=[
|
||||||
|
GraphNode(
|
||||||
|
id="source",
|
||||||
|
type="source.reference",
|
||||||
|
label="Cases",
|
||||||
|
position=GraphPosition(x=0, y=0),
|
||||||
|
config={
|
||||||
|
"source_ref": datasource_ref,
|
||||||
|
"source_name": "monthly_cases",
|
||||||
|
"expected_fingerprint": fingerprint,
|
||||||
|
"consistency": "current",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GraphNode(
|
||||||
|
id="output",
|
||||||
|
type="output",
|
||||||
|
label="Output",
|
||||||
|
position=GraphPosition(x=200, y=0),
|
||||||
|
config={},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
GraphEdge(
|
||||||
|
id="source-output",
|
||||||
|
source="source",
|
||||||
|
target="output",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -30,6 +30,10 @@ GOVOPLAN_CORE_ROOT="$ROOT" PYTHON="$PYTHON" CHECK_TESTCLIENT_DEPRECATIONS=1 bash
|
|||||||
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
||||||
PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$META_ROOT/tools/checks/check-manifest-shapes.py"
|
PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$META_ROOT/tools/checks/check-manifest-shapes.py"
|
||||||
|
|
||||||
|
cd "$META_ROOT"
|
||||||
|
"$PYTHON" -m unittest tests.test_deployment_installer
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
"$PYTHON" - <<'PY'
|
"$PYTHON" - <<'PY'
|
||||||
import ast
|
import ast
|
||||||
import pathlib
|
import pathlib
|
||||||
@@ -69,6 +73,14 @@ PY
|
|||||||
"$PYTHON" -c 'import govoplan_core.db.bootstrap; import govoplan_access.backend.admin.service; import govoplan_addresses.backend.manifest; import govoplan_files.backend.router; import govoplan_mail.backend.sending.imap; print("targeted backend imports passed")'
|
"$PYTHON" -c 'import govoplan_core.db.bootstrap; import govoplan_access.backend.admin.service; import govoplan_addresses.backend.manifest; import govoplan_files.backend.router; import govoplan_mail.backend.sending.imap; print("targeted backend imports passed")'
|
||||||
"$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
"$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
||||||
"$PYTHON" -m unittest tests.test_module_system
|
"$PYTHON" -m unittest tests.test_module_system
|
||||||
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-connectors/tests
|
||||||
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-datasources/tests
|
||||||
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-dataflow/tests
|
||||||
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-workflow/tests
|
||||||
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-views/tests
|
||||||
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-dashboard/tests
|
||||||
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-postbox/tests
|
||||||
|
"$PYTHON" "$META_ROOT/tools/checks/check-datasource-composition.py"
|
||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
|
||||||
"$PYTHON" -m unittest tests.test_api_smoke.ApiSmokeTests.test_mailbox_message_listing_reports_total_count
|
"$PYTHON" -m unittest tests.test_api_smoke.ApiSmokeTests.test_mailbox_message_listing_reports_total_count
|
||||||
|
|
||||||
@@ -77,6 +89,21 @@ cd "$ROOT/webui"
|
|||||||
"$NPM" run test:module-capabilities
|
"$NPM" run test:module-capabilities
|
||||||
"$NPM" run test:module-permutations
|
"$NPM" run test:module-permutations
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan-dataflow/webui
|
||||||
|
"$NPM" run test:structure
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan-datasources/webui
|
||||||
|
"$NPM" run typecheck
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan-workflow/webui
|
||||||
|
"$NPM" run typecheck
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan-dashboard/webui
|
||||||
|
"$NPM" run test:dashboard-layout
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan-postbox/webui
|
||||||
|
"$NPM" run test:ui-structure
|
||||||
|
|
||||||
cd /mnt/DATA/git/govoplan-mail/webui
|
cd /mnt/DATA/git/govoplan-mail/webui
|
||||||
"$NPM" run test:mail-ui
|
"$NPM" run test:mail-ui
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ fi
|
|||||||
|
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
||||||
"$PYTHON" -m unittest tests.test_module_system tests.test_access_contracts
|
"$PYTHON" "$META_ROOT/tools/checks/release_integration.py" artifacts \
|
||||||
|
--requirements "$META_ROOT/requirements-release.txt"
|
||||||
|
"$PYTHON" "$META_ROOT/tools/checks/release_integration.py" core-tests \
|
||||||
|
--core-root "$ROOT"
|
||||||
"$PYTHON" "$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
"$PYTHON" "$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
||||||
|
|
||||||
cd "$ROOT/webui"
|
cd "$ROOT/webui"
|
||||||
|
|||||||
@@ -60,24 +60,6 @@ retry() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
run_discovered_tests() {
|
|
||||||
local suite_dir="$1"
|
|
||||||
local status
|
|
||||||
if ! find "$suite_dir" -type f -name 'test*.py' -print -quit | grep -q .; then
|
|
||||||
echo "No unittest test files found under $suite_dir; skipping."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
set +e
|
|
||||||
"$PYTHON" -m unittest discover -s "$suite_dir"
|
|
||||||
status=$?
|
|
||||||
set -e
|
|
||||||
if [[ "$status" == 5 ]]; then
|
|
||||||
echo "No unittest tests discovered under $suite_dir; skipping."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
return "$status"
|
|
||||||
}
|
|
||||||
|
|
||||||
install_cloned_webui_dependencies() {
|
install_cloned_webui_dependencies() {
|
||||||
local webui_dir="$1"
|
local webui_dir="$1"
|
||||||
if [[ ! -f "$webui_dir/package.json" ]]; then
|
if [[ ! -f "$webui_dir/package.json" ]]; then
|
||||||
@@ -244,7 +226,7 @@ PY
|
|||||||
run_step "Clone release module test sources"
|
run_step "Clone release module test sources"
|
||||||
for repo in govoplan-mail govoplan-calendar govoplan-campaign; do
|
for repo in govoplan-mail govoplan-calendar govoplan-campaign; do
|
||||||
repo_tag="$(release_tag_for_package "$repo")"
|
repo_tag="$(release_tag_for_package "$repo")"
|
||||||
git clone --depth 1 --branch "$repo_tag" "git@git.add-ideas.de:add-ideas/${repo}.git" "$WORK_ROOT/$repo"
|
git clone --depth 1 --branch "$repo_tag" "git@git.add-ideas.de:GovOPlaN/${repo}.git" "$WORK_ROOT/$repo"
|
||||||
done
|
done
|
||||||
|
|
||||||
run_step "Run core backend release tests"
|
run_step "Run core backend release tests"
|
||||||
@@ -253,9 +235,10 @@ run_step "Run core backend release tests"
|
|||||||
"$PYTHON" "$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
"$PYTHON" "$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
||||||
|
|
||||||
run_step "Run cloned module backend tests"
|
run_step "Run cloned module backend tests"
|
||||||
run_discovered_tests "$WORK_ROOT/govoplan-mail/tests"
|
for repo in govoplan-mail govoplan-calendar govoplan-campaign; do
|
||||||
run_discovered_tests "$WORK_ROOT/govoplan-calendar/tests"
|
"$PYTHON" "$META_ROOT/tools/checks/release_integration.py" module-tests \
|
||||||
run_discovered_tests "$WORK_ROOT/govoplan-campaign/tests"
|
--module-root "$WORK_ROOT/$repo"
|
||||||
|
done
|
||||||
|
|
||||||
run_step "Run core WebUI release tests and build"
|
run_step "Run core WebUI release tests and build"
|
||||||
cd "$ROOT/webui"
|
cd "$ROOT/webui"
|
||||||
|
|||||||
@@ -87,6 +87,37 @@ for flag_name in FAIL_ON_FINDINGS REQUIRE_TOOLS; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# A strict audit is only meaningful when every scanner in the selected mode is
|
||||||
|
# available. CI must enforce the same coverage even while findings are
|
||||||
|
# temporarily report-only.
|
||||||
|
if [[ "$FAIL_ON_FINDINGS" == "1" \
|
||||||
|
|| "${CI:-false}" == "true" \
|
||||||
|
|| "${GITEA_ACTIONS:-false}" == "true" ]]; then
|
||||||
|
REQUIRE_TOOLS=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
declare -a REQUIRED_SCANNERS=(
|
||||||
|
semgrep
|
||||||
|
bandit
|
||||||
|
ruff-security
|
||||||
|
gitleaks
|
||||||
|
)
|
||||||
|
if [[ "$MODE" != "quick" ]]; then
|
||||||
|
REQUIRED_SCANNERS+=(
|
||||||
|
trivy
|
||||||
|
pip-audit
|
||||||
|
npm-audit
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
if [[ "$MODE" == "full" ]]; then
|
||||||
|
REQUIRED_SCANNERS+=(
|
||||||
|
osv-scanner
|
||||||
|
jscpd
|
||||||
|
radon
|
||||||
|
xenon
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
declare -a REPOS=()
|
declare -a REPOS=()
|
||||||
if [[ "$SCOPE" == "govoplan" ]]; then
|
if [[ "$SCOPE" == "govoplan" ]]; then
|
||||||
REPOS_ROOT="${GOVOPLAN_REPOS_ROOT:-$(dirname "$ROOT")}"
|
REPOS_ROOT="${GOVOPLAN_REPOS_ROOT:-$(dirname "$ROOT")}"
|
||||||
@@ -138,12 +169,14 @@ overall_status=0
|
|||||||
finding_status=0
|
finding_status=0
|
||||||
missing_status=0
|
missing_status=0
|
||||||
execution_status=0
|
execution_status=0
|
||||||
|
coverage_complete=true
|
||||||
audit_started_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
audit_started_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||||
audit_completed_at=""
|
audit_completed_at=""
|
||||||
workspace_unchanged="unknown"
|
workspace_unchanged="unknown"
|
||||||
audit_toolbox_fingerprint="${SECURITY_AUDIT_TOOLBOX_FINGERPRINT:-direct-host}"
|
audit_toolbox_fingerprint="${SECURITY_AUDIT_TOOLBOX_FINGERPRINT:-direct-host}"
|
||||||
declare -A REPORT_FILES=()
|
declare -A REPORT_FILES=()
|
||||||
declare -A MACHINE_REPORTS=()
|
declare -A MACHINE_REPORTS=()
|
||||||
|
declare -A SCANNER_TERMINAL_STATUSES=()
|
||||||
|
|
||||||
register_report() {
|
register_report() {
|
||||||
local path="$1"
|
local path="$1"
|
||||||
@@ -169,7 +202,9 @@ prepare_machine_report() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
run_step() {
|
run_step_internal() {
|
||||||
|
local scanner_id="$1"
|
||||||
|
shift
|
||||||
local name="$1"
|
local name="$1"
|
||||||
local exit_contract="$2"
|
local exit_contract="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -178,10 +213,12 @@ run_step() {
|
|||||||
"$@"
|
"$@"
|
||||||
local status=$?
|
local status=$?
|
||||||
local outcome="passed"
|
local outcome="passed"
|
||||||
|
local scanner_outcome="no-findings"
|
||||||
if [[ "$status" -eq 0 ]]; then
|
if [[ "$status" -eq 0 ]]; then
|
||||||
:
|
:
|
||||||
elif [[ "$exit_contract" == "findings-exit-one" && "$status" -eq 1 ]]; then
|
elif [[ "$exit_contract" == "findings-exit-one" && "$status" -eq 1 ]]; then
|
||||||
outcome="findings"
|
outcome="findings"
|
||||||
|
scanner_outcome="findings"
|
||||||
echo "Audit step reported findings: $name (exit $status)" >&2
|
echo "Audit step reported findings: $name (exit $status)" >&2
|
||||||
finding_status=1
|
finding_status=1
|
||||||
if [[ "$FAIL_ON_FINDINGS" == "1" ]]; then
|
if [[ "$FAIL_ON_FINDINGS" == "1" ]]; then
|
||||||
@@ -189,21 +226,77 @@ run_step() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
outcome="execution-error"
|
outcome="execution-error"
|
||||||
|
scanner_outcome="scanner-failure"
|
||||||
echo "Audit step failed to execute successfully: $name (exit $status)" >&2
|
echo "Audit step failed to execute successfully: $name (exit $status)" >&2
|
||||||
execution_status=1
|
execution_status=1
|
||||||
overall_status=1
|
overall_status=1
|
||||||
fi
|
fi
|
||||||
printf '%s\t%s\t%s\n' "$name" "$status" "$outcome" >> "$REPORTS_DIR/step-status.tsv"
|
printf '%s\t%s\t%s\n' "$name" "$status" "$outcome" >> "$REPORTS_DIR/step-status.tsv"
|
||||||
|
if [[ -n "$scanner_id" ]]; then
|
||||||
|
SCANNER_TERMINAL_STATUSES["$scanner_id"]="$scanner_outcome"
|
||||||
|
if [[ "$scanner_outcome" == "scanner-failure" ]]; then
|
||||||
|
coverage_complete=false
|
||||||
|
fi
|
||||||
|
printf '%s\t%s\t%s\t%s\n' \
|
||||||
|
"$scanner_id" \
|
||||||
|
"$name" \
|
||||||
|
"$status" \
|
||||||
|
"$scanner_outcome" \
|
||||||
|
>> "$REPORTS_DIR/scanner-status.tsv"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_step() {
|
||||||
|
run_step_internal "" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_scanner_step() {
|
||||||
|
local scanner_id="$1"
|
||||||
|
shift
|
||||||
|
run_step_internal "$scanner_id" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
skip_or_fail_missing() {
|
skip_or_fail_missing() {
|
||||||
local tool="$1"
|
local scanner_id="$1"
|
||||||
|
local tool="$2"
|
||||||
echo "Skipping $tool: command not found. Use tools/checks/security-audit/run.sh for the containerized toolbox." >&2
|
echo "Skipping $tool: command not found. Use tools/checks/security-audit/run.sh for the containerized toolbox." >&2
|
||||||
missing_status=1
|
missing_status=1
|
||||||
|
coverage_complete=false
|
||||||
|
SCANNER_TERMINAL_STATUSES["$scanner_id"]="skipped"
|
||||||
if [[ "$REQUIRE_TOOLS" == "1" ]]; then
|
if [[ "$REQUIRE_TOOLS" == "1" ]]; then
|
||||||
overall_status=1
|
overall_status=1
|
||||||
fi
|
fi
|
||||||
printf '%s\t127\tmissing\n' "$tool" >> "$REPORTS_DIR/step-status.tsv"
|
printf '%s\t127\tmissing\n' "$tool" >> "$REPORTS_DIR/step-status.tsv"
|
||||||
|
printf '%s\t%s\t127\tskipped\n' \
|
||||||
|
"$scanner_id" \
|
||||||
|
"$tool" \
|
||||||
|
>> "$REPORTS_DIR/scanner-status.tsv"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_scanner_status_contract() {
|
||||||
|
local scanner_id
|
||||||
|
local scanner_status
|
||||||
|
local invalid_status=0
|
||||||
|
for scanner_id in "${REQUIRED_SCANNERS[@]}"; do
|
||||||
|
scanner_status="${SCANNER_TERMINAL_STATUSES[$scanner_id]:-}"
|
||||||
|
case "$scanner_status" in
|
||||||
|
no-findings|findings) ;;
|
||||||
|
scanner-failure|skipped)
|
||||||
|
coverage_complete=false
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
echo "Required scanner did not record a terminal status: $scanner_id" >&2
|
||||||
|
coverage_complete=false
|
||||||
|
invalid_status=2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Required scanner recorded an invalid status: $scanner_id ($scanner_status)" >&2
|
||||||
|
coverage_complete=false
|
||||||
|
invalid_status=2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
return "$invalid_status"
|
||||||
}
|
}
|
||||||
|
|
||||||
write_manifest() {
|
write_manifest() {
|
||||||
@@ -229,7 +322,10 @@ write_manifest() {
|
|||||||
"$finding_status" \
|
"$finding_status" \
|
||||||
"$execution_status" \
|
"$execution_status" \
|
||||||
"$missing_status" \
|
"$missing_status" \
|
||||||
|
"$coverage_complete" \
|
||||||
"$audit_toolbox_fingerprint" \
|
"$audit_toolbox_fingerprint" \
|
||||||
|
"${#REQUIRED_SCANNERS[@]}" \
|
||||||
|
"${REQUIRED_SCANNERS[@]}" \
|
||||||
"${#REPOS[@]}" \
|
"${#REPOS[@]}" \
|
||||||
"${REPOS[@]}" \
|
"${REPOS[@]}" \
|
||||||
"${#present_reports[@]}" \
|
"${#present_reports[@]}" \
|
||||||
@@ -254,16 +350,51 @@ import sys
|
|||||||
finding_status,
|
finding_status,
|
||||||
execution_status,
|
execution_status,
|
||||||
missing_status,
|
missing_status,
|
||||||
|
coverage_complete,
|
||||||
toolbox_fingerprint,
|
toolbox_fingerprint,
|
||||||
repository_count,
|
|
||||||
*remaining,
|
*remaining,
|
||||||
) = sys.argv[1:]
|
) = sys.argv[1:]
|
||||||
|
required_scanner_count = int(remaining[0])
|
||||||
|
required_scanners = remaining[1 : required_scanner_count + 1]
|
||||||
|
remaining = remaining[required_scanner_count + 1 :]
|
||||||
|
repository_count = remaining[0]
|
||||||
|
remaining = remaining[1:]
|
||||||
repo_count = int(repository_count)
|
repo_count = int(repository_count)
|
||||||
repositories = remaining[:repo_count]
|
repositories = remaining[:repo_count]
|
||||||
remaining = remaining[repo_count:]
|
remaining = remaining[repo_count:]
|
||||||
report_count = int(remaining[0])
|
report_count = int(remaining[0])
|
||||||
reports = remaining[1 : report_count + 1]
|
reports = remaining[1 : report_count + 1]
|
||||||
expected_reports = remaining[report_count + 1 :]
|
expected_reports = remaining[report_count + 1 :]
|
||||||
|
scanner_results = []
|
||||||
|
scanner_status_path = Path(manifest_path).with_name("scanner-status.tsv")
|
||||||
|
if scanner_status_path.is_file():
|
||||||
|
for line_number, raw_line in enumerate(
|
||||||
|
scanner_status_path.read_text(encoding="utf-8").splitlines(),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
fields = raw_line.split("\t")
|
||||||
|
if len(fields) != 4:
|
||||||
|
raise SystemExit(
|
||||||
|
f"{scanner_status_path}:{line_number}: expected four fields"
|
||||||
|
)
|
||||||
|
scanner_id, name, exit_code, status = fields
|
||||||
|
scanner_results.append(
|
||||||
|
{
|
||||||
|
"id": scanner_id,
|
||||||
|
"name": name,
|
||||||
|
"exit_code": int(exit_code),
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result_ids = {result["id"] for result in scanner_results}
|
||||||
|
incomplete_scanners = sorted(
|
||||||
|
{
|
||||||
|
result["id"]
|
||||||
|
for result in scanner_results
|
||||||
|
if result["status"] in {"scanner-failure", "skipped"}
|
||||||
|
}
|
||||||
|
| (set(required_scanners) - result_ids)
|
||||||
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"scope": scope,
|
"scope": scope,
|
||||||
@@ -276,6 +407,12 @@ payload = {
|
|||||||
"finding_status": int(finding_status),
|
"finding_status": int(finding_status),
|
||||||
"execution_error_status": int(execution_status),
|
"execution_error_status": int(execution_status),
|
||||||
"missing_tool_status": int(missing_status),
|
"missing_tool_status": int(missing_status),
|
||||||
|
"coverage_status": "complete" if coverage_complete == "true" else "incomplete",
|
||||||
|
"scanner_coverage": {
|
||||||
|
"required": required_scanners,
|
||||||
|
"results": scanner_results,
|
||||||
|
"incomplete": incomplete_scanners,
|
||||||
|
},
|
||||||
"tool_versions": "tool-versions.txt",
|
"tool_versions": "tool-versions.txt",
|
||||||
"workspace_state_start": "workspace-state-start.tsv",
|
"workspace_state_start": "workspace-state-start.tsv",
|
||||||
"workspace_state_end": "workspace-state-end.tsv",
|
"workspace_state_end": "workspace-state-end.tsv",
|
||||||
@@ -350,7 +487,10 @@ write_tool_versions() {
|
|||||||
: > "$destination"
|
: > "$destination"
|
||||||
local tool
|
local tool
|
||||||
for tool in semgrep bandit ruff gitleaks trivy pip-audit npm osv-scanner jscpd radon xenon; do
|
for tool in semgrep bandit ruff gitleaks trivy pip-audit npm osv-scanner jscpd radon xenon; do
|
||||||
has_tool "$tool" || continue
|
if ! has_tool "$tool"; then
|
||||||
|
printf '%s\t%s\n' "$tool" "missing" >> "$destination"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
if [[ "$tool" == "gitleaks" ]]; then
|
if [[ "$tool" == "gitleaks" ]]; then
|
||||||
version="$("$tool" version 2>&1)"
|
version="$("$tool" version 2>&1)"
|
||||||
else
|
else
|
||||||
@@ -496,7 +636,12 @@ run_bandit() {
|
|||||||
fi
|
fi
|
||||||
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
|
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
|
||||||
prepare_machine_report "$REPORTS_DIR/bandit-tests.json" || return 2
|
prepare_machine_report "$REPORTS_DIR/bandit-tests.json" || return 2
|
||||||
bandit -r "${PY_TEST_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit-tests.json"
|
# Tests intentionally use assertions and synthetic credentials. Keep the
|
||||||
|
# separate test scan useful by excluding only those test-specific signals.
|
||||||
|
bandit -r "${PY_TEST_ROOTS[@]}" \
|
||||||
|
--skip B101,B105,B106,B107 \
|
||||||
|
-f json \
|
||||||
|
-o "$REPORTS_DIR/bandit-tests.json"
|
||||||
local test_status=$?
|
local test_status=$?
|
||||||
[[ "$test_status" -le 1 ]] || status=2
|
[[ "$test_status" -le 1 ]] || status=2
|
||||||
fi
|
fi
|
||||||
@@ -512,7 +657,11 @@ run_ruff_security() {
|
|||||||
fi
|
fi
|
||||||
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
|
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
|
||||||
prepare_machine_report "$REPORTS_DIR/ruff-security-tests.json" || return 2
|
prepare_machine_report "$REPORTS_DIR/ruff-security-tests.json" || return 2
|
||||||
ruff check --select S --output-format json "${PY_TEST_ROOTS[@]}" > "$REPORTS_DIR/ruff-security-tests.json"
|
ruff check \
|
||||||
|
--select S \
|
||||||
|
--ignore S101,S105,S106,S107 \
|
||||||
|
--output-format json \
|
||||||
|
"${PY_TEST_ROOTS[@]}" > "$REPORTS_DIR/ruff-security-tests.json"
|
||||||
local test_status=$?
|
local test_status=$?
|
||||||
[[ "$test_status" -le 1 ]] || status=2
|
[[ "$test_status" -le 1 ]] || status=2
|
||||||
fi
|
fi
|
||||||
@@ -582,14 +731,39 @@ run_trivy() {
|
|||||||
run_pip_audit_manifests() {
|
run_pip_audit_manifests() {
|
||||||
local status=0
|
local status=0
|
||||||
for repo in "${REPOS[@]}"; do
|
for repo in "${REPOS[@]}"; do
|
||||||
[[ -f "$repo/requirements.txt" ]] || continue
|
local repo_name
|
||||||
local name
|
repo_name="$(safe_name "$repo")"
|
||||||
name="$(safe_name "$repo")"
|
while IFS= read -r -d '' requirements_file; do
|
||||||
prepare_machine_report "$REPORTS_DIR/pip-audit-$name.json" || return 2
|
local manifest_name report_path
|
||||||
|
manifest_name="$(safe_name "$requirements_file")"
|
||||||
|
report_path="$REPORTS_DIR/pip-audit-$repo_name-$manifest_name.json"
|
||||||
|
prepare_machine_report "$report_path" || return 2
|
||||||
# Requirements may contain project-relative entries such as `.[server]`.
|
# Requirements may contain project-relative entries such as `.[server]`.
|
||||||
# Resolve them from the owning repository instead of the meta-repository.
|
# Resolve them from the directory containing the manifest.
|
||||||
(cd "$repo" && pip-audit -r requirements.txt --progress-spinner off --format json --output "$REPORTS_DIR/pip-audit-$name.json")
|
(
|
||||||
|
cd "$(dirname "$requirements_file")" &&
|
||||||
|
pip-audit \
|
||||||
|
-r "$(basename "$requirements_file")" \
|
||||||
|
--progress-spinner off \
|
||||||
|
--format json \
|
||||||
|
--output "$report_path"
|
||||||
|
)
|
||||||
accumulate_exit_status status "$?"
|
accumulate_exit_status status "$?"
|
||||||
|
done < <(
|
||||||
|
find "$repo" \
|
||||||
|
-type d \( \
|
||||||
|
-name .git \
|
||||||
|
-o -name .venv \
|
||||||
|
-o -name node_modules \
|
||||||
|
-o -name dist \
|
||||||
|
-o -name build \
|
||||||
|
-o -name runtime \
|
||||||
|
-o -name audit-reports \
|
||||||
|
-o -name '.*-test-build' \
|
||||||
|
\) -prune \
|
||||||
|
-o -type f -name 'requirements*.txt' -print0 |
|
||||||
|
sort -z
|
||||||
|
)
|
||||||
done
|
done
|
||||||
return "$status"
|
return "$status"
|
||||||
}
|
}
|
||||||
@@ -597,12 +771,44 @@ run_pip_audit_manifests() {
|
|||||||
run_npm_audit_manifests() {
|
run_npm_audit_manifests() {
|
||||||
local status=0
|
local status=0
|
||||||
for repo in "${REPOS[@]}"; do
|
for repo in "${REPOS[@]}"; do
|
||||||
[[ -f "$repo/webui/package-lock.json" ]] || continue
|
local repo_name
|
||||||
local name
|
repo_name="$(safe_name "$repo")"
|
||||||
name="$(safe_name "$repo")"
|
while IFS= read -r -d '' lock_file; do
|
||||||
prepare_machine_report "$REPORTS_DIR/npm-audit-$name.json" || return 2
|
local lock_dir lock_name runtime_report all_report
|
||||||
(cd "$repo/webui" && npm audit --omit=dev --json > "$REPORTS_DIR/npm-audit-$name.json")
|
lock_dir="$(dirname "$lock_file")"
|
||||||
|
lock_name="$(
|
||||||
|
printf '%s' "${lock_file#"$repo"/}" |
|
||||||
|
tr -c 'A-Za-z0-9_.-' '_'
|
||||||
|
)"
|
||||||
|
runtime_report="$REPORTS_DIR/npm-audit-runtime-$repo_name-$lock_name.json"
|
||||||
|
all_report="$REPORTS_DIR/npm-audit-all-$repo_name-$lock_name.json"
|
||||||
|
prepare_machine_report "$runtime_report" || return 2
|
||||||
|
(
|
||||||
|
cd "$lock_dir" &&
|
||||||
|
npm audit --omit=dev --json > "$runtime_report"
|
||||||
|
)
|
||||||
accumulate_exit_status status "$?"
|
accumulate_exit_status status "$?"
|
||||||
|
prepare_machine_report "$all_report" || return 2
|
||||||
|
(
|
||||||
|
cd "$lock_dir" &&
|
||||||
|
npm audit --json > "$all_report"
|
||||||
|
)
|
||||||
|
accumulate_exit_status status "$?"
|
||||||
|
done < <(
|
||||||
|
find "$repo" \
|
||||||
|
-type d \( \
|
||||||
|
-name .git \
|
||||||
|
-o -name .venv \
|
||||||
|
-o -name node_modules \
|
||||||
|
-o -name dist \
|
||||||
|
-o -name build \
|
||||||
|
-o -name runtime \
|
||||||
|
-o -name audit-reports \
|
||||||
|
-o -name '.*-test-build' \
|
||||||
|
\) -prune \
|
||||||
|
-o -type f -name package-lock.json -print0 |
|
||||||
|
sort -z
|
||||||
|
)
|
||||||
done
|
done
|
||||||
return "$status"
|
return "$status"
|
||||||
}
|
}
|
||||||
@@ -665,6 +871,19 @@ run_jscpd() {
|
|||||||
--output "$REPORTS_DIR/jscpd" \
|
--output "$REPORTS_DIR/jscpd" \
|
||||||
--ignore "$ignored_path_pattern" \
|
--ignore "$ignored_path_pattern" \
|
||||||
"${REPOS[@]}"
|
"${REPOS[@]}"
|
||||||
|
local jscpd_status=$?
|
||||||
|
[[ "$jscpd_status" -eq 0 ]] || return 2
|
||||||
|
python3 - "$REPORTS_DIR/jscpd/jscpd-report.json" <<'PY'
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
report = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
raise SystemExit(1 if report.get("duplicates") else 0)
|
||||||
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
run_radon() {
|
run_radon() {
|
||||||
@@ -680,10 +899,12 @@ run_xenon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
register_report "$REPORTS_DIR/step-status.tsv"
|
register_report "$REPORTS_DIR/step-status.tsv"
|
||||||
|
register_report "$REPORTS_DIR/scanner-status.tsv"
|
||||||
register_report "$REPORTS_DIR/tool-versions.txt"
|
register_report "$REPORTS_DIR/tool-versions.txt"
|
||||||
register_report "$REPORTS_DIR/workspace-state-start.tsv"
|
register_report "$REPORTS_DIR/workspace-state-start.tsv"
|
||||||
register_report "$REPORTS_DIR/workspace-state-end.tsv"
|
register_report "$REPORTS_DIR/workspace-state-end.tsv"
|
||||||
: > "$REPORTS_DIR/step-status.tsv"
|
: > "$REPORTS_DIR/step-status.tsv"
|
||||||
|
: > "$REPORTS_DIR/scanner-status.tsv"
|
||||||
if ! write_workspace_state "$REPORTS_DIR/workspace-state-start.tsv"; then
|
if ! write_workspace_state "$REPORTS_DIR/workspace-state-start.tsv"; then
|
||||||
echo "Could not capture the repository state before the audit." >&2
|
echo "Could not capture the repository state before the audit." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -691,75 +912,76 @@ fi
|
|||||||
run_step "Record audit tool versions" execution-only write_tool_versions
|
run_step "Record audit tool versions" execution-only write_tool_versions
|
||||||
|
|
||||||
if has_tool semgrep; then
|
if has_tool semgrep; then
|
||||||
run_step "Semgrep SAST" findings-exit-one run_semgrep
|
run_scanner_step semgrep "Semgrep SAST" findings-exit-one run_semgrep
|
||||||
else
|
else
|
||||||
skip_or_fail_missing semgrep
|
skip_or_fail_missing semgrep semgrep
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool bandit; then
|
if has_tool bandit; then
|
||||||
run_step "Bandit Python security scan" findings-exit-one run_bandit
|
run_scanner_step bandit "Bandit Python security scan" findings-exit-one run_bandit
|
||||||
else
|
else
|
||||||
skip_or_fail_missing bandit
|
skip_or_fail_missing bandit bandit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool ruff; then
|
if has_tool ruff; then
|
||||||
run_step "Ruff flake8-bandit security rules" findings-exit-one run_ruff_security
|
run_scanner_step ruff-security "Ruff flake8-bandit security rules" findings-exit-one run_ruff_security
|
||||||
else
|
else
|
||||||
skip_or_fail_missing ruff
|
skip_or_fail_missing ruff-security ruff
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool gitleaks; then
|
if has_tool gitleaks; then
|
||||||
run_step "Gitleaks secret scan" findings-exit-one run_gitleaks
|
run_scanner_step gitleaks "Gitleaks secret scan" findings-exit-one run_gitleaks
|
||||||
else
|
else
|
||||||
skip_or_fail_missing gitleaks
|
skip_or_fail_missing gitleaks gitleaks
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$MODE" != "quick" ]]; then
|
if [[ "$MODE" != "quick" ]]; then
|
||||||
if has_tool trivy; then
|
if has_tool trivy; then
|
||||||
run_step "Trivy filesystem vulnerability/secret/misconfig scan" findings-exit-one run_trivy
|
run_scanner_step trivy "Trivy filesystem vulnerability/secret/misconfig scan" findings-exit-one run_trivy
|
||||||
else
|
else
|
||||||
skip_or_fail_missing trivy
|
skip_or_fail_missing trivy trivy
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool pip-audit; then
|
if has_tool pip-audit; then
|
||||||
run_step "pip-audit requirements scan" findings-exit-one run_pip_audit_manifests
|
run_scanner_step pip-audit "pip-audit requirements scan" findings-exit-one run_pip_audit_manifests
|
||||||
else
|
else
|
||||||
skip_or_fail_missing pip-audit
|
skip_or_fail_missing pip-audit pip-audit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool npm; then
|
if has_tool npm; then
|
||||||
run_step "npm audit lockfile scan" findings-exit-one run_npm_audit_manifests
|
run_scanner_step npm-audit "npm audit lockfile scan" findings-exit-one run_npm_audit_manifests
|
||||||
else
|
else
|
||||||
skip_or_fail_missing npm
|
skip_or_fail_missing npm-audit npm
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$MODE" == "full" ]]; then
|
if [[ "$MODE" == "full" ]]; then
|
||||||
if has_tool osv-scanner; then
|
if has_tool osv-scanner; then
|
||||||
run_step "OSV-Scanner recursive dependency scan" findings-exit-one run_osv_scanner
|
run_scanner_step osv-scanner "OSV-Scanner recursive dependency scan" findings-exit-one run_osv_scanner
|
||||||
else
|
else
|
||||||
skip_or_fail_missing osv-scanner
|
skip_or_fail_missing osv-scanner osv-scanner
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool jscpd; then
|
if has_tool jscpd; then
|
||||||
run_step "jscpd duplicate code scan" execution-only run_jscpd
|
run_scanner_step jscpd "jscpd duplicate code scan" findings-exit-one run_jscpd
|
||||||
else
|
else
|
||||||
skip_or_fail_missing jscpd
|
skip_or_fail_missing jscpd jscpd
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool radon; then
|
if has_tool radon; then
|
||||||
run_step "Radon complexity report" execution-only run_radon
|
run_scanner_step radon "Radon complexity report" execution-only run_radon
|
||||||
else
|
else
|
||||||
skip_or_fail_missing radon
|
skip_or_fail_missing radon radon
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool xenon; then
|
if has_tool xenon; then
|
||||||
run_step "Xenon complexity threshold scan" findings-exit-one run_xenon
|
run_scanner_step xenon "Xenon complexity threshold scan" findings-exit-one run_xenon
|
||||||
else
|
else
|
||||||
skip_or_fail_missing xenon
|
skip_or_fail_missing xenon xenon
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
run_step "Validate required scanner coverage records" execution-only validate_scanner_status_contract
|
||||||
run_step "Validate machine-readable audit reports" execution-only validate_machine_reports
|
run_step "Validate machine-readable audit reports" execution-only validate_machine_reports
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ def _check_env(*, database_url: str, modules: str, app_env: str) -> dict[str, st
|
|||||||
env.setdefault("FILE_STORAGE_BACKEND", "local")
|
env.setdefault("FILE_STORAGE_BACKEND", "local")
|
||||||
env.setdefault("FILE_STORAGE_LOCAL_ROOT", str(ROOT / "runtime" / "postgres-check-files"))
|
env.setdefault("FILE_STORAGE_LOCAL_ROOT", str(ROOT / "runtime" / "postgres-check-files"))
|
||||||
env.setdefault("MOCK_MAILBOX_DIR", str(ROOT / "runtime" / "postgres-check-mock-mailbox"))
|
env.setdefault("MOCK_MAILBOX_DIR", str(ROOT / "runtime" / "postgres-check-mock-mailbox"))
|
||||||
|
env.setdefault("CORS_ORIGINS", "http://127.0.0.1:5173,http://localhost:5173")
|
||||||
|
env.setdefault("GOVOPLAN_TRUSTED_HOSTS", "127.0.0.1,localhost,testserver")
|
||||||
|
env.setdefault("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS", "false")
|
||||||
|
env.setdefault("GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE", "true")
|
||||||
env["PYTHONPATH"] = os.pathsep.join(part for part in (str(SRC), env.get("PYTHONPATH", "")) if part)
|
env["PYTHONPATH"] = os.pathsep.join(part for part in (str(SRC), env.get("PYTHONPATH", "")) if part)
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import argparse
|
|||||||
import importlib.metadata as metadata
|
import importlib.metadata as metadata
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -35,6 +36,9 @@ SOURCE_COUPLED_CORE_TESTS: Mapping[str, str] = {
|
|||||||
"tests.test_module_system.ModuleSystemTests.test_release_catalog_generator_reads_manifest_interface_contracts": (
|
"tests.test_module_system.ModuleSystemTests.test_release_catalog_generator_reads_manifest_interface_contracts": (
|
||||||
"requires the sibling meta/source workspace and is retained by the normal source gate"
|
"requires the sibling meta/source workspace and is retained by the normal source gate"
|
||||||
),
|
),
|
||||||
|
"tests.test_module_system.ModuleSystemTests.test_registry_keeps_optional_auth_modules_access_free": (
|
||||||
|
"requires optional Poll, Evaluation, and Scheduling source packages outside the tagged base release set"
|
||||||
|
),
|
||||||
"tests.test_identity_organization_contracts.IdentityOrganizationContractTests."
|
"tests.test_identity_organization_contracts.IdentityOrganizationContractTests."
|
||||||
"test_sql_identity_and_organization_directories_return_dtos": (
|
"test_sql_identity_and_organization_directories_return_dtos": (
|
||||||
"directly constructs the current IDM source implementation instead of using its public capability factory"
|
"directly constructs the current IDM source implementation instead of using its public capability factory"
|
||||||
@@ -354,6 +358,24 @@ def run_core_release_tests(core_root: Path) -> int:
|
|||||||
return 0 if result.wasSuccessful() else 1
|
return 0 if result.wasSuccessful() else 1
|
||||||
|
|
||||||
|
|
||||||
|
def run_module_release_tests(module_root: Path) -> int:
|
||||||
|
resolved_root = module_root.resolve()
|
||||||
|
tests_root = resolved_root / "tests"
|
||||||
|
if not tests_root.is_dir() or not any(tests_root.rglob("test*.py")):
|
||||||
|
print(f"No test files found under {tests_root}; skipping.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
(sys.executable, "-m", "pytest", "-q", "tests"),
|
||||||
|
cwd=resolved_root,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode == 5:
|
||||||
|
print(f"No tests collected under {tests_root}; skipping.")
|
||||||
|
return 0
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
|
||||||
def _parser() -> argparse.ArgumentParser:
|
def _parser() -> argparse.ArgumentParser:
|
||||||
meta_root = Path(__file__).resolve().parents[2]
|
meta_root = Path(__file__).resolve().parents[2]
|
||||||
parser = argparse.ArgumentParser(description="Artifact-aware GovOPlaN release integration checks")
|
parser = argparse.ArgumentParser(description="Artifact-aware GovOPlaN release integration checks")
|
||||||
@@ -366,6 +388,8 @@ def _parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
core_parser = subparsers.add_parser("core-tests", help="Run Core tests that are valid for tagged module artifacts")
|
core_parser = subparsers.add_parser("core-tests", help="Run Core tests that are valid for tagged module artifacts")
|
||||||
core_parser.add_argument("--core-root", type=Path, required=True)
|
core_parser.add_argument("--core-root", type=Path, required=True)
|
||||||
|
module_parser = subparsers.add_parser("module-tests", help="Run tests from one tagged module source checkout")
|
||||||
|
module_parser.add_argument("--module-root", type=Path, required=True)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -373,7 +397,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
args = _parser().parse_args(argv)
|
args = _parser().parse_args(argv)
|
||||||
if args.command == "artifacts":
|
if args.command == "artifacts":
|
||||||
return run_installed_artifact_checks(args.requirements)
|
return run_installed_artifact_checks(args.requirements)
|
||||||
|
if args.command == "core-tests":
|
||||||
return run_core_release_tests(args.core_root)
|
return run_core_release_tests(args.core_root)
|
||||||
|
return run_module_release_tests(args.module_root)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ RUN python -m pip install --upgrade pip \
|
|||||||
&& osv-scanner --version \
|
&& osv-scanner --version \
|
||||||
&& trivy --version \
|
&& trivy --version \
|
||||||
&& jscpd --version \
|
&& jscpd --version \
|
||||||
&& pip-audit --progress-spinner off
|
&& pip-audit --progress-spinner off \
|
||||||
|
&& npm --version \
|
||||||
|
&& radon --version \
|
||||||
|
&& xenon --version
|
||||||
|
|
||||||
RUN mkdir -p /workspace \
|
RUN mkdir -p /workspace \
|
||||||
&& chmod 0777 /workspace
|
&& chmod 0777 /workspace
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import posixpath
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
VOLUME_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*")
|
||||||
|
UNSAFE_BIND_SOURCES = {
|
||||||
|
Path("/"),
|
||||||
|
Path("/bin"),
|
||||||
|
Path("/boot"),
|
||||||
|
Path("/dev"),
|
||||||
|
Path("/etc"),
|
||||||
|
Path("/home"),
|
||||||
|
Path("/lib"),
|
||||||
|
Path("/lib64"),
|
||||||
|
Path("/mnt"),
|
||||||
|
Path("/opt"),
|
||||||
|
Path("/proc"),
|
||||||
|
Path("/root"),
|
||||||
|
Path("/run"),
|
||||||
|
Path("/sbin"),
|
||||||
|
Path("/srv"),
|
||||||
|
Path("/sys"),
|
||||||
|
Path("/tmp"),
|
||||||
|
Path("/usr"),
|
||||||
|
Path("/var"),
|
||||||
|
Path("/var/run"),
|
||||||
|
}
|
||||||
|
SENSITIVE_BIND_ROOTS = {
|
||||||
|
Path("/dev"),
|
||||||
|
Path("/etc"),
|
||||||
|
Path("/proc"),
|
||||||
|
Path("/root"),
|
||||||
|
Path("/run"),
|
||||||
|
Path("/sys"),
|
||||||
|
Path("/var/run"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MountResolutionError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_absolute_path(
|
||||||
|
value: str,
|
||||||
|
*,
|
||||||
|
field: str,
|
||||||
|
resolve_symlinks: bool = True,
|
||||||
|
) -> Path:
|
||||||
|
if not value.startswith("/") or value.startswith("//"):
|
||||||
|
raise MountResolutionError(f"{field} must be an absolute path")
|
||||||
|
path = Path(posixpath.normpath(value))
|
||||||
|
return path.resolve(strict=False) if resolve_symlinks else path
|
||||||
|
|
||||||
|
|
||||||
|
def contains_path(container: Path, path: Path) -> bool:
|
||||||
|
return path == container or container in path.parents
|
||||||
|
|
||||||
|
|
||||||
|
def mount_option_value(value: str, *, field: str) -> str:
|
||||||
|
if not value or any(character in value for character in (",", "\n", "\r")):
|
||||||
|
raise MountResolutionError(f"{field} cannot be represented safely")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_workspace_mount(
|
||||||
|
mounts: Any,
|
||||||
|
*,
|
||||||
|
root: str,
|
||||||
|
scope: str,
|
||||||
|
reports_dir: str,
|
||||||
|
) -> str:
|
||||||
|
if scope not in {"current", "govoplan"}:
|
||||||
|
raise MountResolutionError(f"unsupported audit scope: {scope}")
|
||||||
|
if not isinstance(mounts, list):
|
||||||
|
raise MountResolutionError("Docker mount metadata must be a list")
|
||||||
|
|
||||||
|
root_path = normalized_absolute_path(root, field="audit root")
|
||||||
|
scan_root = root_path if scope == "current" else root_path.parent
|
||||||
|
reports_path = Path(reports_dir)
|
||||||
|
if not reports_path.is_absolute():
|
||||||
|
reports_path = root_path / reports_path
|
||||||
|
reports_path = reports_path.resolve(strict=False)
|
||||||
|
|
||||||
|
parsed_mounts: list[tuple[Path, dict[str, Any]]] = []
|
||||||
|
for mount in mounts:
|
||||||
|
if not isinstance(mount, dict) or not isinstance(mount.get("Destination"), str):
|
||||||
|
continue
|
||||||
|
destination = normalized_absolute_path(
|
||||||
|
mount["Destination"],
|
||||||
|
field="mount destination",
|
||||||
|
)
|
||||||
|
parsed_mounts.append((destination, mount))
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
(destination, mount)
|
||||||
|
for destination, mount in parsed_mounts
|
||||||
|
if destination != Path("/") and contains_path(destination, scan_root)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
raise MountResolutionError(
|
||||||
|
f"no job-container mount covers audit scope root {scan_root}"
|
||||||
|
)
|
||||||
|
longest_depth = max(len(destination.parts) for destination, _ in candidates)
|
||||||
|
selected = [
|
||||||
|
candidate
|
||||||
|
for candidate in candidates
|
||||||
|
if len(candidate[0].parts) == longest_depth
|
||||||
|
]
|
||||||
|
if len(selected) != 1:
|
||||||
|
raise MountResolutionError("job-container workspace mount is ambiguous")
|
||||||
|
|
||||||
|
destination, mount = selected[0]
|
||||||
|
nested_mounts = [
|
||||||
|
nested_destination
|
||||||
|
for nested_destination, _ in parsed_mounts
|
||||||
|
if nested_destination != destination
|
||||||
|
and contains_path(scan_root, nested_destination)
|
||||||
|
]
|
||||||
|
if nested_mounts:
|
||||||
|
raise MountResolutionError(
|
||||||
|
"nested job-container mounts inside the audit scope cannot be "
|
||||||
|
"reproduced safely"
|
||||||
|
)
|
||||||
|
if mount.get("RW") is not True:
|
||||||
|
raise MountResolutionError("job-container workspace mount is not writable")
|
||||||
|
if not contains_path(destination, reports_path):
|
||||||
|
raise MountResolutionError(
|
||||||
|
f"audit reports path {reports_path} is outside the workspace mount"
|
||||||
|
)
|
||||||
|
|
||||||
|
destination_value = mount_option_value(
|
||||||
|
str(destination),
|
||||||
|
field="mount destination",
|
||||||
|
)
|
||||||
|
mount_type = mount.get("Type")
|
||||||
|
if mount_type == "volume":
|
||||||
|
name = mount.get("Name")
|
||||||
|
if not isinstance(name, str) or VOLUME_NAME.fullmatch(name) is None:
|
||||||
|
raise MountResolutionError("workspace volume name is missing or malformed")
|
||||||
|
source_value = name
|
||||||
|
elif mount_type == "bind":
|
||||||
|
source = mount.get("Source")
|
||||||
|
if not isinstance(source, str):
|
||||||
|
raise MountResolutionError("workspace bind source is missing")
|
||||||
|
source_path = normalized_absolute_path(
|
||||||
|
source,
|
||||||
|
field="mount source",
|
||||||
|
resolve_symlinks=False,
|
||||||
|
)
|
||||||
|
if source_path in UNSAFE_BIND_SOURCES or any(
|
||||||
|
contains_path(sensitive_root, source_path)
|
||||||
|
for sensitive_root in SENSITIVE_BIND_ROOTS
|
||||||
|
):
|
||||||
|
raise MountResolutionError(
|
||||||
|
"workspace bind source is too broad or sensitive"
|
||||||
|
)
|
||||||
|
source_value = mount_option_value(str(source_path), field="mount source")
|
||||||
|
else:
|
||||||
|
raise MountResolutionError(f"unsupported workspace mount type: {mount_type!r}")
|
||||||
|
|
||||||
|
return f"type={mount_type},source={source_value},target={destination_value}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Resolve one safe Gitea Actions workspace mount for an audit container."
|
||||||
|
)
|
||||||
|
parser.add_argument("--root", required=True)
|
||||||
|
parser.add_argument("--scope", choices=("current", "govoplan"), required=True)
|
||||||
|
parser.add_argument("--reports-dir", required=True)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
try:
|
||||||
|
mounts = json.load(sys.stdin)
|
||||||
|
print(
|
||||||
|
resolve_workspace_mount(
|
||||||
|
mounts,
|
||||||
|
root=args.root,
|
||||||
|
scope=args.scope,
|
||||||
|
reports_dir=args.reports_dir,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, MountResolutionError) as exc:
|
||||||
|
print(f"workspace mount error: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Regular → Executable
+80
-2
@@ -70,6 +70,10 @@ done
|
|||||||
REBUILD="${REBUILD:-0}"
|
REBUILD="${REBUILD:-0}"
|
||||||
UPDATE="${UPDATE:-0}"
|
UPDATE="${UPDATE:-0}"
|
||||||
BUILD_ONLY="${BUILD_ONLY:-0}"
|
BUILD_ONLY="${BUILD_ONLY:-0}"
|
||||||
|
REQUIRE_TOOLS="${SECURITY_AUDIT_REQUIRE_TOOLS:-0}"
|
||||||
|
if [[ "${CI:-false}" == "true" || "${GITEA_ACTIONS:-false}" == "true" ]]; then
|
||||||
|
REQUIRE_TOOLS=1
|
||||||
|
fi
|
||||||
|
|
||||||
declare -a DOCKER_CLI=()
|
declare -a DOCKER_CLI=()
|
||||||
DOCKER_LOCATION=""
|
DOCKER_LOCATION=""
|
||||||
@@ -139,16 +143,89 @@ if [[ "$BUILD_ONLY" == "1" ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
declare -a WORKSPACE_MOUNT=()
|
||||||
|
if [[ "${GITEA_ACTIONS:-}" == "true" ]]; then
|
||||||
|
ACTIONS_CONTAINER_ID="$(hostname)"
|
||||||
|
if ! ACTIONS_MOUNTS_JSON="$(
|
||||||
|
"${DOCKER_CLI[@]}" container inspect \
|
||||||
|
--format '{{json .Mounts}}' \
|
||||||
|
"$ACTIONS_CONTAINER_ID"
|
||||||
|
)"; then
|
||||||
|
echo "Gitea Actions is using a host Docker daemon, but the current job container could not be resolved." >&2
|
||||||
|
echo "Cannot safely share the checked-out workspace with the audit container." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
MOUNT_PYTHON=python3
|
||||||
|
elif command -v python >/dev/null 2>&1; then
|
||||||
|
MOUNT_PYTHON=python
|
||||||
|
else
|
||||||
|
echo "Python is required to validate the Gitea Actions workspace mount." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! ACTIONS_WORKSPACE_MOUNT="$(
|
||||||
|
printf '%s' "$ACTIONS_MOUNTS_JSON" \
|
||||||
|
| "$MOUNT_PYTHON" "$ROOT/tools/checks/security-audit/resolve_workspace_mount.py" \
|
||||||
|
--root "$ROOT" \
|
||||||
|
--scope "$SCOPE" \
|
||||||
|
--reports-dir "$REPORTS_DIR"
|
||||||
|
)"; then
|
||||||
|
echo "Cannot safely share the Gitea Actions workspace with the audit container." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
WORKSPACE_MOUNT=(--mount "$ACTIONS_WORKSPACE_MOUNT")
|
||||||
|
MOUNT_ROOT="$(dirname "$ROOT")"
|
||||||
|
WORKDIR="$ROOT"
|
||||||
if [[ "$SCOPE" == "govoplan" ]]; then
|
if [[ "$SCOPE" == "govoplan" ]]; then
|
||||||
|
EXTRA_ENV=(-e "GOVOPLAN_REPOS_ROOT=$MOUNT_ROOT")
|
||||||
|
else
|
||||||
|
EXTRA_ENV=()
|
||||||
|
fi
|
||||||
|
elif [[ "$SCOPE" == "govoplan" ]]; then
|
||||||
MOUNT_ROOT="$(dirname "$ROOT")"
|
MOUNT_ROOT="$(dirname "$ROOT")"
|
||||||
WORKDIR="/workspace/$(basename "$ROOT")"
|
WORKDIR="/workspace/$(basename "$ROOT")"
|
||||||
|
WORKSPACE_MOUNT=(-v "$MOUNT_ROOT:/workspace")
|
||||||
EXTRA_ENV=(-e "GOVOPLAN_REPOS_ROOT=/workspace")
|
EXTRA_ENV=(-e "GOVOPLAN_REPOS_ROOT=/workspace")
|
||||||
else
|
else
|
||||||
MOUNT_ROOT="$ROOT"
|
MOUNT_ROOT="$ROOT"
|
||||||
WORKDIR="/workspace"
|
WORKDIR="/workspace"
|
||||||
|
WORKSPACE_MOUNT=(-v "$MOUNT_ROOT:/workspace")
|
||||||
EXTRA_ENV=()
|
EXTRA_ENV=()
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
declare -a GIT_CONFIG_ENV=()
|
||||||
|
GIT_CONFIG_COUNT_VALUE="${GIT_CONFIG_COUNT:-0}"
|
||||||
|
if ! [[ "$GIT_CONFIG_COUNT_VALUE" =~ ^[0-9]+$ ]] || (( GIT_CONFIG_COUNT_VALUE > 8 )); then
|
||||||
|
echo "GIT_CONFIG_COUNT must be an integer between 0 and 8." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for ((index = 0; index < GIT_CONFIG_COUNT_VALUE; index++)); do
|
||||||
|
key_name="GIT_CONFIG_KEY_$index"
|
||||||
|
value_name="GIT_CONFIG_VALUE_$index"
|
||||||
|
if ! [[ -v "$key_name" && -v "$value_name" ]]; then
|
||||||
|
echo "Both $key_name and $value_name are required." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
key_value="${!key_name}"
|
||||||
|
rewrite_value="${!value_name}"
|
||||||
|
target_url="${key_value#url.}"
|
||||||
|
target_url="${target_url%.insteadOf}"
|
||||||
|
if [[ "$key_value" != url.https://*.insteadOf ]] \
|
||||||
|
|| [[ "${target_url#https://}" == *"@"* ]] \
|
||||||
|
|| [[ "$rewrite_value" != git@* && "$rewrite_value" != ssh://git@* ]] \
|
||||||
|
|| [[ "$key_value" == *$'\n'* || "$rewrite_value" == *$'\n'* ]]; then
|
||||||
|
echo "Only credential-free HTTPS Git insteadOf rewrites may enter the audit container." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
GIT_CONFIG_ENV+=(
|
||||||
|
-e "$key_name=$key_value"
|
||||||
|
-e "$value_name=$rewrite_value"
|
||||||
|
)
|
||||||
|
done
|
||||||
|
if (( GIT_CONFIG_COUNT_VALUE > 0 )); then
|
||||||
|
GIT_CONFIG_ENV+=(-e "GIT_CONFIG_COUNT=$GIT_CONFIG_COUNT_VALUE")
|
||||||
|
fi
|
||||||
|
|
||||||
"${DOCKER_CLI[@]}" run --rm \
|
"${DOCKER_CLI[@]}" run --rm \
|
||||||
--user "$(id -u):$(id -g)" \
|
--user "$(id -u):$(id -g)" \
|
||||||
-e HOME=/tmp/security-audit-home \
|
-e HOME=/tmp/security-audit-home \
|
||||||
@@ -156,10 +233,11 @@ fi
|
|||||||
-e SECURITY_AUDIT_MODE="$MODE" \
|
-e SECURITY_AUDIT_MODE="$MODE" \
|
||||||
-e SECURITY_AUDIT_REPORTS_DIR="$REPORTS_DIR" \
|
-e SECURITY_AUDIT_REPORTS_DIR="$REPORTS_DIR" \
|
||||||
-e SECURITY_AUDIT_FAIL_ON_FINDINGS="${SECURITY_AUDIT_FAIL_ON_FINDINGS:-0}" \
|
-e SECURITY_AUDIT_FAIL_ON_FINDINGS="${SECURITY_AUDIT_FAIL_ON_FINDINGS:-0}" \
|
||||||
-e SECURITY_AUDIT_REQUIRE_TOOLS="${SECURITY_AUDIT_REQUIRE_TOOLS:-0}" \
|
-e SECURITY_AUDIT_REQUIRE_TOOLS="$REQUIRE_TOOLS" \
|
||||||
-e SECURITY_AUDIT_TOOLBOX_FINGERPRINT="$IMAGE_FINGERPRINT" \
|
-e SECURITY_AUDIT_TOOLBOX_FINGERPRINT="$IMAGE_FINGERPRINT" \
|
||||||
"${EXTRA_ENV[@]}" \
|
"${EXTRA_ENV[@]}" \
|
||||||
-v "$MOUNT_ROOT:/workspace" \
|
"${GIT_CONFIG_ENV[@]}" \
|
||||||
|
"${WORKSPACE_MOUNT[@]}" \
|
||||||
-w "$WORKDIR" \
|
-w "$WORKDIR" \
|
||||||
"$FINGERPRINT_IMAGE" \
|
"$FINGERPRINT_IMAGE" \
|
||||||
bash tools/checks/check-security-audit.sh --mode "$MODE" --scope "$SCOPE" --reports-dir "$REPORTS_DIR" "${SCRIPT_ARGS[@]}"
|
bash tools/checks/check-security-audit.sh --mode "$MODE" --scope "$SCOPE" --reports-dir "$REPORTS_DIR" "${SCRIPT_ARGS[@]}"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Executable entry point for the single-file GovOPlaN deployer."""
|
||||||
|
|
||||||
|
from govoplan_deploy.cli import main
|
||||||
|
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the dependency-free GovOPlaN deployer as one executable zipapp."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from hashlib import sha256
|
||||||
|
from pathlib import Path
|
||||||
|
import zipapp
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
DEFAULT_OUTPUT = ROOT.parent.parent / "runtime" / "deployment" / "govoplan-deploy.pyz"
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
output = args.output.expanduser().resolve()
|
||||||
|
if output == ROOT or ROOT in output.parents:
|
||||||
|
raise SystemExit("output must be outside the deployment source directory")
|
||||||
|
output.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
temporary = output.with_name(f".{output.name}.tmp")
|
||||||
|
if temporary.exists():
|
||||||
|
temporary.unlink()
|
||||||
|
zipapp.create_archive(
|
||||||
|
ROOT,
|
||||||
|
target=temporary,
|
||||||
|
interpreter="/usr/bin/env python3",
|
||||||
|
compressed=True,
|
||||||
|
filter=_include_source,
|
||||||
|
)
|
||||||
|
temporary.chmod(0o755)
|
||||||
|
temporary.replace(output)
|
||||||
|
digest = sha256(output.read_bytes()).hexdigest()
|
||||||
|
print(f"{digest} {output}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _include_source(path: Path) -> bool:
|
||||||
|
return (
|
||||||
|
"__pycache__" not in path.parts
|
||||||
|
and path.suffix not in {".pyc", ".pyo"}
|
||||||
|
and path.name != "build-deployer-zipapp.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GovOPlaN deployment entry point."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS_ROOT = Path(__file__).resolve().parent
|
||||||
|
if str(TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(TOOLS_ROOT))
|
||||||
|
|
||||||
|
from govoplan_deploy.cli import main # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Declarative GovOPlaN host deployment tooling."""
|
||||||
|
|
||||||
|
from .model import (
|
||||||
|
BASE_MODULES,
|
||||||
|
FULL_MODULES,
|
||||||
|
InstallationSpec,
|
||||||
|
SpecError,
|
||||||
|
default_spec,
|
||||||
|
load_spec,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BASE_MODULES",
|
||||||
|
"FULL_MODULES",
|
||||||
|
"InstallationSpec",
|
||||||
|
"SpecError",
|
||||||
|
"default_spec",
|
||||||
|
"load_spec",
|
||||||
|
]
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
"""Secret handling and deterministic Compose bundle rendering."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import stat
|
||||||
|
from typing import Mapping
|
||||||
|
from urllib.parse import quote, urlsplit
|
||||||
|
|
||||||
|
from .model import ENV_NAME_PATTERN, InstallationSpec
|
||||||
|
|
||||||
|
|
||||||
|
SPEC_FILENAME = "installation.json"
|
||||||
|
ENV_FILENAME = "secrets.env"
|
||||||
|
COMPOSE_FILENAME = "compose.json"
|
||||||
|
GARAGE_CONFIG_FILENAME = "garage.toml"
|
||||||
|
LOAD_BALANCER_CONFIG_FILENAME = "load-balancer.cfg"
|
||||||
|
PLAN_FILENAME = "plan.json"
|
||||||
|
RECEIPT_FILENAME = "receipt.json"
|
||||||
|
LOCK_FILENAME = ".deployment.lock"
|
||||||
|
RUNTIME_ENV_KEYS = (
|
||||||
|
"APP_ENV",
|
||||||
|
"GOVOPLAN_INSTALL_PROFILE",
|
||||||
|
"MASTER_KEY_B64",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"GOVOPLAN_DATABASE_URL_PGTOOLS",
|
||||||
|
"ENABLED_MODULES",
|
||||||
|
"CELERY_ENABLED",
|
||||||
|
"CELERY_QUEUES",
|
||||||
|
"REDIS_URL",
|
||||||
|
"CORS_ORIGINS",
|
||||||
|
"GOVOPLAN_TRUSTED_HOSTS",
|
||||||
|
"FORWARDED_ALLOW_IPS",
|
||||||
|
"AUTH_COOKIE_SECURE",
|
||||||
|
"AUTH_COOKIE_SAMESITE",
|
||||||
|
"GOVOPLAN_HTTP_HSTS_SECONDS",
|
||||||
|
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||||
|
"GOVOPLAN_MIGRATION_TRACK",
|
||||||
|
"DEV_AUTO_MIGRATE_ENABLED",
|
||||||
|
"DEV_BOOTSTRAP_ENABLED",
|
||||||
|
"GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE",
|
||||||
|
"GOVOPLAN_DEPLOYMENT_SPEC_PATH",
|
||||||
|
"FILE_STORAGE_BACKEND",
|
||||||
|
"FILE_STORAGE_LOCAL_ROOT",
|
||||||
|
"FILE_STORAGE_S3_ENDPOINT_URL",
|
||||||
|
"FILE_STORAGE_S3_REGION",
|
||||||
|
"FILE_STORAGE_S3_ACCESS_KEY_ID",
|
||||||
|
"FILE_STORAGE_S3_SECRET_ACCESS_KEY",
|
||||||
|
"FILE_STORAGE_S3_BUCKET",
|
||||||
|
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BundlePaths:
|
||||||
|
root: Path
|
||||||
|
spec: Path
|
||||||
|
env: Path
|
||||||
|
compose: Path
|
||||||
|
garage_config: Path
|
||||||
|
load_balancer_config: Path
|
||||||
|
plan: Path
|
||||||
|
receipt: Path
|
||||||
|
lock: Path
|
||||||
|
|
||||||
|
|
||||||
|
def bundle_paths(root: Path) -> BundlePaths:
|
||||||
|
expanded = root.expanduser()
|
||||||
|
if expanded.is_symlink():
|
||||||
|
raise ValueError(f"installation root must not be a symbolic link: {expanded}")
|
||||||
|
resolved = expanded.resolve()
|
||||||
|
return BundlePaths(
|
||||||
|
root=resolved,
|
||||||
|
spec=resolved / SPEC_FILENAME,
|
||||||
|
env=resolved / ENV_FILENAME,
|
||||||
|
compose=resolved / COMPOSE_FILENAME,
|
||||||
|
garage_config=resolved / GARAGE_CONFIG_FILENAME,
|
||||||
|
load_balancer_config=resolved / LOAD_BALANCER_CONFIG_FILENAME,
|
||||||
|
plan=resolved / PLAN_FILENAME,
|
||||||
|
receipt=resolved / RECEIPT_FILENAME,
|
||||||
|
lock=resolved / LOCK_FILENAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_private_directory(path: Path) -> None:
|
||||||
|
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
if path.is_symlink() or not path.is_dir():
|
||||||
|
raise ValueError(f"installation root must be a real directory: {path}")
|
||||||
|
current = stat.S_IMODE(path.stat().st_mode)
|
||||||
|
if current & 0o077:
|
||||||
|
path.chmod(0o700)
|
||||||
|
|
||||||
|
|
||||||
|
def initial_secrets(
|
||||||
|
spec: InstallationSpec,
|
||||||
|
*,
|
||||||
|
supplied: Mapping[str, str] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
values = dict(supplied or {})
|
||||||
|
values.setdefault(
|
||||||
|
"MASTER_KEY_B64",
|
||||||
|
base64.urlsafe_b64encode(os.urandom(32)).decode("ascii"),
|
||||||
|
)
|
||||||
|
values.setdefault("POSTGRES_DB", "govoplan")
|
||||||
|
values.setdefault("POSTGRES_USER", "govoplan")
|
||||||
|
values.setdefault("POSTGRES_PASSWORD", secrets.token_urlsafe(36))
|
||||||
|
values.setdefault("REDIS_PASSWORD", secrets.token_urlsafe(36))
|
||||||
|
return reconcile_runtime_environment(spec, values)
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_runtime_environment(
|
||||||
|
spec: InstallationSpec,
|
||||||
|
current: Mapping[str, str],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
values = dict(current)
|
||||||
|
postgres = spec.components.postgres
|
||||||
|
if postgres.mode == "managed":
|
||||||
|
database = values.setdefault("POSTGRES_DB", "govoplan")
|
||||||
|
username = values.setdefault("POSTGRES_USER", "govoplan")
|
||||||
|
password = values.setdefault("POSTGRES_PASSWORD", secrets.token_urlsafe(36))
|
||||||
|
encoded_user = quote(username, safe="")
|
||||||
|
encoded_password = quote(password, safe="")
|
||||||
|
encoded_database = quote(database, safe="")
|
||||||
|
values["DATABASE_URL"] = (
|
||||||
|
f"postgresql+psycopg://{encoded_user}:{encoded_password}"
|
||||||
|
f"@postgres:5432/{encoded_database}"
|
||||||
|
)
|
||||||
|
values["GOVOPLAN_DATABASE_URL_PGTOOLS"] = (
|
||||||
|
f"postgresql://{encoded_user}:{encoded_password}"
|
||||||
|
f"@postgres:5432/{encoded_database}"
|
||||||
|
)
|
||||||
|
elif not values.get(postgres.url_env):
|
||||||
|
raise ValueError(
|
||||||
|
f"external PostgreSQL requires {postgres.url_env} in {ENV_FILENAME}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_validate_service_url(
|
||||||
|
values[postgres.url_env],
|
||||||
|
schemes={"postgresql", "postgresql+psycopg"},
|
||||||
|
label="external PostgreSQL URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
redis = spec.components.redis
|
||||||
|
if redis.mode == "managed":
|
||||||
|
password = values.setdefault("REDIS_PASSWORD", secrets.token_urlsafe(36))
|
||||||
|
values["REDIS_URL"] = f"redis://:{quote(password, safe='')}@redis:6379/0"
|
||||||
|
elif redis.mode == "external":
|
||||||
|
if not values.get(redis.url_env):
|
||||||
|
raise ValueError(
|
||||||
|
f"external Redis requires {redis.url_env} in {ENV_FILENAME}"
|
||||||
|
)
|
||||||
|
_validate_service_url(
|
||||||
|
values[redis.url_env],
|
||||||
|
schemes={"redis", "rediss"},
|
||||||
|
label="external Redis URL",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
values["REDIS_URL"] = ""
|
||||||
|
|
||||||
|
public = urlsplit(spec.public_url)
|
||||||
|
values.update(
|
||||||
|
{
|
||||||
|
"APP_ENV": "production" if spec.profile == "self-hosted" else "staging",
|
||||||
|
"GOVOPLAN_INSTALL_PROFILE": spec.profile,
|
||||||
|
"ENABLED_MODULES": ",".join(spec.enabled_modules),
|
||||||
|
"CELERY_ENABLED": "true" if redis.mode != "disabled" else "false",
|
||||||
|
"CELERY_QUEUES": (
|
||||||
|
"send_email,append_sent,notifications,calendar,dataflow,events,default"
|
||||||
|
),
|
||||||
|
"CORS_ORIGINS": spec.public_url,
|
||||||
|
"GOVOPLAN_TRUSTED_HOSTS": public.hostname or "",
|
||||||
|
"FORWARDED_ALLOW_IPS": spec.network_subnet,
|
||||||
|
"AUTH_COOKIE_SECURE": "true" if public.scheme == "https" else "false",
|
||||||
|
"AUTH_COOKIE_SAMESITE": "lax",
|
||||||
|
"GOVOPLAN_HTTP_HSTS_SECONDS": (
|
||||||
|
"31536000" if public.scheme == "https" else "0"
|
||||||
|
),
|
||||||
|
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false",
|
||||||
|
"GOVOPLAN_MIGRATION_TRACK": "release",
|
||||||
|
"DEV_AUTO_MIGRATE_ENABLED": "false",
|
||||||
|
"DEV_BOOTSTRAP_ENABLED": "false",
|
||||||
|
"GOVOPLAN_DEPLOYMENT_SPEC_PATH": "/etc/govoplan/deployment/installation.json",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if redis.mode == "disabled":
|
||||||
|
values["GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE"] = "true"
|
||||||
|
else:
|
||||||
|
values["GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE"] = "false"
|
||||||
|
|
||||||
|
storage = spec.components.storage
|
||||||
|
if storage.mode == "local":
|
||||||
|
values["FILE_STORAGE_BACKEND"] = "local"
|
||||||
|
values["FILE_STORAGE_S3_DEPLOYMENT_MANAGED"] = "false"
|
||||||
|
values["FILE_STORAGE_LOCAL_ROOT"] = "/var/lib/govoplan/files"
|
||||||
|
elif storage.mode == "garage":
|
||||||
|
access_key = values.setdefault(
|
||||||
|
"GARAGE_DEFAULT_ACCESS_KEY",
|
||||||
|
f"GK{secrets.token_hex(16)}",
|
||||||
|
)
|
||||||
|
secret_key = values.setdefault(
|
||||||
|
"GARAGE_DEFAULT_SECRET_KEY",
|
||||||
|
secrets.token_hex(32),
|
||||||
|
)
|
||||||
|
bucket = values.setdefault("GARAGE_DEFAULT_BUCKET", "govoplan-files")
|
||||||
|
values.setdefault("GARAGE_RPC_SECRET", secrets.token_hex(32))
|
||||||
|
values.setdefault("GARAGE_ADMIN_TOKEN", secrets.token_urlsafe(48))
|
||||||
|
values.setdefault("GARAGE_METRICS_TOKEN", secrets.token_urlsafe(48))
|
||||||
|
values.update(
|
||||||
|
{
|
||||||
|
"FILE_STORAGE_BACKEND": "s3",
|
||||||
|
"FILE_STORAGE_S3_ENDPOINT_URL": "http://garage:3900",
|
||||||
|
"FILE_STORAGE_S3_REGION": "garage",
|
||||||
|
"FILE_STORAGE_S3_ACCESS_KEY_ID": access_key,
|
||||||
|
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": secret_key,
|
||||||
|
"FILE_STORAGE_S3_BUCKET": bucket,
|
||||||
|
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED": "true",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
values["FILE_STORAGE_BACKEND"] = "s3"
|
||||||
|
values["FILE_STORAGE_S3_DEPLOYMENT_MANAGED"] = "false"
|
||||||
|
required = (
|
||||||
|
"FILE_STORAGE_S3_ENDPOINT_URL",
|
||||||
|
"FILE_STORAGE_S3_REGION",
|
||||||
|
"FILE_STORAGE_S3_ACCESS_KEY_ID",
|
||||||
|
"FILE_STORAGE_S3_SECRET_ACCESS_KEY",
|
||||||
|
"FILE_STORAGE_S3_BUCKET",
|
||||||
|
)
|
||||||
|
missing = [name for name in required if not values.get(name)]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
"S3 storage requires values in secrets.env: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
endpoint = _validate_service_url(
|
||||||
|
values["FILE_STORAGE_S3_ENDPOINT_URL"],
|
||||||
|
schemes={"http", "https"},
|
||||||
|
label="S3 endpoint URL",
|
||||||
|
)
|
||||||
|
if spec.profile == "self-hosted" and endpoint.scheme != "https":
|
||||||
|
raise ValueError("self-hosted S3 endpoint URL must use HTTPS")
|
||||||
|
return dict(sorted(values.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
||||||
|
runtime_env = {
|
||||||
|
"environment": _environment_references(RUNTIME_ENV_KEYS),
|
||||||
|
}
|
||||||
|
deployment_mount = (
|
||||||
|
f"./{SPEC_FILENAME}:/etc/govoplan/deployment/installation.json:ro"
|
||||||
|
)
|
||||||
|
data_mounts = [deployment_mount]
|
||||||
|
if spec.components.storage.mode == "local":
|
||||||
|
data_mounts.append("files-data:/var/lib/govoplan/files")
|
||||||
|
|
||||||
|
dependency_conditions: dict[str, dict[str, str]] = {}
|
||||||
|
services: dict[str, object] = {}
|
||||||
|
if spec.components.postgres.mode == "managed":
|
||||||
|
services["postgres"] = {
|
||||||
|
"image": spec.components.postgres.image,
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"environment": _environment_references(
|
||||||
|
("POSTGRES_DB", "POSTGRES_USER", "POSTGRES_PASSWORD"),
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
"healthcheck": {
|
||||||
|
"test": [
|
||||||
|
"CMD-SHELL",
|
||||||
|
'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"',
|
||||||
|
],
|
||||||
|
"interval": "5s",
|
||||||
|
"timeout": "3s",
|
||||||
|
"retries": 30,
|
||||||
|
},
|
||||||
|
"volumes": ["postgres-data:/var/lib/postgresql/data"],
|
||||||
|
"networks": ["internal"],
|
||||||
|
}
|
||||||
|
dependency_conditions["postgres"] = {"condition": "service_healthy"}
|
||||||
|
|
||||||
|
if spec.components.redis.mode == "managed":
|
||||||
|
services["redis"] = {
|
||||||
|
"image": spec.components.redis.image,
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"environment": _environment_references(
|
||||||
|
("REDIS_PASSWORD",),
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
"command": [
|
||||||
|
"sh",
|
||||||
|
"-ec",
|
||||||
|
'exec redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"',
|
||||||
|
],
|
||||||
|
"healthcheck": {
|
||||||
|
"test": [
|
||||||
|
"CMD-SHELL",
|
||||||
|
'redis-cli -a "$${REDIS_PASSWORD}" --no-auth-warning ping',
|
||||||
|
],
|
||||||
|
"interval": "5s",
|
||||||
|
"timeout": "3s",
|
||||||
|
"retries": 30,
|
||||||
|
},
|
||||||
|
"volumes": ["redis-data:/data"],
|
||||||
|
"networks": ["internal"],
|
||||||
|
}
|
||||||
|
dependency_conditions["redis"] = {"condition": "service_healthy"}
|
||||||
|
|
||||||
|
if spec.components.storage.mode == "garage":
|
||||||
|
garage_environment = _environment_references(
|
||||||
|
(
|
||||||
|
"GARAGE_DEFAULT_ACCESS_KEY",
|
||||||
|
"GARAGE_DEFAULT_SECRET_KEY",
|
||||||
|
"GARAGE_DEFAULT_BUCKET",
|
||||||
|
"GARAGE_RPC_SECRET",
|
||||||
|
"GARAGE_ADMIN_TOKEN",
|
||||||
|
"GARAGE_METRICS_TOKEN",
|
||||||
|
),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
services["garage"] = {
|
||||||
|
"image": spec.components.storage.image,
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"command": [
|
||||||
|
"/garage",
|
||||||
|
"server",
|
||||||
|
"--single-node",
|
||||||
|
"--default-bucket",
|
||||||
|
],
|
||||||
|
"environment": garage_environment,
|
||||||
|
"healthcheck": {
|
||||||
|
"test": ["CMD", "/garage", "status"],
|
||||||
|
"interval": "10s",
|
||||||
|
"timeout": "5s",
|
||||||
|
"retries": 30,
|
||||||
|
"start_period": "15s",
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"org.govoplan.configuration-sha256": sha256(
|
||||||
|
render_garage_config().encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
},
|
||||||
|
"security_opt": ["no-new-privileges:true"],
|
||||||
|
"volumes": [
|
||||||
|
f"./{GARAGE_CONFIG_FILENAME}:/etc/garage.toml:ro",
|
||||||
|
"garage-meta:/var/lib/garage/meta",
|
||||||
|
"garage-data:/var/lib/garage/data",
|
||||||
|
],
|
||||||
|
"networks": ["internal"],
|
||||||
|
}
|
||||||
|
dependency_conditions["garage"] = {"condition": "service_healthy"}
|
||||||
|
|
||||||
|
if spec.components.mail.mode == "test-mail":
|
||||||
|
services["test-mail"] = {
|
||||||
|
"image": spec.components.mail.image,
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"environment": {
|
||||||
|
"GREENMAIL_OPTS": (
|
||||||
|
"-Dgreenmail.setup.test.smtp -Dgreenmail.setup.test.imap "
|
||||||
|
"-Dgreenmail.hostname=0.0.0.0"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"networks": ["internal"],
|
||||||
|
}
|
||||||
|
|
||||||
|
common_runtime: dict[str, object] = {
|
||||||
|
**runtime_env,
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"volumes": data_mounts,
|
||||||
|
"networks": ["internal"],
|
||||||
|
}
|
||||||
|
if dependency_conditions:
|
||||||
|
common_runtime["depends_on"] = dependency_conditions
|
||||||
|
|
||||||
|
services["migrate"] = {
|
||||||
|
**runtime_env,
|
||||||
|
"image": spec.release.api_image,
|
||||||
|
"command": ["python", "-m", "govoplan_core.commands.init_db"],
|
||||||
|
"restart": "no",
|
||||||
|
"volumes": data_mounts,
|
||||||
|
"networks": ["internal"],
|
||||||
|
**({"depends_on": dependency_conditions} if dependency_conditions else {}),
|
||||||
|
}
|
||||||
|
services["api"] = {
|
||||||
|
**common_runtime,
|
||||||
|
"image": spec.release.api_image,
|
||||||
|
"scale": spec.replicas.api,
|
||||||
|
"command": [
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"uvicorn",
|
||||||
|
"govoplan_core.server.app:app",
|
||||||
|
"--host",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--port",
|
||||||
|
"8000",
|
||||||
|
"--proxy-headers",
|
||||||
|
],
|
||||||
|
"healthcheck": {
|
||||||
|
"test": [
|
||||||
|
"CMD",
|
||||||
|
"python",
|
||||||
|
"-c",
|
||||||
|
(
|
||||||
|
"import urllib.request;"
|
||||||
|
"urllib.request.urlopen('http://127.0.0.1:8000/health',timeout=3)"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"interval": "10s",
|
||||||
|
"timeout": "5s",
|
||||||
|
"retries": 30,
|
||||||
|
"start_period": "20s",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
services["web"] = {
|
||||||
|
"image": spec.release.web_image,
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"scale": spec.replicas.web,
|
||||||
|
"environment": {"GOVOPLAN_API_UPSTREAM": "http://load-balancer:8000"},
|
||||||
|
"networks": ["internal"],
|
||||||
|
}
|
||||||
|
services["load-balancer"] = {
|
||||||
|
"image": spec.components.load_balancer.image,
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"healthcheck": {
|
||||||
|
"test": [
|
||||||
|
"CMD",
|
||||||
|
"haproxy",
|
||||||
|
"-c",
|
||||||
|
"-f",
|
||||||
|
"/usr/local/etc/haproxy/haproxy.cfg",
|
||||||
|
],
|
||||||
|
"interval": "10s",
|
||||||
|
"timeout": "5s",
|
||||||
|
"retries": 10,
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"org.govoplan.configuration-sha256": sha256(
|
||||||
|
render_load_balancer_config(spec).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
},
|
||||||
|
"ports": [_published_port(spec.listen.address, spec.listen.port, 8080)],
|
||||||
|
"read_only": True,
|
||||||
|
"security_opt": ["no-new-privileges:true"],
|
||||||
|
"volumes": [
|
||||||
|
(f"./{LOAD_BALANCER_CONFIG_FILENAME}:/usr/local/etc/haproxy/haproxy.cfg:ro")
|
||||||
|
],
|
||||||
|
"networks": ["internal"],
|
||||||
|
}
|
||||||
|
if spec.components.redis.mode != "disabled":
|
||||||
|
services["worker"] = {
|
||||||
|
**common_runtime,
|
||||||
|
"image": spec.release.api_image,
|
||||||
|
"scale": spec.replicas.worker,
|
||||||
|
"command": [
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"celery",
|
||||||
|
"-A",
|
||||||
|
"govoplan_core.celery_app:celery",
|
||||||
|
"worker",
|
||||||
|
"--queues",
|
||||||
|
(
|
||||||
|
"send_email,append_sent,notifications,calendar,"
|
||||||
|
"dataflow,events,default"
|
||||||
|
),
|
||||||
|
"--loglevel",
|
||||||
|
"INFO",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
services["scheduler"] = {
|
||||||
|
**common_runtime,
|
||||||
|
"image": spec.release.api_image,
|
||||||
|
"command": [
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"celery",
|
||||||
|
"-A",
|
||||||
|
"govoplan_core.celery_app:celery",
|
||||||
|
"beat",
|
||||||
|
"--loglevel",
|
||||||
|
"INFO",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
volumes: dict[str, object] = {}
|
||||||
|
if spec.components.postgres.mode == "managed":
|
||||||
|
volumes["postgres-data"] = {}
|
||||||
|
if spec.components.redis.mode == "managed":
|
||||||
|
volumes["redis-data"] = {}
|
||||||
|
if spec.components.storage.mode == "local":
|
||||||
|
volumes["files-data"] = {}
|
||||||
|
if spec.components.storage.mode == "garage":
|
||||||
|
volumes["garage-meta"] = {}
|
||||||
|
volumes["garage-data"] = {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": spec.installation_id,
|
||||||
|
"services": services,
|
||||||
|
"volumes": volumes,
|
||||||
|
"networks": {
|
||||||
|
"internal": {
|
||||||
|
"driver": "bridge",
|
||||||
|
"ipam": {"config": [{"subnet": spec.network_subnet}]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_garage_config() -> str:
|
||||||
|
return """metadata_dir = "/var/lib/garage/meta"
|
||||||
|
data_dir = "/var/lib/garage/data"
|
||||||
|
db_engine = "sqlite"
|
||||||
|
|
||||||
|
replication_factor = 1
|
||||||
|
|
||||||
|
rpc_bind_addr = "[::]:3901"
|
||||||
|
rpc_public_addr = "127.0.0.1:3901"
|
||||||
|
|
||||||
|
[s3_api]
|
||||||
|
s3_region = "garage"
|
||||||
|
api_bind_addr = "[::]:3900"
|
||||||
|
root_domain = ".s3.garage.localhost"
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
api_bind_addr = "[::]:3903"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_load_balancer_config(spec: InstallationSpec) -> str:
|
||||||
|
return f"""global
|
||||||
|
log stdout format raw local0
|
||||||
|
maxconn 4096
|
||||||
|
|
||||||
|
defaults
|
||||||
|
log global
|
||||||
|
mode http
|
||||||
|
option httplog
|
||||||
|
option redispatch
|
||||||
|
timeout connect 5s
|
||||||
|
timeout client 60s
|
||||||
|
timeout server 60s
|
||||||
|
|
||||||
|
resolvers docker
|
||||||
|
nameserver dns 127.0.0.11:53
|
||||||
|
resolve_retries 3
|
||||||
|
timeout resolve 1s
|
||||||
|
timeout retry 1s
|
||||||
|
hold other 10s
|
||||||
|
hold refused 10s
|
||||||
|
hold nx 10s
|
||||||
|
hold timeout 10s
|
||||||
|
hold valid 10s
|
||||||
|
hold obsolete 10s
|
||||||
|
|
||||||
|
frontend public_web
|
||||||
|
bind :8080
|
||||||
|
default_backend web_replicas
|
||||||
|
|
||||||
|
backend web_replicas
|
||||||
|
balance roundrobin
|
||||||
|
option httpchk GET /health
|
||||||
|
http-check expect status 200
|
||||||
|
server-template web- {spec.replicas.web} web:8080 check resolvers docker init-addr libc,none
|
||||||
|
|
||||||
|
frontend internal_api
|
||||||
|
bind :8000
|
||||||
|
default_backend api_replicas
|
||||||
|
|
||||||
|
backend api_replicas
|
||||||
|
balance leastconn
|
||||||
|
option httpchk GET /health
|
||||||
|
http-check expect status 200
|
||||||
|
server-template api- {spec.replicas.api} api:8000 check resolvers docker init-addr libc,none
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def service_names(spec: InstallationSpec) -> tuple[str, ...]:
|
||||||
|
return tuple(render_compose(spec)["services"].keys())
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json(value: object) -> bytes:
|
||||||
|
return (
|
||||||
|
json.dumps(value, indent=2, sort_keys=True, separators=(",", ": ")) + "\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def digest_json(value: object) -> str:
|
||||||
|
return sha256(canonical_json(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def environment_fingerprint(values: Mapping[str, str]) -> str:
|
||||||
|
key = values.get("MASTER_KEY_B64", "").encode("utf-8")
|
||||||
|
if not key:
|
||||||
|
return ""
|
||||||
|
payload = canonical_json(dict(sorted(values.items())))
|
||||||
|
return hmac.new(key, payload, sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def read_env(path: Path) -> dict[str, str]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for line_number, raw_line in enumerate(
|
||||||
|
path.read_text(encoding="utf-8").splitlines(), start=1
|
||||||
|
):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
key, separator, raw_value = line.partition("=")
|
||||||
|
if not separator or not ENV_NAME_PATTERN.fullmatch(key):
|
||||||
|
raise ValueError(f"invalid environment line {line_number} in {path}")
|
||||||
|
if key in values:
|
||||||
|
raise ValueError(f"duplicate environment key {key!r} on line {line_number}")
|
||||||
|
value = raw_value
|
||||||
|
if value.startswith('"'):
|
||||||
|
try:
|
||||||
|
decoded = json.loads(value)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"invalid quoted environment value on line {line_number}"
|
||||||
|
) from exc
|
||||||
|
if not isinstance(decoded, str):
|
||||||
|
raise ValueError(
|
||||||
|
f"environment value on line {line_number} must be a string"
|
||||||
|
)
|
||||||
|
value = decoded
|
||||||
|
elif value.startswith("'"):
|
||||||
|
value = _single_quoted_env_value(value, line_number=line_number)
|
||||||
|
values[key] = value
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def write_env(path: Path, values: Mapping[str, str]) -> None:
|
||||||
|
invalid = sorted(key for key in values if not ENV_NAME_PATTERN.fullmatch(key))
|
||||||
|
if invalid:
|
||||||
|
raise ValueError("invalid environment variable names: " + ", ".join(invalid))
|
||||||
|
lines = [
|
||||||
|
"# Generated by govoplan-deploy. Keep this file private.",
|
||||||
|
*[f"{key}={_env_value(value)}" for key, value in sorted(values.items())],
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
atomic_write(path, "\n".join(lines).encode("utf-8"), mode=0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write(path: Path, payload: bytes, *, mode: int) -> None:
|
||||||
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.{secrets.token_hex(8)}.tmp")
|
||||||
|
descriptor = os.open(
|
||||||
|
temporary,
|
||||||
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||||
|
mode,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "wb", closefd=True) as handle:
|
||||||
|
handle.write(payload)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
path.chmod(mode)
|
||||||
|
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory_fd)
|
||||||
|
finally:
|
||||||
|
os.close(directory_fd)
|
||||||
|
finally:
|
||||||
|
if temporary.exists():
|
||||||
|
temporary.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def _env_value(value: str) -> str:
|
||||||
|
if "\x00" in value or "\n" in value or "\r" in value:
|
||||||
|
raise ValueError("environment values must not contain NUL or line breaks")
|
||||||
|
if value and all(
|
||||||
|
character.isalnum() or character in "_./:@%+,-" for character in value
|
||||||
|
):
|
||||||
|
return value
|
||||||
|
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
|
||||||
|
return f"'{escaped}'"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_service_url(
|
||||||
|
value: str,
|
||||||
|
*,
|
||||||
|
schemes: set[str],
|
||||||
|
label: str,
|
||||||
|
):
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
try:
|
||||||
|
parsed.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{label} contains an invalid port") from exc
|
||||||
|
if parsed.scheme not in schemes or not parsed.hostname:
|
||||||
|
raise ValueError(
|
||||||
|
f"{label} must use one of {', '.join(sorted(schemes))} and include a host"
|
||||||
|
)
|
||||||
|
if parsed.fragment:
|
||||||
|
raise ValueError(f"{label} must not contain a fragment")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _single_quoted_env_value(value: str, *, line_number: int) -> str:
|
||||||
|
if len(value) < 2 or not value.endswith("'"):
|
||||||
|
raise ValueError(
|
||||||
|
f"unterminated single-quoted environment value on line {line_number}"
|
||||||
|
)
|
||||||
|
body = value[1:-1]
|
||||||
|
output: list[str] = []
|
||||||
|
index = 0
|
||||||
|
while index < len(body):
|
||||||
|
character = body[index]
|
||||||
|
if character != "\\":
|
||||||
|
output.append(character)
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
index += 1
|
||||||
|
if index >= len(body) or body[index] not in {"\\", "'"}:
|
||||||
|
raise ValueError(f"invalid single-quoted escape on line {line_number}")
|
||||||
|
output.append(body[index])
|
||||||
|
index += 1
|
||||||
|
return "".join(output)
|
||||||
|
|
||||||
|
|
||||||
|
def _published_port(address: str, host_port: int, container_port: int) -> str:
|
||||||
|
host = f"[{address}]" if ":" in address else address
|
||||||
|
return f"{host}:{host_port}:{container_port}"
|
||||||
|
|
||||||
|
|
||||||
|
def _environment_references(
|
||||||
|
keys: tuple[str, ...],
|
||||||
|
*,
|
||||||
|
required: bool = False,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
suffix = ":?required by GovOPlaN deployment" if required else ":-"
|
||||||
|
return {key: f"${{{key}{suffix}}}" for key in keys}
|
||||||
@@ -0,0 +1,850 @@
|
|||||||
|
"""Command-line interface for GovOPlaN deployment reconciliation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import fcntl
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Iterator, Mapping, Sequence
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
from .bundle import (
|
||||||
|
atomic_write,
|
||||||
|
bundle_paths,
|
||||||
|
canonical_json,
|
||||||
|
digest_json,
|
||||||
|
environment_fingerprint,
|
||||||
|
ensure_private_directory,
|
||||||
|
initial_secrets,
|
||||||
|
read_env,
|
||||||
|
reconcile_runtime_environment,
|
||||||
|
render_compose,
|
||||||
|
render_garage_config,
|
||||||
|
render_load_balancer_config,
|
||||||
|
service_names,
|
||||||
|
write_env,
|
||||||
|
)
|
||||||
|
from .model import (
|
||||||
|
ComponentConfig,
|
||||||
|
DEFAULT_GARAGE_IMAGE,
|
||||||
|
DEFAULT_LOAD_BALANCER_IMAGE,
|
||||||
|
InstallationSpec,
|
||||||
|
ListenConfig,
|
||||||
|
ReplicaConfig,
|
||||||
|
SpecError,
|
||||||
|
default_spec,
|
||||||
|
load_spec,
|
||||||
|
parse_spec,
|
||||||
|
)
|
||||||
|
from .planning import DeploymentPlan, build_plan
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="govoplan-deploy",
|
||||||
|
description=(
|
||||||
|
"Create, validate, and reconcile a declarative GovOPlaN Compose deployment."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
init = subparsers.add_parser(
|
||||||
|
"init", help="Create a new private installation bundle."
|
||||||
|
)
|
||||||
|
_directory_argument(init)
|
||||||
|
_configuration_arguments(init)
|
||||||
|
init.add_argument(
|
||||||
|
"--non-interactive",
|
||||||
|
action="store_true",
|
||||||
|
help="Use flags/defaults without prompting.",
|
||||||
|
)
|
||||||
|
|
||||||
|
configure = subparsers.add_parser(
|
||||||
|
"configure",
|
||||||
|
help="Change installation choices while preserving generated secrets.",
|
||||||
|
)
|
||||||
|
_directory_argument(configure)
|
||||||
|
_configuration_arguments(configure, defaults=False)
|
||||||
|
|
||||||
|
render = subparsers.add_parser(
|
||||||
|
"render", help="Regenerate Compose and the deployment plan without applying."
|
||||||
|
)
|
||||||
|
_directory_argument(render)
|
||||||
|
render.add_argument("--json", action="store_true", help="Print the plan as JSON.")
|
||||||
|
|
||||||
|
doctor = subparsers.add_parser(
|
||||||
|
"doctor", help="Run specification, secret, host, and provenance checks."
|
||||||
|
)
|
||||||
|
_directory_argument(doctor)
|
||||||
|
doctor.add_argument("--json", action="store_true", help="Print the plan as JSON.")
|
||||||
|
|
||||||
|
apply_parser = subparsers.add_parser(
|
||||||
|
"apply", help="Apply an allowed plan with Docker Compose."
|
||||||
|
)
|
||||||
|
_directory_argument(apply_parser)
|
||||||
|
apply_parser.add_argument(
|
||||||
|
"--allow-unverified-images",
|
||||||
|
action="store_true",
|
||||||
|
help="Allow mutable/unverified images for an evaluation profile only.",
|
||||||
|
)
|
||||||
|
apply_parser.add_argument(
|
||||||
|
"--skip-pull",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not pull images before reconciliation.",
|
||||||
|
)
|
||||||
|
apply_parser.add_argument(
|
||||||
|
"--health-timeout-seconds",
|
||||||
|
type=float,
|
||||||
|
default=120.0,
|
||||||
|
help="Maximum time to wait for the public health endpoint.",
|
||||||
|
)
|
||||||
|
|
||||||
|
status = subparsers.add_parser(
|
||||||
|
"status", help="Show desired state and current Compose process state."
|
||||||
|
)
|
||||||
|
_directory_argument(status)
|
||||||
|
status.add_argument("--json", action="store_true", help="Print JSON.")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _directory_argument(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"--directory",
|
||||||
|
type=Path,
|
||||||
|
default=Path.home()
|
||||||
|
/ ".local"
|
||||||
|
/ "share"
|
||||||
|
/ "govoplan"
|
||||||
|
/ "installations"
|
||||||
|
/ "default",
|
||||||
|
help="Private installation state directory.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_arguments(
|
||||||
|
parser: argparse.ArgumentParser, *, defaults: bool = True
|
||||||
|
) -> None:
|
||||||
|
default = (lambda value: value) if defaults else (lambda _value: None)
|
||||||
|
parser.add_argument("--installation-id", default=default("govoplan-local"))
|
||||||
|
parser.add_argument(
|
||||||
|
"--profile",
|
||||||
|
choices=("evaluation", "self-hosted"),
|
||||||
|
default=default("evaluation"),
|
||||||
|
)
|
||||||
|
parser.add_argument("--public-url", default=default("http://127.0.0.1:8080"))
|
||||||
|
parser.add_argument("--listen-address", default=default("127.0.0.1"))
|
||||||
|
parser.add_argument("--listen-port", type=int, default=default(8080))
|
||||||
|
parser.add_argument(
|
||||||
|
"--postgres",
|
||||||
|
choices=("managed", "external"),
|
||||||
|
default=default("managed"),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--database-url", help="External PostgreSQL URL; stored privately."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--redis",
|
||||||
|
choices=("managed", "external", "disabled"),
|
||||||
|
default=default("managed"),
|
||||||
|
)
|
||||||
|
parser.add_argument("--redis-url", help="External Redis URL; stored privately.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--mail",
|
||||||
|
choices=("disabled", "external-relay", "test-mail"),
|
||||||
|
default=default("disabled"),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--storage",
|
||||||
|
choices=("local", "garage", "s3"),
|
||||||
|
default=default("local"),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--garage-image",
|
||||||
|
default=default(DEFAULT_GARAGE_IMAGE),
|
||||||
|
help="Garage image used by managed object storage.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--s3-endpoint-url", help="S3-compatible endpoint URL.")
|
||||||
|
parser.add_argument("--s3-region", help="S3 region.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--s3-access-key-id", help="S3 access key id; stored privately."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--s3-secret-access-key",
|
||||||
|
help="S3 secret access key; stored privately.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--s3-bucket", help="S3 bucket for managed files.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--load-balancer-image",
|
||||||
|
default=default(DEFAULT_LOAD_BALANCER_IMAGE),
|
||||||
|
help="HAProxy image used by the managed local load balancer.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--api-replicas",
|
||||||
|
type=int,
|
||||||
|
default=default(1),
|
||||||
|
help="API containers on this Compose host (1-64).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--web-replicas",
|
||||||
|
type=int,
|
||||||
|
default=default(1),
|
||||||
|
help="WebUI containers on this Compose host (1-64).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--worker-replicas",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Worker containers on this Compose host (1-128, or 0 without Redis).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--module-set",
|
||||||
|
choices=("core", "base", "full"),
|
||||||
|
default=default("base"),
|
||||||
|
)
|
||||||
|
parser.add_argument("--api-image", default=default("govoplan-api:unpublished"))
|
||||||
|
parser.add_argument("--web-image", default=default("govoplan-web:unpublished"))
|
||||||
|
parser.add_argument("--release-version", default=default("unpublished"))
|
||||||
|
parser.add_argument("--release-channel", default=default("stable"))
|
||||||
|
parser.add_argument("--manifest-url", default=default(""))
|
||||||
|
parser.add_argument("--manifest-sha256", default=default(""))
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
try:
|
||||||
|
if args.command == "init":
|
||||||
|
return _init(args)
|
||||||
|
if args.command == "configure":
|
||||||
|
return _configure(args)
|
||||||
|
if args.command in {"render", "doctor"}:
|
||||||
|
return _render_or_doctor(args)
|
||||||
|
if args.command == "apply":
|
||||||
|
return _apply(args)
|
||||||
|
if args.command == "status":
|
||||||
|
return _status(args)
|
||||||
|
except (SpecError, ValueError, OSError, subprocess.SubprocessError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
raise RuntimeError(f"unsupported command: {args.command}")
|
||||||
|
|
||||||
|
|
||||||
|
def _init(args: argparse.Namespace) -> int:
|
||||||
|
paths = bundle_paths(args.directory)
|
||||||
|
ensure_private_directory(paths.root)
|
||||||
|
if paths.spec.exists():
|
||||||
|
raise ValueError(
|
||||||
|
f"{paths.spec} already exists; use configure for an existing installation"
|
||||||
|
)
|
||||||
|
if not args.non_interactive and sys.stdin.isatty():
|
||||||
|
_prompt_configuration(args)
|
||||||
|
spec = default_spec(
|
||||||
|
installation_id=args.installation_id,
|
||||||
|
profile=args.profile,
|
||||||
|
public_url=args.public_url,
|
||||||
|
listen_address=args.listen_address,
|
||||||
|
listen_port=args.listen_port,
|
||||||
|
postgres_mode=args.postgres,
|
||||||
|
redis_mode=args.redis,
|
||||||
|
mail_mode=args.mail,
|
||||||
|
storage_mode=args.storage,
|
||||||
|
garage_image=args.garage_image,
|
||||||
|
load_balancer_image=args.load_balancer_image,
|
||||||
|
api_replicas=args.api_replicas,
|
||||||
|
web_replicas=args.web_replicas,
|
||||||
|
worker_replicas=args.worker_replicas,
|
||||||
|
module_set=args.module_set,
|
||||||
|
api_image=args.api_image,
|
||||||
|
web_image=args.web_image,
|
||||||
|
manifest_url=args.manifest_url,
|
||||||
|
manifest_sha256=args.manifest_sha256,
|
||||||
|
version=args.release_version,
|
||||||
|
channel=args.release_channel,
|
||||||
|
)
|
||||||
|
supplied = _supplied_secret_values(args)
|
||||||
|
secrets = initial_secrets(spec, supplied=supplied)
|
||||||
|
_write_bundle(spec, paths, secrets)
|
||||||
|
plan = build_plan(spec, paths, include_host_checks=False)
|
||||||
|
_write_plan(paths.plan, plan)
|
||||||
|
print(f"Created GovOPlaN installation bundle at {paths.root}")
|
||||||
|
print(f"Edit choices with: govoplan-deploy configure --directory {paths.root}")
|
||||||
|
print(f"Check readiness with: govoplan-deploy doctor --directory {paths.root}")
|
||||||
|
if any(check.level == "error" for check in plan.checks):
|
||||||
|
print("The bundle is not apply-ready yet; run doctor for the blocking checks.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _configure(args: argparse.Namespace) -> int:
|
||||||
|
paths = bundle_paths(args.directory)
|
||||||
|
ensure_private_directory(paths.root)
|
||||||
|
current = load_spec(paths.spec)
|
||||||
|
if args.installation_id and args.installation_id != current.installation_id:
|
||||||
|
raise ValueError(
|
||||||
|
"installation_id is immutable; create a separate installation "
|
||||||
|
"instead of renaming a Compose project"
|
||||||
|
)
|
||||||
|
spec = _updated_spec(current, args)
|
||||||
|
if (
|
||||||
|
current.components.postgres.mode == "managed"
|
||||||
|
and spec.components.postgres.mode == "external"
|
||||||
|
and not args.database_url
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"switching PostgreSQL from managed to external requires --database-url"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
current.components.redis.mode != "external"
|
||||||
|
and spec.components.redis.mode == "external"
|
||||||
|
and not args.redis_url
|
||||||
|
):
|
||||||
|
raise ValueError("switching Redis to external requires --redis-url")
|
||||||
|
if current.components.storage.mode != "s3" and spec.components.storage.mode == "s3":
|
||||||
|
missing_s3_options = [
|
||||||
|
flag
|
||||||
|
for flag, value in (
|
||||||
|
("--s3-endpoint-url", args.s3_endpoint_url),
|
||||||
|
("--s3-region", args.s3_region),
|
||||||
|
("--s3-access-key-id", args.s3_access_key_id),
|
||||||
|
("--s3-secret-access-key", args.s3_secret_access_key),
|
||||||
|
("--s3-bucket", args.s3_bucket),
|
||||||
|
)
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing_s3_options:
|
||||||
|
raise ValueError(
|
||||||
|
"switching to external S3 requires: " + ", ".join(missing_s3_options)
|
||||||
|
)
|
||||||
|
secrets = read_env(paths.env)
|
||||||
|
secrets.update(_supplied_secret_values(args))
|
||||||
|
secrets = reconcile_runtime_environment(spec, secrets)
|
||||||
|
_write_bundle(spec, paths, secrets)
|
||||||
|
plan = build_plan(spec, paths, include_host_checks=False)
|
||||||
|
_write_plan(paths.plan, plan)
|
||||||
|
print(f"Updated desired state at {paths.root}")
|
||||||
|
_print_plan(plan)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _render_or_doctor(args: argparse.Namespace) -> int:
|
||||||
|
paths = bundle_paths(args.directory)
|
||||||
|
spec = load_spec(paths.spec)
|
||||||
|
secrets = reconcile_runtime_environment(spec, read_env(paths.env))
|
||||||
|
_write_bundle(spec, paths, secrets)
|
||||||
|
plan = build_plan(
|
||||||
|
spec,
|
||||||
|
paths,
|
||||||
|
include_host_checks=args.command == "doctor",
|
||||||
|
)
|
||||||
|
_write_plan(paths.plan, plan)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(plan.to_dict(), indent=2, sort_keys=True))
|
||||||
|
else:
|
||||||
|
_print_plan(plan)
|
||||||
|
return 1 if plan.blocked else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(args: argparse.Namespace) -> int:
|
||||||
|
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)
|
||||||
|
with _deployment_lock(paths.lock):
|
||||||
|
secrets = reconcile_runtime_environment(spec, read_env(paths.env))
|
||||||
|
_write_bundle(spec, paths, secrets)
|
||||||
|
plan = build_plan(spec, paths, include_host_checks=True)
|
||||||
|
_write_plan(paths.plan, plan)
|
||||||
|
effective_errors = [
|
||||||
|
check
|
||||||
|
for check in plan.checks
|
||||||
|
if check.level == "error"
|
||||||
|
and not (
|
||||||
|
args.allow_unverified_images
|
||||||
|
and check.id.startswith(
|
||||||
|
(
|
||||||
|
"release.api_image.",
|
||||||
|
"release.web_image.",
|
||||||
|
"components.postgres.image.",
|
||||||
|
"components.redis.image.",
|
||||||
|
"components.mail.image.",
|
||||||
|
"components.storage.image.",
|
||||||
|
"components.load_balancer.image.",
|
||||||
|
"release.manifest",
|
||||||
|
"modules.image_composition",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if effective_errors:
|
||||||
|
_print_plan(plan)
|
||||||
|
raise ValueError("deployment plan is blocked; resolve doctor errors first")
|
||||||
|
docker = shutil.which("docker")
|
||||||
|
if docker is None:
|
||||||
|
raise ValueError("Docker CLI is required for apply")
|
||||||
|
compose = [
|
||||||
|
docker,
|
||||||
|
"compose",
|
||||||
|
"--env-file",
|
||||||
|
str(paths.env),
|
||||||
|
"--project-name",
|
||||||
|
spec.installation_id,
|
||||||
|
"--file",
|
||||||
|
str(paths.compose),
|
||||||
|
]
|
||||||
|
if not args.skip_pull:
|
||||||
|
_run([*compose, "pull"], cwd=paths.root)
|
||||||
|
dependencies = [
|
||||||
|
name
|
||||||
|
for name in ("postgres", "redis", "garage", "test-mail")
|
||||||
|
if name in service_names(spec)
|
||||||
|
]
|
||||||
|
if dependencies:
|
||||||
|
_run([*compose, "up", "--detach", *dependencies], cwd=paths.root)
|
||||||
|
_run([*compose, "run", "--rm", "migrate"], cwd=paths.root)
|
||||||
|
if _receipt_uses_direct_web_port(paths.receipt):
|
||||||
|
_run([*compose, "stop", "web"], cwd=paths.root)
|
||||||
|
runtime_services = [
|
||||||
|
name
|
||||||
|
for name in ("api", "web", "load-balancer", "worker", "scheduler")
|
||||||
|
if name in service_names(spec)
|
||||||
|
]
|
||||||
|
_run(
|
||||||
|
[*compose, "up", "--detach", "--remove-orphans", *runtime_services],
|
||||||
|
cwd=paths.root,
|
||||||
|
)
|
||||||
|
_wait_for_health(
|
||||||
|
f"{spec.public_url}/health",
|
||||||
|
timeout_seconds=args.health_timeout_seconds,
|
||||||
|
)
|
||||||
|
receipt = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"installation_id": spec.installation_id,
|
||||||
|
"applied_at": _now(),
|
||||||
|
"spec_sha256": digest_json(spec.to_dict()),
|
||||||
|
"compose_sha256": digest_json(render_compose(spec)),
|
||||||
|
"environment_fingerprint": environment_fingerprint(secrets),
|
||||||
|
"release": {
|
||||||
|
"channel": spec.release.channel,
|
||||||
|
"version": spec.release.version,
|
||||||
|
"manifest_sha256": spec.release.manifest_sha256,
|
||||||
|
"api_image": spec.release.api_image,
|
||||||
|
"web_image": spec.release.web_image,
|
||||||
|
},
|
||||||
|
"services": list(service_names(spec)),
|
||||||
|
"replicas": {
|
||||||
|
"api": spec.replicas.api,
|
||||||
|
"web": spec.replicas.web,
|
||||||
|
"worker": spec.replicas.worker,
|
||||||
|
},
|
||||||
|
"listen": {
|
||||||
|
"address": spec.listen.address,
|
||||||
|
"port": spec.listen.port,
|
||||||
|
},
|
||||||
|
"management": {
|
||||||
|
"mode": "govoplan-deploy",
|
||||||
|
"agent": "cli",
|
||||||
|
"web_updates": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
atomic_write(paths.receipt, canonical_json(receipt), mode=0o600)
|
||||||
|
print(f"GovOPlaN is ready at {spec.public_url}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _status(args: argparse.Namespace) -> int:
|
||||||
|
paths = bundle_paths(args.directory)
|
||||||
|
spec = load_spec(paths.spec)
|
||||||
|
plan = build_plan(spec, paths, include_host_checks=False)
|
||||||
|
docker = shutil.which("docker")
|
||||||
|
processes: object = []
|
||||||
|
process_error = ""
|
||||||
|
if docker:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
docker,
|
||||||
|
"compose",
|
||||||
|
"--env-file",
|
||||||
|
str(paths.env),
|
||||||
|
"--project-name",
|
||||||
|
spec.installation_id,
|
||||||
|
"--file",
|
||||||
|
str(paths.compose),
|
||||||
|
"ps",
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
],
|
||||||
|
cwd=paths.root,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
processes = _parse_compose_ps(result.stdout)
|
||||||
|
else:
|
||||||
|
process_error = result.stderr.strip() or result.stdout.strip()
|
||||||
|
else:
|
||||||
|
process_error = "Docker CLI is unavailable."
|
||||||
|
payload = {
|
||||||
|
"installation_id": spec.installation_id,
|
||||||
|
"public_url": spec.public_url,
|
||||||
|
"plan": plan.to_dict(),
|
||||||
|
"processes": processes,
|
||||||
|
"process_error": process_error,
|
||||||
|
}
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
else:
|
||||||
|
_print_plan(plan)
|
||||||
|
if process_error:
|
||||||
|
print(f"Runtime: {process_error}")
|
||||||
|
elif isinstance(processes, list):
|
||||||
|
print(f"Runtime: {len(processes)} Compose process(es) reported.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _updated_spec(
|
||||||
|
current: InstallationSpec, args: argparse.Namespace
|
||||||
|
) -> InstallationSpec:
|
||||||
|
module_set = getattr(args, "module_set", None)
|
||||||
|
if module_set:
|
||||||
|
modules = default_spec(module_set=module_set).enabled_modules
|
||||||
|
else:
|
||||||
|
modules = current.enabled_modules
|
||||||
|
release = replace(
|
||||||
|
current.release,
|
||||||
|
channel=args.release_channel or current.release.channel,
|
||||||
|
version=args.release_version or current.release.version,
|
||||||
|
manifest_url=(
|
||||||
|
args.manifest_url
|
||||||
|
if args.manifest_url is not None
|
||||||
|
else current.release.manifest_url
|
||||||
|
),
|
||||||
|
manifest_sha256=(
|
||||||
|
args.manifest_sha256
|
||||||
|
if args.manifest_sha256 is not None
|
||||||
|
else current.release.manifest_sha256
|
||||||
|
),
|
||||||
|
api_image=args.api_image or current.release.api_image,
|
||||||
|
web_image=args.web_image or current.release.web_image,
|
||||||
|
)
|
||||||
|
storage_mode = args.storage or current.components.storage.mode
|
||||||
|
components = ComponentConfig(
|
||||||
|
postgres=replace(
|
||||||
|
current.components.postgres,
|
||||||
|
mode=args.postgres or current.components.postgres.mode,
|
||||||
|
),
|
||||||
|
redis=replace(
|
||||||
|
current.components.redis,
|
||||||
|
mode=args.redis or current.components.redis.mode,
|
||||||
|
),
|
||||||
|
mail=replace(
|
||||||
|
current.components.mail,
|
||||||
|
mode=args.mail or current.components.mail.mode,
|
||||||
|
),
|
||||||
|
storage=replace(
|
||||||
|
current.components.storage,
|
||||||
|
mode=storage_mode,
|
||||||
|
image=(
|
||||||
|
args.garage_image
|
||||||
|
or current.components.storage.image
|
||||||
|
or DEFAULT_GARAGE_IMAGE
|
||||||
|
)
|
||||||
|
if storage_mode == "garage"
|
||||||
|
else "",
|
||||||
|
),
|
||||||
|
load_balancer=replace(
|
||||||
|
current.components.load_balancer,
|
||||||
|
image=(args.load_balancer_image or current.components.load_balancer.image),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
replicas = ReplicaConfig(
|
||||||
|
api=(
|
||||||
|
args.api_replicas if args.api_replicas is not None else current.replicas.api
|
||||||
|
),
|
||||||
|
web=(
|
||||||
|
args.web_replicas if args.web_replicas is not None else current.replicas.web
|
||||||
|
),
|
||||||
|
worker=(
|
||||||
|
args.worker_replicas
|
||||||
|
if args.worker_replicas is not None
|
||||||
|
else (
|
||||||
|
0
|
||||||
|
if components.redis.mode == "disabled"
|
||||||
|
else max(current.replicas.worker, 1)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
value = replace(
|
||||||
|
current,
|
||||||
|
installation_id=args.installation_id or current.installation_id,
|
||||||
|
profile=args.profile or current.profile,
|
||||||
|
public_url=args.public_url or current.public_url,
|
||||||
|
listen=ListenConfig(
|
||||||
|
address=args.listen_address or current.listen.address,
|
||||||
|
port=args.listen_port or current.listen.port,
|
||||||
|
),
|
||||||
|
release=release,
|
||||||
|
components=components,
|
||||||
|
replicas=replicas,
|
||||||
|
enabled_modules=modules,
|
||||||
|
)
|
||||||
|
return parse_spec(value.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def _supplied_secret_values(args: argparse.Namespace) -> dict[str, str]:
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
if getattr(args, "database_url", None):
|
||||||
|
values["DATABASE_URL"] = args.database_url
|
||||||
|
values["GOVOPLAN_DATABASE_URL_PGTOOLS"] = _pgtools_url(args.database_url)
|
||||||
|
if getattr(args, "redis_url", None):
|
||||||
|
values["REDIS_URL"] = args.redis_url
|
||||||
|
s3_values = {
|
||||||
|
"FILE_STORAGE_S3_ENDPOINT_URL": getattr(args, "s3_endpoint_url", None),
|
||||||
|
"FILE_STORAGE_S3_REGION": getattr(args, "s3_region", None),
|
||||||
|
"FILE_STORAGE_S3_ACCESS_KEY_ID": getattr(args, "s3_access_key_id", None),
|
||||||
|
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": getattr(
|
||||||
|
args, "s3_secret_access_key", None
|
||||||
|
),
|
||||||
|
"FILE_STORAGE_S3_BUCKET": getattr(args, "s3_bucket", None),
|
||||||
|
}
|
||||||
|
values.update({key: value for key, value in s3_values.items() if value})
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _pgtools_url(database_url: str) -> str:
|
||||||
|
if database_url.startswith("postgresql+psycopg://"):
|
||||||
|
return "postgresql://" + database_url.removeprefix("postgresql+psycopg://")
|
||||||
|
return database_url
|
||||||
|
|
||||||
|
|
||||||
|
def _write_bundle(
|
||||||
|
spec: InstallationSpec,
|
||||||
|
paths,
|
||||||
|
secrets: Mapping[str, str],
|
||||||
|
) -> None:
|
||||||
|
ensure_private_directory(paths.root)
|
||||||
|
atomic_write(paths.spec, canonical_json(spec.to_dict()), mode=0o600)
|
||||||
|
write_env(paths.env, secrets)
|
||||||
|
atomic_write(paths.compose, canonical_json(render_compose(spec)), mode=0o600)
|
||||||
|
atomic_write(
|
||||||
|
paths.load_balancer_config,
|
||||||
|
render_load_balancer_config(spec).encode("utf-8"),
|
||||||
|
mode=0o644,
|
||||||
|
)
|
||||||
|
atomic_write(
|
||||||
|
paths.garage_config,
|
||||||
|
render_garage_config().encode("utf-8"),
|
||||||
|
mode=0o644,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_plan(path: Path, plan: DeploymentPlan) -> None:
|
||||||
|
atomic_write(path, canonical_json(plan.to_dict()), mode=0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_plan(plan: DeploymentPlan) -> None:
|
||||||
|
state = "BLOCKED" if plan.blocked else "READY"
|
||||||
|
print(f"GovOPlaN deployment plan: {state}")
|
||||||
|
for action in plan.actions:
|
||||||
|
print(f" {action.action:12} {action.target}: {action.detail}")
|
||||||
|
for check in plan.checks:
|
||||||
|
prefix = {"ok": "OK", "warning": "WARN", "error": "ERROR"}.get(
|
||||||
|
check.level, check.level.upper()
|
||||||
|
)
|
||||||
|
print(f" [{prefix}] {check.message}")
|
||||||
|
if check.action and check.level != "ok":
|
||||||
|
print(f" {check.action}")
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_configuration(args: argparse.Namespace) -> None:
|
||||||
|
args.profile = _prompt_choice(
|
||||||
|
"Installation profile", args.profile, ("evaluation", "self-hosted")
|
||||||
|
)
|
||||||
|
if args.profile == "self-hosted" and args.public_url.startswith("http://"):
|
||||||
|
args.public_url = "https://govoplan.example.org"
|
||||||
|
args.public_url = _prompt("Public URL", args.public_url)
|
||||||
|
args.postgres = _prompt_choice("PostgreSQL", args.postgres, ("managed", "external"))
|
||||||
|
if args.postgres == "external" and not args.database_url:
|
||||||
|
args.database_url = getpass.getpass(
|
||||||
|
"External PostgreSQL URL (input hidden): "
|
||||||
|
).strip()
|
||||||
|
redis_choices = (
|
||||||
|
("managed", "external")
|
||||||
|
if args.profile == "self-hosted"
|
||||||
|
else ("managed", "external", "disabled")
|
||||||
|
)
|
||||||
|
args.redis = _prompt_choice("Redis", args.redis, redis_choices)
|
||||||
|
if args.redis == "external" and not args.redis_url:
|
||||||
|
args.redis_url = getpass.getpass("External Redis URL (input hidden): ").strip()
|
||||||
|
mail_choices = (
|
||||||
|
("disabled", "external-relay")
|
||||||
|
if args.profile == "self-hosted"
|
||||||
|
else ("disabled", "external-relay", "test-mail")
|
||||||
|
)
|
||||||
|
args.mail = _prompt_choice(
|
||||||
|
"Mail integration",
|
||||||
|
args.mail,
|
||||||
|
mail_choices,
|
||||||
|
)
|
||||||
|
args.storage = _prompt_choice(
|
||||||
|
"File storage",
|
||||||
|
args.storage,
|
||||||
|
("local", "garage", "s3"),
|
||||||
|
)
|
||||||
|
if args.storage == "s3":
|
||||||
|
args.s3_endpoint_url = args.s3_endpoint_url or _prompt(
|
||||||
|
"S3 endpoint URL", "https://s3.example.org"
|
||||||
|
)
|
||||||
|
args.s3_region = args.s3_region or _prompt("S3 region", "eu-central-1")
|
||||||
|
args.s3_access_key_id = args.s3_access_key_id or _prompt("S3 access key id", "")
|
||||||
|
args.s3_secret_access_key = (
|
||||||
|
args.s3_secret_access_key
|
||||||
|
or getpass.getpass("S3 secret access key (input hidden): ").strip()
|
||||||
|
)
|
||||||
|
args.s3_bucket = args.s3_bucket or _prompt("S3 bucket", "govoplan-files")
|
||||||
|
args.api_replicas = _prompt_integer(
|
||||||
|
"API replicas on this host",
|
||||||
|
args.api_replicas,
|
||||||
|
minimum=1,
|
||||||
|
maximum=64,
|
||||||
|
)
|
||||||
|
args.web_replicas = _prompt_integer(
|
||||||
|
"WebUI replicas on this host",
|
||||||
|
args.web_replicas,
|
||||||
|
minimum=1,
|
||||||
|
maximum=64,
|
||||||
|
)
|
||||||
|
if args.redis != "disabled":
|
||||||
|
args.worker_replicas = _prompt_integer(
|
||||||
|
"Worker replicas on this host",
|
||||||
|
args.worker_replicas or 1,
|
||||||
|
minimum=1,
|
||||||
|
maximum=128,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
args.worker_replicas = 0
|
||||||
|
args.module_set = _prompt_choice(
|
||||||
|
"Initial module set", args.module_set, ("core", "base", "full")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt(label: str, default: str) -> str:
|
||||||
|
value = input(f"{label} [{default}]: ").strip()
|
||||||
|
return value or default
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_choice(label: str, default: str, choices: tuple[str, ...]) -> str:
|
||||||
|
while True:
|
||||||
|
value = _prompt(f"{label} ({'/'.join(choices)})", default)
|
||||||
|
if value in choices:
|
||||||
|
return value
|
||||||
|
print(f"Choose one of: {', '.join(choices)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_integer(
|
||||||
|
label: str,
|
||||||
|
default: int,
|
||||||
|
*,
|
||||||
|
minimum: int,
|
||||||
|
maximum: int,
|
||||||
|
) -> int:
|
||||||
|
while True:
|
||||||
|
raw = _prompt(label, str(default))
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
value = minimum - 1
|
||||||
|
if minimum <= value <= maximum:
|
||||||
|
return value
|
||||||
|
print(f"Choose a number between {minimum} and {maximum}.")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _deployment_lock(path: Path) -> Iterator[None]:
|
||||||
|
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"another deployment operation owns the installation lock"
|
||||||
|
) from exc
|
||||||
|
os.ftruncate(descriptor, 0)
|
||||||
|
os.write(descriptor, f"{os.getpid()}\n".encode("ascii"))
|
||||||
|
os.fsync(descriptor)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(argv: Sequence[str], *, cwd: Path) -> None:
|
||||||
|
result = subprocess.run(list(argv), cwd=cwd, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise subprocess.CalledProcessError(result.returncode, list(argv))
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_health(url: str, *, timeout_seconds: float) -> None:
|
||||||
|
deadline = time.monotonic() + max(timeout_seconds, 1.0)
|
||||||
|
last_error = "health endpoint did not answer"
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
with urlopen(url, timeout=3) as response: # noqa: S310 - URL originates in the validated installation specification.
|
||||||
|
if 200 <= response.status < 300:
|
||||||
|
return
|
||||||
|
last_error = f"health endpoint returned HTTP {response.status}"
|
||||||
|
except (OSError, URLError) as exc:
|
||||||
|
last_error = str(exc)
|
||||||
|
time.sleep(2)
|
||||||
|
raise ValueError(f"GovOPlaN did not become healthy: {last_error}")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_compose_ps(output: str) -> list[object]:
|
||||||
|
stripped = output.strip()
|
||||||
|
if not stripped:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
value = json.loads(stripped)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
rows = []
|
||||||
|
for line in stripped.splitlines():
|
||||||
|
try:
|
||||||
|
rows.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return [{"raw": stripped}]
|
||||||
|
return rows
|
||||||
|
return value if isinstance(value, list) else [value]
|
||||||
|
|
||||||
|
|
||||||
|
def _receipt_uses_direct_web_port(path: Path) -> bool:
|
||||||
|
if not path.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
receipt = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
services = receipt.get("services") if isinstance(receipt, dict) else None
|
||||||
|
return (
|
||||||
|
isinstance(services, list)
|
||||||
|
and "web" in services
|
||||||
|
and "load-balancer" not in services
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return (
|
||||||
|
datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
)
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
"""Installation intent model and validation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
DEFAULT_GARAGE_IMAGE = "dxflrs/garage:v2.3.0"
|
||||||
|
DEFAULT_LOAD_BALANCER_IMAGE = "haproxy:3.2.21-alpine"
|
||||||
|
INSTALLATION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]{1,47}$")
|
||||||
|
ENV_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$")
|
||||||
|
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
IMAGE_DIGEST_PATTERN = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$")
|
||||||
|
RFC1918_NETWORKS = tuple(
|
||||||
|
ipaddress.ip_network(value)
|
||||||
|
for value in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE_MODULES = (
|
||||||
|
"tenancy",
|
||||||
|
"organizations",
|
||||||
|
"identity",
|
||||||
|
"idm",
|
||||||
|
"access",
|
||||||
|
"admin",
|
||||||
|
"dashboard",
|
||||||
|
"policy",
|
||||||
|
"audit",
|
||||||
|
"docs",
|
||||||
|
"ops",
|
||||||
|
)
|
||||||
|
|
||||||
|
FULL_MODULES = (
|
||||||
|
*BASE_MODULES,
|
||||||
|
"addresses",
|
||||||
|
"dist_lists",
|
||||||
|
"files",
|
||||||
|
"mail",
|
||||||
|
"campaigns",
|
||||||
|
"calendar",
|
||||||
|
"poll",
|
||||||
|
"scheduling",
|
||||||
|
"connectors",
|
||||||
|
"datasources",
|
||||||
|
"dataflow",
|
||||||
|
"workflow",
|
||||||
|
"views",
|
||||||
|
"search",
|
||||||
|
"risk_compliance",
|
||||||
|
"postbox",
|
||||||
|
"evaluation",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SpecError(ValueError):
|
||||||
|
"""Raised when an installation specification is malformed."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ListenConfig:
|
||||||
|
address: str
|
||||||
|
port: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReleaseConfig:
|
||||||
|
channel: str
|
||||||
|
version: str
|
||||||
|
manifest_url: str
|
||||||
|
manifest_sha256: str
|
||||||
|
api_image: str
|
||||||
|
web_image: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ServiceConfig:
|
||||||
|
mode: str
|
||||||
|
image: str = ""
|
||||||
|
url_env: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StorageConfig:
|
||||||
|
mode: str
|
||||||
|
image: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ComponentConfig:
|
||||||
|
postgres: ServiceConfig
|
||||||
|
redis: ServiceConfig
|
||||||
|
mail: ServiceConfig
|
||||||
|
storage: StorageConfig
|
||||||
|
load_balancer: ServiceConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReplicaConfig:
|
||||||
|
api: int
|
||||||
|
web: int
|
||||||
|
worker: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class InstallationSpec:
|
||||||
|
schema_version: int
|
||||||
|
installation_id: str
|
||||||
|
profile: str
|
||||||
|
public_url: str
|
||||||
|
listen: ListenConfig
|
||||||
|
network_subnet: str
|
||||||
|
release: ReleaseConfig
|
||||||
|
components: ComponentConfig
|
||||||
|
replicas: ReplicaConfig
|
||||||
|
enabled_modules: tuple[str, ...]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
value = asdict(self)
|
||||||
|
value["enabled_modules"] = list(self.enabled_modules)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def default_spec(
|
||||||
|
*,
|
||||||
|
installation_id: str = "govoplan-local",
|
||||||
|
profile: str = "evaluation",
|
||||||
|
public_url: str = "http://127.0.0.1:8080",
|
||||||
|
listen_address: str = "127.0.0.1",
|
||||||
|
listen_port: int = 8080,
|
||||||
|
postgres_mode: str = "managed",
|
||||||
|
redis_mode: str = "managed",
|
||||||
|
mail_mode: str = "disabled",
|
||||||
|
storage_mode: str = "local",
|
||||||
|
garage_image: str = DEFAULT_GARAGE_IMAGE,
|
||||||
|
load_balancer_image: str = DEFAULT_LOAD_BALANCER_IMAGE,
|
||||||
|
api_replicas: int = 1,
|
||||||
|
web_replicas: int = 1,
|
||||||
|
worker_replicas: int | None = None,
|
||||||
|
module_set: str = "base",
|
||||||
|
api_image: str = "govoplan-api:unpublished",
|
||||||
|
web_image: str = "govoplan-web:unpublished",
|
||||||
|
manifest_url: str = "",
|
||||||
|
manifest_sha256: str = "",
|
||||||
|
version: str = "unpublished",
|
||||||
|
channel: str = "stable",
|
||||||
|
) -> InstallationSpec:
|
||||||
|
modules = {
|
||||||
|
"core": (),
|
||||||
|
"base": BASE_MODULES,
|
||||||
|
"full": FULL_MODULES,
|
||||||
|
}.get(module_set)
|
||||||
|
if modules is None:
|
||||||
|
raise SpecError("module_set must be core, base, or full")
|
||||||
|
effective_worker_replicas = (
|
||||||
|
(0 if redis_mode == "disabled" else 1)
|
||||||
|
if worker_replicas is None
|
||||||
|
else worker_replicas
|
||||||
|
)
|
||||||
|
subnet_octet = 32 + (sum(installation_id.encode("utf-8")) % 192)
|
||||||
|
raw = {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"installation_id": installation_id,
|
||||||
|
"profile": profile,
|
||||||
|
"public_url": public_url,
|
||||||
|
"listen": {"address": listen_address, "port": listen_port},
|
||||||
|
"network_subnet": f"172.30.{subnet_octet}.0/24",
|
||||||
|
"release": {
|
||||||
|
"channel": channel,
|
||||||
|
"version": version,
|
||||||
|
"manifest_url": manifest_url,
|
||||||
|
"manifest_sha256": manifest_sha256,
|
||||||
|
"api_image": api_image,
|
||||||
|
"web_image": web_image,
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"postgres": {
|
||||||
|
"mode": postgres_mode,
|
||||||
|
"image": "postgres:16-alpine",
|
||||||
|
"url_env": "DATABASE_URL",
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"mode": redis_mode,
|
||||||
|
"image": "redis:7-alpine",
|
||||||
|
"url_env": "REDIS_URL",
|
||||||
|
},
|
||||||
|
"mail": {
|
||||||
|
"mode": mail_mode,
|
||||||
|
"image": "greenmail/standalone:2.1.9",
|
||||||
|
"url_env": "",
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"mode": storage_mode,
|
||||||
|
"image": garage_image if storage_mode == "garage" else "",
|
||||||
|
},
|
||||||
|
"load_balancer": {
|
||||||
|
"mode": "managed",
|
||||||
|
"image": load_balancer_image,
|
||||||
|
"url_env": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"replicas": {
|
||||||
|
"api": api_replicas,
|
||||||
|
"web": web_replicas,
|
||||||
|
"worker": effective_worker_replicas,
|
||||||
|
},
|
||||||
|
"enabled_modules": list(modules),
|
||||||
|
}
|
||||||
|
return parse_spec(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def load_spec(path: Path) -> InstallationSpec:
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise SpecError(f"installation specification does not exist: {path}") from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise SpecError(f"installation specification is not valid JSON: {exc}") from exc
|
||||||
|
return parse_spec(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_spec(raw: object) -> InstallationSpec:
|
||||||
|
root = _mapping(raw, "installation")
|
||||||
|
_only_keys(
|
||||||
|
root,
|
||||||
|
{
|
||||||
|
"schema_version",
|
||||||
|
"installation_id",
|
||||||
|
"profile",
|
||||||
|
"public_url",
|
||||||
|
"listen",
|
||||||
|
"network_subnet",
|
||||||
|
"release",
|
||||||
|
"components",
|
||||||
|
"replicas",
|
||||||
|
"enabled_modules",
|
||||||
|
},
|
||||||
|
"installation",
|
||||||
|
)
|
||||||
|
schema_version = _integer(root, "schema_version")
|
||||||
|
if schema_version != SCHEMA_VERSION:
|
||||||
|
raise SpecError(
|
||||||
|
f"schema_version must be {SCHEMA_VERSION}; found {schema_version}"
|
||||||
|
)
|
||||||
|
|
||||||
|
installation_id = _string(root, "installation_id")
|
||||||
|
if not INSTALLATION_ID_PATTERN.fullmatch(installation_id):
|
||||||
|
raise SpecError(
|
||||||
|
"installation_id must start with a lowercase letter and contain only "
|
||||||
|
"lowercase letters, digits, or hyphens (2-48 characters)"
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = _choice(root, "profile", {"evaluation", "self-hosted"})
|
||||||
|
public_url = _http_url(_string(root, "public_url"), "public_url")
|
||||||
|
public_parts = urlsplit(public_url)
|
||||||
|
if profile == "self-hosted" and public_parts.scheme != "https":
|
||||||
|
raise SpecError("self-hosted public_url must use HTTPS")
|
||||||
|
|
||||||
|
listen_raw = _mapping(root.get("listen"), "listen")
|
||||||
|
_only_keys(listen_raw, {"address", "port"}, "listen")
|
||||||
|
listen = ListenConfig(
|
||||||
|
address=_ip_address(_string(listen_raw, "address"), "listen.address"),
|
||||||
|
port=_port(_integer(listen_raw, "port"), "listen.port"),
|
||||||
|
)
|
||||||
|
|
||||||
|
network_subnet = _network(_string(root, "network_subnet"), "network_subnet")
|
||||||
|
release = _release(root.get("release"))
|
||||||
|
components = _components(root.get("components"), profile=profile)
|
||||||
|
replicas = _replicas(root.get("replicas"), components=components)
|
||||||
|
|
||||||
|
enabled_raw = root.get("enabled_modules")
|
||||||
|
if not isinstance(enabled_raw, list):
|
||||||
|
raise SpecError("enabled_modules must be an array")
|
||||||
|
enabled_modules: list[str] = []
|
||||||
|
seen_modules: set[str] = set()
|
||||||
|
for index, value in enumerate(enabled_raw):
|
||||||
|
if not isinstance(value, str) or not re.fullmatch(
|
||||||
|
r"[a-z][a-z0-9_]{1,63}", value
|
||||||
|
):
|
||||||
|
raise SpecError(f"enabled_modules[{index}] must be a canonical module id")
|
||||||
|
if value in seen_modules:
|
||||||
|
raise SpecError(f"enabled_modules contains duplicate module {value!r}")
|
||||||
|
enabled_modules.append(value)
|
||||||
|
seen_modules.add(value)
|
||||||
|
|
||||||
|
return InstallationSpec(
|
||||||
|
schema_version=schema_version,
|
||||||
|
installation_id=installation_id,
|
||||||
|
profile=profile,
|
||||||
|
public_url=public_url,
|
||||||
|
listen=listen,
|
||||||
|
network_subnet=network_subnet,
|
||||||
|
release=release,
|
||||||
|
components=components,
|
||||||
|
replicas=replicas,
|
||||||
|
enabled_modules=tuple(enabled_modules),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _release(raw: object) -> ReleaseConfig:
|
||||||
|
value = _mapping(raw, "release")
|
||||||
|
_only_keys(
|
||||||
|
value,
|
||||||
|
{
|
||||||
|
"channel",
|
||||||
|
"version",
|
||||||
|
"manifest_url",
|
||||||
|
"manifest_sha256",
|
||||||
|
"api_image",
|
||||||
|
"web_image",
|
||||||
|
},
|
||||||
|
"release",
|
||||||
|
)
|
||||||
|
channel = _string(value, "channel")
|
||||||
|
if not re.fullmatch(r"[a-z][a-z0-9-]{1,31}", channel):
|
||||||
|
raise SpecError("release.channel must be a canonical channel name")
|
||||||
|
version = _string(value, "version")
|
||||||
|
if not version or len(version) > 80 or any(char.isspace() for char in version):
|
||||||
|
raise SpecError("release.version must be a non-empty version token")
|
||||||
|
manifest_url = _optional_string(value, "manifest_url")
|
||||||
|
if manifest_url:
|
||||||
|
manifest_url = _http_url(manifest_url, "release.manifest_url")
|
||||||
|
if urlsplit(manifest_url).scheme != "https":
|
||||||
|
raise SpecError("release.manifest_url must use HTTPS")
|
||||||
|
manifest_sha256 = _optional_string(value, "manifest_sha256").lower()
|
||||||
|
if manifest_sha256 and not SHA256_PATTERN.fullmatch(manifest_sha256):
|
||||||
|
raise SpecError(
|
||||||
|
"release.manifest_sha256 must be a lowercase SHA-256 hex digest"
|
||||||
|
)
|
||||||
|
api_image = _image(_string(value, "api_image"), "release.api_image")
|
||||||
|
web_image = _image(_string(value, "web_image"), "release.web_image")
|
||||||
|
return ReleaseConfig(
|
||||||
|
channel=channel,
|
||||||
|
version=version,
|
||||||
|
manifest_url=manifest_url,
|
||||||
|
manifest_sha256=manifest_sha256,
|
||||||
|
api_image=api_image,
|
||||||
|
web_image=web_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _components(raw: object, *, profile: str) -> ComponentConfig:
|
||||||
|
value = _mapping(raw, "components")
|
||||||
|
_only_keys(
|
||||||
|
value,
|
||||||
|
{"postgres", "redis", "mail", "storage", "load_balancer"},
|
||||||
|
"components",
|
||||||
|
)
|
||||||
|
postgres = _service(
|
||||||
|
value.get("postgres"),
|
||||||
|
"components.postgres",
|
||||||
|
modes={"managed", "external"},
|
||||||
|
image_required_for={"managed"},
|
||||||
|
url_env_required_for={"external"},
|
||||||
|
)
|
||||||
|
redis = _service(
|
||||||
|
value.get("redis"),
|
||||||
|
"components.redis",
|
||||||
|
modes={"managed", "external", "disabled"},
|
||||||
|
image_required_for={"managed"},
|
||||||
|
url_env_required_for={"external"},
|
||||||
|
)
|
||||||
|
mail = _service(
|
||||||
|
value.get("mail"),
|
||||||
|
"components.mail",
|
||||||
|
modes={"disabled", "external-relay", "test-mail"},
|
||||||
|
image_required_for={"test-mail"},
|
||||||
|
url_env_required_for=set(),
|
||||||
|
)
|
||||||
|
storage_raw = _mapping(value.get("storage"), "components.storage")
|
||||||
|
_only_keys(storage_raw, {"mode", "image"}, "components.storage")
|
||||||
|
storage_mode = _choice(storage_raw, "mode", {"local", "s3", "garage"})
|
||||||
|
storage_image = _optional_string(storage_raw, "image")
|
||||||
|
if storage_mode == "garage":
|
||||||
|
storage_image = _image(
|
||||||
|
storage_image or DEFAULT_GARAGE_IMAGE,
|
||||||
|
"components.storage.image",
|
||||||
|
)
|
||||||
|
elif storage_image:
|
||||||
|
raise SpecError(
|
||||||
|
"components.storage.image is only valid for managed Garage storage"
|
||||||
|
)
|
||||||
|
storage = StorageConfig(mode=storage_mode, image=storage_image)
|
||||||
|
load_balancer = _service(
|
||||||
|
value.get(
|
||||||
|
"load_balancer",
|
||||||
|
{
|
||||||
|
"mode": "managed",
|
||||||
|
"image": DEFAULT_LOAD_BALANCER_IMAGE,
|
||||||
|
"url_env": "",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"components.load_balancer",
|
||||||
|
modes={"managed"},
|
||||||
|
image_required_for={"managed"},
|
||||||
|
url_env_required_for=set(),
|
||||||
|
)
|
||||||
|
if postgres.url_env != "DATABASE_URL":
|
||||||
|
raise SpecError("components.postgres.url_env must be DATABASE_URL")
|
||||||
|
if redis.url_env != "REDIS_URL":
|
||||||
|
raise SpecError("components.redis.url_env must be REDIS_URL")
|
||||||
|
if mail.url_env:
|
||||||
|
raise SpecError("components.mail.url_env must be empty")
|
||||||
|
if load_balancer.url_env:
|
||||||
|
raise SpecError("components.load_balancer.url_env must be empty")
|
||||||
|
if profile == "self-hosted" and redis.mode == "disabled":
|
||||||
|
raise SpecError("self-hosted installations require managed or external Redis")
|
||||||
|
if profile == "self-hosted" and mail.mode == "test-mail":
|
||||||
|
raise SpecError("the test-mail component is restricted to evaluation installs")
|
||||||
|
return ComponentConfig(
|
||||||
|
postgres=postgres,
|
||||||
|
redis=redis,
|
||||||
|
mail=mail,
|
||||||
|
storage=storage,
|
||||||
|
load_balancer=load_balancer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replicas(raw: object, *, components: ComponentConfig) -> ReplicaConfig:
|
||||||
|
if raw is None:
|
||||||
|
return ReplicaConfig(
|
||||||
|
api=1,
|
||||||
|
web=1,
|
||||||
|
worker=0 if components.redis.mode == "disabled" else 1,
|
||||||
|
)
|
||||||
|
value = _mapping(raw, "replicas")
|
||||||
|
_only_keys(value, {"api", "web", "worker"}, "replicas")
|
||||||
|
replicas = ReplicaConfig(
|
||||||
|
api=_bounded_integer(value, "api", minimum=1, maximum=64),
|
||||||
|
web=_bounded_integer(value, "web", minimum=1, maximum=64),
|
||||||
|
worker=_bounded_integer(value, "worker", minimum=0, maximum=128),
|
||||||
|
)
|
||||||
|
if components.redis.mode == "disabled":
|
||||||
|
if replicas.worker:
|
||||||
|
raise SpecError("replicas.worker must be 0 when Redis is disabled")
|
||||||
|
if replicas.api > 1:
|
||||||
|
raise SpecError(
|
||||||
|
"multiple API replicas require Redis for shared throttling and queues"
|
||||||
|
)
|
||||||
|
elif replicas.worker < 1:
|
||||||
|
raise SpecError("replicas.worker must be at least 1 when Redis is enabled")
|
||||||
|
return replicas
|
||||||
|
|
||||||
|
|
||||||
|
def _service(
|
||||||
|
raw: object,
|
||||||
|
label: str,
|
||||||
|
*,
|
||||||
|
modes: set[str],
|
||||||
|
image_required_for: set[str],
|
||||||
|
url_env_required_for: set[str],
|
||||||
|
) -> ServiceConfig:
|
||||||
|
value = _mapping(raw, label)
|
||||||
|
_only_keys(value, {"mode", "image", "url_env"}, label)
|
||||||
|
mode = _choice(value, "mode", modes)
|
||||||
|
image = _optional_string(value, "image")
|
||||||
|
url_env = _optional_string(value, "url_env")
|
||||||
|
if mode in image_required_for:
|
||||||
|
image = _image(image, f"{label}.image")
|
||||||
|
if mode in url_env_required_for:
|
||||||
|
if not ENV_NAME_PATTERN.fullmatch(url_env):
|
||||||
|
raise SpecError(f"{label}.url_env must name an environment variable")
|
||||||
|
return ServiceConfig(mode=mode, image=image, url_env=url_env)
|
||||||
|
|
||||||
|
|
||||||
|
def image_is_digest_pinned(value: str) -> bool:
|
||||||
|
return bool(IMAGE_DIGEST_PATTERN.fullmatch(value))
|
||||||
|
|
||||||
|
|
||||||
|
def image_is_unpublished(value: str) -> bool:
|
||||||
|
return value.endswith(":unpublished")
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: object, label: str) -> Mapping[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise SpecError(f"{label} must be an object")
|
||||||
|
if not all(isinstance(key, str) for key in value):
|
||||||
|
raise SpecError(f"{label} keys must be strings")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _only_keys(value: Mapping[str, Any], allowed: set[str], label: str) -> None:
|
||||||
|
unknown = sorted(set(value) - allowed)
|
||||||
|
if unknown:
|
||||||
|
raise SpecError(f"{label} contains unknown fields: {', '.join(unknown)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _string(value: Mapping[str, Any], key: str) -> str:
|
||||||
|
result = value.get(key)
|
||||||
|
if not isinstance(result, str):
|
||||||
|
raise SpecError(f"{key} must be a string")
|
||||||
|
return result.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string(value: Mapping[str, Any], key: str) -> str:
|
||||||
|
result = value.get(key, "")
|
||||||
|
if not isinstance(result, str):
|
||||||
|
raise SpecError(f"{key} must be a string")
|
||||||
|
return result.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _integer(value: Mapping[str, Any], key: str) -> int:
|
||||||
|
result = value.get(key)
|
||||||
|
if isinstance(result, bool) or not isinstance(result, int):
|
||||||
|
raise SpecError(f"{key} must be an integer")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_integer(
|
||||||
|
value: Mapping[str, Any],
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
minimum: int,
|
||||||
|
maximum: int,
|
||||||
|
) -> int:
|
||||||
|
result = _integer(value, key)
|
||||||
|
if result < minimum or result > maximum:
|
||||||
|
raise SpecError(f"{key} must be between {minimum} and {maximum}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _choice(value: Mapping[str, Any], key: str, choices: set[str]) -> str:
|
||||||
|
result = _string(value, key)
|
||||||
|
if result not in choices:
|
||||||
|
raise SpecError(f"{key} must be one of: {', '.join(sorted(choices))}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _http_url(value: str, label: str) -> str:
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
parsed.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SpecError(f"{label} contains an invalid host or port") from exc
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise SpecError(f"{label} must be an absolute HTTP(S) URL")
|
||||||
|
if parsed.username or parsed.password or parsed.fragment or parsed.query:
|
||||||
|
raise SpecError(f"{label} must not contain credentials, a query, or a fragment")
|
||||||
|
if label == "public_url" and parsed.path not in {"", "/"}:
|
||||||
|
raise SpecError("public_url must address the site root without a path")
|
||||||
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _image(value: str, label: str) -> str:
|
||||||
|
if (
|
||||||
|
not value
|
||||||
|
or len(value) > 300
|
||||||
|
or any(character.isspace() for character in value)
|
||||||
|
or value.startswith("-")
|
||||||
|
):
|
||||||
|
raise SpecError(f"{label} must be a valid non-empty OCI image reference")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _ip_address(value: str, label: str) -> str:
|
||||||
|
try:
|
||||||
|
return str(ipaddress.ip_address(value))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SpecError(f"{label} must be an IPv4 or IPv6 address") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _network(value: str, label: str) -> str:
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(value, strict=True)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SpecError(f"{label} must be a canonical private IP network") from exc
|
||||||
|
if (
|
||||||
|
network.version != 4
|
||||||
|
or not any(network.subnet_of(parent) for parent in RFC1918_NETWORKS)
|
||||||
|
or not 24 <= network.prefixlen <= 28
|
||||||
|
):
|
||||||
|
raise SpecError(f"{label} must be an RFC1918 IPv4 /24 to /28 network")
|
||||||
|
return str(network)
|
||||||
|
|
||||||
|
|
||||||
|
def _port(value: int, label: str) -> int:
|
||||||
|
if value < 1 or value > 65535:
|
||||||
|
raise SpecError(f"{label} must be between 1 and 65535")
|
||||||
|
return value
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
"""Deployment plan and host preflight checks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
from typing import Callable, Mapping, Sequence
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from .bundle import (
|
||||||
|
BundlePaths,
|
||||||
|
digest_json,
|
||||||
|
environment_fingerprint,
|
||||||
|
read_env,
|
||||||
|
render_compose,
|
||||||
|
service_names,
|
||||||
|
)
|
||||||
|
from .model import (
|
||||||
|
InstallationSpec,
|
||||||
|
image_is_digest_pinned,
|
||||||
|
image_is_unpublished,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Check:
|
||||||
|
id: str
|
||||||
|
level: str
|
||||||
|
message: str
|
||||||
|
action: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, str]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PlanAction:
|
||||||
|
action: str
|
||||||
|
target: str
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, str]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DeploymentPlan:
|
||||||
|
installation_id: str
|
||||||
|
desired_spec_sha256: str
|
||||||
|
desired_compose_sha256: str
|
||||||
|
desired_environment_fingerprint: str
|
||||||
|
actions: tuple[PlanAction, ...]
|
||||||
|
checks: tuple[Check, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocked(self) -> bool:
|
||||||
|
return any(check.level == "error" for check in self.checks)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"installation_id": self.installation_id,
|
||||||
|
"desired_spec_sha256": self.desired_spec_sha256,
|
||||||
|
"desired_compose_sha256": self.desired_compose_sha256,
|
||||||
|
"desired_environment_fingerprint": (self.desired_environment_fingerprint),
|
||||||
|
"blocked": self.blocked,
|
||||||
|
"actions": [action.to_dict() for action in self.actions],
|
||||||
|
"checks": [check.to_dict() for check in self.checks],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CommandRunner = Callable[[Sequence[str], Path], subprocess.CompletedProcess[str]]
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan(
|
||||||
|
spec: InstallationSpec,
|
||||||
|
paths: BundlePaths,
|
||||||
|
*,
|
||||||
|
include_host_checks: bool = True,
|
||||||
|
command_runner: CommandRunner | None = None,
|
||||||
|
) -> DeploymentPlan:
|
||||||
|
compose = render_compose(spec)
|
||||||
|
desired_services = set(service_names(spec))
|
||||||
|
previous = _read_receipt(paths.receipt)
|
||||||
|
previous_services = {
|
||||||
|
str(item) for item in previous.get("services", []) if isinstance(item, str)
|
||||||
|
}
|
||||||
|
previous_spec_digest = previous.get("spec_sha256")
|
||||||
|
previous_compose_digest = previous.get("compose_sha256")
|
||||||
|
previous_environment_fingerprint = previous.get("environment_fingerprint")
|
||||||
|
spec_digest = digest_json(spec.to_dict())
|
||||||
|
compose_digest = digest_json(compose)
|
||||||
|
environment_digest = environment_fingerprint(read_env(paths.env))
|
||||||
|
|
||||||
|
actions: list[PlanAction] = []
|
||||||
|
if not previous:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
"create",
|
||||||
|
"installation",
|
||||||
|
"Create the first deployment revision.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if previous_spec_digest != spec_digest:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
"reconfigure",
|
||||||
|
"installation",
|
||||||
|
"Reconcile runtime configuration with installation.json.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if previous_compose_digest != compose_digest:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
"render",
|
||||||
|
"compose",
|
||||||
|
"Render a new deterministic Compose definition.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if previous_environment_fingerprint != environment_digest:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
"reconfigure",
|
||||||
|
"environment",
|
||||||
|
"Reconcile changed secret bindings or runtime environment values.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name in sorted(desired_services - previous_services):
|
||||||
|
actions.append(PlanAction("start", name, "Start the selected service."))
|
||||||
|
for name in sorted(previous_services - desired_services):
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
"remove",
|
||||||
|
name,
|
||||||
|
"Remove the service container; retained volumes are not deleted.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
previous
|
||||||
|
and previous_spec_digest == spec_digest
|
||||||
|
and previous_compose_digest == compose_digest
|
||||||
|
and previous_environment_fingerprint == environment_digest
|
||||||
|
and previous_services == desired_services
|
||||||
|
):
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
"noop", "installation", "Desired state matches the last receipt."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
checks = list(static_checks(spec, paths))
|
||||||
|
if include_host_checks:
|
||||||
|
checks.extend(host_checks(spec, paths, command_runner=command_runner))
|
||||||
|
return DeploymentPlan(
|
||||||
|
installation_id=spec.installation_id,
|
||||||
|
desired_spec_sha256=spec_digest,
|
||||||
|
desired_compose_sha256=compose_digest,
|
||||||
|
desired_environment_fingerprint=environment_digest,
|
||||||
|
actions=tuple(actions),
|
||||||
|
checks=tuple(checks),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ...]:
|
||||||
|
checks: list[Check] = []
|
||||||
|
images = {
|
||||||
|
"release.api_image": spec.release.api_image,
|
||||||
|
"release.web_image": spec.release.web_image,
|
||||||
|
}
|
||||||
|
if spec.components.postgres.mode == "managed":
|
||||||
|
images["components.postgres.image"] = spec.components.postgres.image
|
||||||
|
if spec.components.redis.mode == "managed":
|
||||||
|
images["components.redis.image"] = spec.components.redis.image
|
||||||
|
if spec.components.mail.mode == "test-mail":
|
||||||
|
images["components.mail.image"] = spec.components.mail.image
|
||||||
|
if spec.components.storage.mode == "garage":
|
||||||
|
images["components.storage.image"] = spec.components.storage.image
|
||||||
|
images["components.load_balancer.image"] = spec.components.load_balancer.image
|
||||||
|
|
||||||
|
for label, image in images.items():
|
||||||
|
if image_is_unpublished(image):
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
f"{label}.published",
|
||||||
|
"error",
|
||||||
|
f"{label} still uses the unpublished placeholder.",
|
||||||
|
"Set an available image reference before apply.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif not image_is_digest_pinned(image):
|
||||||
|
level = "error" if spec.profile == "self-hosted" else "warning"
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
f"{label}.digest",
|
||||||
|
level,
|
||||||
|
f"{label} is not pinned by OCI digest.",
|
||||||
|
"Use an image@sha256:... reference from a verified distribution.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
f"{label}.digest",
|
||||||
|
"ok",
|
||||||
|
f"{label} is pinned by OCI digest.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if spec.release.manifest_url and spec.release.manifest_sha256:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"release.manifest",
|
||||||
|
"ok",
|
||||||
|
"A distribution manifest URL and expected digest are recorded.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"release.manifest",
|
||||||
|
"error" if spec.profile == "self-hosted" else "warning",
|
||||||
|
"No verified distribution manifest is recorded.",
|
||||||
|
"Use a published signed distribution manifest for self-hosted apply.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if spec.profile == "self-hosted":
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"release.signature_verification",
|
||||||
|
"error",
|
||||||
|
"Signed distribution-manifest verification is not implemented in the deployer yet.",
|
||||||
|
"Use the published verifier/bootstrap slice before a production apply.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"modules.image_composition",
|
||||||
|
"error" if spec.enabled_modules else "warning",
|
||||||
|
"The selected module set is not yet verified against image package contents.",
|
||||||
|
"Use the signed distribution composition evidence before production apply.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
values = read_env(paths.env)
|
||||||
|
required = {"MASTER_KEY_B64", "DATABASE_URL"}
|
||||||
|
if spec.components.redis.mode != "disabled":
|
||||||
|
required.add("REDIS_URL")
|
||||||
|
if spec.components.storage.mode in {"s3", "garage"}:
|
||||||
|
required.update(
|
||||||
|
{
|
||||||
|
"FILE_STORAGE_S3_ENDPOINT_URL",
|
||||||
|
"FILE_STORAGE_S3_REGION",
|
||||||
|
"FILE_STORAGE_S3_ACCESS_KEY_ID",
|
||||||
|
"FILE_STORAGE_S3_SECRET_ACCESS_KEY",
|
||||||
|
"FILE_STORAGE_S3_BUCKET",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if spec.components.storage.mode == "garage":
|
||||||
|
required.update(
|
||||||
|
{
|
||||||
|
"GARAGE_DEFAULT_ACCESS_KEY",
|
||||||
|
"GARAGE_DEFAULT_SECRET_KEY",
|
||||||
|
"GARAGE_DEFAULT_BUCKET",
|
||||||
|
"GARAGE_RPC_SECRET",
|
||||||
|
"GARAGE_ADMIN_TOKEN",
|
||||||
|
"GARAGE_METRICS_TOKEN",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
missing = sorted(name for name in required if not values.get(name))
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"secrets.required",
|
||||||
|
"error" if missing else "ok",
|
||||||
|
(
|
||||||
|
"Missing required secret values: " + ", ".join(missing)
|
||||||
|
if missing
|
||||||
|
else "Required runtime secret references are populated."
|
||||||
|
),
|
||||||
|
"Re-run configure with the required external service values."
|
||||||
|
if missing
|
||||||
|
else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if paths.env.exists():
|
||||||
|
mode = stat.S_IMODE(paths.env.stat().st_mode)
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"secrets.permissions",
|
||||||
|
"error" if mode & 0o077 else "ok",
|
||||||
|
(
|
||||||
|
f"{paths.env.name} has unsafe mode {mode:04o}."
|
||||||
|
if mode & 0o077
|
||||||
|
else f"{paths.env.name} is private ({mode:04o})."
|
||||||
|
),
|
||||||
|
f"Run chmod 600 {paths.env}" if mode & 0o077 else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if spec.profile == "self-hosted":
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"bootstrap.administrator",
|
||||||
|
"warning",
|
||||||
|
"Production first-administrator enrollment is not automated yet.",
|
||||||
|
"Use the controlled one-time administrator procedure until the enrollment slice lands.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if spec.components.mail.mode == "external-relay":
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"mail.provisioning",
|
||||||
|
"warning",
|
||||||
|
"The external relay is selected but Mail profile seeding remains an explicit post-install configuration task.",
|
||||||
|
"Create the reusable server and credential profile in Mail after first login.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if spec.components.storage.mode == "local" and spec.profile == "self-hosted":
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"storage.local",
|
||||||
|
"warning",
|
||||||
|
"Local file storage is suitable for one host but prevents stateless API scale-out.",
|
||||||
|
"Include files-data in backup/restore drills or configure S3 storage.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if spec.components.storage.mode == "garage":
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"storage.garage.single_node",
|
||||||
|
"warning",
|
||||||
|
(
|
||||||
|
"Managed Garage is persistent S3-compatible storage, but "
|
||||||
|
"this Compose profile runs one Garage node without data redundancy."
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Use an external multi-node Garage/S3 service and tested "
|
||||||
|
"backup/restore for an availability-sensitive installation."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"topology.load_balancing",
|
||||||
|
"ok",
|
||||||
|
(
|
||||||
|
"Managed HAProxy balances "
|
||||||
|
f"{spec.replicas.web} WebUI and {spec.replicas.api} API replica(s)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if spec.replicas.worker > 1:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"topology.worker_scaling",
|
||||||
|
"ok",
|
||||||
|
f"{spec.replicas.worker} workers share the configured Redis queues.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(checks)
|
||||||
|
|
||||||
|
|
||||||
|
def host_checks(
|
||||||
|
spec: InstallationSpec,
|
||||||
|
paths: BundlePaths,
|
||||||
|
*,
|
||||||
|
command_runner: CommandRunner | None = None,
|
||||||
|
) -> tuple[Check, ...]:
|
||||||
|
checks: list[Check] = []
|
||||||
|
machine = platform.machine().lower()
|
||||||
|
supported = machine in {"x86_64", "amd64", "aarch64", "arm64"}
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"host.architecture",
|
||||||
|
"ok" if supported else "error",
|
||||||
|
f"Host architecture is {machine or 'unknown'}.",
|
||||||
|
"Use a supported amd64 or arm64 host." if not supported else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
memory_bytes = _memory_bytes()
|
||||||
|
if memory_bytes is not None:
|
||||||
|
minimum = 4 * 1024**3
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"host.memory",
|
||||||
|
"ok" if memory_bytes >= minimum else "warning",
|
||||||
|
f"Host memory is approximately {memory_bytes / 1024**3:.1f} GiB.",
|
||||||
|
"Use at least 4 GiB for the base container profile."
|
||||||
|
if memory_bytes < minimum
|
||||||
|
else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cpu_count = os.cpu_count()
|
||||||
|
if cpu_count is not None:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"host.cpu",
|
||||||
|
"ok" if cpu_count >= 2 else "warning",
|
||||||
|
f"Host reports {cpu_count} logical CPU(s).",
|
||||||
|
"Use at least two logical CPUs for the base container profile."
|
||||||
|
if cpu_count < 2
|
||||||
|
else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entropy = _entropy_available()
|
||||||
|
if entropy is not None:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"host.entropy",
|
||||||
|
"ok" if entropy >= 256 else "warning",
|
||||||
|
f"Kernel entropy availability is {entropy}.",
|
||||||
|
"Wait for or provide sufficient host entropy before generating production keys."
|
||||||
|
if entropy < 256
|
||||||
|
else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
free_bytes = shutil.disk_usage(paths.root).free
|
||||||
|
minimum_free = 10 * 1024**3
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"host.disk",
|
||||||
|
"ok" if free_bytes >= minimum_free else "warning",
|
||||||
|
f"Free installation filesystem space is approximately {free_bytes / 1024**3:.1f} GiB.",
|
||||||
|
"Keep at least 10 GiB free before pulling images and creating data volumes."
|
||||||
|
if free_bytes < minimum_free
|
||||||
|
else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
docker = shutil.which("docker")
|
||||||
|
if docker is None:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"host.compose",
|
||||||
|
"error",
|
||||||
|
"Docker CLI is not installed or not on PATH.",
|
||||||
|
"Install Docker Engine with the Compose v2 plugin.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
runner = command_runner or _run_command
|
||||||
|
result = runner((docker, "compose", "version", "--short"), paths.root)
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"host.compose",
|
||||||
|
"ok" if result.returncode == 0 else "error",
|
||||||
|
(
|
||||||
|
f"Docker Compose is available ({result.stdout.strip()})."
|
||||||
|
if result.returncode == 0
|
||||||
|
else "Docker Compose v2 is not available."
|
||||||
|
),
|
||||||
|
"Install or enable the Docker Compose v2 plugin."
|
||||||
|
if result.returncode != 0
|
||||||
|
else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
daemon = runner(
|
||||||
|
(docker, "info", "--format", "{{.ServerVersion}}"),
|
||||||
|
paths.root,
|
||||||
|
)
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"host.container_runtime",
|
||||||
|
"ok" if daemon.returncode == 0 else "error",
|
||||||
|
(
|
||||||
|
f"Docker daemon is reachable ({daemon.stdout.strip()})."
|
||||||
|
if daemon.returncode == 0
|
||||||
|
else "Docker CLI cannot reach the Docker daemon."
|
||||||
|
),
|
||||||
|
"Start Docker and grant this operator access to the daemon."
|
||||||
|
if daemon.returncode != 0
|
||||||
|
else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
values = read_env(paths.env)
|
||||||
|
if spec.components.postgres.mode == "external":
|
||||||
|
checks.append(
|
||||||
|
_endpoint_check(
|
||||||
|
"external.postgres",
|
||||||
|
"External PostgreSQL",
|
||||||
|
values.get("DATABASE_URL", ""),
|
||||||
|
default_ports={"postgresql": 5432, "postgresql+psycopg": 5432},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if spec.components.redis.mode == "external":
|
||||||
|
checks.append(
|
||||||
|
_endpoint_check(
|
||||||
|
"external.redis",
|
||||||
|
"External Redis",
|
||||||
|
values.get("REDIS_URL", ""),
|
||||||
|
default_ports={"redis": 6379, "rediss": 6379},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if spec.components.storage.mode == "s3":
|
||||||
|
checks.append(
|
||||||
|
_endpoint_check(
|
||||||
|
"external.s3",
|
||||||
|
"S3-compatible storage",
|
||||||
|
values.get("FILE_STORAGE_S3_ENDPOINT_URL", ""),
|
||||||
|
default_ports={"http": 80, "https": 443},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt = _read_receipt(paths.receipt)
|
||||||
|
previous_listen = receipt.get("listen")
|
||||||
|
desired_listen = {
|
||||||
|
"address": spec.listen.address,
|
||||||
|
"port": spec.listen.port,
|
||||||
|
}
|
||||||
|
if not receipt or previous_listen != desired_listen:
|
||||||
|
available = _port_available(spec.listen.address, spec.listen.port)
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
"host.listen_port",
|
||||||
|
"ok" if available else "error",
|
||||||
|
(
|
||||||
|
f"Listen endpoint {spec.listen.address}:{spec.listen.port} is available."
|
||||||
|
if available
|
||||||
|
else f"Listen endpoint {spec.listen.address}:{spec.listen.port} is already in use."
|
||||||
|
),
|
||||||
|
"Choose another listen port or stop the conflicting service."
|
||||||
|
if not available
|
||||||
|
else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(checks)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_receipt(path: Path) -> Mapping[str, object]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _memory_bytes() -> int | None:
|
||||||
|
try:
|
||||||
|
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
|
||||||
|
if line.startswith("MemTotal:"):
|
||||||
|
return int(line.split()[1]) * 1024
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _entropy_available() -> int | None:
|
||||||
|
try:
|
||||||
|
return int(
|
||||||
|
Path("/proc/sys/kernel/random/entropy_avail")
|
||||||
|
.read_text(encoding="utf-8")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _port_available(address: str, port: int) -> bool:
|
||||||
|
family = socket.AF_INET6 if ":" in address else socket.AF_INET
|
||||||
|
with socket.socket(family, socket.SOCK_STREAM) as handle:
|
||||||
|
try:
|
||||||
|
handle.bind((address, port))
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint_check(
|
||||||
|
check_id: str,
|
||||||
|
label: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
default_ports: Mapping[str, int],
|
||||||
|
) -> Check:
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
host = parsed.hostname
|
||||||
|
port = parsed.port or default_ports.get(parsed.scheme)
|
||||||
|
except ValueError as exc:
|
||||||
|
return Check(
|
||||||
|
check_id,
|
||||||
|
"error",
|
||||||
|
f"{label} endpoint is invalid: {exc}",
|
||||||
|
"Correct the private endpoint binding and rerun doctor.",
|
||||||
|
)
|
||||||
|
if not host or port is None:
|
||||||
|
return Check(
|
||||||
|
check_id,
|
||||||
|
"error",
|
||||||
|
f"{label} endpoint has no usable host and port.",
|
||||||
|
"Correct the private endpoint binding and rerun doctor.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=1.5):
|
||||||
|
pass
|
||||||
|
except OSError as exc:
|
||||||
|
return Check(
|
||||||
|
check_id,
|
||||||
|
"error",
|
||||||
|
f"{label} is not reachable at {host}:{port}: {exc}",
|
||||||
|
"Check DNS, routing, firewall, service health, and the selected endpoint.",
|
||||||
|
)
|
||||||
|
return Check(
|
||||||
|
check_id,
|
||||||
|
"ok",
|
||||||
|
f"{label} is reachable at {host}:{port}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_command(argv: Sequence[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
list(argv),
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
@@ -27,12 +27,17 @@ EXCLUDED_PARTS = {
|
|||||||
".git",
|
".git",
|
||||||
".gitea",
|
".gitea",
|
||||||
".venv",
|
".venv",
|
||||||
|
".mypy_cache",
|
||||||
|
".pytest_cache",
|
||||||
|
".ruff_cache",
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"__pycache__",
|
"__pycache__",
|
||||||
|
"audit-reports",
|
||||||
"dist",
|
"dist",
|
||||||
"build",
|
"build",
|
||||||
".cache",
|
".cache",
|
||||||
".module-test-build",
|
".module-test-build",
|
||||||
|
"runtime",
|
||||||
}
|
}
|
||||||
EXCLUDED_NAMES = {
|
EXCLUDED_NAMES = {
|
||||||
"package-lock.json",
|
"package-lock.json",
|
||||||
@@ -40,6 +45,14 @@ EXCLUDED_NAMES = {
|
|||||||
"yarn.lock",
|
"yarn.lock",
|
||||||
"uv.lock",
|
"uv.lock",
|
||||||
}
|
}
|
||||||
|
REPO_DOC_NAME_TOKENS = {
|
||||||
|
"architecture",
|
||||||
|
"backlog",
|
||||||
|
"plan",
|
||||||
|
"roadmap",
|
||||||
|
"todo",
|
||||||
|
"workflow",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
@dataclasses.dataclass(frozen=True)
|
||||||
@@ -216,7 +229,8 @@ def is_repo_doc(repo_root_path: pathlib.Path, path: pathlib.Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
if rel.parts[0] in {"docs", "doc", "codex"} and path.suffix.lower() in {".md", ".txt", ".csv"}:
|
if rel.parts[0] in {"docs", "doc", "codex"} and path.suffix.lower() in {".md", ".txt", ".csv"}:
|
||||||
return True
|
return True
|
||||||
if re.search(r"(backlog|todo|roadmap|plan|architecture|workflow|manifest)", path.name, re.IGNORECASE):
|
name_tokens = set(re.split(r"[^a-z0-9]+", path.stem.lower()))
|
||||||
|
if name_tokens & REPO_DOC_NAME_TOKENS:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const [metaRootArgument] = process.argv.slice(2);
|
||||||
|
if (!metaRootArgument) {
|
||||||
|
throw new Error("Usage: extract-webui-structure.mjs META_ROOT");
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaRoot = path.resolve(metaRootArgument);
|
||||||
|
const repositoryCatalog = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(metaRoot, "repositories.json"), "utf8")
|
||||||
|
);
|
||||||
|
const workspaceRoot = path.resolve(repositoryCatalog.default_parent);
|
||||||
|
const typescriptPath = path.join(
|
||||||
|
workspaceRoot,
|
||||||
|
"govoplan-core",
|
||||||
|
"webui",
|
||||||
|
"node_modules",
|
||||||
|
"typescript",
|
||||||
|
"lib",
|
||||||
|
"typescript.js"
|
||||||
|
);
|
||||||
|
const ts = await import(pathToFileURL(typescriptPath).href);
|
||||||
|
|
||||||
|
const fieldComponents = new Set([
|
||||||
|
"input",
|
||||||
|
"select",
|
||||||
|
"textarea",
|
||||||
|
"Checkbox",
|
||||||
|
"DateTimeField",
|
||||||
|
"EmailAddressInput",
|
||||||
|
"ReferenceSelect",
|
||||||
|
"SearchableSelect",
|
||||||
|
"ToggleSwitch"
|
||||||
|
]);
|
||||||
|
const fieldComponentPattern =
|
||||||
|
/(?:Input|Field|Select|Picker|Toggle|Checkbox|Radio|Editor)$/;
|
||||||
|
const labelAttributes = new Set([
|
||||||
|
"aria-label",
|
||||||
|
"description",
|
||||||
|
"help",
|
||||||
|
"helperText",
|
||||||
|
"helpText",
|
||||||
|
"label",
|
||||||
|
"placeholder",
|
||||||
|
"title"
|
||||||
|
]);
|
||||||
|
const helpAttributes = new Set([
|
||||||
|
"description",
|
||||||
|
"help",
|
||||||
|
"helperText",
|
||||||
|
"helpText"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
fields: [],
|
||||||
|
labels: [],
|
||||||
|
visibleText: [],
|
||||||
|
translationCatalog: {},
|
||||||
|
translationUsages: [],
|
||||||
|
dynamicTranslationUsages: [],
|
||||||
|
routes: [],
|
||||||
|
navigation: [],
|
||||||
|
frontendApiReferences: [],
|
||||||
|
uiCapabilities: []
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const repository of repositoryCatalog.repositories) {
|
||||||
|
const sourceRoot = path.join(workspaceRoot, repository.path, "webui", "src");
|
||||||
|
if (!fs.existsSync(sourceRoot)) continue;
|
||||||
|
for (const sourcePath of sourceFiles(sourceRoot)) {
|
||||||
|
inspectSource(repository.name, sourceRoot, sourcePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||||
|
|
||||||
|
function sourceFiles(root) {
|
||||||
|
const files = [];
|
||||||
|
const pending = [root];
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const current = pending.pop();
|
||||||
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
|
if (
|
||||||
|
entry.name === "node_modules" ||
|
||||||
|
entry.name.startsWith(".") ||
|
||||||
|
entry.name === "dist"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) pending.push(candidate);
|
||||||
|
else if (/\.(?:ts|tsx)$/.test(entry.name)) files.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectSource(repository, sourceRoot, sourcePath) {
|
||||||
|
const sourceText = fs.readFileSync(sourcePath, "utf8");
|
||||||
|
const sourceFile = ts.createSourceFile(
|
||||||
|
sourcePath,
|
||||||
|
sourceText,
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
sourcePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
||||||
|
);
|
||||||
|
const relativeFile = path.relative(path.join(workspaceRoot, repository), sourcePath);
|
||||||
|
|
||||||
|
function location(node) {
|
||||||
|
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
||||||
|
return {
|
||||||
|
repository,
|
||||||
|
file: relativeFile,
|
||||||
|
line: position.line + 1,
|
||||||
|
column: position.character + 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
||||||
|
inspectJsxOpening(node, location);
|
||||||
|
}
|
||||||
|
if (ts.isJsxText(node)) {
|
||||||
|
const value = node.getText(sourceFile).replace(/\s+/g, " ").trim();
|
||||||
|
if (value) {
|
||||||
|
result.visibleText.push({
|
||||||
|
...location(node),
|
||||||
|
value,
|
||||||
|
translationKey: value.startsWith("i18n:") ? value : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ts.isStringLiteralLike(node)) {
|
||||||
|
inspectString(node.text, node, location);
|
||||||
|
} else if (ts.isTemplateExpression(node)) {
|
||||||
|
inspectString(templateText(node), node, location);
|
||||||
|
}
|
||||||
|
if (ts.isPropertyAssignment(node)) {
|
||||||
|
inspectProperty(node, location);
|
||||||
|
inspectTranslationProperty(node, location);
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(sourceFile);
|
||||||
|
|
||||||
|
function inspectJsxOpening(node, locate) {
|
||||||
|
const component = node.tagName.getText(sourceFile);
|
||||||
|
const attributes = new Map();
|
||||||
|
for (const attribute of node.attributes.properties) {
|
||||||
|
if (!ts.isJsxAttribute(attribute)) continue;
|
||||||
|
attributes.set(
|
||||||
|
attribute.name.getText(sourceFile),
|
||||||
|
jsxAttributeValue(attribute)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [attribute, value] of attributes) {
|
||||||
|
if (!labelAttributes.has(attribute) || value === null) continue;
|
||||||
|
result.labels.push({
|
||||||
|
...locate(node),
|
||||||
|
component,
|
||||||
|
attribute,
|
||||||
|
value,
|
||||||
|
translationKey: value.startsWith("i18n:") ? value : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isField =
|
||||||
|
fieldComponents.has(component) ||
|
||||||
|
(fieldComponentPattern.test(component) && component !== "FormField");
|
||||||
|
if (!isField) return;
|
||||||
|
|
||||||
|
const parentFormField = nearestFormField(node);
|
||||||
|
const parentAttributes = parentFormField
|
||||||
|
? jsxAttributes(parentFormField)
|
||||||
|
: new Map();
|
||||||
|
const label =
|
||||||
|
attributes.get("label") ??
|
||||||
|
attributes.get("aria-label") ??
|
||||||
|
parentAttributes.get("label") ??
|
||||||
|
null;
|
||||||
|
const help = firstAttribute(attributes, helpAttributes) ??
|
||||||
|
firstAttribute(parentAttributes, helpAttributes);
|
||||||
|
result.fields.push({
|
||||||
|
...locate(node),
|
||||||
|
component,
|
||||||
|
name:
|
||||||
|
attributes.get("name") ??
|
||||||
|
attributes.get("id") ??
|
||||||
|
attributes.get("field") ??
|
||||||
|
null,
|
||||||
|
label,
|
||||||
|
placeholder: attributes.get("placeholder") ?? null,
|
||||||
|
help: help ?? null,
|
||||||
|
helpCandidate: help === null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestFormField(node) {
|
||||||
|
let current = node.parent;
|
||||||
|
while (current) {
|
||||||
|
if (
|
||||||
|
ts.isJsxElement(current) &&
|
||||||
|
current.openingElement.tagName.getText(sourceFile) === "FormField"
|
||||||
|
) {
|
||||||
|
return current.openingElement;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ts.isJsxOpeningElement(current) ||
|
||||||
|
ts.isJsxSelfClosingElement(current)
|
||||||
|
) {
|
||||||
|
const name = current.tagName.getText(sourceFile);
|
||||||
|
if (name !== "FormField") return null;
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsxAttributes(node) {
|
||||||
|
const mapped = new Map();
|
||||||
|
for (const attribute of node.attributes.properties) {
|
||||||
|
if (!ts.isJsxAttribute(attribute)) continue;
|
||||||
|
mapped.set(
|
||||||
|
attribute.name.getText(sourceFile),
|
||||||
|
jsxAttributeValue(attribute)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsxAttributeValue(attribute) {
|
||||||
|
if (!attribute.initializer) return "true";
|
||||||
|
if (ts.isStringLiteral(attribute.initializer)) return attribute.initializer.text;
|
||||||
|
if (!ts.isJsxExpression(attribute.initializer)) return null;
|
||||||
|
const expression = attribute.initializer.expression;
|
||||||
|
if (!expression) return null;
|
||||||
|
if (ts.isStringLiteralLike(expression)) return expression.text;
|
||||||
|
if (ts.isNoSubstitutionTemplateLiteral(expression)) return expression.text;
|
||||||
|
if (ts.isTemplateExpression(expression)) return templateText(expression);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectString(value, node, locate) {
|
||||||
|
if (value.startsWith("i18n:") && value.length > "i18n:".length) {
|
||||||
|
const target = value.includes("${}") || isStringPrefixCheck(node)
|
||||||
|
? result.dynamicTranslationUsages
|
||||||
|
: result.translationUsages;
|
||||||
|
target.push({ ...locate(node), key: value });
|
||||||
|
}
|
||||||
|
if (value.includes("/api/")) {
|
||||||
|
result.frontendApiReferences.push({ ...locate(node), path: value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringPrefixCheck(node) {
|
||||||
|
const call = node.parent;
|
||||||
|
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return ["endsWith", "includes", "startsWith"].includes(
|
||||||
|
call.expression.name.text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectProperty(node, locate) {
|
||||||
|
const propertyName = propertyNameText(node.name);
|
||||||
|
const value = staticExpressionText(node.initializer);
|
||||||
|
if (value === null) return;
|
||||||
|
if (propertyName === "path" && value.startsWith("/") && !value.includes("/api/")) {
|
||||||
|
result.routes.push({ ...locate(node), path: value });
|
||||||
|
}
|
||||||
|
if (propertyName === "to" && value.startsWith("/")) {
|
||||||
|
result.navigation.push({ ...locate(node), path: value });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ancestorPropertyName(node, "uiCapabilities") &&
|
||||||
|
typeof propertyName === "string"
|
||||||
|
) {
|
||||||
|
result.uiCapabilities.push({ ...locate(node), name: propertyName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectTranslationProperty(node, locate) {
|
||||||
|
const key = propertyNameText(node.name);
|
||||||
|
if (!key?.startsWith("i18n:")) return;
|
||||||
|
const value = staticExpressionText(node.initializer);
|
||||||
|
if (value === null) return;
|
||||||
|
const locale = nearestLocaleProperty(node);
|
||||||
|
if (!locale) return;
|
||||||
|
result.translationCatalog[locale] ??= {};
|
||||||
|
result.translationCatalog[locale][key] = {
|
||||||
|
value,
|
||||||
|
...locate(node)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestLocaleProperty(node) {
|
||||||
|
let current = node.parent;
|
||||||
|
while (current) {
|
||||||
|
if (
|
||||||
|
ts.isPropertyAssignment(current) &&
|
||||||
|
(propertyNameText(current.name) === "en" ||
|
||||||
|
propertyNameText(current.name) === "de")
|
||||||
|
) {
|
||||||
|
return propertyNameText(current.name);
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ancestorPropertyName(node, expected) {
|
||||||
|
let current = node.parent;
|
||||||
|
while (current) {
|
||||||
|
if (
|
||||||
|
ts.isPropertyAssignment(current) &&
|
||||||
|
propertyNameText(current.name) === expected
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstAttribute(attributes, names) {
|
||||||
|
for (const name of names) {
|
||||||
|
const value = attributes.get(name);
|
||||||
|
if (value !== undefined && value !== null) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function propertyNameText(name) {
|
||||||
|
if (
|
||||||
|
ts.isIdentifier(name) ||
|
||||||
|
ts.isStringLiteral(name) ||
|
||||||
|
ts.isNumericLiteral(name)
|
||||||
|
) {
|
||||||
|
return name.text;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function staticExpressionText(node) {
|
||||||
|
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||||
|
return node.text;
|
||||||
|
}
|
||||||
|
if (ts.isTemplateExpression(node)) return templateText(node);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateText(node) {
|
||||||
|
return (
|
||||||
|
node.head.text +
|
||||||
|
node.templateSpans
|
||||||
|
.map((span) => "${}" + span.literal.text)
|
||||||
|
.join("")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract a source-derived GovOPlaN UI, translation, module, and API inventory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import asdict, is_dataclass
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put"}
|
||||||
|
PATH_PARAMETER = re.compile(r"\$\{[^{}]*\}|\{[^{}]+\}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-dir",
|
||||||
|
type=Path,
|
||||||
|
default=META_ROOT / "audit-reports" / "platform-inventory",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--strict",
|
||||||
|
action="store_true",
|
||||||
|
help="Fail when a used translation key is absent from a generated locale catalog.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
catalog = json.loads(
|
||||||
|
(META_ROOT / "repositories.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
workspace_root = Path(catalog["default_parent"]).resolve()
|
||||||
|
webui = _extract_webui()
|
||||||
|
backend_endpoints = _extract_backend_endpoints(catalog, workspace_root)
|
||||||
|
manifests = _extract_manifests(catalog, workspace_root)
|
||||||
|
inventory = _assemble_inventory(
|
||||||
|
webui=webui,
|
||||||
|
backend_endpoints=backend_endpoints,
|
||||||
|
manifests=manifests,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_dir = args.output_dir.resolve()
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
json_path = output_dir / "platform-interface-inventory.json"
|
||||||
|
markdown_path = output_dir / "platform-interface-inventory.md"
|
||||||
|
json_path.write_text(
|
||||||
|
json.dumps(inventory, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
markdown_path.write_text(_render_markdown(inventory), encoding="utf-8")
|
||||||
|
print(f"Platform inventory JSON: {json_path}")
|
||||||
|
print(f"Platform inventory summary: {markdown_path}")
|
||||||
|
|
||||||
|
if args.strict and inventory["translation_health"]["missing_catalog_entries"]:
|
||||||
|
print(
|
||||||
|
"Used translation keys are missing from generated catalogs.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_webui() -> dict[str, Any]:
|
||||||
|
helper = META_ROOT / "tools" / "inventory" / "extract-webui-structure.mjs"
|
||||||
|
completed = subprocess.run(
|
||||||
|
["node", str(helper), str(META_ROOT)],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_backend_endpoints(
|
||||||
|
catalog: dict[str, Any],
|
||||||
|
workspace_root: Path,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
endpoints: list[dict[str, Any]] = []
|
||||||
|
for repository in catalog["repositories"]:
|
||||||
|
repository_root = workspace_root / repository["path"]
|
||||||
|
source_root = repository_root / "src"
|
||||||
|
if not source_root.is_dir():
|
||||||
|
continue
|
||||||
|
for source_path in sorted(source_root.rglob("*.py")):
|
||||||
|
try:
|
||||||
|
tree = ast.parse(
|
||||||
|
source_path.read_text(encoding="utf-8"),
|
||||||
|
filename=str(source_path),
|
||||||
|
)
|
||||||
|
except (OSError, SyntaxError, UnicodeDecodeError):
|
||||||
|
continue
|
||||||
|
prefixes = _router_prefixes(tree)
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
continue
|
||||||
|
for decorator in node.decorator_list:
|
||||||
|
endpoint = _endpoint_from_decorator(
|
||||||
|
decorator,
|
||||||
|
prefixes=prefixes,
|
||||||
|
)
|
||||||
|
if endpoint is None:
|
||||||
|
continue
|
||||||
|
endpoints.append(
|
||||||
|
{
|
||||||
|
"repository": repository["name"],
|
||||||
|
"file": str(source_path.relative_to(repository_root)),
|
||||||
|
"line": node.lineno,
|
||||||
|
"handler": node.name,
|
||||||
|
**endpoint,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(
|
||||||
|
endpoints,
|
||||||
|
key=lambda item: (
|
||||||
|
item["repository"],
|
||||||
|
item["path"],
|
||||||
|
item["method"],
|
||||||
|
item["file"],
|
||||||
|
item["line"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _router_prefixes(tree: ast.AST) -> dict[str, str]:
|
||||||
|
prefixes: dict[str, str] = {}
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||||
|
continue
|
||||||
|
value = node.value
|
||||||
|
if not isinstance(value, ast.Call):
|
||||||
|
continue
|
||||||
|
function_name = _call_name(value.func)
|
||||||
|
if function_name not in {"APIRouter", "fastapi.APIRouter"}:
|
||||||
|
continue
|
||||||
|
prefix = ""
|
||||||
|
for keyword in value.keywords:
|
||||||
|
if keyword.arg == "prefix":
|
||||||
|
prefix = _static_string(keyword.value) or ""
|
||||||
|
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
||||||
|
for target in targets:
|
||||||
|
if isinstance(target, ast.Name):
|
||||||
|
prefixes[target.id] = prefix
|
||||||
|
return prefixes
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint_from_decorator(
|
||||||
|
decorator: ast.expr,
|
||||||
|
*,
|
||||||
|
prefixes: dict[str, str],
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
if not isinstance(decorator, ast.Call) or not isinstance(
|
||||||
|
decorator.func, ast.Attribute
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
method = decorator.func.attr.lower()
|
||||||
|
if method not in HTTP_METHODS or not decorator.args:
|
||||||
|
return None
|
||||||
|
route = _static_string(decorator.args[0])
|
||||||
|
if route is None:
|
||||||
|
return None
|
||||||
|
owner = decorator.func.value.id if isinstance(decorator.func.value, ast.Name) else ""
|
||||||
|
prefix = prefixes.get(owner, "")
|
||||||
|
return {
|
||||||
|
"method": method.upper(),
|
||||||
|
"path": _join_route(prefix, route),
|
||||||
|
"router": owner,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_manifests(
|
||||||
|
catalog: dict[str, Any],
|
||||||
|
workspace_root: Path,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
source_roots = [
|
||||||
|
workspace_root / repository["path"] / "src"
|
||||||
|
for repository in catalog["repositories"]
|
||||||
|
if (workspace_root / repository["path"] / "src").is_dir()
|
||||||
|
]
|
||||||
|
sys.path[:0] = [str(path) for path in source_roots]
|
||||||
|
manifests: list[dict[str, Any]] = []
|
||||||
|
for repository in catalog["repositories"]:
|
||||||
|
source_root = workspace_root / repository["path"] / "src"
|
||||||
|
if not source_root.is_dir():
|
||||||
|
continue
|
||||||
|
for manifest_path in sorted(source_root.glob("*/backend/manifest.py")):
|
||||||
|
module_name = ".".join(
|
||||||
|
manifest_path.relative_to(source_root).with_suffix("").parts
|
||||||
|
)
|
||||||
|
loaded = importlib.import_module(module_name)
|
||||||
|
manifest = loaded.get_manifest()
|
||||||
|
frontend = manifest.frontend
|
||||||
|
manifests.append(
|
||||||
|
{
|
||||||
|
"repository": repository["name"],
|
||||||
|
"id": manifest.id,
|
||||||
|
"name": manifest.name,
|
||||||
|
"version": manifest.version,
|
||||||
|
"dependencies": list(manifest.dependencies),
|
||||||
|
"optional_dependencies": list(manifest.optional_dependencies),
|
||||||
|
"required_capabilities": list(manifest.required_capabilities),
|
||||||
|
"provided_interfaces": [
|
||||||
|
{"name": item.name, "version": item.version}
|
||||||
|
for item in manifest.provides_interfaces
|
||||||
|
],
|
||||||
|
"runtime_capabilities": sorted(manifest.capability_factories),
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"scope": permission.scope,
|
||||||
|
"label": permission.label,
|
||||||
|
"level": permission.level,
|
||||||
|
}
|
||||||
|
for permission in manifest.permissions
|
||||||
|
],
|
||||||
|
"frontend": (
|
||||||
|
{
|
||||||
|
"package": frontend.package_name,
|
||||||
|
"routes": [
|
||||||
|
_plain_value(route) for route in frontend.routes
|
||||||
|
],
|
||||||
|
"nav_items": [
|
||||||
|
_plain_value(item) for item in frontend.nav_items
|
||||||
|
],
|
||||||
|
"view_surfaces": [
|
||||||
|
_plain_value(surface)
|
||||||
|
for surface in frontend.view_surfaces
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if frontend is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(manifests, key=lambda item: item["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble_inventory(
|
||||||
|
*,
|
||||||
|
webui: dict[str, Any],
|
||||||
|
backend_endpoints: list[dict[str, Any]],
|
||||||
|
manifests: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
frontend_refs = webui["frontendApiReferences"]
|
||||||
|
frontend_paths = {
|
||||||
|
canonical_api_path(reference["path"])
|
||||||
|
for reference in frontend_refs
|
||||||
|
if canonical_api_path(reference["path"])
|
||||||
|
}
|
||||||
|
unreferenced = [
|
||||||
|
endpoint
|
||||||
|
for endpoint in backend_endpoints
|
||||||
|
if canonical_api_path(endpoint["path"]) not in frontend_paths
|
||||||
|
]
|
||||||
|
usages = {item["key"] for item in webui["translationUsages"]}
|
||||||
|
catalogs = webui["translationCatalog"]
|
||||||
|
catalog_keys = {
|
||||||
|
locale: set(entries)
|
||||||
|
for locale, entries in catalogs.items()
|
||||||
|
}
|
||||||
|
expected_locales = sorted(catalog_keys)
|
||||||
|
missing_catalog_entries = [
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"missing_locales": [
|
||||||
|
locale
|
||||||
|
for locale in expected_locales
|
||||||
|
if key not in catalog_keys[locale]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for key in sorted(usages)
|
||||||
|
if any(key not in catalog_keys[locale] for locale in expected_locales)
|
||||||
|
]
|
||||||
|
fields = webui["fields"]
|
||||||
|
help_candidates = [field for field in fields if field["helpCandidate"]]
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"scope": {
|
||||||
|
"source": "local GovOPlaN repository catalog",
|
||||||
|
"limitations": [
|
||||||
|
"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 field marked as a help candidate may receive contextual help from a surrounding dynamic component.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"modules": manifests,
|
||||||
|
"ui": {
|
||||||
|
"fields": fields,
|
||||||
|
"labels": webui["labels"],
|
||||||
|
"visible_text": webui["visibleText"],
|
||||||
|
"routes": webui["routes"],
|
||||||
|
"navigation": webui["navigation"],
|
||||||
|
"ui_capabilities": webui["uiCapabilities"],
|
||||||
|
"help_candidates": help_candidates,
|
||||||
|
},
|
||||||
|
"translations": {
|
||||||
|
"catalog": catalogs,
|
||||||
|
"usages": webui["translationUsages"],
|
||||||
|
"dynamic_usages": webui["dynamicTranslationUsages"],
|
||||||
|
},
|
||||||
|
"translation_health": {
|
||||||
|
"locales": expected_locales,
|
||||||
|
"used_keys": len(usages),
|
||||||
|
"missing_catalog_entries": missing_catalog_entries,
|
||||||
|
},
|
||||||
|
"api": {
|
||||||
|
"backend_endpoints": backend_endpoints,
|
||||||
|
"frontend_references": frontend_refs,
|
||||||
|
"unreferenced_by_static_webui_scan": unreferenced,
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"modules": len(manifests),
|
||||||
|
"ui_fields": len(fields),
|
||||||
|
"ui_fields_with_static_help": len(fields) - len(help_candidates),
|
||||||
|
"help_review_candidates": len(help_candidates),
|
||||||
|
"label_attributes": len(webui["labels"]),
|
||||||
|
"visible_text_nodes": len(webui["visibleText"]),
|
||||||
|
"frontend_routes": len(webui["routes"]),
|
||||||
|
"backend_endpoints": len(backend_endpoints),
|
||||||
|
"frontend_api_references": len(frontend_refs),
|
||||||
|
"backend_endpoints_without_static_webui_reference": len(unreferenced),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_markdown(inventory: dict[str, Any]) -> str:
|
||||||
|
summary = inventory["summary"]
|
||||||
|
missing = inventory["translation_health"]["missing_catalog_entries"]
|
||||||
|
help_by_repository = Counter(
|
||||||
|
item["repository"] for item in inventory["ui"]["help_candidates"]
|
||||||
|
)
|
||||||
|
endpoint_by_repository = Counter(
|
||||||
|
item["repository"]
|
||||||
|
for item in inventory["api"]["unreferenced_by_static_webui_scan"]
|
||||||
|
)
|
||||||
|
lines = [
|
||||||
|
"# GovOPlaN Platform Interface Inventory",
|
||||||
|
"",
|
||||||
|
"Generated from runtime module manifests plus TypeScript and Python ASTs.",
|
||||||
|
"Counts are source evidence, not a claim that every surface is enabled or reachable.",
|
||||||
|
"",
|
||||||
|
"## Summary",
|
||||||
|
"",
|
||||||
|
f"- Modules: {summary['modules']}",
|
||||||
|
f"- UI fields: {summary['ui_fields']}",
|
||||||
|
f"- Fields with statically associated help: {summary['ui_fields_with_static_help']}",
|
||||||
|
f"- Help review candidates: {summary['help_review_candidates']}",
|
||||||
|
f"- Label attributes: {summary['label_attributes']}",
|
||||||
|
f"- Frontend routes: {summary['frontend_routes']}",
|
||||||
|
f"- Backend endpoints: {summary['backend_endpoints']}",
|
||||||
|
f"- Frontend API references: {summary['frontend_api_references']}",
|
||||||
|
(
|
||||||
|
"- Backend endpoints without a static WebUI reference: "
|
||||||
|
f"{summary['backend_endpoints_without_static_webui_reference']}"
|
||||||
|
),
|
||||||
|
f"- Used translation keys missing from a locale catalog: {len(missing)}",
|
||||||
|
"",
|
||||||
|
"## Help Review Candidates",
|
||||||
|
"",
|
||||||
|
"| Repository | Fields |",
|
||||||
|
"| --- | ---: |",
|
||||||
|
]
|
||||||
|
lines.extend(
|
||||||
|
f"| `{repository}` | {count} |"
|
||||||
|
for repository, count in sorted(help_by_repository.items())
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## Endpoints Without Static WebUI References",
|
||||||
|
"",
|
||||||
|
"These are review candidates. Public APIs, worker callbacks, connector",
|
||||||
|
"endpoints, health checks, and dynamically assembled paths are legitimate",
|
||||||
|
"reasons for appearing here.",
|
||||||
|
"",
|
||||||
|
"| Repository | Endpoints |",
|
||||||
|
"| --- | ---: |",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
f"| `{repository}` | {count} |"
|
||||||
|
for repository, count in sorted(endpoint_by_repository.items())
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## Interpretation",
|
||||||
|
"",
|
||||||
|
"Use the JSON artifact for exact file and line evidence. Missing help is",
|
||||||
|
"a triage list, not an automatic defect. Endpoint coverage requires an",
|
||||||
|
"owner classification before enforcement. Runtime-computed structures",
|
||||||
|
"need explicit manifest metadata to become canonically visible.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_api_path(value: str) -> str:
|
||||||
|
path = value.split("?", 1)[0].strip()
|
||||||
|
if "/api/" in path:
|
||||||
|
path = path[path.index("/api/") :]
|
||||||
|
path = re.sub(r"^/api/v\d+", "", path)
|
||||||
|
path = PATH_PARAMETER.sub("{}", path)
|
||||||
|
path = re.sub(r"/+", "/", path)
|
||||||
|
return path.rstrip("/") or "/"
|
||||||
|
|
||||||
|
|
||||||
|
def _join_route(prefix: str, route: str) -> str:
|
||||||
|
return f"/{prefix.strip('/')}/{route.strip('/')}".replace("//", "/")
|
||||||
|
|
||||||
|
|
||||||
|
def _static_string(node: ast.AST) -> str | None:
|
||||||
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||||
|
return node.value
|
||||||
|
if isinstance(node, ast.JoinedStr):
|
||||||
|
pieces: list[str] = []
|
||||||
|
for value in node.values:
|
||||||
|
if isinstance(value, ast.Constant) and isinstance(value.value, str):
|
||||||
|
pieces.append(value.value)
|
||||||
|
elif isinstance(value, ast.FormattedValue):
|
||||||
|
pieces.append("${}")
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return "".join(pieces)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _call_name(node: ast.AST) -> str:
|
||||||
|
if isinstance(node, ast.Name):
|
||||||
|
return node.id
|
||||||
|
if isinstance(node, ast.Attribute):
|
||||||
|
parent = _call_name(node.value)
|
||||||
|
return f"{parent}.{node.attr}" if parent else node.attr
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_value(value: Any) -> Any:
|
||||||
|
if is_dataclass(value):
|
||||||
|
return {
|
||||||
|
key: _plain_value(item)
|
||||||
|
for key, item in asdict(value).items()
|
||||||
|
}
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return [_plain_value(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
str(key): _plain_value(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -22,6 +22,7 @@ FRONTEND_USE_POLLING="${GOVOPLAN_FRONTEND_USE_POLLING:-1}"
|
|||||||
FRONTEND_POLLING_INTERVAL="${GOVOPLAN_FRONTEND_POLLING_INTERVAL:-500}"
|
FRONTEND_POLLING_INTERVAL="${GOVOPLAN_FRONTEND_POLLING_INTERVAL:-500}"
|
||||||
AUTO_SYNC_PYTHON="${GOVOPLAN_AUTO_SYNC_PYTHON:-1}"
|
AUTO_SYNC_PYTHON="${GOVOPLAN_AUTO_SYNC_PYTHON:-1}"
|
||||||
DEV_DATABASE_BACKEND="${GOVOPLAN_DEV_DATABASE_BACKEND:-postgres}"
|
DEV_DATABASE_BACKEND="${GOVOPLAN_DEV_DATABASE_BACKEND:-postgres}"
|
||||||
|
BACKEND_RELOAD_MODULES="${GOVOPLAN_BACKEND_RELOAD_MODULES:-}"
|
||||||
|
|
||||||
LOG_DIR="${GOVOPLAN_DEV_LOG_DIR:-$META_ROOT/runtime/dev-launcher}"
|
LOG_DIR="${GOVOPLAN_DEV_LOG_DIR:-$META_ROOT/runtime/dev-launcher}"
|
||||||
BACKEND_LOG="$LOG_DIR/backend.log"
|
BACKEND_LOG="$LOG_DIR/backend.log"
|
||||||
@@ -167,7 +168,16 @@ port_is_free "$BACKEND_HOST" "$BACKEND_PORT" || fail "$BACKEND_URL is already in
|
|||||||
port_is_free "$FRONTEND_HOST" "$FRONTEND_PORT" || fail "$FRONTEND_URL is already in use"
|
port_is_free "$FRONTEND_HOST" "$FRONTEND_PORT" || fail "$FRONTEND_URL is already in use"
|
||||||
|
|
||||||
printf 'Starting GovOPlaN backend at %s\n' "$BACKEND_URL"
|
printf 'Starting GovOPlaN backend at %s\n' "$BACKEND_URL"
|
||||||
run_grouped "$ROOT" "$BACKEND_LOG" "$PYTHON" -m govoplan_core.devserver --host "$BACKEND_HOST" --port "$BACKEND_PORT"
|
backend_args=(-m govoplan_core.devserver --host "$BACKEND_HOST" --port "$BACKEND_PORT")
|
||||||
|
if [ "$BACKEND_RELOAD_MODULES" = "none" ]; then
|
||||||
|
backend_args+=(--reload-core-only)
|
||||||
|
elif [ -n "$BACKEND_RELOAD_MODULES" ]; then
|
||||||
|
IFS=',' read -r -a reload_modules <<< "$BACKEND_RELOAD_MODULES"
|
||||||
|
for reload_module in "${reload_modules[@]}"; do
|
||||||
|
[ -n "$reload_module" ] && backend_args+=(--reload-module "$reload_module")
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
run_grouped "$ROOT" "$BACKEND_LOG" "$PYTHON" "${backend_args[@]}"
|
||||||
backend_pid="$run_grouped_pid"
|
backend_pid="$run_grouped_pid"
|
||||||
|
|
||||||
printf 'Waiting for %s/health\n' "$BACKEND_URL"
|
printf 'Waiting for %s/health\n' "$BACKEND_URL"
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ set +a
|
|||||||
|
|
||||||
export APP_ENV="${APP_ENV:-staging}"
|
export APP_ENV="${APP_ENV:-staging}"
|
||||||
export GOVOPLAN_INSTALL_PROFILE="${GOVOPLAN_INSTALL_PROFILE:-production-like}"
|
export GOVOPLAN_INSTALL_PROFILE="${GOVOPLAN_INSTALL_PROFILE:-production-like}"
|
||||||
export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops}"
|
export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,views,search,risk_compliance,notifications,docs,ops}"
|
||||||
export DATABASE_URL="${DATABASE_URL:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL:-postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
|
export DATABASE_URL="${DATABASE_URL:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL:-postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
|
||||||
export GOVOPLAN_DATABASE_URL_PGTOOLS="${GOVOPLAN_DATABASE_URL_PGTOOLS:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS:-postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
|
export GOVOPLAN_DATABASE_URL_PGTOOLS="${GOVOPLAN_DATABASE_URL_PGTOOLS:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS:-postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
|
||||||
export REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}"
|
export REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}"
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export DATABASE_URL="${DATABASE_URL:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL:-po
|
|||||||
export GOVOPLAN_DATABASE_URL_PGTOOLS="${GOVOPLAN_DATABASE_URL_PGTOOLS:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS:-postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
|
export GOVOPLAN_DATABASE_URL_PGTOOLS="${GOVOPLAN_DATABASE_URL_PGTOOLS:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS:-postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
|
||||||
export REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}"
|
export REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}"
|
||||||
export CELERY_ENABLED="${CELERY_ENABLED:-true}"
|
export CELERY_ENABLED="${CELERY_ENABLED:-true}"
|
||||||
export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops}"
|
export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,views,search,risk_compliance,notifications,docs,ops}"
|
||||||
export FILE_STORAGE_BACKEND="${FILE_STORAGE_BACKEND:-local}"
|
export FILE_STORAGE_BACKEND="${FILE_STORAGE_BACKEND:-local}"
|
||||||
export FILE_STORAGE_LOCAL_ROOT="${FILE_STORAGE_LOCAL_ROOT:-$META_ROOT/runtime/production-like/files}"
|
export FILE_STORAGE_LOCAL_ROOT="${FILE_STORAGE_LOCAL_ROOT:-$META_ROOT/runtime/production-like/files}"
|
||||||
export DEV_AUTO_MIGRATE_ENABLED="${DEV_AUTO_MIGRATE_ENABLED:-false}"
|
export DEV_AUTO_MIGRATE_ENABLED="${DEV_AUTO_MIGRATE_ENABLED:-false}"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from govoplan_core.server.registry import available_module_manifests # noqa: E4
|
|||||||
from govoplan_release.version_alignment import selected_repository_version_issues # noqa: E402
|
from govoplan_release.version_alignment import selected_repository_version_issues # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
|
GITEA_BASE = "git+ssh://git@git.add-ideas.de/GovOPlaN"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -50,6 +50,7 @@ CATALOG_MODULES = (
|
|||||||
name="Tenancy",
|
name="Tenancy",
|
||||||
description="Tenant registry, tenant settings, and tenant resolution platform module.",
|
description="Tenant registry, tenant settings, and tenant resolution platform module.",
|
||||||
tags=("official", "platform-module"),
|
tags=("official", "platform-module"),
|
||||||
|
webui_package="@govoplan/tenancy-webui",
|
||||||
),
|
),
|
||||||
CatalogModule(
|
CatalogModule(
|
||||||
module_id="organizations",
|
module_id="organizations",
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""Independent identities for immutable Python wheel release artifacts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from email.policy import compat32
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
|
||||||
|
WHEEL_PAYLOAD_ALGORITHM = "govoplan-wheel-declared-payload-v1"
|
||||||
|
INSTALLED_PAYLOAD_ALGORITHM = "govoplan-installed-record-payload-v1"
|
||||||
|
MAX_WHEEL_BYTES = 512 * 1024 * 1024
|
||||||
|
MAX_WHEEL_ENTRIES = 10_000
|
||||||
|
MAX_WHEEL_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024
|
||||||
|
MAX_WHEEL_ENTRY_BYTES = 64 * 1024 * 1024
|
||||||
|
MAX_METADATA_BYTES = 1024 * 1024
|
||||||
|
_PACKAGE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||||
|
_VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,127}$")
|
||||||
|
_PYTHON_REF = re.compile(
|
||||||
|
r"/(?P<repo>govoplan-[a-z0-9-]+)[.]git@v(?P<version>[^\s;]+)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactIdentityError(ValueError):
|
||||||
|
"""A built artifact cannot provide a bounded immutable identity."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PythonWheelIdentity:
|
||||||
|
package_name: str
|
||||||
|
package_version: str
|
||||||
|
archive_sha256: str
|
||||||
|
archive_size: int
|
||||||
|
payload_sha256: str
|
||||||
|
payload_file_count: int
|
||||||
|
requires_installer_receipt: bool
|
||||||
|
|
||||||
|
def catalog_payload(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"artifact_kind": "python-wheel",
|
||||||
|
"package_name": self.package_name,
|
||||||
|
"package_version": self.package_version,
|
||||||
|
"archive_sha256": self.archive_sha256,
|
||||||
|
"archive_size": self.archive_size,
|
||||||
|
"installed_payload": {
|
||||||
|
"algorithm": WHEEL_PAYLOAD_ALGORITHM,
|
||||||
|
"sha256": self.payload_sha256,
|
||||||
|
"file_count": self.payload_file_count,
|
||||||
|
},
|
||||||
|
"requires_installer_receipt": self.requires_installer_receipt,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_python_wheel(path: Path | str) -> PythonWheelIdentity:
|
||||||
|
"""Hash a regular built wheel and derive its install-stable payload identity."""
|
||||||
|
|
||||||
|
wheel = Path(path).expanduser()
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(wheel, flags)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ArtifactIdentityError("built artifact cannot be opened safely") from exc
|
||||||
|
try:
|
||||||
|
archive_sha256, archive_size, initial = _hash_open_artifact(descriptor)
|
||||||
|
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||||
|
try:
|
||||||
|
with os.fdopen(os.dup(descriptor), "rb") as artifact_handle:
|
||||||
|
with zipfile.ZipFile(artifact_handle) as archive:
|
||||||
|
(
|
||||||
|
package_name,
|
||||||
|
package_version,
|
||||||
|
payload_rows,
|
||||||
|
requires_receipt,
|
||||||
|
) = _inspect_wheel_archive(archive)
|
||||||
|
except (OSError, zipfile.BadZipFile, RuntimeError) as exc:
|
||||||
|
raise ArtifactIdentityError(
|
||||||
|
"built Python artifact is not a readable wheel"
|
||||||
|
) from exc
|
||||||
|
final = os.fstat(descriptor)
|
||||||
|
if _stat_fingerprint(final) != _stat_fingerprint(initial):
|
||||||
|
raise ArtifactIdentityError("built artifact changed while it was inspected")
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
return PythonWheelIdentity(
|
||||||
|
package_name=package_name,
|
||||||
|
package_version=package_version,
|
||||||
|
archive_sha256=archive_sha256,
|
||||||
|
archive_size=archive_size,
|
||||||
|
payload_sha256=payload_identity_sha256(
|
||||||
|
algorithm=WHEEL_PAYLOAD_ALGORITHM, rows=payload_rows
|
||||||
|
),
|
||||||
|
payload_file_count=len(payload_rows),
|
||||||
|
requires_installer_receipt=requires_receipt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _inspect_wheel_archive(
|
||||||
|
archive: zipfile.ZipFile,
|
||||||
|
) -> tuple[str, str, list[dict[str, object]], bool]:
|
||||||
|
infos = archive.infolist()
|
||||||
|
if not infos or len(infos) > MAX_WHEEL_ENTRIES:
|
||||||
|
raise ArtifactIdentityError("wheel entry count is empty or exceeds its limit")
|
||||||
|
_validate_members(infos)
|
||||||
|
metadata_members = [
|
||||||
|
item
|
||||||
|
for item in infos
|
||||||
|
if not item.is_dir()
|
||||||
|
and PurePosixPath(item.filename).name == "METADATA"
|
||||||
|
and PurePosixPath(item.filename).parent.name.endswith(".dist-info")
|
||||||
|
]
|
||||||
|
if len(metadata_members) != 1:
|
||||||
|
raise ArtifactIdentityError(
|
||||||
|
"wheel must contain exactly one dist-info METADATA file"
|
||||||
|
)
|
||||||
|
metadata_member = metadata_members[0]
|
||||||
|
dist_info = PurePosixPath(metadata_member.filename).parent
|
||||||
|
metadata_bytes = _read_member(
|
||||||
|
archive, metadata_member, max_bytes=MAX_METADATA_BYTES
|
||||||
|
)
|
||||||
|
parsed = BytesParser(policy=compat32).parsebytes(metadata_bytes)
|
||||||
|
package_name = normalize_package_name(str(parsed.get("Name") or ""))
|
||||||
|
package_version = str(parsed.get("Version") or "").strip()
|
||||||
|
if _PACKAGE.fullmatch(package_name) is None:
|
||||||
|
raise ArtifactIdentityError("wheel METADATA has an invalid package name")
|
||||||
|
if _VERSION.fullmatch(package_version) is None:
|
||||||
|
raise ArtifactIdentityError("wheel METADATA has an invalid package version")
|
||||||
|
|
||||||
|
payload_rows: list[dict[str, object]] = []
|
||||||
|
requires_receipt = False
|
||||||
|
for info in infos:
|
||||||
|
if info.is_dir():
|
||||||
|
continue
|
||||||
|
normalized, transformed = _wheel_payload_path(
|
||||||
|
PurePosixPath(info.filename), dist_info=dist_info
|
||||||
|
)
|
||||||
|
requires_receipt = requires_receipt or transformed
|
||||||
|
if normalized is None:
|
||||||
|
continue
|
||||||
|
encoded = _read_member(archive, info, max_bytes=MAX_WHEEL_ENTRY_BYTES)
|
||||||
|
payload_rows.append(
|
||||||
|
{
|
||||||
|
"path": normalized,
|
||||||
|
"sha256": hashlib.sha256(encoded).hexdigest(),
|
||||||
|
"size": len(encoded),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload_rows.sort(key=lambda item: str(item["path"]))
|
||||||
|
if not payload_rows:
|
||||||
|
raise ArtifactIdentityError("wheel has no immutable payload entries")
|
||||||
|
if len({str(item["path"]) for item in payload_rows}) != len(payload_rows):
|
||||||
|
raise ArtifactIdentityError(
|
||||||
|
"wheel payload paths are ambiguous after installation mapping"
|
||||||
|
)
|
||||||
|
entry_points = f"{dist_info.as_posix()}/entry_points.txt"
|
||||||
|
if any(item.filename == entry_points for item in infos):
|
||||||
|
entry_point_info = next(item for item in infos if item.filename == entry_points)
|
||||||
|
entry_point_text = _read_member(
|
||||||
|
archive, entry_point_info, max_bytes=MAX_METADATA_BYTES
|
||||||
|
).decode("utf-8", errors="replace")
|
||||||
|
if re.search(r"(?m)^\s*\[(?:console|gui)_scripts\]\s*$", entry_point_text):
|
||||||
|
requires_receipt = True
|
||||||
|
return package_name, package_version, payload_rows, requires_receipt
|
||||||
|
|
||||||
|
|
||||||
|
def selected_artifact_identity_issues(payload: object) -> tuple[str, ...]:
|
||||||
|
"""Report selected Python releases without a matching built-wheel identity."""
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return ("candidate catalog must be a JSON object",)
|
||||||
|
release = payload.get("release")
|
||||||
|
selected_units = release.get("selected_units") if isinstance(release, dict) else None
|
||||||
|
artifacts = release.get("artifacts") if isinstance(release, dict) else None
|
||||||
|
if not isinstance(selected_units, list) or not selected_units:
|
||||||
|
return ("release.selected_units is missing or empty",)
|
||||||
|
entries: list[object] = [payload.get("core_release")]
|
||||||
|
modules = payload.get("modules")
|
||||||
|
if isinstance(modules, list):
|
||||||
|
entries.extend(modules)
|
||||||
|
package_by_repo: dict[str, tuple[str, str]] = {}
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
python_ref = entry.get("python_ref")
|
||||||
|
match = _PYTHON_REF.search(python_ref) if isinstance(python_ref, str) else None
|
||||||
|
package_name = entry.get("python_package")
|
||||||
|
version = entry.get("version")
|
||||||
|
if (
|
||||||
|
match is not None
|
||||||
|
and isinstance(package_name, str)
|
||||||
|
and _PACKAGE.fullmatch(package_name) is not None
|
||||||
|
and isinstance(version, str)
|
||||||
|
):
|
||||||
|
package_by_repo[match.group("repo")] = (package_name, version)
|
||||||
|
artifacts_by_package: dict[str, dict[str, object]] = {}
|
||||||
|
malformed_artifacts = False
|
||||||
|
if not isinstance(artifacts, list):
|
||||||
|
artifacts = []
|
||||||
|
for artifact in artifacts:
|
||||||
|
if not isinstance(artifact, dict):
|
||||||
|
malformed_artifacts = True
|
||||||
|
continue
|
||||||
|
package_name = artifact.get("package_name")
|
||||||
|
if not isinstance(package_name, str) or package_name in artifacts_by_package:
|
||||||
|
malformed_artifacts = True
|
||||||
|
continue
|
||||||
|
if not _catalog_artifact_shape_valid(artifact):
|
||||||
|
malformed_artifacts = True
|
||||||
|
continue
|
||||||
|
artifacts_by_package[package_name] = artifact
|
||||||
|
issues: list[str] = []
|
||||||
|
if malformed_artifacts:
|
||||||
|
issues.append("release.artifacts contains malformed or duplicate identities")
|
||||||
|
seen_repos: set[str] = set()
|
||||||
|
for unit in selected_units:
|
||||||
|
if not isinstance(unit, dict):
|
||||||
|
issues.append("release.selected_units contains a malformed entry")
|
||||||
|
continue
|
||||||
|
repo = unit.get("repo")
|
||||||
|
if not isinstance(repo, str) or repo in seen_repos:
|
||||||
|
issues.append("release.selected_units contains a duplicate or invalid repository")
|
||||||
|
continue
|
||||||
|
seen_repos.add(repo)
|
||||||
|
expected = package_by_repo.get(repo)
|
||||||
|
if expected is None:
|
||||||
|
continue # Selected non-Python repositories have no wheel identity.
|
||||||
|
package_name, version = expected
|
||||||
|
artifact = artifacts_by_package.get(package_name)
|
||||||
|
if artifact is None:
|
||||||
|
issues.append(
|
||||||
|
f"selected Python repository {repo} has no built artifact identity for {package_name} {version}"
|
||||||
|
)
|
||||||
|
elif artifact.get("package_version") != version:
|
||||||
|
issues.append(
|
||||||
|
f"selected Python repository {repo} artifact identity has version {artifact.get('package_version')!r}, expected {version!r}"
|
||||||
|
)
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_artifact_shape_valid(value: dict[str, object]) -> bool:
|
||||||
|
if set(value) != {
|
||||||
|
"artifact_kind",
|
||||||
|
"package_name",
|
||||||
|
"package_version",
|
||||||
|
"archive_sha256",
|
||||||
|
"archive_size",
|
||||||
|
"installed_payload",
|
||||||
|
"requires_installer_receipt",
|
||||||
|
}:
|
||||||
|
return False
|
||||||
|
package_name = value.get("package_name")
|
||||||
|
version = value.get("package_version")
|
||||||
|
archive_sha256 = value.get("archive_sha256")
|
||||||
|
archive_size = value.get("archive_size")
|
||||||
|
installed_payload = value.get("installed_payload")
|
||||||
|
return (
|
||||||
|
value.get("artifact_kind") == "python-wheel"
|
||||||
|
and isinstance(package_name, str)
|
||||||
|
and _PACKAGE.fullmatch(package_name) is not None
|
||||||
|
and isinstance(version, str)
|
||||||
|
and _VERSION.fullmatch(version) is not None
|
||||||
|
and isinstance(archive_sha256, str)
|
||||||
|
and re.fullmatch(r"[0-9a-f]{64}", archive_sha256) is not None
|
||||||
|
and isinstance(archive_size, int)
|
||||||
|
and not isinstance(archive_size, bool)
|
||||||
|
and 0 < archive_size <= MAX_WHEEL_BYTES
|
||||||
|
and isinstance(value.get("requires_installer_receipt"), bool)
|
||||||
|
and isinstance(installed_payload, dict)
|
||||||
|
and set(installed_payload) == {"algorithm", "sha256", "file_count"}
|
||||||
|
and installed_payload.get("algorithm") == WHEEL_PAYLOAD_ALGORITHM
|
||||||
|
and isinstance(installed_payload.get("sha256"), str)
|
||||||
|
and re.fullmatch(r"[0-9a-f]{64}", str(installed_payload["sha256"]))
|
||||||
|
is not None
|
||||||
|
and isinstance(installed_payload.get("file_count"), int)
|
||||||
|
and not isinstance(installed_payload.get("file_count"), bool)
|
||||||
|
and 0 < int(installed_payload["file_count"]) <= MAX_WHEEL_ENTRIES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def payload_identity_sha256(
|
||||||
|
*, algorithm: str, rows: list[dict[str, object]]
|
||||||
|
) -> str:
|
||||||
|
payload = {"algorithm": algorithm, "files": rows}
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def record_hash_hex(value: str) -> str | None:
|
||||||
|
try:
|
||||||
|
decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
return decoded.hex() if len(decoded) == hashlib.sha256().digest_size else None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_package_name(value: str) -> str:
|
||||||
|
return re.sub(r"[-_.]+", "-", value.strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_open_artifact(descriptor: int) -> tuple[str, int, os.stat_result]:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
total = 0
|
||||||
|
initial = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(initial.st_mode) or initial.st_size > MAX_WHEEL_BYTES:
|
||||||
|
raise ArtifactIdentityError("built artifact must be a bounded regular file")
|
||||||
|
while total < initial.st_size:
|
||||||
|
chunk = os.read(descriptor, min(1024 * 1024, initial.st_size - total))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
digest.update(chunk)
|
||||||
|
final = os.fstat(descriptor)
|
||||||
|
if total != initial.st_size or _stat_fingerprint(final) != _stat_fingerprint(initial):
|
||||||
|
raise ArtifactIdentityError("built artifact changed while it was hashed")
|
||||||
|
return digest.hexdigest(), total, initial
|
||||||
|
|
||||||
|
|
||||||
|
def _stat_fingerprint(value: os.stat_result) -> tuple[int, int, int, int, int]:
|
||||||
|
return (
|
||||||
|
value.st_dev,
|
||||||
|
value.st_ino,
|
||||||
|
value.st_size,
|
||||||
|
value.st_mtime_ns,
|
||||||
|
value.st_ctime_ns,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_members(infos: list[zipfile.ZipInfo]) -> None:
|
||||||
|
names: set[str] = set()
|
||||||
|
total = 0
|
||||||
|
for info in infos:
|
||||||
|
path = PurePosixPath(info.filename.replace("\\", "/"))
|
||||||
|
if (
|
||||||
|
not info.filename
|
||||||
|
or path.is_absolute()
|
||||||
|
or ".." in path.parts
|
||||||
|
or any(ord(character) < 32 for character in info.filename)
|
||||||
|
or info.flag_bits & 0x1
|
||||||
|
):
|
||||||
|
raise ArtifactIdentityError("wheel contains an unsafe member")
|
||||||
|
normalized = path.as_posix()
|
||||||
|
if normalized in names:
|
||||||
|
raise ArtifactIdentityError("wheel contains duplicate members")
|
||||||
|
names.add(normalized)
|
||||||
|
if _zip_member_is_symlink(info):
|
||||||
|
raise ArtifactIdentityError("wheel contains a symbolic-link member")
|
||||||
|
total += info.file_size
|
||||||
|
if info.file_size > MAX_WHEEL_ENTRY_BYTES or total > MAX_WHEEL_UNCOMPRESSED_BYTES:
|
||||||
|
raise ArtifactIdentityError("wheel uncompressed payload exceeds its limit")
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_member_is_symlink(info: zipfile.ZipInfo) -> bool:
|
||||||
|
return stat.S_ISLNK((info.external_attr >> 16) & 0o177777)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_member(
|
||||||
|
archive: zipfile.ZipFile, info: zipfile.ZipInfo, *, max_bytes: int
|
||||||
|
) -> bytes:
|
||||||
|
if info.file_size > max_bytes:
|
||||||
|
raise ArtifactIdentityError("wheel member exceeds its size limit")
|
||||||
|
with archive.open(info, "r") as handle:
|
||||||
|
encoded = handle.read(max_bytes + 1)
|
||||||
|
if len(encoded) > max_bytes or len(encoded) != info.file_size:
|
||||||
|
raise ArtifactIdentityError("wheel member size is inconsistent")
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
|
||||||
|
def _wheel_payload_path(
|
||||||
|
path: PurePosixPath, *, dist_info: PurePosixPath
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
|
if path.parent == dist_info and path.name in {"RECORD", "RECORD.jws", "RECORD.p7s"}:
|
||||||
|
return None, False
|
||||||
|
parts = path.parts
|
||||||
|
data_prefix = dist_info.name.removesuffix(".dist-info") + ".data"
|
||||||
|
if parts and parts[0] == data_prefix:
|
||||||
|
if len(parts) < 3:
|
||||||
|
raise ArtifactIdentityError("wheel data member has no installation target")
|
||||||
|
scheme = parts[1]
|
||||||
|
remainder = PurePosixPath(*parts[2:]).as_posix()
|
||||||
|
if scheme in {"purelib", "platlib"}:
|
||||||
|
return remainder, False
|
||||||
|
if scheme == "data":
|
||||||
|
return f"@data/{remainder}", False
|
||||||
|
if scheme in {"scripts", "headers"}:
|
||||||
|
return None, True
|
||||||
|
raise ArtifactIdentityError("wheel uses an unsupported installation scheme")
|
||||||
|
return path.as_posix(), False
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Opaque, workspace-scoped handles for generated catalog candidates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .catalog import canonical_hash
|
||||||
|
|
||||||
|
|
||||||
|
MAX_CATALOG_BYTES = 16 * 1024 * 1024
|
||||||
|
_CANDIDATE_ID = re.compile(r"^candidate-[0-9a-f]{32}$")
|
||||||
|
_CHANNEL = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateArtifactError(ValueError):
|
||||||
|
"""A candidate handle or the artifact behind it is unsafe or inconsistent."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CandidateArtifactReceipt:
|
||||||
|
candidate_id: str
|
||||||
|
catalog_sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
def issue_candidate_id(*seed_parts: str) -> str:
|
||||||
|
"""Derive an opaque, deterministic candidate basename before output exists."""
|
||||||
|
|
||||||
|
if not seed_parts or any(
|
||||||
|
not isinstance(part, str) or not part or len(part) > 512
|
||||||
|
for part in seed_parts
|
||||||
|
):
|
||||||
|
raise CandidateArtifactError("candidate ID seeds must be non-empty bounded strings")
|
||||||
|
encoded = json.dumps(
|
||||||
|
list(seed_parts), sort_keys=False, separators=(",", ":"), ensure_ascii=True
|
||||||
|
).encode("utf-8")
|
||||||
|
return f"candidate-{hashlib.sha256(encoded).hexdigest()[:32]}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_release_channel(value: str) -> str:
|
||||||
|
"""Return one bounded channel basename or reject path-capable input."""
|
||||||
|
|
||||||
|
if not isinstance(value, str) or _CHANNEL.fullmatch(value) is None:
|
||||||
|
raise CandidateArtifactError(
|
||||||
|
"release channel must be a bounded lowercase identifier"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_output_path(root: Path | str, candidate_id: str) -> Path:
|
||||||
|
"""Resolve a not-yet-created candidate path below a trusted configured root."""
|
||||||
|
|
||||||
|
checked_id = _checked_candidate_id(candidate_id)
|
||||||
|
root_path = Path(root).expanduser()
|
||||||
|
ensure_private_candidate_root(root_path, create=True)
|
||||||
|
candidate = root_path / checked_id
|
||||||
|
try:
|
||||||
|
mode = candidate.lstat().st_mode
|
||||||
|
except FileNotFoundError:
|
||||||
|
return candidate
|
||||||
|
except OSError as exc:
|
||||||
|
raise CandidateArtifactError("candidate output path cannot be inspected") from exc
|
||||||
|
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
||||||
|
raise CandidateArtifactError("candidate output path must be a real directory")
|
||||||
|
_require_private_owner_mode(candidate, directory=True, label="candidate output path")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_private_candidate_root(
|
||||||
|
root: Path | str, *, create: bool = False
|
||||||
|
) -> Path:
|
||||||
|
"""Admit only an operator-owned candidate root inaccessible to other users."""
|
||||||
|
|
||||||
|
root_path = Path(root).expanduser()
|
||||||
|
_reject_symlink_components(root_path, allow_missing=create)
|
||||||
|
if create:
|
||||||
|
try:
|
||||||
|
root_path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise CandidateArtifactError("candidate root cannot be created") from exc
|
||||||
|
_reject_symlink_components(root_path, allow_missing=False)
|
||||||
|
_require_private_owner_mode(root_path, directory=True, label="candidate root")
|
||||||
|
return root_path
|
||||||
|
|
||||||
|
|
||||||
|
def harden_private_candidate_tree(path: Path | str) -> Path:
|
||||||
|
"""Seal a newly generated, operator-owned candidate tree before a receipt."""
|
||||||
|
|
||||||
|
root = Path(path).expanduser()
|
||||||
|
_reject_symlink_components(root, allow_missing=False)
|
||||||
|
_require_current_owner(root, label="candidate directory")
|
||||||
|
try:
|
||||||
|
os.chmod(root, 0o700, follow_symlinks=False)
|
||||||
|
for current, directory_names, file_names in os.walk(root, followlinks=False):
|
||||||
|
current_path = Path(current)
|
||||||
|
_require_current_owner(current_path, label="candidate directory")
|
||||||
|
os.chmod(current_path, 0o700, follow_symlinks=False)
|
||||||
|
for name in (*directory_names, *file_names):
|
||||||
|
child = current_path / name
|
||||||
|
mode = child.lstat().st_mode
|
||||||
|
if stat.S_ISLNK(mode):
|
||||||
|
raise CandidateArtifactError("candidate tree contains a symlink")
|
||||||
|
_require_current_owner(child, label="candidate tree member")
|
||||||
|
if stat.S_ISDIR(mode):
|
||||||
|
os.chmod(child, 0o700, follow_symlinks=False)
|
||||||
|
elif stat.S_ISREG(mode):
|
||||||
|
os.chmod(child, 0o600, follow_symlinks=False)
|
||||||
|
else:
|
||||||
|
raise CandidateArtifactError(
|
||||||
|
"candidate tree contains a non-file member"
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise CandidateArtifactError("candidate tree cannot be sealed") from exc
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def issue_candidate_receipt(
|
||||||
|
*, root: Path | str, candidate_id: str, channel: str
|
||||||
|
) -> CandidateArtifactReceipt:
|
||||||
|
"""Hash the signed catalog at a generated candidate handle."""
|
||||||
|
|
||||||
|
candidate = _existing_candidate_path(root, candidate_id)
|
||||||
|
payload = _read_signed_catalog(candidate, channel=channel)
|
||||||
|
return CandidateArtifactReceipt(
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
catalog_sha256=canonical_hash(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_candidate_receipt(
|
||||||
|
*,
|
||||||
|
root: Path | str,
|
||||||
|
candidate_id: str,
|
||||||
|
catalog_sha256: str,
|
||||||
|
channel: str,
|
||||||
|
) -> Path:
|
||||||
|
"""Re-resolve and re-hash a persisted receipt before candidate consumption."""
|
||||||
|
|
||||||
|
if re.fullmatch(r"[0-9a-f]{64}", catalog_sha256) is None:
|
||||||
|
raise CandidateArtifactError("candidate catalog digest is malformed")
|
||||||
|
candidate = _existing_candidate_path(root, candidate_id)
|
||||||
|
payload = _read_signed_catalog(candidate, channel=channel)
|
||||||
|
if not hmac.compare_digest(canonical_hash(payload), catalog_sha256):
|
||||||
|
raise CandidateArtifactError("candidate catalog no longer matches its receipt")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _checked_candidate_id(candidate_id: str) -> str:
|
||||||
|
if not isinstance(candidate_id, str) or _CANDIDATE_ID.fullmatch(candidate_id) is None:
|
||||||
|
raise CandidateArtifactError("candidate ID is not an issued opaque basename")
|
||||||
|
return candidate_id
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_candidate_path(root: Path | str, candidate_id: str) -> Path:
|
||||||
|
candidate = candidate_output_path(root, candidate_id)
|
||||||
|
try:
|
||||||
|
mode = candidate.lstat().st_mode
|
||||||
|
except OSError as exc:
|
||||||
|
raise CandidateArtifactError("candidate directory is unavailable") from exc
|
||||||
|
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
||||||
|
raise CandidateArtifactError("candidate directory must be a real directory")
|
||||||
|
_reject_symlink_components(candidate, allow_missing=False)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _read_signed_catalog(candidate: Path, *, channel: str) -> dict[str, object]:
|
||||||
|
channel = validate_release_channel(channel)
|
||||||
|
channels = candidate / "channels"
|
||||||
|
catalog_path = channels / f"{channel}.json"
|
||||||
|
_require_real_directory(channels, label="candidate channels directory")
|
||||||
|
_require_regular_file(catalog_path, label="candidate catalog")
|
||||||
|
try:
|
||||||
|
with catalog_path.open("rb") as handle:
|
||||||
|
encoded = handle.read(MAX_CATALOG_BYTES + 1)
|
||||||
|
except OSError as exc:
|
||||||
|
raise CandidateArtifactError("candidate catalog cannot be read") from exc
|
||||||
|
if len(encoded) > MAX_CATALOG_BYTES:
|
||||||
|
raise CandidateArtifactError("candidate catalog exceeds its size limit")
|
||||||
|
try:
|
||||||
|
payload = json.loads(encoded.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise CandidateArtifactError("candidate catalog is not valid JSON") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise CandidateArtifactError("candidate catalog must be a JSON object")
|
||||||
|
if payload.get("channel") != channel:
|
||||||
|
raise CandidateArtifactError(
|
||||||
|
"candidate catalog channel does not match its requested handle"
|
||||||
|
)
|
||||||
|
signatures = payload.get("signatures")
|
||||||
|
if not isinstance(signatures, list) or not signatures:
|
||||||
|
raise CandidateArtifactError("candidate catalog has no signature envelope")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_symlink_components(path: Path, *, allow_missing: bool) -> None:
|
||||||
|
absolute = path.absolute()
|
||||||
|
parts: Iterable[Path] = reversed((absolute, *absolute.parents))
|
||||||
|
for component in parts:
|
||||||
|
try:
|
||||||
|
mode = component.lstat().st_mode
|
||||||
|
except FileNotFoundError:
|
||||||
|
if allow_missing:
|
||||||
|
continue
|
||||||
|
raise CandidateArtifactError("candidate path has a missing component")
|
||||||
|
except OSError as exc:
|
||||||
|
raise CandidateArtifactError("candidate path cannot be inspected") from exc
|
||||||
|
if stat.S_ISLNK(mode):
|
||||||
|
raise CandidateArtifactError("candidate path must not traverse symlinks")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_real_directory(path: Path, *, label: str) -> None:
|
||||||
|
try:
|
||||||
|
mode = path.lstat().st_mode
|
||||||
|
except OSError as exc:
|
||||||
|
raise CandidateArtifactError(f"{label} is unavailable") from exc
|
||||||
|
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
||||||
|
raise CandidateArtifactError(f"{label} must be a real directory")
|
||||||
|
_require_private_owner_mode(path, directory=True, label=label)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_regular_file(path: Path, *, label: str) -> None:
|
||||||
|
try:
|
||||||
|
mode = path.lstat().st_mode
|
||||||
|
except OSError as exc:
|
||||||
|
raise CandidateArtifactError(f"{label} is unavailable") from exc
|
||||||
|
if stat.S_ISLNK(mode) or not stat.S_ISREG(mode):
|
||||||
|
raise CandidateArtifactError(f"{label} must be a regular file")
|
||||||
|
_require_private_owner_mode(path, directory=False, label=label)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_current_owner(path: Path, *, label: str) -> os.stat_result:
|
||||||
|
try:
|
||||||
|
observed = path.lstat()
|
||||||
|
except OSError as exc:
|
||||||
|
raise CandidateArtifactError(f"{label} cannot be inspected") from exc
|
||||||
|
if observed.st_uid != os.geteuid():
|
||||||
|
raise CandidateArtifactError(f"{label} is not owned by the current operator")
|
||||||
|
return observed
|
||||||
|
|
||||||
|
|
||||||
|
def _require_private_owner_mode(
|
||||||
|
path: Path, *, directory: bool, label: str
|
||||||
|
) -> None:
|
||||||
|
observed = _require_current_owner(path, label=label)
|
||||||
|
expected_type = stat.S_ISDIR if directory else stat.S_ISREG
|
||||||
|
if not expected_type(observed.st_mode) or stat.S_IMODE(observed.st_mode) & 0o077:
|
||||||
|
raise CandidateArtifactError(f"{label} is accessible to another user")
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -163,18 +164,66 @@ def git_success(path: Path, *args: str, timeout: int = 10) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def git(path: Path, *args: str, timeout: int = 10) -> subprocess.CompletedProcess[str]:
|
def git(path: Path, *args: str, timeout: int = 10) -> subprocess.CompletedProcess[str]:
|
||||||
|
command = scoped_git_command(path, *args)
|
||||||
try:
|
try:
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["git", *args],
|
command,
|
||||||
cwd=path,
|
cwd=path,
|
||||||
check=False,
|
check=False,
|
||||||
text=True,
|
text=True,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
env=sanitized_git_environment(),
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
return subprocess.CompletedProcess(["git", *args], 124, exc.stdout or "", exc.stderr or "git command timed out")
|
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "git command timed out")
|
||||||
|
|
||||||
|
|
||||||
|
def scoped_git_command(path: Path, *args: str) -> tuple[str, ...]:
|
||||||
|
"""Trust exactly one resolved catalog checkout for one Git invocation."""
|
||||||
|
|
||||||
|
return (
|
||||||
|
"/usr/bin/git",
|
||||||
|
"-c",
|
||||||
|
"core.hooksPath=/dev/null",
|
||||||
|
"-c",
|
||||||
|
f"safe.directory={path.resolve()}",
|
||||||
|
*args,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitized_git_environment(
|
||||||
|
source: dict[str, str] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Keep only deliberate process/auth inputs and neutralize Git redirection."""
|
||||||
|
|
||||||
|
environment = os.environ if source is None else source
|
||||||
|
result = {
|
||||||
|
key: environment[key]
|
||||||
|
for key in (
|
||||||
|
"HOME",
|
||||||
|
"LANG",
|
||||||
|
"LC_ALL",
|
||||||
|
"LC_CTYPE",
|
||||||
|
"SSH_AUTH_SOCK",
|
||||||
|
)
|
||||||
|
if environment.get(key)
|
||||||
|
}
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"GIT_CONFIG_GLOBAL": os.devnull,
|
||||||
|
"GIT_CONFIG_NOSYSTEM": "1",
|
||||||
|
"GIT_CONFIG_COUNT": "0",
|
||||||
|
"GIT_NO_REPLACE_OBJECTS": "1",
|
||||||
|
"GIT_OPTIONAL_LOCKS": "0",
|
||||||
|
"GIT_PAGER": "cat",
|
||||||
|
"GIT_SSH_COMMAND": "/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8",
|
||||||
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
"PATH": "/usr/bin:/bin",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def git_error(result: subprocess.CompletedProcess[str]) -> str:
|
def git_error(result: subprocess.CompletedProcess[str]) -> str:
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ class ReleasePlanUnit:
|
|||||||
status: str
|
status: str
|
||||||
blockers: tuple[str, ...] = ()
|
blockers: tuple[str, ...] = ()
|
||||||
warnings: tuple[str, ...] = ()
|
warnings: tuple[str, ...] = ()
|
||||||
|
capabilities: tuple[str, ...] = ()
|
||||||
provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = ()
|
provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = ()
|
||||||
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
|
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
|
||||||
gate_findings: tuple[ReleaseGateFinding, ...] = ()
|
gate_findings: tuple[ReleaseGateFinding, ...] = ()
|
||||||
@@ -334,6 +335,7 @@ class CatalogPublishResult:
|
|||||||
target_keyring_path: str
|
target_keyring_path: str
|
||||||
branch: str | None
|
branch: str | None
|
||||||
tag_name: str | None
|
tag_name: str | None
|
||||||
|
remote: str
|
||||||
validation_valid: bool
|
validation_valid: bool
|
||||||
validation_error: str | None = None
|
validation_error: str | None = None
|
||||||
validation_warnings: tuple[str, ...] = ()
|
validation_warnings: tuple[str, ...] = ()
|
||||||
@@ -343,6 +345,9 @@ class CatalogPublishResult:
|
|||||||
target_keyring_hash_before: str | None = None
|
target_keyring_hash_before: str | None = None
|
||||||
catalog_changed: bool = False
|
catalog_changed: bool = False
|
||||||
keyring_changed: bool = False
|
keyring_changed: bool = False
|
||||||
|
publication_commit_sha: str | None = None
|
||||||
|
publication_tag_object_sha: str | None = None
|
||||||
|
publication_tag_commit_sha: str | None = None
|
||||||
steps: tuple[CatalogPublishStep, ...] = ()
|
steps: tuple[CatalogPublishStep, ...] = ()
|
||||||
notes: tuple[str, ...] = ()
|
notes: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import re
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .catalog import DEFAULT_PUBLIC_BASE_URL, canonical_hash
|
from .catalog import DEFAULT_PUBLIC_BASE_URL, canonical_hash
|
||||||
|
from .candidate_artifact import validate_release_channel
|
||||||
from .release_intelligence import compatibility_rows, module_rows, signature_rows
|
from .release_intelligence import compatibility_rows, module_rows, signature_rows
|
||||||
|
|
||||||
|
|
||||||
@@ -42,7 +43,16 @@ def module_directory_payloads(
|
|||||||
channel: str,
|
channel: str,
|
||||||
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
||||||
) -> tuple[tuple[Path, dict[str, Any]], ...]:
|
) -> tuple[tuple[Path, dict[str, Any]], ...]:
|
||||||
generated_at = json_datetime(datetime.now(tz=UTC))
|
channel = validate_release_channel(channel)
|
||||||
|
# Publication artifacts must be reproducible from the signed catalog so an
|
||||||
|
# interrupted push can later be reconciled against the immutable commit.
|
||||||
|
# Older/ad-hoc callers without a catalog timestamp retain the old fallback.
|
||||||
|
catalog_generated_at = catalog_payload.get("generated_at")
|
||||||
|
generated_at = (
|
||||||
|
catalog_generated_at
|
||||||
|
if isinstance(catalog_generated_at, str) and catalog_generated_at
|
||||||
|
else json_datetime(datetime.now(tz=UTC))
|
||||||
|
)
|
||||||
modules = module_rows(catalog_payload)
|
modules = module_rows(catalog_payload)
|
||||||
compatibility = compatibility_rows(modules)
|
compatibility = compatibility_rows(modules)
|
||||||
signatures = signature_rows(catalog_payload, keyring_payload)
|
signatures = signature_rows(catalog_payload, keyring_payload)
|
||||||
@@ -59,8 +69,8 @@ def module_directory_payloads(
|
|||||||
if not module_id:
|
if not module_id:
|
||||||
continue
|
continue
|
||||||
version = str(module.get("version") or "0.0.0")
|
version = str(module.get("version") or "0.0.0")
|
||||||
module_slug = safe_path_part(module_id)
|
module_slug = safe_module_id(module_id)
|
||||||
version_slug = safe_path_part(version)
|
version_slug = safe_version(version)
|
||||||
module_base = f"{base_url}/catalogs/v1/modules/{module_slug}"
|
module_base = f"{base_url}/catalogs/v1/modules/{module_slug}"
|
||||||
manifest_url = f"{module_base}/{version_slug}/manifest.json"
|
manifest_url = f"{module_base}/{version_slug}/manifest.json"
|
||||||
module_index_url = f"{module_base}/index.json"
|
module_index_url = f"{module_base}/index.json"
|
||||||
@@ -139,7 +149,25 @@ def module_directory_payloads(
|
|||||||
|
|
||||||
|
|
||||||
def safe_path_part(value: str) -> str:
|
def safe_path_part(value: str) -> str:
|
||||||
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value.strip()) or "_"
|
if (
|
||||||
|
not isinstance(value, str)
|
||||||
|
or value in {".", ".."}
|
||||||
|
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.+!-]{0,127}", value) is None
|
||||||
|
):
|
||||||
|
raise ValueError("module-directory path part is not canonical")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def safe_module_id(value: str) -> str:
|
||||||
|
if re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", value) is None:
|
||||||
|
raise ValueError("module ID is not canonical")
|
||||||
|
return safe_path_part(value)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_version(value: str) -> str:
|
||||||
|
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+!-]{0,127}", value) is None:
|
||||||
|
raise ValueError("module version is not canonical")
|
||||||
|
return safe_path_part(value)
|
||||||
|
|
||||||
|
|
||||||
def json_datetime(value: datetime) -> str:
|
def json_datetime(value: datetime) -> str:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user