Compare commits
56 Commits
bd715a8473
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ddee5c00dc | |||
| 4c16069f88 | |||
| 22f5c2ff4e | |||
| 19c4b63ade | |||
| 1dc9148ec3 | |||
| ead697d049 | |||
| 65aa8e0b7c | |||
| 6c0b003dee | |||
| d0dc916837 |
15
.env.example
15
.env.example
@@ -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: |
|
||||||
|
|||||||
30
.gitea/workflows/deployment-installer.yml
Normal file
30
.gitea/workflows/deployment-installer.yml
Normal file
@@ -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,23 +29,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 release dependencies
|
- name: Install backend release dependencies
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: |
|
run: |
|
||||||
@@ -43,6 +45,21 @@ jobs:
|
|||||||
- name: Install WebUI release dependencies with test scripts
|
- name: Install WebUI release dependencies with test scripts
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: bash tools/release/install-webui-release-dependencies.sh ../govoplan-core/webui
|
run: bash tools/release/install-webui-release-dependencies.sh ../govoplan-core/webui
|
||||||
|
- name: Validate 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,7 +12,7 @@ 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"
|
||||||
@@ -21,6 +20,10 @@ jobs:
|
|||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
with:
|
with:
|
||||||
path: govoplan
|
path: govoplan
|
||||||
|
- name: Use anonymous HTTPS for public GovOPlaN repositories
|
||||||
|
run: |
|
||||||
|
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan"
|
||||||
|
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan"
|
||||||
- name: Configure SSH for repository bootstrap
|
- name: Configure SSH for repository bootstrap
|
||||||
env:
|
env:
|
||||||
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
|
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
|
||||||
@@ -37,13 +40,13 @@ jobs:
|
|||||||
chmod 600 ~/.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
|
||||||
- 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
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -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,18 @@ 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).
|
||||||
|
|
||||||
## 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 +174,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,8 +186,14 @@ 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 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`.
|
`docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md`. Its rerun tooling can collect and
|
||||||
|
verify a bounded installed composition; target, provider and production claims
|
||||||
|
remain separate, expiring attestations signed by independently scoped proof
|
||||||
|
authorities.
|
||||||
|
|
||||||
# GovOPlaN Docker
|
# GovOPlaN Docker
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -360,7 +360,11 @@ windows. Only `active` and `next` entries are trusted; unknown statuses,
|
|||||||
duplicate key IDs and malformed structured keyrings fail closed, while revoked,
|
duplicate key IDs and malformed structured keyrings fail closed, while revoked,
|
||||||
disabled and retired entries are ignored. Do not establish this trust file by
|
disabled and retired entries are ignored. Do not establish this trust file by
|
||||||
downloading it in the same rerun. Public JSON responses are bounded to 16 MiB.
|
downloading it in the same rerun. Public JSON responses are bounded to 16 MiB.
|
||||||
Use `--json` or `--output PATH` for automation.
|
Use `--json` or `--output PATH` for automation. Evidence and report files are
|
||||||
|
written through a bounded same-directory atomic replacement, with mode `0600`
|
||||||
|
and file and directory `fsync`. All parent directories must already exist, no
|
||||||
|
parent component may be a symlink, and an existing symlink or non-regular output
|
||||||
|
target is rejected.
|
||||||
|
|
||||||
Exit status `0` means the assessed release metadata is current, `2` means
|
Exit status `0` means the assessed release metadata is current, `2` means
|
||||||
explicit review is required, and `1` means the schema or trust checks are
|
explicit review is required, and `1` means the schema or trust checks are
|
||||||
@@ -372,23 +376,229 @@ metadata, published-keyring integrity and optional local tag provenance; it does
|
|||||||
not prove installed artifacts, target providers, target infrastructure, or
|
not prove installed artifacts, target providers, target infrastructure, or
|
||||||
production fitness. Those remain separate proof checks above.
|
production fitness. Those remain separate proof checks above.
|
||||||
|
|
||||||
|
### Installed-composition evidence
|
||||||
|
|
||||||
|
The same rerun can inspect the Python environment in which it executes and bind
|
||||||
|
that observation to the assessment and signed catalog:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python tools/assessments/capability-fit.py \
|
||||||
|
--public \
|
||||||
|
--trusted-keyring /srv/govoplan/trust/catalog-keyring.json \
|
||||||
|
--collect-installed-evidence /var/tmp/govoplan-installed-evidence.json \
|
||||||
|
--output /var/tmp/govoplan-fit-review.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The collector follows the strict version `0.4.0`
|
||||||
|
[`installed-composition-evidence.schema.json`](installed-composition-evidence.schema.json)
|
||||||
|
contract. It enumerates all installed distributions whose normalized name starts
|
||||||
|
with `govoplan-`, compares the enabled assessed package and module-manifest
|
||||||
|
versions, and identifies missing, duplicate and extra GovOPlaN distributions.
|
||||||
|
Those differences produce stable review targets for the affected composition
|
||||||
|
entries and evidence-backed conclusions.
|
||||||
|
|
||||||
|
For each distribution, the collector also:
|
||||||
|
|
||||||
|
- reads the installed wheel `RECORD` file directly, with a 4 MiB metadata bound,
|
||||||
|
rather than relying on an interpreted distribution file list; missing files,
|
||||||
|
duplicate or malformed declarations, mismatched declared sizes and hashes all
|
||||||
|
prevent a `verified` result;
|
||||||
|
- verifies every supported SHA-256 wheel-payload declaration in that local
|
||||||
|
metadata, within fixed limits of 256 GovOPlaN distributions, 10,000 files and
|
||||||
|
512 MiB hashed per distribution, 50,000 files and 2 GiB hashed for one
|
||||||
|
collection, and 64 MiB for any single file;
|
||||||
|
- accepts package-owned data-file targets below the installation prefix and
|
||||||
|
declared `console_scripts`, while rejecting other traversal, undeclared
|
||||||
|
scripts, paths outside the prefix and leaf symlinks before opening a file;
|
||||||
|
- reports pip-generated, unhashed `__pycache__/*.pyc` entries separately only
|
||||||
|
when their derived source is a hashed payload entry, including generated
|
||||||
|
files below a confined package-owned runtime-data prefix;
|
||||||
|
- labels every PEP 610 observation with the explicit
|
||||||
|
`local-pep610-metadata` basis and distinguishes coherent declared Git commits
|
||||||
|
and archive hashes from editable installs, local directories,
|
||||||
|
package-index/unknown origins and malformed metadata;
|
||||||
|
- compares a non-editable VCS commit with the assessment commit and, when one is
|
||||||
|
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
|
||||||
|
and stable error codes. It never writes direct URLs, paths, hostnames,
|
||||||
|
usernames, exception text or file contents.
|
||||||
|
|
||||||
|
`RECORD` `verified` means complete matching coverage of the wheel payload
|
||||||
|
declared by the local metadata, not every runtime byte and not a release-origin
|
||||||
|
binding. The proof scope exposes the aggregate count of generated unhashed
|
||||||
|
bytecode; runtime activation remains unchecked. `RECORD` agreement proves that
|
||||||
|
payload files agree with installed metadata, but does not make self-consistent
|
||||||
|
metadata a trusted release origin. Similarly, a version string is not artifact
|
||||||
|
integrity. PEP 610 VCS metadata is accepted only for
|
||||||
|
`vcs: git`, a full 40–64 hexadecimal commit, one mutually exclusive provenance
|
||||||
|
form and a coherent bounded URL shape. Editable and directory-backed installs
|
||||||
|
remain mutable even when their small editable-install `RECORD` is valid. Archive
|
||||||
|
or index artifacts without a hash anchored by the assessed release remain
|
||||||
|
unanchored. A matching declaration is reported under
|
||||||
|
`installed_source_provenance` as local consistency only, with
|
||||||
|
`release_origin_bound: false` until the separate origin proof succeeds. A signed
|
||||||
|
catalog can contain `release.artifacts` identities computed directly from built,
|
||||||
|
bounded wheel files. Wheels with installer-transformed scripts or headers are
|
||||||
|
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
|
||||||
|
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
|
||||||
|
`--installed-evidence PATH` to compare evidence collected in a separate
|
||||||
|
environment. Imported JSON without a valid independent installer receipt
|
||||||
|
carries a blocker: it can identify differences but cannot establish checked or
|
||||||
|
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,
|
||||||
|
receipt authentication, freshness, and whether evaluation used current time or
|
||||||
|
an explicit historical override. Evidence input and output are bounded to 4
|
||||||
|
MiB. Collection loads the `govoplan.modules` entry-point factories in order to
|
||||||
|
read module IDs and manifest versions; that executes installed GovOPlaN manifest
|
||||||
|
code. Run it only inside the installation being assessed and with the same
|
||||||
|
isolation expected for other installed-artifact acceptance checks. This
|
||||||
|
collector does not observe
|
||||||
|
which modules a running service activated, migrations, configuration, health,
|
||||||
|
or reference-journey behavior. Runtime activation therefore remains an explicit
|
||||||
|
unchecked boundary.
|
||||||
|
|
||||||
|
### Target, provider and production proof boundary
|
||||||
|
|
||||||
|
Installed evidence cannot approve a target environment, an external provider,
|
||||||
|
or production use. These scopes use a separate, expiring
|
||||||
|
[`capability-fit-boundary-evidence.schema.json`](capability-fit-boundary-evidence.schema.json)
|
||||||
|
bundle. The bundle is bound to the assessment ID, assessment release and exact
|
||||||
|
installed-evidence SHA-256 digest. It contains only opaque subject/control/result
|
||||||
|
IDs and content hashes, not endpoints, credentials, people or raw result files.
|
||||||
|
|
||||||
|
Boundary evidence is accepted only when at least one Ed25519 signature validates
|
||||||
|
against a separately provisioned
|
||||||
|
[`capability-fit-proof-authority-keyring.schema.json`](capability-fit-proof-authority-keyring.schema.json).
|
||||||
|
Each authority key explicitly lists the scopes it may attest. Target and provider
|
||||||
|
claims use `passed` or `failed`; production claims use `approved` or `rejected`.
|
||||||
|
One claim per scope, unique control/artifact IDs, `issued_at < expires_at`, current
|
||||||
|
validity and exact digest binding are mandatory. Any schema, binding, time,
|
||||||
|
signature or authority blocker leaves every supplied boundary claim unchecked;
|
||||||
|
one malformed authority member or invalid validity interval invalidates the
|
||||||
|
supplied authority set as a whole. An authorized negative result is checked but
|
||||||
|
requires review only after every prerequisite, including installed
|
||||||
|
release-origin binding, is established.
|
||||||
|
Signatures cover UTF-8 JSON with the `signatures` member omitted, object keys
|
||||||
|
sorted, compact `,`/`:` separators and non-ASCII characters escaped, matching
|
||||||
|
the tool's deterministic canonicalization.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python tools/assessments/capability-fit.py \
|
||||||
|
--public \
|
||||||
|
--trusted-keyring /srv/govoplan/trust/catalog-keyring.json \
|
||||||
|
--installed-evidence /srv/govoplan/evidence/installed.json \
|
||||||
|
--boundary-evidence /srv/govoplan/evidence/target-proof.json \
|
||||||
|
--boundary-authority-keyring /srv/govoplan/trust/proof-authorities.json \
|
||||||
|
--expected-external-provider-subject provider-production
|
||||||
|
```
|
||||||
|
|
||||||
|
With `--installed-evidence`, this command performs comparison and proof-binding
|
||||||
|
diagnostics. Without an installer receipt, the imported document remains
|
||||||
|
unsigned, so neither it nor the boundary claim becomes accepted proof. Direct
|
||||||
|
in-process collection can match catalog-anchored artifact identities for wheels
|
||||||
|
without installer-transformed files. The installer-receipt flow below
|
||||||
|
authenticates a durable cross-process observation and is required for transformed
|
||||||
|
script/header payloads.
|
||||||
|
|
||||||
|
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
|
||||||
|
`deployment_profile.id` as `subject_id`. External-provider claims require the
|
||||||
|
operator to supply a bounded opaque expected subject with
|
||||||
|
`--expected-external-provider-subject`; without it, such a claim remains
|
||||||
|
unchecked and blocks. Expected and observed IDs are retained in proof scope.
|
||||||
|
|
||||||
|
Both authority keyrings are governance trust roots. Installer receipt keys use
|
||||||
|
the strict
|
||||||
|
[`installer-receipt-authority-keyring.schema.json`](installer-receipt-authority-keyring.schema.json)
|
||||||
|
contract and may attest only `installed_release_origin`; their public material
|
||||||
|
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
|
||||||
|
exclusive. A target operator or approver must validate the referenced
|
||||||
|
drill/result artifacts before signing. The rerun verifies the
|
||||||
|
attestation and its bindings; it does not fetch or reinterpret those artifacts.
|
||||||
|
`--verification-time` exists only for reproducible historical review. Supplying
|
||||||
|
it always marks the machine and human report as a **HISTORICAL override**; callers
|
||||||
|
cannot relabel it as current. Live admission must omit it and use the actual
|
||||||
|
current time.
|
||||||
|
|
||||||
|
No boundary bundle or production authority has been supplied for this current
|
||||||
|
assessment. Target environment, provider and production proof therefore remain
|
||||||
|
explicitly unchecked rather than inferred from the local GreenMail journey,
|
||||||
|
source tests or signed release metadata.
|
||||||
|
|
||||||
## Evidence used in this slice
|
## Evidence used in this slice
|
||||||
|
|
||||||
- [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
|
||||||
- Annotated source tags `govoplan-core/v0.1.13` and
|
- Annotated source tags `govoplan-core/v0.1.13` and
|
||||||
`govoplan-campaign/v0.1.10`, including their catalogued Python and WebUI refs
|
`govoplan-campaign/v0.1.10`, including their catalogued Python and WebUI refs
|
||||||
|
- Installed-composition, boundary-proof and independently scoped proof-authority
|
||||||
|
schemas plus their deterministic review tests; no current target or production
|
||||||
|
proof bundle is asserted
|
||||||
|
|
||||||
Checks retained from the first assessment slice against its then-current
|
Checks retained from the first assessment slice against its then-current
|
||||||
workspace:
|
workspace:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
82
docs/DATASOURCE_AND_DEFINITION_GRAPH_ARCHITECTURE.md
Normal file
82
docs/DATASOURCE_AND_DEFINITION_GRAPH_ARCHITECTURE.md
Normal file
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
267
docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md
Normal file
267
docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# 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 or external S3-compatible storage;
|
||||||
|
- 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 \
|
||||||
|
--mail test-mail \
|
||||||
|
--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 |
|
||||||
|
| `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.** A self-hosted profile 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. `s3` requires endpoint, region, access key, secret key, and
|
||||||
|
bucket values. Self-hosted 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- 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, S3 policy, 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.
|
||||||
|
|||||||
126
docs/PLATFORM_CONTROL_PLANE.md
Normal file
126
docs/PLATFORM_CONTROL_PLANE.md
Normal file
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -18,10 +19,13 @@ candidate/publish actions:
|
|||||||
- configure target versions per release unit in the web UI
|
- configure target versions per release unit in the web UI
|
||||||
- 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
|
||||||
- create annotated source release tags for selected clean, version-aligned
|
- freeze a selective plan as a durable, resumable local release run
|
||||||
repositories and publish each branch/tag pair atomically
|
- durably preflight a creation-time-bound repository, create its annotated tag,
|
||||||
- generate signed catalog candidates for the selected release units
|
and publish its branch/tag pair atomically
|
||||||
- preview/apply/push reviewed catalog candidates behind explicit confirmations
|
- 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
|
||||||
|
|
||||||
Start it from the meta repository:
|
Start it from the meta repository:
|
||||||
|
|
||||||
@@ -29,8 +33,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
|
||||||
@@ -49,6 +77,171 @@ tag. `source_preflight_ready` means that the plan-visible source gates pass; the
|
|||||||
non-mutating `Preview Tag + Publish` remains mandatory for remote, manifest, and
|
non-mutating `Preview Tag + Publish` remains mandatory for remote, manifest, and
|
||||||
immutable-tag checks.
|
immutable-tag checks.
|
||||||
|
|
||||||
|
## Durable release runs
|
||||||
|
|
||||||
|
The **Durable Run State** card turns the current repository/version selection
|
||||||
|
into a versioned local run record. The server rebuilds the selective plan and
|
||||||
|
requires the plan to resolve exactly the requested repositories and target
|
||||||
|
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.
|
||||||
|
Every executable repository step also carries its creation-time full HEAD,
|
||||||
|
branch, worktree state, target tag, and a SHA-256 over both the fetch and push
|
||||||
|
URLs of `origin`. Both URLs must exactly equal the remote registered in
|
||||||
|
`repositories.json`; a changed HEAD, branch, worktree, remote, or push URL
|
||||||
|
requires a new run rather than silently retargeting the frozen compatibility
|
||||||
|
decision.
|
||||||
|
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;
|
||||||
|
these digests detect accidental or manual corruption, not an attacker who can
|
||||||
|
replace the private record and recompute its checksums. Changing a target,
|
||||||
|
channel, or gate input requires a new run.
|
||||||
|
|
||||||
|
Creation requires a caller-generated `request_id`. Its SHA-256 fingerprint is
|
||||||
|
the private, workspace-scoped durable mapping to exactly one run; the raw
|
||||||
|
identifier is never persisted. Repeating the same identifier with the same
|
||||||
|
immutable inputs returns that run without rebuilding the plan while it remains
|
||||||
|
inside the bounded local retention window, even if the live dashboard has
|
||||||
|
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
|
||||||
|
successful create remains shown as saved if only the subsequent list refresh
|
||||||
|
fails.
|
||||||
|
|
||||||
|
Run records survive console restarts, but remain local operator state rather
|
||||||
|
than a signed release artifact or the system audit log. The default location is
|
||||||
|
`$XDG_STATE_HOME/govoplan/release-console/workspace-<sha256>/release-runs/`, or
|
||||||
|
`~/.local/state/...` when `XDG_STATE_HOME` is unset or relative. It is outside
|
||||||
|
the source checkout so filesystems without enforceable POSIX modes cannot
|
||||||
|
silently weaken the journal. Newly created state directories use mode `0700`
|
||||||
|
and records use `0600`. Writes use a cross-process lock, a same-directory
|
||||||
|
temporary file, `fsync`, atomic replacement, and directory/parent `fsync`.
|
||||||
|
Symbolic-link paths, untrusted owners or writable ancestry, overly broad record
|
||||||
|
modes, malformed schemas, unknown fields, invalid state combinations,
|
||||||
|
oversized files, and digest mismatches fail closed. A bad record is neither
|
||||||
|
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
|
||||||
|
namespace and immutable input snapshot. Every list, read, and state transition
|
||||||
|
checks it. An alternate workspace therefore cannot list or resume another
|
||||||
|
workspace's runs. This remains true for an embedding/test `run_state_root`
|
||||||
|
override: the server always appends
|
||||||
|
`workspace-<full-sha256>/release-runs/` rather than treating the override as a
|
||||||
|
shared record directory. Durable candidates use its private sibling
|
||||||
|
`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`,
|
||||||
|
`failed`, or `interrupted` state. Plan order remains a prerequisite: a later
|
||||||
|
step is unavailable until earlier steps have succeeded. Exact attempt and
|
||||||
|
resume/retry/reconciliation request identifiers are fingerprinted so delayed
|
||||||
|
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
|
||||||
|
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
|
||||||
|
enum event type, timestamp, step identifier, and bounded result code—never
|
||||||
|
commands, process output, confirmation text, credentials, bearer tokens, or
|
||||||
|
signing material.
|
||||||
|
|
||||||
|
Before a step can enter `running`, the store reserves the two command-ledger
|
||||||
|
slots needed for worst-case recovery. It also projects the serialized record
|
||||||
|
through start, finish/failure, resume, and the required terminal reconciliation
|
||||||
|
or read-only retry; the start is rejected unless every required atomic write
|
||||||
|
fits the record-size bound. An explicit resume consumes one slot and is
|
||||||
|
accepted only while a persisted attempt is actually running. A mutating attempt
|
||||||
|
always retains its final `effect_absent` or `effect_succeeded` slot;
|
||||||
|
`unresolved` may be recorded at most once for that attempt and only when an
|
||||||
|
additional ledger slot and serialized terminal-write capacity are available.
|
||||||
|
Read-only interruption and known failure retain the slot and byte capacity
|
||||||
|
needed to prepare a retry. Capacity exhaustion is therefore detected before
|
||||||
|
starting an effect rather than stranding an ambiguous attempt.
|
||||||
|
|
||||||
|
An explicit resume after a process restart converts every persisted `running`
|
||||||
|
step to `interrupted`; it never guesses whether an external effect happened.
|
||||||
|
An interrupted read-only step can be prepared for retry. An interrupted
|
||||||
|
mutating step remains unavailable until the operator independently reconciles
|
||||||
|
local and remote state, selects `effect_absent`, `effect_succeeded`, or
|
||||||
|
`unresolved`, and types `RECONCILE`. `effect_absent` prepares a safe new
|
||||||
|
attempt, `effect_succeeded` advances the run without repeating the external
|
||||||
|
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
|
||||||
|
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, tag, and push steps persist a
|
||||||
|
bounded repository-state receipt. 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.
|
||||||
|
|
||||||
|
The safe baseline is intentionally narrower than the complete dry-run plan.
|
||||||
|
Dirty worktrees and version changes still show commit/version steps, but those
|
||||||
|
steps have no durable executor because the frozen run does not bind the exact
|
||||||
|
proposed file content. A run selecting Core together with one or more modules
|
||||||
|
begins with a disabled dependency-ordering barrier: local module tags, Core
|
||||||
|
release-lock regeneration/commit, Core tagging, alignment verification, and
|
||||||
|
pushes need an explicit DAG executor before that composition can mutate.
|
||||||
|
Operators must prepare and review those changes outside the console and create
|
||||||
|
a new run from the resulting clean HEAD. The console never skips these steps or
|
||||||
|
claims an end-to-end release succeeded.
|
||||||
|
|
||||||
|
The browser likewise retains the request identifier for an uncertain
|
||||||
|
resume/retry/reconciliation response and replays it after reload. A successful
|
||||||
|
replay selects the returned run state. Transport and server failures retain the
|
||||||
|
identifier; a deterministic `4xx` rejection clears it so a stale command cannot
|
||||||
|
poison a later attempt.
|
||||||
|
|
||||||
|
The run API is covered by the same local console token middleware as every
|
||||||
|
other `/api/` route:
|
||||||
|
|
||||||
|
- `POST /api/release-runs` requires `request_id`, then idempotently rebuilds and
|
||||||
|
freezes a selective plan only when that creation is not already known.
|
||||||
|
- `GET /api/release-runs` lists bounded summaries ordered by immutable
|
||||||
|
`created_at` and run identifier. `next_cursor` advances a stable descending
|
||||||
|
traversal even while older runs are updated, without offset duplicates or
|
||||||
|
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.
|
||||||
|
- `POST /api/release-runs/{run_id}/resume` records explicit recovery.
|
||||||
|
- `POST /api/release-runs/{run_id}/steps/{step_id}/retry` prepares a failed or
|
||||||
|
read-only interrupted step for another attempt.
|
||||||
|
- `POST /api/release-runs/{run_id}/steps/{step_id}/reconcile` records a
|
||||||
|
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
|
||||||
|
release-preview collection continue and show the bounded storage remediation.
|
||||||
|
|
||||||
|
The run record is execution evidence only for a supported step whose durable
|
||||||
|
claim and bounded result receipt were persisted. The console never infers
|
||||||
|
success from a button click or process exit alone. An executor exception or a
|
||||||
|
lost result write leaves the attempt interrupted and non-retriable until
|
||||||
|
explicit recovery. Catalog publication persists the candidate and keyring
|
||||||
|
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
|
||||||
with a remediation, marks the dashboard blocked, and becomes a structured
|
with a remediation, marks the dashboard blocked, and becomes a structured
|
||||||
@@ -74,11 +267,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
|
||||||
@@ -86,13 +280,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
|
||||||
@@ -117,6 +315,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
|
||||||
@@ -124,7 +369,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:
|
||||||
|
|
||||||
@@ -145,18 +393,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:
|
||||||
|
|
||||||
@@ -171,8 +429,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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -180,7 +440,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 \
|
||||||
@@ -191,7 +451,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 \
|
||||||
@@ -199,6 +459,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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
137
docs/VIEWS_ARCHITECTURE.md
Normal file
137
docs/VIEWS_ARCHITECTURE.md
Normal file
@@ -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
|
||||||
148
docs/capability-fit-boundary-evidence.schema.json
Normal file
148
docs/capability-fit-boundary-evidence.schema.json
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$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",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"evidence_kind",
|
||||||
|
"proof_id",
|
||||||
|
"assessment_id",
|
||||||
|
"assessment_release",
|
||||||
|
"installed_evidence_sha256",
|
||||||
|
"issued_at",
|
||||||
|
"expires_at",
|
||||||
|
"claims",
|
||||||
|
"signatures"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"$schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri-reference"
|
||||||
|
},
|
||||||
|
"schema_version": {
|
||||||
|
"const": "0.1.0"
|
||||||
|
},
|
||||||
|
"evidence_kind": {
|
||||||
|
"const": "govoplan.capability-fit-boundary-proof"
|
||||||
|
},
|
||||||
|
"proof_id": {
|
||||||
|
"$ref": "#/$defs/opaque_id"
|
||||||
|
},
|
||||||
|
"assessment_id": {
|
||||||
|
"$ref": "#/$defs/opaque_id"
|
||||||
|
},
|
||||||
|
"assessment_release": {
|
||||||
|
"$ref": "#/$defs/opaque_id"
|
||||||
|
},
|
||||||
|
"installed_evidence_sha256": {
|
||||||
|
"$ref": "#/$defs/sha256"
|
||||||
|
},
|
||||||
|
"issued_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"claims": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 3,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/claim"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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._:-]*$"
|
||||||
|
},
|
||||||
|
"claim": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["scope", "result", "subject_id", "control_ids", "artifacts"],
|
||||||
|
"properties": {
|
||||||
|
"scope": {
|
||||||
|
"enum": [
|
||||||
|
"target_environment",
|
||||||
|
"external_providers",
|
||||||
|
"production_approval"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"enum": ["passed", "failed", "approved", "rejected"]
|
||||||
|
},
|
||||||
|
"subject_id": {
|
||||||
|
"$ref": "#/$defs/opaque_id"
|
||||||
|
},
|
||||||
|
"control_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 256,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/opaque_id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"artifacts": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 256,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/evidence_artifact"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"evidence_artifact": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["artifact_id", "sha256"],
|
||||||
|
"properties": {
|
||||||
|
"artifact_id": {
|
||||||
|
"$ref": "#/$defs/opaque_id"
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"$ref": "#/$defs/sha256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": [],
|
||||||
@@ -967,10 +967,12 @@
|
|||||||
],
|
],
|
||||||
"proof_checks": [
|
"proof_checks": [
|
||||||
"Materialize the signed catalog into an isolated installation and rerun contract, migration and module-permutation gates against the installed artifacts.",
|
"Materialize the signed catalog into an isolated installation and rerun contract, migration and module-permutation gates against the installed artifacts.",
|
||||||
|
"Collect the isolated installation with the bounded installed-composition evidence contract; require exact enabled package/module versions, complete RECORD verification and immutable provenance anchored to this assessment.",
|
||||||
"Run a safe target-like Campaign through SMTP acceptance, IMAP append, reporting and audit.",
|
"Run a safe target-like Campaign through SMTP acceptance, IMAP append, reporting and audit.",
|
||||||
"Drill worker, Redis and ambiguous-delivery failures without duplicate sends.",
|
"Drill worker, Redis and ambiguous-delivery failures without duplicate sends.",
|
||||||
"Restore PostgreSQL, managed files, configuration and encrypted credentials and measure RPO/RTO.",
|
"Restore PostgreSQL, managed files, configuration and encrypted credentials and measure RPO/RTO.",
|
||||||
"Validate proxy/TLS, cookies/CORS, account bootstrap, secret redaction, monitoring and alert delivery.",
|
"Validate proxy/TLS, cookies/CORS, account bootstrap, secret redaction, monitoring and alert delivery.",
|
||||||
"Measure representative Campaign/file/queue/database load and external throttling."
|
"Measure representative Campaign/file/queue/database load and external throttling.",
|
||||||
|
"Require separately issued, expiring and independently scope-authorized evidence before marking target environment, external provider or production approval proof as checked."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
75
docs/capability-fit-proof-authority-keyring.schema.json
Normal file
75
docs/capability-fit-proof-authority-keyring.schema.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$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",
|
||||||
|
"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.capability-fit-proof-authorities"
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"type": "array",
|
||||||
|
"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": 3,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"enum": [
|
||||||
|
"target_environment",
|
||||||
|
"external_providers",
|
||||||
|
"production_approval"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"not_before": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"not_after": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
269
docs/installation-spec.schema.json
Normal file
269
docs/installation-spec.schema.json
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
{
|
||||||
|
"$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",
|
||||||
|
"s3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
335
docs/installed-composition-evidence.schema.json
Normal file
335
docs/installed-composition-evidence.schema.json
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/installed-composition-evidence.schema.json",
|
||||||
|
"title": "GovOPlaN installed composition evidence",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"evidence_kind",
|
||||||
|
"assessment_id",
|
||||||
|
"assessment_release",
|
||||||
|
"collected_at",
|
||||||
|
"scope",
|
||||||
|
"artifacts",
|
||||||
|
"collection_issues"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"$schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri-reference"
|
||||||
|
},
|
||||||
|
"schema_version": {
|
||||||
|
"const": "0.4.0"
|
||||||
|
},
|
||||||
|
"evidence_kind": {
|
||||||
|
"const": "govoplan.installed-composition"
|
||||||
|
},
|
||||||
|
"assessment_id": {
|
||||||
|
"$ref": "#/$defs/opaque_id"
|
||||||
|
},
|
||||||
|
"assessment_release": {
|
||||||
|
"$ref": "#/$defs/opaque_id"
|
||||||
|
},
|
||||||
|
"collected_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"const": "current-python-environment.govoplan-distributions"
|
||||||
|
},
|
||||||
|
"artifacts": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 256,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/artifact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection_issues": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 256,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/collection_issue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$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",
|
||||||
|
"modules",
|
||||||
|
"source_provenance",
|
||||||
|
"record_integrity"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"package_name": {
|
||||||
|
"$ref": "#/$defs/package_name"
|
||||||
|
},
|
||||||
|
"package_version": {
|
||||||
|
"$ref": "#/$defs/version"
|
||||||
|
},
|
||||||
|
"modules": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 16,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/module"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"source_provenance": {
|
||||||
|
"$ref": "#/$defs/source_provenance"
|
||||||
|
},
|
||||||
|
"record_integrity": {
|
||||||
|
"$ref": "#/$defs/record_integrity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"module": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["module_id", "manifest_version"],
|
||||||
|
"properties": {
|
||||||
|
"module_id": {
|
||||||
|
"$ref": "#/$defs/opaque_id"
|
||||||
|
},
|
||||||
|
"manifest_version": {
|
||||||
|
"$ref": "#/$defs/version"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"source_provenance": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["basis", "kind", "commit"],
|
||||||
|
"properties": {
|
||||||
|
"basis": {
|
||||||
|
"const": "local-pep610-metadata"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"const": "vcs-commit"
|
||||||
|
},
|
||||||
|
"commit": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9a-f]{40,64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["basis", "kind", "sha256"],
|
||||||
|
"properties": {
|
||||||
|
"basis": {
|
||||||
|
"const": "local-pep610-metadata"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"const": "archive"
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"$ref": "#/$defs/sha256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["basis", "kind"],
|
||||||
|
"properties": {
|
||||||
|
"basis": {
|
||||||
|
"const": "local-pep610-metadata"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"enum": [
|
||||||
|
"editable-local",
|
||||||
|
"local-directory",
|
||||||
|
"index-or-unknown",
|
||||||
|
"malformed-direct-url"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"record_integrity": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"hashed_file_count",
|
||||||
|
"permitted_unhashed_file_count",
|
||||||
|
"generated_unhashed_file_count",
|
||||||
|
"unverifiable_file_count",
|
||||||
|
"missing_file_count",
|
||||||
|
"mismatched_file_count",
|
||||||
|
"artifact_payload_identity",
|
||||||
|
"installed_payload_identity"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"verified",
|
||||||
|
"partial",
|
||||||
|
"mismatch",
|
||||||
|
"unavailable",
|
||||||
|
"limit-exceeded"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hashed_file_count": {
|
||||||
|
"$ref": "#/$defs/count"
|
||||||
|
},
|
||||||
|
"permitted_unhashed_file_count": {
|
||||||
|
"$ref": "#/$defs/count"
|
||||||
|
},
|
||||||
|
"generated_unhashed_file_count": {
|
||||||
|
"$ref": "#/$defs/count"
|
||||||
|
},
|
||||||
|
"unverifiable_file_count": {
|
||||||
|
"$ref": "#/$defs/count"
|
||||||
|
},
|
||||||
|
"missing_file_count": {
|
||||||
|
"$ref": "#/$defs/count"
|
||||||
|
},
|
||||||
|
"mismatched_file_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": [
|
||||||
|
{
|
||||||
|
"if": { "properties": { "status": { "const": "verified" } } },
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"hashed_file_count": { "minimum": 1 },
|
||||||
|
"permitted_unhashed_file_count": { "minimum": 1 },
|
||||||
|
"unverifiable_file_count": { "const": 0 },
|
||||||
|
"missing_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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": { "properties": { "status": { "const": "partial" } } },
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"hashed_file_count": { "minimum": 1 },
|
||||||
|
"unverifiable_file_count": { "minimum": 1 },
|
||||||
|
"missing_file_count": { "const": 0 },
|
||||||
|
"mismatched_file_count": { "const": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": { "properties": { "status": { "const": "mismatch" } } },
|
||||||
|
"then": {
|
||||||
|
"anyOf": [
|
||||||
|
{ "properties": { "missing_file_count": { "minimum": 1 } } },
|
||||||
|
{ "properties": { "mismatched_file_count": { "minimum": 1 } } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": { "properties": { "status": { "const": "unavailable" } } },
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"hashed_file_count": { "const": 0 },
|
||||||
|
"missing_file_count": { "const": 0 },
|
||||||
|
"mismatched_file_count": { "const": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"collection_issue": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["code", "package_name"],
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"enum": [
|
||||||
|
"distribution-metadata-invalid",
|
||||||
|
"duplicate-distribution",
|
||||||
|
"module-entry-point-load-failed",
|
||||||
|
"module-entry-point-invalid",
|
||||||
|
"module-entry-point-limit-exceeded",
|
||||||
|
"collection-limit-exceeded"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"package_name": {
|
||||||
|
"$ref": "#/$defs/package_name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1000000
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9a-f]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
docs/installer-receipt-authority-keyring.schema.json
Normal file
53
docs/installer-receipt-authority-keyring.schema.json
Normal file
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
docs/installer-receipt.schema.json
Normal file
122
docs/installer-receipt.schema.json
Normal file
@@ -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}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
5
requirements-release-tests.txt
Normal file
5
requirements-release-tests.txt
Normal file
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
190
tests/test_capability_fit_atomic_io.py
Normal file
190
tests/test_capability_fit_atomic_io.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ASSESSMENT_TOOLS_ROOT = META_ROOT / "tools" / "assessments"
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
for tools_root in (ASSESSMENT_TOOLS_ROOT, RELEASE_TOOLS_ROOT):
|
||||||
|
if str(tools_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(tools_root))
|
||||||
|
|
||||||
|
from govoplan_assessment.atomic_io import ( # noqa: E402
|
||||||
|
AtomicJsonWriteError,
|
||||||
|
atomic_write_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityFitAtomicIOTests(unittest.TestCase):
|
||||||
|
def test_writes_private_json_with_expected_content(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
target = Path(temporary_directory) / "evidence.json"
|
||||||
|
payload = {"z": [1, 2], "message": "Grüße"}
|
||||||
|
created_in: list[Path] = []
|
||||||
|
original_mkstemp = tempfile.mkstemp
|
||||||
|
|
||||||
|
def observed_mkstemp(*args, **kwargs):
|
||||||
|
created_in.append(Path(kwargs["dir"]))
|
||||||
|
return original_mkstemp(*args, **kwargs)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"govoplan_assessment.atomic_io.tempfile.mkstemp",
|
||||||
|
side_effect=observed_mkstemp,
|
||||||
|
),
|
||||||
|
mock.patch(
|
||||||
|
"govoplan_assessment.atomic_io.os.fsync",
|
||||||
|
wraps=os.fsync,
|
||||||
|
) as fsync,
|
||||||
|
):
|
||||||
|
atomic_write_json(target, payload, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertEqual(payload, json.loads(target.read_text(encoding="utf-8")))
|
||||||
|
self.assertTrue(target.read_bytes().endswith(b"\n"))
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(target.stat().st_mode))
|
||||||
|
self.assertEqual([target.parent], created_in)
|
||||||
|
self.assertEqual(2, fsync.call_count)
|
||||||
|
|
||||||
|
def test_replaces_existing_regular_file_and_secures_mode(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
target = Path(temporary_directory) / "report.json"
|
||||||
|
target.write_text('{"old": true}\n', encoding="utf-8")
|
||||||
|
target.chmod(0o644)
|
||||||
|
old_handle = target.open("rb")
|
||||||
|
self.addCleanup(old_handle.close)
|
||||||
|
|
||||||
|
atomic_write_json(target, {"new": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertEqual(b'{"old": true}\n', old_handle.read())
|
||||||
|
self.assertEqual({"new": True}, json.loads(target.read_text("utf-8")))
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(target.stat().st_mode))
|
||||||
|
|
||||||
|
def test_size_bound_leaves_existing_target_unchanged(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
target = directory / "evidence.json"
|
||||||
|
original = b'{"original": true}\n'
|
||||||
|
target.write_bytes(original)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(AtomicJsonWriteError, "size limit"):
|
||||||
|
atomic_write_json(target, {"large": "x" * 100}, max_bytes=32)
|
||||||
|
|
||||||
|
self.assertEqual(original, target.read_bytes())
|
||||||
|
self.assertEqual([], list(directory.glob(".govoplan-json-*.tmp")))
|
||||||
|
|
||||||
|
@unittest.skipUnless(hasattr(os, "symlink"), "symbolic links are unavailable")
|
||||||
|
def test_rejects_symlink_without_modifying_link_or_referent(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
referent = directory / "outside.json"
|
||||||
|
referent.write_bytes(b'{"keep": true}\n')
|
||||||
|
target = directory / "evidence.json"
|
||||||
|
target.symlink_to(referent.name)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(AtomicJsonWriteError, "symbolic link"):
|
||||||
|
atomic_write_json(target, {"replace": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertTrue(target.is_symlink())
|
||||||
|
self.assertEqual(b'{"keep": true}\n', referent.read_bytes())
|
||||||
|
self.assertEqual([], list(directory.glob(".govoplan-json-*.tmp")))
|
||||||
|
|
||||||
|
@unittest.skipUnless(hasattr(os, "symlink"), "symbolic links are unavailable")
|
||||||
|
def test_rejects_symlinked_parent_component(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
real_parent = directory / "real-parent"
|
||||||
|
real_parent.mkdir()
|
||||||
|
linked_parent = directory / "linked-parent"
|
||||||
|
linked_parent.symlink_to(real_parent, target_is_directory=True)
|
||||||
|
target = linked_parent / "new" / "evidence.json"
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(AtomicJsonWriteError, "parent path"):
|
||||||
|
atomic_write_json(target, {"unsafe": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertFalse((real_parent / "new").exists())
|
||||||
|
|
||||||
|
def test_requires_existing_parent_without_creating_directories(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
missing_parent = directory / "missing" / "nested"
|
||||||
|
target = missing_parent / "evidence.json"
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(AtomicJsonWriteError, "already exist"):
|
||||||
|
atomic_write_json(target, {"safe": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertFalse(missing_parent.exists())
|
||||||
|
|
||||||
|
def test_rejects_post_replace_mode_or_identity_change(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
target = directory / "evidence.json"
|
||||||
|
original_replace = os.replace
|
||||||
|
|
||||||
|
def insecure_replace(source, destination):
|
||||||
|
original_replace(source, destination)
|
||||||
|
Path(destination).chmod(0o644)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"govoplan_assessment.atomic_io.os.replace",
|
||||||
|
side_effect=insecure_replace,
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
AtomicJsonWriteError,
|
||||||
|
"permissions changed and were restored",
|
||||||
|
):
|
||||||
|
atomic_write_json(target, {"safe": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(target.stat().st_mode))
|
||||||
|
self.assertEqual({"safe": True}, json.loads(target.read_text("utf-8")))
|
||||||
|
|
||||||
|
def test_does_not_unlink_unknown_post_replace_inode(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
target = directory / "evidence.json"
|
||||||
|
original_replace = os.replace
|
||||||
|
|
||||||
|
def replaced_again(source, destination):
|
||||||
|
original_replace(source, destination)
|
||||||
|
Path(destination).unlink()
|
||||||
|
Path(destination).write_bytes(b"unknown replacement\n")
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"govoplan_assessment.atomic_io.os.replace",
|
||||||
|
side_effect=replaced_again,
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
AtomicJsonWriteError,
|
||||||
|
"did not preserve",
|
||||||
|
):
|
||||||
|
atomic_write_json(target, {"secret": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertEqual(b"unknown replacement\n", target.read_bytes())
|
||||||
|
|
||||||
|
def test_replace_failure_removes_private_temporary_file(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
target = directory / "evidence.json"
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"govoplan_assessment.atomic_io.os.replace",
|
||||||
|
side_effect=OSError("simulated replacement failure"),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
AtomicJsonWriteError, "could not be written atomically"
|
||||||
|
):
|
||||||
|
atomic_write_json(target, {"safe": True}, max_bytes=1024)
|
||||||
|
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertEqual([], list(directory.glob(".govoplan-json-*.tmp")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
1795
tests/test_capability_fit_evidence.py
Normal file
1795
tests/test_capability_fit_evidence.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -360,7 +360,8 @@ class CapabilityFitReviewTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("Capability fit rerun: current", rendered)
|
self.assertIn("Capability fit rerun: current", rendered)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"No installed-artifact, target-provider, or production proof", rendered
|
"No installed-composition, installed-release-origin, target-environment, external-provider, production-approval proof",
|
||||||
|
rendered,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_public_fetch_failure_is_generic_and_blocking(self) -> None:
|
def test_public_fetch_failure_is_generic_and_blocking(self) -> None:
|
||||||
@@ -605,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()
|
||||||
@@ -660,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",
|
||||||
|
|||||||
644
tests/test_deployment_installer.py
Normal file
644
tests/test_deployment_installer.py
Normal file
@@ -0,0 +1,644 @@
|
|||||||
|
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,
|
||||||
|
write_env,
|
||||||
|
)
|
||||||
|
from govoplan_deploy.cli import 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.assertNotIn("test-mail", compose["services"])
|
||||||
|
self.assertEqual(
|
||||||
|
["127.0.0.1:8080:8080"],
|
||||||
|
compose["services"]["web"]["ports"],
|
||||||
|
)
|
||||||
|
|
||||||
|
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_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)
|
||||||
|
)
|
||||||
|
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_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)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
49
tests/test_gitea_sync_wiki.py
Normal file
49
tests/test_gitea_sync_wiki.py
Normal file
@@ -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()
|
||||||
66
tests/test_platform_interface_inventory.py
Normal file
66
tests/test_platform_interface_inventory.py
Normal file
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|||||||
277
tests/test_release_artifact_identity.py
Normal file
277
tests/test_release_artifact_identity.py
Normal file
@@ -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()
|
||||||
184
tests/test_release_base_catalog_trust.py
Normal file
184
tests/test_release_base_catalog_trust.py
Normal file
@@ -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()
|
||||||
153
tests/test_release_candidate_artifact.py
Normal file
153
tests/test_release_candidate_artifact.py
Normal file
@@ -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:
|
||||||
|
|||||||
956
tests/test_release_execution.py
Normal file
956
tests/test_release_execution.py
Normal file
@@ -0,0 +1,956 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
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.model import RepositorySpec # noqa: E402
|
||||||
|
from govoplan_release.git_state import sanitized_git_environment # noqa: E402
|
||||||
|
from govoplan_release.release_execution import ( # noqa: E402
|
||||||
|
ExecutorSpec,
|
||||||
|
ReleaseExecutionAmbiguous,
|
||||||
|
ReleaseExecutionBlocked,
|
||||||
|
build_selected_wheels,
|
||||||
|
execute_repository_step,
|
||||||
|
executor_spec,
|
||||||
|
reconciled_repository_receipt,
|
||||||
|
repository_state_receipt,
|
||||||
|
require_trusted_release_runtime,
|
||||||
|
verify_frozen_repository_tag_receipt,
|
||||||
|
verify_repository_preflight_binding,
|
||||||
|
_clone_catalog_sources,
|
||||||
|
_checkout_frozen_source,
|
||||||
|
_flatpak_proxy_environment,
|
||||||
|
_run_isolated_wheel_builder,
|
||||||
|
_trusted_flatpak_builder_proxy,
|
||||||
|
_trusted_host_bubblewrap,
|
||||||
|
_verify_exact_publication_delta,
|
||||||
|
isolated_builder_launcher,
|
||||||
|
)
|
||||||
|
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||||
|
enforce_frozen_selected_source_provenance,
|
||||||
|
enforce_selected_source_provenance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseExecutionTests(unittest.TestCase):
|
||||||
|
def test_flatpak_proxy_environment_drops_unrelated_values(self) -> None:
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/attacker/bus",
|
||||||
|
"XDG_RUNTIME_DIR": "/run/user/1000",
|
||||||
|
"PYTHONPATH": "/attacker",
|
||||||
|
"GIT_DIR": "/attacker/repository",
|
||||||
|
},
|
||||||
|
clear=True,
|
||||||
|
):
|
||||||
|
environment = _flatpak_proxy_environment()
|
||||||
|
|
||||||
|
self.assertEqual("/usr/bin:/bin", environment["PATH"])
|
||||||
|
self.assertEqual(
|
||||||
|
"unix:path=/run/flatpak/bus",
|
||||||
|
environment["DBUS_SESSION_BUS_ADDRESS"],
|
||||||
|
)
|
||||||
|
self.assertNotIn("XDG_RUNTIME_DIR", environment)
|
||||||
|
self.assertNotIn("PYTHONPATH", environment)
|
||||||
|
self.assertNotIn("GIT_DIR", environment)
|
||||||
|
|
||||||
|
def test_host_bubblewrap_preflight_requires_root_owned_safe_binary(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_execution.subprocess.run",
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
returncode=0,
|
||||||
|
stdout="81ed|0\n",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
self.assertTrue(_trusted_host_bubblewrap())
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_execution.subprocess.run",
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
returncode=0,
|
||||||
|
stdout="81ff|1000\n",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
self.assertFalse(_trusted_host_bubblewrap())
|
||||||
|
|
||||||
|
def test_builder_launcher_uses_only_preflighted_flatpak_proxy(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_execution._trusted_flatpak_builder_proxy",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
("/usr/bin/flatpak-spawn", "--host", "/usr/bin/bwrap"),
|
||||||
|
isolated_builder_launcher(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_flatpak_proxy_requires_recognized_container(self) -> None:
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
self.assertFalse(_trusted_flatpak_builder_proxy())
|
||||||
|
|
||||||
|
def test_flatpak_builder_launch_uses_sanitized_environment(self) -> None:
|
||||||
|
process = SimpleNamespace(wait=lambda timeout: 0)
|
||||||
|
command = (
|
||||||
|
"/usr/bin/flatpak-spawn",
|
||||||
|
"--host",
|
||||||
|
"/usr/bin/bwrap",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/attacker/bus",
|
||||||
|
"PYTHONPATH": "/attacker",
|
||||||
|
},
|
||||||
|
clear=True,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution.subprocess.Popen",
|
||||||
|
return_value=process,
|
||||||
|
) as popen,
|
||||||
|
):
|
||||||
|
self.assertEqual(0, _run_isolated_wheel_builder(command))
|
||||||
|
|
||||||
|
environment = popen.call_args.kwargs["env"]
|
||||||
|
self.assertEqual(
|
||||||
|
"unix:path=/run/flatpak/bus",
|
||||||
|
environment["DBUS_SESSION_BUS_ADDRESS"],
|
||||||
|
)
|
||||||
|
self.assertNotIn("PYTHONPATH", environment)
|
||||||
|
|
||||||
|
def test_git_environment_ignores_inherited_redirection_and_commands(self) -> None:
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"GIT_DIR": "/attacker/repository",
|
||||||
|
"GIT_CONFIG_GLOBAL": "/attacker/config",
|
||||||
|
"GIT_SSH_COMMAND": "/attacker/ssh",
|
||||||
|
"PATH": "/attacker/bin",
|
||||||
|
"SSH_AUTH_SOCK": "/operator/agent.sock",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
environment = sanitized_git_environment()
|
||||||
|
|
||||||
|
self.assertNotIn("GIT_DIR", environment)
|
||||||
|
self.assertEqual("/dev/null", environment["GIT_CONFIG_GLOBAL"])
|
||||||
|
self.assertEqual("/usr/bin:/bin", environment["PATH"])
|
||||||
|
self.assertEqual(
|
||||||
|
"/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8",
|
||||||
|
environment["GIT_SSH_COMMAND"],
|
||||||
|
)
|
||||||
|
self.assertEqual("/operator/agent.sock", environment["SSH_AUTH_SOCK"])
|
||||||
|
|
||||||
|
def test_publication_reconciliation_rejects_unrelated_commit_delta(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
website = Path(temp_dir) / "website"
|
||||||
|
_git(Path(temp_dir), "init", "-b", "main", str(website))
|
||||||
|
_git(website, "config", "user.name", "Release Test")
|
||||||
|
_git(website, "config", "user.email", "release@example.test")
|
||||||
|
catalog = website / "public/catalogs/v1/channels/stable.json"
|
||||||
|
keyring = website / "public/catalogs/v1/keyring.json"
|
||||||
|
modules = website / "public/catalogs/v1/modules"
|
||||||
|
catalog.parent.mkdir(parents=True)
|
||||||
|
modules.mkdir(parents=True)
|
||||||
|
catalog.write_text("{}\n", encoding="utf-8")
|
||||||
|
keyring.write_text("{}\n", encoding="utf-8")
|
||||||
|
(website / "README.md").write_text("before\n", encoding="utf-8")
|
||||||
|
_git(website, "add", ".")
|
||||||
|
_git(website, "commit", "-m", "Base")
|
||||||
|
parent = _git_output(website, "rev-parse", "HEAD")
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"public/catalogs/v1/channels/stable.json": b'{"new": true}\n',
|
||||||
|
"public/catalogs/v1/keyring.json": b"{}\n",
|
||||||
|
}
|
||||||
|
catalog.write_bytes(expected["public/catalogs/v1/channels/stable.json"])
|
||||||
|
(website / "README.md").write_text("unrelated\n", encoding="utf-8")
|
||||||
|
_git(website, "add", ".")
|
||||||
|
_git(website, "commit", "-m", "Catalog plus unrelated change")
|
||||||
|
commit = _git_output(website, "rev-parse", "HEAD")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ReleaseExecutionBlocked,
|
||||||
|
"outside the exact derived delta",
|
||||||
|
):
|
||||||
|
_verify_exact_publication_delta(
|
||||||
|
web_root=website,
|
||||||
|
frozen_parent=parent,
|
||||||
|
commit=commit,
|
||||||
|
expected_blobs=expected,
|
||||||
|
module_root=modules,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_runtime_trust_rejects_replaceable_workspace_boundary(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
meta = root / "govoplan"
|
||||||
|
workspace = root / "workspace"
|
||||||
|
_git(root, "init", "-b", "main", str(meta))
|
||||||
|
(meta / "tools" / "release").mkdir(parents=True)
|
||||||
|
(meta / "tools" / "checks").mkdir(parents=True)
|
||||||
|
(meta / "tools" / "release" / "tool.py").write_text(
|
||||||
|
"# trusted\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(meta / "tools" / "checks" / "check.py").write_text(
|
||||||
|
"# trusted\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
registry = meta / "repositories.json"
|
||||||
|
registry.write_text('{"repositories": []}\n', encoding="utf-8")
|
||||||
|
workspace.mkdir(mode=0o700)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution.META_ROOT",
|
||||||
|
meta,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution.REPOSITORIES_FILE",
|
||||||
|
registry,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution.publication_runtime_trust_issues",
|
||||||
|
return_value=(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
require_trusted_release_runtime(workspace_root=workspace)
|
||||||
|
workspace.chmod(0o777)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ReleaseExecutionBlocked, "operator-owned"
|
||||||
|
):
|
||||||
|
require_trusted_release_runtime(workspace_root=workspace)
|
||||||
|
|
||||||
|
def test_durable_binding_rejects_group_writable_git_configuration(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
repository = workspace / "govoplan-files"
|
||||||
|
remote = workspace / "files.git"
|
||||||
|
_git(workspace, "init", "--bare", str(remote))
|
||||||
|
_git(workspace, "init", "-b", "main", str(repository))
|
||||||
|
_git(repository, "config", "user.name", "Release Test")
|
||||||
|
_git(repository, "config", "user.email", "release@example.test")
|
||||||
|
(repository / "README.md").write_text("release\n", encoding="utf-8")
|
||||||
|
_git(repository, "add", "README.md")
|
||||||
|
_git(repository, "commit", "-m", "Initial")
|
||||||
|
_git(repository, "remote", "add", "origin", str(remote))
|
||||||
|
(repository / ".git" / "config").chmod(0o664)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_execution.load_repository_specs",
|
||||||
|
return_value=(_repository_spec(remote),),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ReleaseExecutionBlocked, "group/world writable"
|
||||||
|
):
|
||||||
|
repository_state_receipt(
|
||||||
|
repo="govoplan-files",
|
||||||
|
target_tag="v1.2.3",
|
||||||
|
workspace_root=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_durable_binding_rejects_nested_writable_git_authority(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
repository = workspace / "govoplan-files"
|
||||||
|
remote = workspace / "files.git"
|
||||||
|
_git(workspace, "init", "--bare", str(remote))
|
||||||
|
_git(workspace, "init", "-b", "main", str(repository))
|
||||||
|
_git(repository, "config", "user.name", "Release Test")
|
||||||
|
_git(repository, "config", "user.email", "release@example.test")
|
||||||
|
(repository / "README.md").write_text("release\n", encoding="utf-8")
|
||||||
|
_git(repository, "add", "README.md")
|
||||||
|
_git(repository, "commit", "-m", "Initial")
|
||||||
|
_git(repository, "remote", "add", "origin", str(remote))
|
||||||
|
(repository / ".git" / "refs" / "heads").chmod(0o777)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_execution.load_repository_specs",
|
||||||
|
return_value=(_repository_spec(remote),),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ReleaseExecutionBlocked,
|
||||||
|
"Git metadata directory",
|
||||||
|
):
|
||||||
|
repository_state_receipt(
|
||||||
|
repo="govoplan-files",
|
||||||
|
target_tag="v1.2.3",
|
||||||
|
workspace_root=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_creation_binding_rejects_head_and_push_remote_drift(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
repository = workspace / "govoplan-files"
|
||||||
|
remote = workspace / "files.git"
|
||||||
|
_git(workspace, "init", "--bare", str(remote))
|
||||||
|
_git(workspace, "init", "-b", "main", str(repository))
|
||||||
|
_git(repository, "config", "user.name", "Release Test")
|
||||||
|
_git(repository, "config", "user.email", "release@example.test")
|
||||||
|
(repository / "README.md").write_text("one\n", encoding="utf-8")
|
||||||
|
_git(repository, "add", "README.md")
|
||||||
|
_git(repository, "commit", "-m", "Initial")
|
||||||
|
_git(repository, "remote", "add", "origin", str(remote))
|
||||||
|
_git(repository, "push", "-u", "origin", "main")
|
||||||
|
spec = _repository_spec(remote)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_execution.load_repository_specs",
|
||||||
|
return_value=(spec,),
|
||||||
|
):
|
||||||
|
frozen = repository_state_receipt(
|
||||||
|
repo="govoplan-files",
|
||||||
|
target_tag="v1.2.3",
|
||||||
|
workspace_root=workspace,
|
||||||
|
)
|
||||||
|
step = {
|
||||||
|
"repo": "govoplan-files",
|
||||||
|
"source_binding": frozen,
|
||||||
|
}
|
||||||
|
(repository / "README.md").write_text("two\n", encoding="utf-8")
|
||||||
|
_git(repository, "add", "README.md")
|
||||||
|
_git(repository, "commit", "-m", "Drift")
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ReleaseExecutionBlocked, "state drifted"
|
||||||
|
):
|
||||||
|
verify_repository_preflight_binding(
|
||||||
|
plan_step=step,
|
||||||
|
version="1.2.3",
|
||||||
|
workspace_root=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
_git(
|
||||||
|
repository,
|
||||||
|
"remote",
|
||||||
|
"set-url",
|
||||||
|
"--add",
|
||||||
|
"--push",
|
||||||
|
"origin",
|
||||||
|
str(workspace / "other.git"),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ReleaseExecutionBlocked, "registered release remote"
|
||||||
|
):
|
||||||
|
repository_state_receipt(
|
||||||
|
repo="govoplan-files",
|
||||||
|
target_tag="v1.2.3",
|
||||||
|
workspace_root=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reconciliation_proves_annotated_local_and_remote_tag(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
repository = workspace / "govoplan-files"
|
||||||
|
remote = workspace / "files.git"
|
||||||
|
_git(workspace, "init", "--bare", str(remote))
|
||||||
|
_git(workspace, "init", "-b", "main", str(repository))
|
||||||
|
_git(repository, "config", "user.name", "Release Test")
|
||||||
|
_git(repository, "config", "user.email", "release@example.test")
|
||||||
|
(repository / "README.md").write_text("release\n", encoding="utf-8")
|
||||||
|
_git(repository, "add", "README.md")
|
||||||
|
_git(repository, "commit", "-m", "Initial")
|
||||||
|
_git(repository, "remote", "add", "origin", str(remote))
|
||||||
|
_git(repository, "push", "-u", "origin", "main")
|
||||||
|
spec = _repository_spec(remote)
|
||||||
|
plan_step = {
|
||||||
|
"repo": "govoplan-files",
|
||||||
|
"source_binding": {},
|
||||||
|
}
|
||||||
|
tag_spec = ExecutorSpec("tag", "TAG", "tag_created", True)
|
||||||
|
push_spec = ExecutorSpec("push", "PUBLISH", "tag_published", True)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_execution.load_repository_specs",
|
||||||
|
return_value=(spec,),
|
||||||
|
):
|
||||||
|
before = repository_state_receipt(
|
||||||
|
repo="govoplan-files",
|
||||||
|
target_tag="v1.2.3",
|
||||||
|
workspace_root=workspace,
|
||||||
|
)
|
||||||
|
_git(repository, "tag", "-a", "v1.2.3", "-m", "Release")
|
||||||
|
tagged = reconciled_repository_receipt(
|
||||||
|
spec=tag_spec,
|
||||||
|
plan_step=plan_step,
|
||||||
|
version="1.2.3",
|
||||||
|
workspace_root=workspace,
|
||||||
|
expected_receipt=before,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(tagged["tag_object"])
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ReleaseExecutionBlocked, "Remote annotated tag"
|
||||||
|
):
|
||||||
|
reconciled_repository_receipt(
|
||||||
|
spec=push_spec,
|
||||||
|
plan_step=plan_step,
|
||||||
|
version="1.2.3",
|
||||||
|
workspace_root=workspace,
|
||||||
|
expected_receipt=tagged,
|
||||||
|
)
|
||||||
|
_git(
|
||||||
|
repository,
|
||||||
|
"push",
|
||||||
|
"--atomic",
|
||||||
|
"origin",
|
||||||
|
"HEAD:refs/heads/main",
|
||||||
|
"refs/tags/v1.2.3",
|
||||||
|
)
|
||||||
|
published = reconciled_repository_receipt(
|
||||||
|
spec=push_spec,
|
||||||
|
plan_step=plan_step,
|
||||||
|
version="1.2.3",
|
||||||
|
workspace_root=workspace,
|
||||||
|
expected_receipt=tagged,
|
||||||
|
)
|
||||||
|
self.assertEqual(tagged, published)
|
||||||
|
(repository / "README.md").write_text(
|
||||||
|
"uncommitted drift\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
tagged,
|
||||||
|
verify_frozen_repository_tag_receipt(
|
||||||
|
receipt=tagged,
|
||||||
|
workspace_root=workspace,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
(repository / "README.md").write_text(
|
||||||
|
"remote branch drift\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
_git(repository, "add", "README.md")
|
||||||
|
_git(repository, "commit", "-m", "Remote branch drift")
|
||||||
|
drift_commit = _git_output(repository, "rev-parse", "HEAD")
|
||||||
|
_git(
|
||||||
|
repository,
|
||||||
|
"push",
|
||||||
|
"origin",
|
||||||
|
f"{drift_commit}:refs/heads/drift-source",
|
||||||
|
)
|
||||||
|
_git(remote, "update-ref", "refs/heads/main", drift_commit)
|
||||||
|
_git(repository, "reset", "--hard", str(tagged["head"]))
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ReleaseExecutionBlocked,
|
||||||
|
"Remote branch",
|
||||||
|
):
|
||||||
|
reconciled_repository_receipt(
|
||||||
|
spec=push_spec,
|
||||||
|
plan_step=plan_step,
|
||||||
|
version="1.2.3",
|
||||||
|
workspace_root=workspace,
|
||||||
|
expected_receipt=tagged,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_post_tag_receipt_failure_is_ambiguous(self) -> None:
|
||||||
|
spec = ExecutorSpec("tag", "TAG", "tag_created", True)
|
||||||
|
plan_step = {
|
||||||
|
"id": "govoplan-files:tag",
|
||||||
|
"repo": "govoplan-files",
|
||||||
|
"source_binding": {"kind": "repository_state"},
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution.require_trusted_release_runtime"
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution.verify_repository_step_precondition"
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution.tag_repositories",
|
||||||
|
return_value={
|
||||||
|
"status": "tagged",
|
||||||
|
"repositories": [{"status": "tagged"}],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution.repository_state_receipt",
|
||||||
|
side_effect=ReleaseExecutionBlocked("post-effect probe failed"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ReleaseExecutionAmbiguous, "post-effect repository receipt"
|
||||||
|
):
|
||||||
|
execute_repository_step(
|
||||||
|
spec=spec,
|
||||||
|
plan_step=plan_step,
|
||||||
|
repo_versions={"govoplan-files": "1.2.3"},
|
||||||
|
workspace_root=Path("/workspace"),
|
||||||
|
remote="origin",
|
||||||
|
expected_receipt={"kind": "repository_state"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_commit_step_has_no_durable_executor(self) -> None:
|
||||||
|
self.assertIsNone(
|
||||||
|
executor_spec(
|
||||||
|
{
|
||||||
|
"id": "govoplan-files:commit",
|
||||||
|
"status": "planned",
|
||||||
|
"mutating": True,
|
||||||
|
"repo": "govoplan-files",
|
||||||
|
"source_binding": {
|
||||||
|
"kind": "repository_state",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_frozen_checkout_uses_receipt_commit_not_live_worktree(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
repository = workspace / "govoplan-files"
|
||||||
|
repository.mkdir()
|
||||||
|
_git(repository, "init", "-b", "main")
|
||||||
|
_git(repository, "config", "user.name", "Release Test")
|
||||||
|
_git(repository, "config", "user.email", "release@example.test")
|
||||||
|
(repository / "version.txt").write_text("frozen\n", encoding="utf-8")
|
||||||
|
_git(repository, "add", "version.txt")
|
||||||
|
_git(repository, "commit", "-m", "Frozen")
|
||||||
|
frozen_commit = _git_output(repository, "rev-parse", "HEAD")
|
||||||
|
_git(repository, "tag", "-a", "v1.2.3", "-m", "Release")
|
||||||
|
tag_object = _git_output(repository, "rev-parse", "refs/tags/v1.2.3")
|
||||||
|
(repository / "version.txt").write_text("live drift\n", encoding="utf-8")
|
||||||
|
|
||||||
|
source_root = workspace / "isolated"
|
||||||
|
source_root.mkdir()
|
||||||
|
checkout = _checkout_frozen_source(
|
||||||
|
repo="govoplan-files",
|
||||||
|
source_remote=repository,
|
||||||
|
commit=frozen_commit,
|
||||||
|
tag="v1.2.3",
|
||||||
|
tag_object=tag_object,
|
||||||
|
source_root=source_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
"frozen\n", (checkout / "version.txt").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_frozen_provenance_accepts_only_exact_clean_detached_tag(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
repository = workspace / "govoplan-files"
|
||||||
|
remote = workspace / "files.git"
|
||||||
|
_git(workspace, "init", "--bare", str(remote))
|
||||||
|
_git(workspace, "init", "-b", "main", str(repository))
|
||||||
|
_git(repository, "config", "user.name", "Release Test")
|
||||||
|
_git(repository, "config", "user.email", "release@example.test")
|
||||||
|
(repository / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "govoplan-files"\nversion = "1.2.3"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_git(repository, "add", "pyproject.toml")
|
||||||
|
_git(repository, "commit", "-m", "Release")
|
||||||
|
commit = _git_output(repository, "rev-parse", "HEAD")
|
||||||
|
_git(repository, "tag", "-a", "v1.2.3", "-m", "Release")
|
||||||
|
tag_object = _git_output(
|
||||||
|
repository, "rev-parse", "refs/tags/v1.2.3"
|
||||||
|
)
|
||||||
|
_git(repository, "remote", "add", "origin", str(remote))
|
||||||
|
_git(repository, "push", "origin", "main", "refs/tags/v1.2.3")
|
||||||
|
_git(repository, "checkout", "--detach", commit)
|
||||||
|
spec = _repository_spec(remote)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_release.source_provenance.load_repository_specs",
|
||||||
|
return_value=(spec,),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.selective_catalog.load_repository_specs",
|
||||||
|
return_value=(spec,),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(ValueError, "named branch"):
|
||||||
|
enforce_selected_source_provenance(
|
||||||
|
repo_versions={"govoplan-files": "1.2.3"},
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
observed = enforce_frozen_selected_source_provenance(
|
||||||
|
repo_versions={"govoplan-files": "1.2.3"},
|
||||||
|
expected_provenance={
|
||||||
|
"govoplan-files": {
|
||||||
|
"commit_sha": commit,
|
||||||
|
"tag_object_sha": tag_object,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(commit, observed["govoplan-files"]["commit_sha"])
|
||||||
|
self.assertEqual(
|
||||||
|
tag_object, observed["govoplan-files"]["tag_object_sha"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wheel_staging_skips_selected_tag_only_repository(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
python_repo = workspace / "govoplan-files"
|
||||||
|
tag_only_repo = workspace / "govoplan-meta-notes"
|
||||||
|
python_repo.mkdir()
|
||||||
|
tag_only_repo.mkdir()
|
||||||
|
(python_repo / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "govoplan-files"\nversion = "1.2.3"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_git(workspace, "init", "-b", "main", str(python_repo))
|
||||||
|
_git(python_repo, "config", "user.name", "Release Test")
|
||||||
|
_git(python_repo, "config", "user.email", "release@example.test")
|
||||||
|
_git(python_repo, "add", "pyproject.toml")
|
||||||
|
_git(python_repo, "commit", "-m", "Source")
|
||||||
|
specs = (
|
||||||
|
RepositorySpec(
|
||||||
|
name="govoplan-files",
|
||||||
|
category="module",
|
||||||
|
subtype="infrastructure",
|
||||||
|
remote="git@example.test/files.git",
|
||||||
|
path="govoplan-files",
|
||||||
|
),
|
||||||
|
RepositorySpec(
|
||||||
|
name="govoplan-meta-notes",
|
||||||
|
category="system",
|
||||||
|
subtype="metadata",
|
||||||
|
remote="git@example.test/notes.git",
|
||||||
|
path="govoplan-meta-notes",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
candidate = workspace / "candidate"
|
||||||
|
|
||||||
|
def build(command: tuple[str, ...]) -> int:
|
||||||
|
wheel_dir = Path(command[command.index("--bind") + 1])
|
||||||
|
second_bind = command.index("--bind", command.index("--bind") + 1)
|
||||||
|
source_dir = Path(command[second_bind + 1])
|
||||||
|
(source_dir / "build-mutated.txt").write_text(
|
||||||
|
"isolated copy",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(
|
||||||
|
wheel_dir / "govoplan_files-1.2.3-py3-none-any.whl"
|
||||||
|
).write_bytes(b"wheel")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution.load_repository_specs",
|
||||||
|
return_value=specs,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution.isolated_builder_launcher",
|
||||||
|
return_value=("/usr/bin/bwrap",),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution._run_isolated_wheel_builder",
|
||||||
|
side_effect=build,
|
||||||
|
) as runner,
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution.inspect_python_wheel",
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
package_name="govoplan-files",
|
||||||
|
package_version="1.2.3",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
artifacts = build_selected_wheels(
|
||||||
|
repo_versions={
|
||||||
|
"govoplan-files": "1.2.3",
|
||||||
|
"govoplan-meta-notes": "1.0.0",
|
||||||
|
},
|
||||||
|
workspace_root=workspace,
|
||||||
|
candidate_output=candidate,
|
||||||
|
source_receipts={
|
||||||
|
"govoplan-files": {
|
||||||
|
"head": "a" * 40,
|
||||||
|
"target_tag": "v1.2.3",
|
||||||
|
"tag_object": "c" * 40,
|
||||||
|
},
|
||||||
|
"govoplan-meta-notes": {
|
||||||
|
"head": "b" * 40,
|
||||||
|
"target_tag": "v1.0.0",
|
||||||
|
"tag_object": "d" * 40,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
frozen_sources={
|
||||||
|
"govoplan-files": python_repo,
|
||||||
|
"govoplan-meta-notes": tag_only_repo,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse((python_repo / "build-mutated.txt").exists())
|
||||||
|
self.assertEqual({"govoplan-files"}, set(artifacts))
|
||||||
|
self.assertEqual(1, runner.call_count)
|
||||||
|
command = runner.call_args.args[0]
|
||||||
|
self.assertNotIn(str(python_repo), command)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
value.startswith(str(candidate / "artifacts"))
|
||||||
|
and value.endswith("/source")
|
||||||
|
for value in command
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn("--unshare-all", command)
|
||||||
|
self.assertIn("PIP_NO_INDEX", command)
|
||||||
|
self.assertNotIn(str(Path.home()), command)
|
||||||
|
|
||||||
|
def test_catalog_synthesis_clones_selected_and_unchanged_base_sources(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
files_repo, files_remote = _tagged_repository(
|
||||||
|
workspace,
|
||||||
|
repo="govoplan-files",
|
||||||
|
bare="files.git",
|
||||||
|
version="1.2.3",
|
||||||
|
)
|
||||||
|
core_repo, core_remote = _tagged_repository(
|
||||||
|
workspace,
|
||||||
|
repo="govoplan-core",
|
||||||
|
bare="core.git",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
specs = (
|
||||||
|
_repository_spec(files_remote),
|
||||||
|
RepositorySpec(
|
||||||
|
name="govoplan-core",
|
||||||
|
category="core",
|
||||||
|
subtype="core",
|
||||||
|
remote=str(core_remote),
|
||||||
|
path="govoplan-core",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
destination = workspace / "synthesis"
|
||||||
|
destination.mkdir()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_execution.load_repository_specs",
|
||||||
|
return_value=specs,
|
||||||
|
):
|
||||||
|
receipt = repository_state_receipt(
|
||||||
|
repo="govoplan-files",
|
||||||
|
target_tag="v1.2.3",
|
||||||
|
workspace_root=workspace,
|
||||||
|
)
|
||||||
|
cloned = _clone_catalog_sources(
|
||||||
|
source_versions={
|
||||||
|
"govoplan-core": "1.0.0",
|
||||||
|
"govoplan-files": "1.2.3",
|
||||||
|
},
|
||||||
|
repo_versions={"govoplan-files": "1.2.3"},
|
||||||
|
source_receipts={"govoplan-files": receipt},
|
||||||
|
workspace_root=workspace,
|
||||||
|
destination=destination,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
_git_output(files_repo, "rev-parse", "refs/tags/v1.2.3^{commit}"),
|
||||||
|
_git_output(cloned["govoplan-files"], "rev-parse", "HEAD"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_git_output(core_repo, "rev-parse", "refs/tags/v1.0.0^{commit}"),
|
||||||
|
_git_output(cloned["govoplan-core"], "rev-parse", "HEAD"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"HEAD",
|
||||||
|
_git_output(cloned["govoplan-core"], "rev-parse", "--abbrev-ref", "HEAD"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_catalog_synthesis_adds_exact_core_bundle_dependency(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
idm_repo, idm_remote = _tagged_repository(
|
||||||
|
workspace,
|
||||||
|
repo="govoplan-idm",
|
||||||
|
bare="idm.git",
|
||||||
|
version="0.1.8",
|
||||||
|
)
|
||||||
|
idm_commit = _git_output(idm_repo, "rev-parse", "v0.1.8^{commit}")
|
||||||
|
(idm_repo / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "govoplan-idm"\nversion = "0.1.9"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_git(idm_repo, "add", "pyproject.toml")
|
||||||
|
_git(idm_repo, "commit", "-m", "Next release")
|
||||||
|
_git(idm_repo, "tag", "-a", "v0.1.9", "-m", "Next release")
|
||||||
|
_git(idm_repo, "push", "origin", "main", "refs/tags/v0.1.9")
|
||||||
|
catalog_commit = _git_output(idm_repo, "rev-parse", "v0.1.9^{commit}")
|
||||||
|
core_repo = workspace / "govoplan-core"
|
||||||
|
core_remote = workspace / "core.git"
|
||||||
|
_git(workspace, "init", "--bare", str(core_remote))
|
||||||
|
_git(workspace, "init", "-b", "main", str(core_repo))
|
||||||
|
_git(core_repo, "config", "user.name", "Release Test")
|
||||||
|
_git(core_repo, "config", "user.email", "release@example.test")
|
||||||
|
(core_repo / "webui").mkdir()
|
||||||
|
(core_repo / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "govoplan-core"\nversion = "1.0.0"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
reference = (
|
||||||
|
"git+ssh://git@example.test/add-ideas/"
|
||||||
|
"govoplan-idm.git#v0.1.8"
|
||||||
|
)
|
||||||
|
(core_repo / "webui/package.release.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {"@govoplan/idm-webui": reference},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(core_repo / "webui/package-lock.release.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@govoplan/idm-webui": reference,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"node_modules/@govoplan/idm-webui": {
|
||||||
|
"version": "0.1.8",
|
||||||
|
"resolved": f"{reference.rsplit('#', 1)[0]}#{idm_commit}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_git(core_repo, "add", ".")
|
||||||
|
_git(core_repo, "commit", "-m", "Release")
|
||||||
|
_git(core_repo, "tag", "-a", "v1.0.0", "-m", "Release")
|
||||||
|
_git(core_repo, "remote", "add", "origin", str(core_remote))
|
||||||
|
_git(core_repo, "push", "-u", "origin", "main", "refs/tags/v1.0.0")
|
||||||
|
specs = (
|
||||||
|
RepositorySpec(
|
||||||
|
name="govoplan-core",
|
||||||
|
category="system",
|
||||||
|
subtype="kernel",
|
||||||
|
remote=str(core_remote),
|
||||||
|
path="govoplan-core",
|
||||||
|
),
|
||||||
|
RepositorySpec(
|
||||||
|
name="govoplan-idm",
|
||||||
|
category="module",
|
||||||
|
subtype="platform",
|
||||||
|
remote=str(idm_remote),
|
||||||
|
path="govoplan-idm",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_execution.load_repository_specs",
|
||||||
|
return_value=specs,
|
||||||
|
):
|
||||||
|
synthesis = workspace / "synthesis"
|
||||||
|
synthesis.mkdir()
|
||||||
|
cloned = _clone_catalog_sources(
|
||||||
|
source_versions={"govoplan-core": "1.0.0"},
|
||||||
|
repo_versions={},
|
||||||
|
source_receipts={},
|
||||||
|
workspace_root=workspace,
|
||||||
|
destination=synthesis,
|
||||||
|
)
|
||||||
|
newer_synthesis = workspace / "newer-synthesis"
|
||||||
|
newer_synthesis.mkdir()
|
||||||
|
cloned_with_newer_catalog_source = _clone_catalog_sources(
|
||||||
|
source_versions={
|
||||||
|
"govoplan-core": "1.0.0",
|
||||||
|
"govoplan-idm": "0.1.9",
|
||||||
|
},
|
||||||
|
repo_versions={},
|
||||||
|
source_receipts={},
|
||||||
|
workspace_root=workspace,
|
||||||
|
destination=newer_synthesis,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{"govoplan-core", "govoplan-idm"},
|
||||||
|
set(cloned),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
idm_commit,
|
||||||
|
_git_output(cloned["govoplan-idm"], "rev-parse", "HEAD"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
catalog_commit,
|
||||||
|
_git_output(
|
||||||
|
cloned_with_newer_catalog_source["govoplan-idm"],
|
||||||
|
"rev-parse",
|
||||||
|
"HEAD",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
idm_commit,
|
||||||
|
_git_output(
|
||||||
|
cloned_with_newer_catalog_source["govoplan-idm"],
|
||||||
|
"rev-parse",
|
||||||
|
"v0.1.8^{commit}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _repository_spec(remote: Path) -> RepositorySpec:
|
||||||
|
return RepositorySpec(
|
||||||
|
name="govoplan-files",
|
||||||
|
category="module",
|
||||||
|
subtype="infrastructure",
|
||||||
|
remote=str(remote),
|
||||||
|
path="govoplan-files",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tagged_repository(
|
||||||
|
workspace: Path, *, repo: str, bare: str, version: str
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
repository = workspace / repo
|
||||||
|
remote = workspace / bare
|
||||||
|
_git(workspace, "init", "--bare", str(remote))
|
||||||
|
_git(workspace, "init", "-b", "main", str(repository))
|
||||||
|
_git(repository, "config", "user.name", "Release Test")
|
||||||
|
_git(repository, "config", "user.email", "release@example.test")
|
||||||
|
(repository / "pyproject.toml").write_text(
|
||||||
|
f'[project]\nname = "{repo}"\nversion = "{version}"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_git(repository, "add", "pyproject.toml")
|
||||||
|
_git(repository, "commit", "-m", "Release")
|
||||||
|
_git(repository, "tag", "-a", f"v{version}", "-m", "Release")
|
||||||
|
_git(repository, "remote", "add", "origin", str(remote))
|
||||||
|
_git(repository, "push", "-u", "origin", "main", f"refs/tags/v{version}")
|
||||||
|
return repository, remote
|
||||||
|
|
||||||
|
|
||||||
|
def _git(cwd: Path, *arguments: str) -> None:
|
||||||
|
subprocess.run( # noqa: S603
|
||||||
|
("git", *arguments),
|
||||||
|
cwd=cwd,
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git_output(cwd: Path, *arguments: str) -> str:
|
||||||
|
return subprocess.run( # noqa: S603
|
||||||
|
("git", *arguments),
|
||||||
|
cwd=cwd,
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
39
tests/test_release_git_state.py
Normal file
39
tests/test_release_git_state.py
Normal file
@@ -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",
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -89,8 +92,67 @@ 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_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()
|
||||||
|
|||||||
82
tests/test_release_module_directory.py
Normal file
82
tests/test_release_module_directory.py
Normal file
@@ -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()
|
||||||
@@ -156,7 +156,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)
|
||||||
|
|
||||||
|
|||||||
2285
tests/test_release_run_state.py
Normal file
2285
tests/test_release_run_state.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||||
|
|||||||
364
tests/test_repository_bootstrap.py
Normal file
364
tests/test_repository_bootstrap.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
248
tests/test_security_audit_mount_resolver.py
Normal file
248
tests/test_security_audit_mount_resolver.py
Normal file
@@ -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,163 @@ 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,
|
||||||
|
) -> tuple[subprocess.CompletedProcess[str], list[list[str]]]:
|
||||||
|
self.docker_log.write_text("", encoding="utf-8")
|
||||||
|
environment = self.environment.copy()
|
||||||
|
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_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()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
@@ -17,11 +18,24 @@ for tools_root in (ASSESSMENT_TOOLS_ROOT, RELEASE_TOOLS_ROOT):
|
|||||||
if str(tools_root) not in sys.path:
|
if str(tools_root) not in sys.path:
|
||||||
sys.path.insert(0, str(tools_root))
|
sys.path.insert(0, str(tools_root))
|
||||||
|
|
||||||
from govoplan_assessment import render_review, review_capability_fit # noqa: E402
|
from govoplan_assessment import ( # noqa: E402
|
||||||
|
collect_installed_composition,
|
||||||
|
render_review,
|
||||||
|
review_capability_fit,
|
||||||
|
)
|
||||||
|
from govoplan_assessment.atomic_io import ( # noqa: E402
|
||||||
|
AtomicJsonWriteError,
|
||||||
|
atomic_write_json,
|
||||||
|
)
|
||||||
|
from govoplan_assessment.evidence import validate_payload # noqa: E402
|
||||||
from govoplan_release.catalog import DEFAULT_PUBLIC_BASE_URL, fetch_json # noqa: E402
|
from govoplan_release.catalog import DEFAULT_PUBLIC_BASE_URL, fetch_json # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
TRUSTED_KEYRING_FILE_ENV = "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE"
|
TRUSTED_KEYRING_FILE_ENV = "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE"
|
||||||
|
MAX_ASSESSMENT_INPUT_BYTES = 16 * 1024 * 1024
|
||||||
|
MAX_EVIDENCE_INPUT_BYTES = 4 * 1024 * 1024
|
||||||
|
MAX_REPORT_OUTPUT_BYTES = 16 * 1024 * 1024
|
||||||
|
OPAQUE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
@@ -71,6 +85,90 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Compare signed metadata without checking local annotated tags.",
|
help="Compare signed metadata without checking local annotated tags.",
|
||||||
)
|
)
|
||||||
|
installed = parser.add_mutually_exclusive_group()
|
||||||
|
installed.add_argument(
|
||||||
|
"--installed-evidence",
|
||||||
|
type=Path,
|
||||||
|
help="Validate an existing bounded installed-composition evidence document.",
|
||||||
|
)
|
||||||
|
installed.add_argument(
|
||||||
|
"--collect-installed-evidence",
|
||||||
|
type=Path,
|
||||||
|
help=(
|
||||||
|
"Collect the current interpreter's GovOPlaN distributions, write the "
|
||||||
|
"bounded evidence document, and include it in this review. Loading "
|
||||||
|
"module entry points executes installed GovOPlaN manifest factories."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--installed-evidence-schema",
|
||||||
|
type=Path,
|
||||||
|
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(
|
||||||
|
"--boundary-evidence",
|
||||||
|
type=Path,
|
||||||
|
help=(
|
||||||
|
"Externally issued target/provider/production proof bound to the "
|
||||||
|
"installed-evidence digest."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--boundary-evidence-schema",
|
||||||
|
type=Path,
|
||||||
|
default=META_ROOT / "docs" / "capability-fit-boundary-evidence.schema.json",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--boundary-authority-keyring",
|
||||||
|
type=Path,
|
||||||
|
help=(
|
||||||
|
"Independently provisioned keyring authorizing proof signers for "
|
||||||
|
"specific target/provider/production scopes."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--boundary-authority-keyring-schema",
|
||||||
|
type=Path,
|
||||||
|
default=META_ROOT
|
||||||
|
/ "docs"
|
||||||
|
/ "capability-fit-proof-authority-keyring.schema.json",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verification-time",
|
||||||
|
help="Optional ISO-8601 time for reproducible boundary-proof verification.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--expected-external-provider-subject",
|
||||||
|
help=(
|
||||||
|
"Opaque provider-environment ID that external-provider proof must match; "
|
||||||
|
"never supply a URL, credential, or endpoint identifier."
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--json", action="store_true", help="Print the machine-readable review report."
|
"--json", action="store_true", help="Print the machine-readable review report."
|
||||||
)
|
)
|
||||||
@@ -88,6 +186,41 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
raise SystemExit("--keyring is required with --catalog")
|
raise SystemExit("--keyring is required with --catalog")
|
||||||
if args.public and args.keyring is not None:
|
if args.public and args.keyring is not None:
|
||||||
raise SystemExit("--keyring cannot be combined with --public")
|
raise SystemExit("--keyring cannot be combined with --public")
|
||||||
|
if (args.boundary_evidence is None) != (args.boundary_authority_keyring is None):
|
||||||
|
raise SystemExit(
|
||||||
|
"--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 (
|
||||||
|
args.installed_evidence is None and args.collect_installed_evidence is None
|
||||||
|
):
|
||||||
|
raise SystemExit(
|
||||||
|
"--boundary-evidence requires --installed-evidence or --collect-installed-evidence"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
args.expected_external_provider_subject is not None
|
||||||
|
and OPAQUE_ID_PATTERN.fullmatch(args.expected_external_provider_subject) is None
|
||||||
|
):
|
||||||
|
raise SystemExit(
|
||||||
|
"--expected-external-provider-subject must be a bounded opaque ID"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
args.collect_installed_evidence is not None
|
||||||
|
and args.output is not None
|
||||||
|
and args.collect_installed_evidence.resolve() == args.output.resolve()
|
||||||
|
):
|
||||||
|
raise SystemExit("Evidence and review output paths must differ")
|
||||||
configured_trusted_keyring = os.getenv(TRUSTED_KEYRING_FILE_ENV, "").strip()
|
configured_trusted_keyring = os.getenv(TRUSTED_KEYRING_FILE_ENV, "").strip()
|
||||||
trusted_keyring_path = args.trusted_keyring or (
|
trusted_keyring_path = args.trusted_keyring or (
|
||||||
Path(configured_trusted_keyring) if configured_trusted_keyring else None
|
Path(configured_trusted_keyring) if configured_trusted_keyring else None
|
||||||
@@ -96,8 +229,16 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"--trusted-keyring or ${TRUSTED_KEYRING_FILE_ENV} is required"
|
f"--trusted-keyring or ${TRUSTED_KEYRING_FILE_ENV} is required"
|
||||||
)
|
)
|
||||||
assessment = read_object(args.assessment, label="assessment")
|
assessment = read_object(
|
||||||
schema = read_object(args.schema, label="schema")
|
args.assessment,
|
||||||
|
label="assessment",
|
||||||
|
max_bytes=MAX_ASSESSMENT_INPUT_BYTES,
|
||||||
|
)
|
||||||
|
schema = read_object(
|
||||||
|
args.schema,
|
||||||
|
label="schema",
|
||||||
|
max_bytes=MAX_ASSESSMENT_INPUT_BYTES,
|
||||||
|
)
|
||||||
if args.public:
|
if args.public:
|
||||||
catalog = fetch_public_json(
|
catalog = fetch_public_json(
|
||||||
f"{DEFAULT_PUBLIC_BASE_URL}/catalogs/v1/channels/stable.json",
|
f"{DEFAULT_PUBLIC_BASE_URL}/catalogs/v1/channels/stable.json",
|
||||||
@@ -108,13 +249,107 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
label="published keyring",
|
label="published keyring",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
catalog = read_object(args.catalog, label="catalog")
|
catalog = read_object(
|
||||||
published_keyring = read_object(args.keyring, label="published keyring")
|
args.catalog, label="catalog", max_bytes=MAX_ASSESSMENT_INPUT_BYTES
|
||||||
|
)
|
||||||
|
published_keyring = read_object(
|
||||||
|
args.keyring,
|
||||||
|
label="published keyring",
|
||||||
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
|
)
|
||||||
trusted_keyring = read_object(
|
trusted_keyring = read_object(
|
||||||
trusted_keyring_path,
|
trusted_keyring_path,
|
||||||
label="trusted keyring",
|
label="trusted keyring",
|
||||||
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
installed_evidence = None
|
||||||
|
installed_evidence_schema = None
|
||||||
|
if (
|
||||||
|
args.installed_evidence is not None
|
||||||
|
or args.collect_installed_evidence is not None
|
||||||
|
):
|
||||||
|
installed_evidence_schema = read_object(
|
||||||
|
args.installed_evidence_schema,
|
||||||
|
label="installed evidence schema",
|
||||||
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
|
)
|
||||||
|
if args.installed_evidence is not None:
|
||||||
|
installed_evidence = read_object(
|
||||||
|
args.installed_evidence,
|
||||||
|
label="installed evidence",
|
||||||
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
installed_evidence = collect_installed_composition(assessment=assessment)
|
||||||
|
collection_schema_errors = validate_payload(
|
||||||
|
payload=installed_evidence,
|
||||||
|
schema=installed_evidence_schema,
|
||||||
|
)
|
||||||
|
if collection_schema_errors:
|
||||||
|
raise SystemExit(
|
||||||
|
"Collected installed evidence did not satisfy its bounded schema"
|
||||||
|
)
|
||||||
|
write_object(
|
||||||
|
args.collect_installed_evidence,
|
||||||
|
installed_evidence,
|
||||||
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
|
label="installed evidence",
|
||||||
|
)
|
||||||
|
|
||||||
|
boundary_evidence = None
|
||||||
|
boundary_evidence_schema = None
|
||||||
|
boundary_authority_keyring = None
|
||||||
|
boundary_authority_keyring_schema = None
|
||||||
|
if args.boundary_evidence is not None:
|
||||||
|
boundary_evidence = read_object(
|
||||||
|
args.boundary_evidence,
|
||||||
|
label="boundary evidence",
|
||||||
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
|
)
|
||||||
|
boundary_evidence_schema = read_object(
|
||||||
|
args.boundary_evidence_schema,
|
||||||
|
label="boundary evidence schema",
|
||||||
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
|
)
|
||||||
|
boundary_authority_keyring = read_object(
|
||||||
|
args.boundary_authority_keyring,
|
||||||
|
label="boundary authority keyring",
|
||||||
|
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
|
||||||
|
)
|
||||||
|
boundary_authority_keyring_schema = read_object(
|
||||||
|
args.boundary_authority_keyring_schema,
|
||||||
|
label="boundary authority keyring schema",
|
||||||
|
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)
|
||||||
report = review_capability_fit(
|
report = review_capability_fit(
|
||||||
assessment=assessment,
|
assessment=assessment,
|
||||||
schema=schema,
|
schema=schema,
|
||||||
@@ -124,27 +359,87 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
workspace_root=None
|
workspace_root=None
|
||||||
if args.skip_tag_provenance
|
if args.skip_tag_provenance
|
||||||
else args.workspace_root.resolve(),
|
else args.workspace_root.resolve(),
|
||||||
|
installed_evidence=installed_evidence,
|
||||||
|
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_schema=boundary_evidence_schema,
|
||||||
|
boundary_authority_keyring=boundary_authority_keyring,
|
||||||
|
boundary_authority_keyring_schema=boundary_authority_keyring_schema,
|
||||||
|
verification_time=verification_time,
|
||||||
|
installed_evidence_mode=(
|
||||||
|
"direct_local"
|
||||||
|
if args.collect_installed_evidence is not None
|
||||||
|
else "imported_unsigned"
|
||||||
|
),
|
||||||
|
expected_external_provider_subject=args.expected_external_provider_subject,
|
||||||
)
|
)
|
||||||
encoded = json.dumps(report, indent=2, sort_keys=True) + "\n"
|
encoded = json.dumps(report, indent=2, sort_keys=True) + "\n"
|
||||||
if args.output is not None:
|
if args.output is not None:
|
||||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
write_object(
|
||||||
args.output.write_text(encoded, encoding="utf-8")
|
args.output,
|
||||||
|
report,
|
||||||
|
max_bytes=MAX_REPORT_OUTPUT_BYTES,
|
||||||
|
label="review report",
|
||||||
|
)
|
||||||
sys.stdout.write(encoded if args.json else render_review(report))
|
sys.stdout.write(encoded if args.json else render_review(report))
|
||||||
return {"current": 0, "blocked": 1, "review_required": 2}[report["status"]]
|
return {"current": 0, "blocked": 1, "review_required": 2}[report["status"]]
|
||||||
|
|
||||||
|
|
||||||
def read_object(path: Path | None, *, label: str) -> dict[str, object]:
|
def read_object(path: Path | None, *, label: str, max_bytes: int) -> dict[str, object]:
|
||||||
if path is None:
|
if path is None:
|
||||||
raise SystemExit(f"Missing {label} path")
|
raise SystemExit(f"Missing {label} path")
|
||||||
try:
|
try:
|
||||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
if path.stat().st_size > max_bytes:
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
raise SystemExit(
|
||||||
|
f"{label.capitalize()} exceeds the {max_bytes}-byte input limit"
|
||||||
|
)
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
encoded = handle.read(max_bytes + 1)
|
||||||
|
if len(encoded) > max_bytes:
|
||||||
|
raise SystemExit(
|
||||||
|
f"{label.capitalize()} exceeds the {max_bytes}-byte input limit"
|
||||||
|
)
|
||||||
|
payload = json.loads(encoded.decode("utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
raise SystemExit(f"Could not read {label} {path}: {exc}") from exc
|
raise SystemExit(f"Could not read {label} {path}: {exc}") from exc
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise SystemExit(f"{label.capitalize()} must be a JSON object: {path}")
|
raise SystemExit(f"{label.capitalize()} must be a JSON object: {path}")
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def write_object(
|
||||||
|
path: Path | None,
|
||||||
|
payload: dict[str, object],
|
||||||
|
*,
|
||||||
|
max_bytes: int,
|
||||||
|
label: str,
|
||||||
|
) -> None:
|
||||||
|
if path is None:
|
||||||
|
raise SystemExit(f"Missing {label} output path")
|
||||||
|
try:
|
||||||
|
atomic_write_json(path, payload, max_bytes=max_bytes)
|
||||||
|
except AtomicJsonWriteError as exc:
|
||||||
|
raise SystemExit(f"Could not securely write {label}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_datetime(value: str | None):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit("--verification-time must be an ISO-8601 timestamp") from exc
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise SystemExit("--verification-time must include a timezone")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def fetch_public_json(url: str, *, label: str) -> dict[str, object]:
|
def fetch_public_json(url: str, *, label: str) -> dict[str, object]:
|
||||||
result = fetch_json(url)
|
result = fetch_json(url)
|
||||||
if result.get("ok") is not True or not isinstance(result.get("payload"), dict):
|
if result.get("ok") is not True or not isinstance(result.get("payload"), dict):
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""Repeatable GovOPlaN product-assessment tooling."""
|
"""Repeatable GovOPlaN product-assessment tooling."""
|
||||||
|
|
||||||
from .capability_fit import review_capability_fit, render_review
|
from .capability_fit import review_capability_fit, render_review
|
||||||
|
from .evidence import collect_installed_composition
|
||||||
|
|
||||||
__all__ = ("render_review", "review_capability_fit")
|
__all__ = (
|
||||||
|
"collect_installed_composition",
|
||||||
|
"render_review",
|
||||||
|
"review_capability_fit",
|
||||||
|
)
|
||||||
|
|||||||
230
tools/assessments/govoplan_assessment/atomic_io.py
Normal file
230
tools/assessments/govoplan_assessment/atomic_io.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""Bounded, private, atomic JSON output for assessment evidence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class AtomicJsonWriteError(RuntimeError):
|
||||||
|
"""Raised when JSON output cannot be written with the required guarantees."""
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_json(
|
||||||
|
path: str | os.PathLike[str],
|
||||||
|
payload: Any,
|
||||||
|
*,
|
||||||
|
max_bytes: int,
|
||||||
|
) -> None:
|
||||||
|
"""Write bounded JSON through a private same-directory temporary file.
|
||||||
|
|
||||||
|
A successful return means the file contents and the containing directory
|
||||||
|
entry have both been flushed. If flushing the directory fails after the
|
||||||
|
atomic replacement, the replacement may be visible but its crash
|
||||||
|
durability is unknown, so the function reports failure. Every parent must
|
||||||
|
already exist as a real directory; symbolic-link components are rejected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
|
||||||
|
raise ValueError("max_bytes must be a positive integer")
|
||||||
|
|
||||||
|
encoded = _encode_bounded_json(payload, max_bytes=max_bytes)
|
||||||
|
target = Path(os.path.abspath(os.fspath(path)))
|
||||||
|
parent = target.parent
|
||||||
|
descriptor = -1
|
||||||
|
verification_descriptor = -1
|
||||||
|
temporary_path: Path | None = None
|
||||||
|
temporary_identity: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
_assert_real_parent(parent)
|
||||||
|
_assert_replaceable_target(target)
|
||||||
|
try:
|
||||||
|
descriptor, temporary_name = tempfile.mkstemp(
|
||||||
|
dir=parent,
|
||||||
|
prefix=".govoplan-json-",
|
||||||
|
suffix=".tmp",
|
||||||
|
)
|
||||||
|
temporary_path = Path(temporary_name)
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if (
|
||||||
|
not stat.S_ISREG(metadata.st_mode)
|
||||||
|
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||||
|
):
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON temporary file could not be secured."
|
||||||
|
)
|
||||||
|
temporary_identity = (metadata.st_dev, metadata.st_ino)
|
||||||
|
verification_descriptor = os.dup(descriptor)
|
||||||
|
|
||||||
|
handle = os.fdopen(descriptor, "wb")
|
||||||
|
descriptor = -1
|
||||||
|
with handle:
|
||||||
|
handle.write(encoded)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
# Recheck after creating and flushing the temporary file so an
|
||||||
|
# unsafe target discovered before replacement is never followed.
|
||||||
|
_assert_real_parent(parent)
|
||||||
|
_assert_replaceable_target(target)
|
||||||
|
os.replace(temporary_path, target)
|
||||||
|
temporary_path = None
|
||||||
|
_assert_installed_target(
|
||||||
|
target,
|
||||||
|
expected_identity=temporary_identity,
|
||||||
|
recovery_descriptor=verification_descriptor,
|
||||||
|
)
|
||||||
|
_fsync_directory(parent)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if descriptor >= 0:
|
||||||
|
os.close(descriptor)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if verification_descriptor >= 0:
|
||||||
|
os.close(verification_descriptor)
|
||||||
|
finally:
|
||||||
|
if temporary_path is not None:
|
||||||
|
try:
|
||||||
|
temporary_path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except AtomicJsonWriteError:
|
||||||
|
raise
|
||||||
|
except OSError as exc:
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"JSON output could not be written atomically."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_bounded_json(payload: Any, *, max_bytes: int) -> bytes:
|
||||||
|
encoder = json.JSONEncoder(
|
||||||
|
allow_nan=False,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
encoded_size = 1 # The document always ends with one newline.
|
||||||
|
for chunk in encoder.iterencode(payload):
|
||||||
|
encoded_chunk = chunk.encode("utf-8")
|
||||||
|
encoded_size += len(encoded_chunk)
|
||||||
|
if encoded_size > max_bytes:
|
||||||
|
raise AtomicJsonWriteError("JSON output exceeds its size limit.")
|
||||||
|
chunks.append(encoded_chunk)
|
||||||
|
return b"".join(chunks) + b"\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_replaceable_target(path: Path) -> None:
|
||||||
|
try:
|
||||||
|
metadata = path.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except OSError as exc:
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"JSON output target could not be inspected safely."
|
||||||
|
) from exc
|
||||||
|
if stat.S_ISLNK(metadata.st_mode):
|
||||||
|
raise AtomicJsonWriteError("JSON output target must not be a symbolic link.")
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise AtomicJsonWriteError("JSON output target must be a regular file.")
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_real_parent(path: Path) -> None:
|
||||||
|
current = Path(path.anchor)
|
||||||
|
for part in path.parts[1:]:
|
||||||
|
current /= part
|
||||||
|
try:
|
||||||
|
metadata = current.lstat()
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"JSON output parent directory must already exist."
|
||||||
|
) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"JSON output parent directory could not be inspected safely."
|
||||||
|
) from exc
|
||||||
|
if stat.S_ISLNK(metadata.st_mode):
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"JSON output parent path must not contain symbolic links."
|
||||||
|
)
|
||||||
|
if not stat.S_ISDIR(metadata.st_mode):
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"JSON output parent path must contain only directories."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_installed_target(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
expected_identity: tuple[int, int] | None,
|
||||||
|
recovery_descriptor: int,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
metadata = path.lstat()
|
||||||
|
except OSError as exc:
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON replacement could not be verified."
|
||||||
|
) from exc
|
||||||
|
observed_identity = (metadata.st_dev, metadata.st_ino)
|
||||||
|
if (
|
||||||
|
expected_identity is None
|
||||||
|
or observed_identity != expected_identity
|
||||||
|
or not stat.S_ISREG(metadata.st_mode)
|
||||||
|
):
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON replacement did not preserve the secured regular file."
|
||||||
|
)
|
||||||
|
if stat.S_IMODE(metadata.st_mode) == 0o600:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
recovery_metadata = os.fstat(recovery_descriptor)
|
||||||
|
if (
|
||||||
|
not stat.S_ISREG(recovery_metadata.st_mode)
|
||||||
|
or (recovery_metadata.st_dev, recovery_metadata.st_ino) != expected_identity
|
||||||
|
):
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON replacement recovery descriptor is invalid."
|
||||||
|
)
|
||||||
|
os.fchmod(recovery_descriptor, 0o600)
|
||||||
|
os.fsync(recovery_descriptor)
|
||||||
|
recovered_metadata = os.fstat(recovery_descriptor)
|
||||||
|
installed_metadata = path.lstat()
|
||||||
|
except OSError as exc:
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON replacement permissions could not be restored."
|
||||||
|
) from exc
|
||||||
|
if (
|
||||||
|
stat.S_IMODE(recovered_metadata.st_mode) != 0o600
|
||||||
|
or (installed_metadata.st_dev, installed_metadata.st_ino) != expected_identity
|
||||||
|
or not stat.S_ISREG(installed_metadata.st_mode)
|
||||||
|
or stat.S_IMODE(installed_metadata.st_mode) != 0o600
|
||||||
|
):
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON replacement permissions could not be restored."
|
||||||
|
)
|
||||||
|
_fsync_directory(path.parent)
|
||||||
|
raise AtomicJsonWriteError(
|
||||||
|
"Atomic JSON replacement permissions changed and were restored."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fsync_directory(path: Path) -> None:
|
||||||
|
flags = os.O_RDONLY
|
||||||
|
if hasattr(os, "O_DIRECTORY"):
|
||||||
|
flags |= os.O_DIRECTORY
|
||||||
|
if hasattr(os, "O_NOFOLLOW"):
|
||||||
|
flags |= os.O_NOFOLLOW
|
||||||
|
descriptor = os.open(path, flags)
|
||||||
|
try:
|
||||||
|
if not stat.S_ISDIR(os.fstat(descriptor).st_mode):
|
||||||
|
raise AtomicJsonWriteError("JSON output parent path must be a directory.")
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
@@ -25,6 +25,14 @@ import govoplan_core.core.module_package_catalog as module_package_catalog
|
|||||||
from govoplan_release.source_provenance import catalog_source_selection
|
from govoplan_release.source_provenance import catalog_source_selection
|
||||||
from govoplan_release.version_alignment import candidate_catalog_version_issues
|
from govoplan_release.version_alignment import candidate_catalog_version_issues
|
||||||
|
|
||||||
|
from .evidence import (
|
||||||
|
BoundaryEvidenceReview,
|
||||||
|
InstalledEvidenceReview,
|
||||||
|
public_key_material_from_keyrings,
|
||||||
|
review_boundary_evidence,
|
||||||
|
review_installed_composition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
RELEASE_REF_PATTERN = re.compile(
|
RELEASE_REF_PATTERN = re.compile(
|
||||||
r"^(?P<channel>[a-z][a-z0-9_-]*)-catalog-(?P<sequence>[0-9]+)$"
|
r"^(?P<channel>[a-z][a-z0-9_-]*)-catalog-(?P<sequence>[0-9]+)$"
|
||||||
@@ -49,6 +57,19 @@ def review_capability_fit(
|
|||||||
published_keyring: dict[str, Any],
|
published_keyring: dict[str, Any],
|
||||||
trusted_keyring: dict[str, Any],
|
trusted_keyring: dict[str, Any],
|
||||||
workspace_root: Path | None = None,
|
workspace_root: Path | None = None,
|
||||||
|
installed_evidence: 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_schema: dict[str, Any] | None = None,
|
||||||
|
boundary_authority_keyring: dict[str, Any] | None = None,
|
||||||
|
boundary_authority_keyring_schema: dict[str, Any] | None = None,
|
||||||
|
verification_time: datetime | None = None,
|
||||||
|
installed_evidence_mode: str = "imported_unsigned",
|
||||||
|
expected_external_provider_subject: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return a secret-free release-drift report for one fit assessment."""
|
"""Return a secret-free release-drift report for one fit assessment."""
|
||||||
|
|
||||||
@@ -68,6 +89,8 @@ def review_capability_fit(
|
|||||||
local_tag_provenance_attempted=0,
|
local_tag_provenance_attempted=0,
|
||||||
local_tag_provenance_expected=0,
|
local_tag_provenance_expected=0,
|
||||||
local_tag_catalog_scope_complete=False,
|
local_tag_catalog_scope_complete=False,
|
||||||
|
installed_review=None,
|
||||||
|
boundary_review=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
catalog_validation = validate_catalog(
|
catalog_validation = validate_catalog(
|
||||||
@@ -340,11 +363,84 @@ def review_capability_fit(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
evidence_verification_time = verification_time or datetime.now(tz=UTC)
|
||||||
|
evidence_verification_mode = (
|
||||||
|
"historical_override" if verification_time is not None else "current"
|
||||||
|
)
|
||||||
|
catalog_dependency_trusted = not any(
|
||||||
|
item.severity == "blocker" for item in findings
|
||||||
|
)
|
||||||
|
installed_review = review_installed_composition(
|
||||||
|
assessment=assessment,
|
||||||
|
catalog_entries=catalog_entries,
|
||||||
|
selected_versions=source_selection.selected_versions,
|
||||||
|
selected_commits=source_selection.selected_commits,
|
||||||
|
evidence=installed_evidence,
|
||||||
|
schema=installed_evidence_schema,
|
||||||
|
observation_mode=installed_evidence_mode,
|
||||||
|
verification_time=evidence_verification_time,
|
||||||
|
verification_mode=evidence_verification_mode,
|
||||||
|
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(
|
||||||
|
Finding(item.severity, item.code, item.message, item.assessment_ids)
|
||||||
|
for item in installed_review.findings
|
||||||
|
)
|
||||||
|
changes.extend(installed_review.changes)
|
||||||
|
changed_repositories.update(installed_review.changed_repositories)
|
||||||
|
|
||||||
|
boundary_review = review_boundary_evidence(
|
||||||
|
assessment=assessment,
|
||||||
|
installed_review=installed_review,
|
||||||
|
evidence=boundary_evidence,
|
||||||
|
evidence_schema=boundary_evidence_schema,
|
||||||
|
authority_keyring=boundary_authority_keyring,
|
||||||
|
authority_keyring_schema=boundary_authority_keyring_schema,
|
||||||
|
verification_time=evidence_verification_time,
|
||||||
|
verification_mode=evidence_verification_mode,
|
||||||
|
expected_external_provider_subject=expected_external_provider_subject,
|
||||||
|
forbidden_authority_public_keys=public_key_material_from_keyrings(
|
||||||
|
published_keyring,
|
||||||
|
trusted_keyring,
|
||||||
|
installer_authority_keyring or {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
findings.extend(
|
||||||
|
Finding(item.severity, item.code, item.message, item.assessment_ids)
|
||||||
|
for item in boundary_review.findings
|
||||||
|
)
|
||||||
|
|
||||||
review_targets = conclusions_needing_review(
|
review_targets = conclusions_needing_review(
|
||||||
assessment=assessment,
|
assessment=assessment,
|
||||||
changed_repositories=changed_repositories,
|
changed_repositories=changed_repositories,
|
||||||
release_changed=assessed_sequence != catalog_sequence,
|
release_changed=assessed_sequence != catalog_sequence,
|
||||||
)
|
)
|
||||||
|
review_targets = merge_review_targets(
|
||||||
|
review_targets,
|
||||||
|
installed_evidence_review_targets(
|
||||||
|
assessment=assessment,
|
||||||
|
installed_review=installed_review,
|
||||||
|
),
|
||||||
|
boundary_review.review_targets,
|
||||||
|
)
|
||||||
return build_report(
|
return build_report(
|
||||||
assessment=assessment,
|
assessment=assessment,
|
||||||
catalog=catalog,
|
catalog=catalog,
|
||||||
@@ -355,6 +451,8 @@ def review_capability_fit(
|
|||||||
local_tag_provenance_attempted=local_tag_provenance_attempted,
|
local_tag_provenance_attempted=local_tag_provenance_attempted,
|
||||||
local_tag_provenance_expected=local_tag_provenance_expected,
|
local_tag_provenance_expected=local_tag_provenance_expected,
|
||||||
local_tag_catalog_scope_complete=local_tag_catalog_scope_complete,
|
local_tag_catalog_scope_complete=local_tag_catalog_scope_complete,
|
||||||
|
installed_review=installed_review,
|
||||||
|
boundary_review=boundary_review,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -512,6 +610,7 @@ def catalog_entries_by_id(
|
|||||||
entries[module_id] = {
|
entries[module_id] = {
|
||||||
"module_id": module_id,
|
"module_id": module_id,
|
||||||
"repository": catalog_repository(item),
|
"repository": catalog_repository(item),
|
||||||
|
"package": catalog_package(item),
|
||||||
"version": _text(item.get("version")),
|
"version": _text(item.get("version")),
|
||||||
"tags": tuple(
|
"tags": tuple(
|
||||||
tag for tag in (catalog_ref_tag(value) for value in refs) if tag
|
tag for tag in (catalog_ref_tag(value) for value in refs) if tag
|
||||||
@@ -534,6 +633,11 @@ def catalog_repository(entry: dict[str, Any]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_package(entry: dict[str, Any]) -> str | None:
|
||||||
|
package = _text(entry.get("python_package"))
|
||||||
|
return package.split("[", 1)[0].strip() if package else None
|
||||||
|
|
||||||
|
|
||||||
def catalog_ref_tag(value: str) -> str | None:
|
def catalog_ref_tag(value: str) -> str | None:
|
||||||
match = TAG_PATTERN.search(value)
|
match = TAG_PATTERN.search(value)
|
||||||
return match.group("tag") if match else None
|
return match.group("tag") if match else None
|
||||||
@@ -709,6 +813,51 @@ def conclusions_needing_review(
|
|||||||
return [targets[key] for key in sorted(targets)]
|
return [targets[key] for key in sorted(targets)]
|
||||||
|
|
||||||
|
|
||||||
|
def installed_evidence_review_targets(
|
||||||
|
*,
|
||||||
|
assessment: dict[str, Any],
|
||||||
|
installed_review: InstalledEvidenceReview,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not installed_review.findings:
|
||||||
|
return []
|
||||||
|
targets: dict[str, dict[str, Any]] = {
|
||||||
|
"assessment.installed_composition": {
|
||||||
|
"id": "assessment.installed_composition",
|
||||||
|
"section": "installed_composition",
|
||||||
|
"reason": "Installed-composition evidence is incomplete, mutable, extra, missing, or inconsistent with the assessed release.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
components = {
|
||||||
|
str(item.get("module_id")): item
|
||||||
|
for item in assessment.get("composition", [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
|
for module_id in sorted(installed_review.affected_module_ids):
|
||||||
|
component = components.get(module_id)
|
||||||
|
target_id = f"composition.{module_id}"
|
||||||
|
targets[target_id] = {
|
||||||
|
"id": target_id,
|
||||||
|
"section": "composition",
|
||||||
|
"repositories": [component.get("repository")]
|
||||||
|
if component is not None
|
||||||
|
else [],
|
||||||
|
"reason": "The observed installed distribution, manifest, RECORD integrity, or immutable provenance differs from the assessed composition.",
|
||||||
|
}
|
||||||
|
return [targets[key] for key in sorted(targets)]
|
||||||
|
|
||||||
|
|
||||||
|
def merge_review_targets(
|
||||||
|
*groups: Iterable[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
merged: dict[str, dict[str, Any]] = {}
|
||||||
|
for group in groups:
|
||||||
|
for item in group:
|
||||||
|
item_id = str(item.get("id") or "")
|
||||||
|
if item_id and item_id not in merged:
|
||||||
|
merged[item_id] = item
|
||||||
|
return [merged[key] for key in sorted(merged)]
|
||||||
|
|
||||||
|
|
||||||
def build_report(
|
def build_report(
|
||||||
*,
|
*,
|
||||||
assessment: dict[str, Any],
|
assessment: dict[str, Any],
|
||||||
@@ -720,6 +869,8 @@ def build_report(
|
|||||||
local_tag_provenance_attempted: int,
|
local_tag_provenance_attempted: int,
|
||||||
local_tag_provenance_expected: int,
|
local_tag_provenance_expected: int,
|
||||||
local_tag_catalog_scope_complete: bool,
|
local_tag_catalog_scope_complete: bool,
|
||||||
|
installed_review: InstalledEvidenceReview | None,
|
||||||
|
boundary_review: BoundaryEvidenceReview | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
finding_list = sorted(
|
finding_list = sorted(
|
||||||
findings,
|
findings,
|
||||||
@@ -736,7 +887,9 @@ def build_report(
|
|||||||
)
|
)
|
||||||
catalog_valid = trusted_signature_valid and published_keyring_valid
|
catalog_valid = trusted_signature_valid and published_keyring_valid
|
||||||
release_valid = catalog_valid and not any(
|
release_valid = catalog_valid and not any(
|
||||||
item.severity in {"blocker", "review"} for item in finding_list
|
item.severity in {"blocker", "review"}
|
||||||
|
and not item.code.startswith(("installed_", "boundary_"))
|
||||||
|
for item in finding_list
|
||||||
)
|
)
|
||||||
tag_provenance_checked = (
|
tag_provenance_checked = (
|
||||||
local_tag_provenance_expected > 0
|
local_tag_provenance_expected > 0
|
||||||
@@ -752,19 +905,36 @@ def build_report(
|
|||||||
status = "review_required"
|
status = "review_required"
|
||||||
else:
|
else:
|
||||||
status = "current"
|
status = "current"
|
||||||
return {
|
installed_scope = (
|
||||||
"report_version": "0.1.0",
|
installed_review.proof_scope
|
||||||
"status": status,
|
if installed_review is not None
|
||||||
"assessment_id": assessment.get("assessment_id"),
|
else {
|
||||||
"assessment_release": assessment.get("release", {}).get("ref")
|
"installed_artifacts": {"checked": False, "valid": None},
|
||||||
if isinstance(assessment.get("release"), dict)
|
"installed_record_integrity": {"checked": False, "valid": None},
|
||||||
else None,
|
"installed_source_provenance": {
|
||||||
"catalog": {
|
"checked": False,
|
||||||
"channel": catalog.get("channel"),
|
"valid": None,
|
||||||
"sequence": catalog.get("sequence"),
|
"basis": "local-pep610-metadata",
|
||||||
"generated_at": catalog.get("generated_at"),
|
"release_origin_bound": False,
|
||||||
},
|
},
|
||||||
"proof_scope": {
|
"installed_release_origin": {
|
||||||
|
"checked": False,
|
||||||
|
"valid": None,
|
||||||
|
"release_origin_bound": False,
|
||||||
|
},
|
||||||
|
"runtime_activation": {"checked": False, "valid": None},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
boundary_scope = (
|
||||||
|
boundary_review.proof_scope
|
||||||
|
if boundary_review is not None
|
||||||
|
else {
|
||||||
|
"target_environment": {"checked": False, "valid": None},
|
||||||
|
"external_providers": {"checked": False, "valid": None},
|
||||||
|
"production_approval": {"checked": False, "valid": None},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
proof_scope = {
|
||||||
"assessment_schema": {"checked": True, "valid": schema_valid},
|
"assessment_schema": {"checked": True, "valid": schema_valid},
|
||||||
"catalog_signature_and_keyring": {
|
"catalog_signature_and_keyring": {
|
||||||
"checked": catalog_checked,
|
"checked": catalog_checked,
|
||||||
@@ -788,11 +958,22 @@ def build_report(
|
|||||||
"attempted_count": local_tag_provenance_attempted,
|
"attempted_count": local_tag_provenance_attempted,
|
||||||
"expected_count": local_tag_provenance_expected,
|
"expected_count": local_tag_provenance_expected,
|
||||||
},
|
},
|
||||||
"installed_artifacts": {"checked": False, "valid": None},
|
**installed_scope,
|
||||||
"target_environment": {"checked": False, "valid": None},
|
**boundary_scope,
|
||||||
"external_providers": {"checked": False, "valid": None},
|
}
|
||||||
"production_approval": {"checked": False, "valid": None},
|
return {
|
||||||
|
"report_version": "0.5.0",
|
||||||
|
"status": status,
|
||||||
|
"assessment_id": assessment.get("assessment_id"),
|
||||||
|
"assessment_release": assessment.get("release", {}).get("ref")
|
||||||
|
if isinstance(assessment.get("release"), dict)
|
||||||
|
else None,
|
||||||
|
"catalog": {
|
||||||
|
"channel": catalog.get("channel"),
|
||||||
|
"sequence": catalog.get("sequence"),
|
||||||
|
"generated_at": catalog.get("generated_at"),
|
||||||
},
|
},
|
||||||
|
"proof_scope": proof_scope,
|
||||||
"findings": [asdict(item) for item in finding_list],
|
"findings": [asdict(item) for item in finding_list],
|
||||||
"changes": sorted(
|
"changes": sorted(
|
||||||
changes,
|
changes,
|
||||||
@@ -803,12 +984,49 @@ def build_report(
|
|||||||
|
|
||||||
|
|
||||||
def render_review(report: dict[str, Any]) -> str:
|
def render_review(report: dict[str, Any]) -> str:
|
||||||
|
proof_scope = report.get("proof_scope", {})
|
||||||
|
installed_scope = proof_scope.get("installed_artifacts", {})
|
||||||
|
installed_checked = installed_scope.get("checked") is True
|
||||||
|
release_origin_checked = (
|
||||||
|
proof_scope.get("installed_release_origin", {}).get("checked") is True
|
||||||
|
)
|
||||||
|
installed_supplied = bool(installed_scope.get("evidence_sha256")) or (
|
||||||
|
"installed_evidence_observation" in proof_scope
|
||||||
|
)
|
||||||
lines = [
|
lines = [
|
||||||
f"Capability fit rerun: {report['status']}",
|
f"Capability fit rerun: {report['status']}",
|
||||||
f"Assessment: {report.get('assessment_id') or '-'} ({report.get('assessment_release') or '-'})",
|
f"Assessment: {report.get('assessment_id') or '-'} ({report.get('assessment_release') or '-'})",
|
||||||
f"Catalog: {report['catalog'].get('channel') or '-'} sequence {report['catalog'].get('sequence') or '-'}",
|
f"Catalog: {report['catalog'].get('channel') or '-'} sequence {report['catalog'].get('sequence') or '-'}",
|
||||||
"Scope: schema, signed release metadata, and optional local tag provenance only.",
|
(
|
||||||
|
"Scope: schema, signed release metadata, optional local tag provenance, "
|
||||||
|
+ (
|
||||||
|
(
|
||||||
|
"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
|
||||||
|
else (
|
||||||
|
"and supplied installed-composition evidence that did not establish local proof."
|
||||||
|
if installed_supplied
|
||||||
|
else "and no installed-composition evidence."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
verification = proof_scope.get("boundary_evidence_verification") or proof_scope.get(
|
||||||
|
"installed_evidence_observation"
|
||||||
|
)
|
||||||
|
if isinstance(verification, dict) and verification.get("evaluated_at"):
|
||||||
|
mode = verification.get("verification_mode")
|
||||||
|
mode_label = (
|
||||||
|
"HISTORICAL override"
|
||||||
|
if mode == "historical_override"
|
||||||
|
else "current-time evaluation"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f"Evidence verification: {mode_label} at {verification['evaluated_at']}."
|
||||||
|
)
|
||||||
findings = report.get("findings") or []
|
findings = report.get("findings") or []
|
||||||
if findings:
|
if findings:
|
||||||
lines.append("Findings:")
|
lines.append("Findings:")
|
||||||
@@ -822,9 +1040,23 @@ def render_review(report: dict[str, Any]) -> str:
|
|||||||
if targets:
|
if targets:
|
||||||
lines.append("Conclusions requiring review:")
|
lines.append("Conclusions requiring review:")
|
||||||
lines.extend(f"- {item['id']}: {item['reason']}" for item in targets)
|
lines.extend(f"- {item['id']}: {item['reason']}" for item in targets)
|
||||||
lines.append(
|
unchecked_boundaries = [
|
||||||
"No installed-artifact, target-provider, or production proof was performed."
|
label
|
||||||
|
for key, label in (
|
||||||
|
("target_environment", "target-environment"),
|
||||||
|
("external_providers", "external-provider"),
|
||||||
|
("production_approval", "production-approval"),
|
||||||
)
|
)
|
||||||
|
if report.get("proof_scope", {}).get(key, {}).get("checked") is not True
|
||||||
|
]
|
||||||
|
if not installed_checked:
|
||||||
|
unchecked_boundaries.insert(0, "installed-composition")
|
||||||
|
if not release_origin_checked:
|
||||||
|
unchecked_boundaries.insert(
|
||||||
|
1 if not installed_checked else 0, "installed-release-origin"
|
||||||
|
)
|
||||||
|
if unchecked_boundaries:
|
||||||
|
lines.append("No " + ", ".join(unchecked_boundaries) + " proof was performed.")
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2898
tools/assessments/govoplan_assessment/evidence.py
Normal file
2898
tools/assessments/govoplan_assessment/evidence.py
Normal file
File diff suppressed because it is too large
Load Diff
204
tools/assessments/govoplan_assessment/installer_receipt.py
Normal file
204
tools/assessments/govoplan_assessment/installer_receipt.py
Normal file
@@ -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
|
||||||
170
tools/assessments/installer-receipt.py
Normal file
170
tools/assessments/installer-receipt.py
Normal file
@@ -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())
|
||||||
322
tools/checks/check-datasource-composition.py
Normal file
322
tools/checks/check-datasource-composition.py
Normal file
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -354,6 +355,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 +385,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 +394,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
|
||||||
|
|||||||
202
tools/checks/security-audit/resolve_workspace_mount.py
Normal file
202
tools/checks/security-audit/resolve_workspace_mount.py
Normal file
@@ -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())
|
||||||
48
tools/checks/security-audit/run.sh
Normal file → Executable file
48
tools/checks/security-audit/run.sh
Normal file → Executable file
@@ -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,13 +143,53 @@ 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
|
||||||
|
|
||||||
@@ -156,10 +200,10 @@ 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" \
|
"${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[@]}"
|
||||||
|
|||||||
6
tools/deployment/__main__.py
Normal file
6
tools/deployment/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Executable entry point for the single-file GovOPlaN deployer."""
|
||||||
|
|
||||||
|
from govoplan_deploy.cli import main
|
||||||
|
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
51
tools/deployment/build-deployer-zipapp.py
Normal file
51
tools/deployment/build-deployer-zipapp.py
Normal file
@@ -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())
|
||||||
18
tools/deployment/govoplan-deploy.py
Normal file
18
tools/deployment/govoplan-deploy.py
Normal file
@@ -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())
|
||||||
19
tools/deployment/govoplan_deploy/__init__.py
Normal file
19
tools/deployment/govoplan_deploy/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
572
tools/deployment/govoplan_deploy/bundle.py
Normal file
572
tools/deployment/govoplan_deploy/bundle.py
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
"""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"
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BundlePaths:
|
||||||
|
root: Path
|
||||||
|
spec: Path
|
||||||
|
env: Path
|
||||||
|
compose: 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,
|
||||||
|
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
|
||||||
|
values["FILE_STORAGE_BACKEND"] = storage.mode
|
||||||
|
if storage.mode == "local":
|
||||||
|
values["FILE_STORAGE_LOCAL_ROOT"] = "/var/lib/govoplan/files"
|
||||||
|
else:
|
||||||
|
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.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,
|
||||||
|
"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",
|
||||||
|
"environment": {"GOVOPLAN_API_UPSTREAM": "http://api:8000"},
|
||||||
|
"depends_on": {"api": {"condition": "service_healthy"}},
|
||||||
|
"ports": [_published_port(spec.listen.address, spec.listen.port, 8080)],
|
||||||
|
"networks": ["internal"],
|
||||||
|
}
|
||||||
|
if spec.components.redis.mode != "disabled":
|
||||||
|
services["worker"] = {
|
||||||
|
**common_runtime,
|
||||||
|
"image": spec.release.api_image,
|
||||||
|
"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"] = {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": spec.installation_id,
|
||||||
|
"services": services,
|
||||||
|
"volumes": volumes,
|
||||||
|
"networks": {
|
||||||
|
"internal": {
|
||||||
|
"driver": "bridge",
|
||||||
|
"ipam": {"config": [{"subnet": spec.network_subnet}]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
692
tools/deployment/govoplan_deploy/cli.py
Normal file
692
tools/deployment/govoplan_deploy/cli.py
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
"""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,
|
||||||
|
service_names,
|
||||||
|
write_env,
|
||||||
|
)
|
||||||
|
from .model import (
|
||||||
|
ComponentConfig,
|
||||||
|
InstallationSpec,
|
||||||
|
ListenConfig,
|
||||||
|
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", "s3"),
|
||||||
|
default=default("local"),
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
"--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,
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
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.",
|
||||||
|
"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", "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)
|
||||||
|
runtime_services = [
|
||||||
|
name
|
||||||
|
for name in ("api", "web", "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)),
|
||||||
|
"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,
|
||||||
|
)
|
||||||
|
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=args.storage or current.components.storage.mode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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", "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.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)}")
|
||||||
|
|
||||||
|
|
||||||
|
@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 _now() -> str:
|
||||||
|
return datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
)
|
||||||
482
tools/deployment/govoplan_deploy/model.py
Normal file
482
tools/deployment/govoplan_deploy/model.py
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
"""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
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ComponentConfig:
|
||||||
|
postgres: ServiceConfig
|
||||||
|
redis: ServiceConfig
|
||||||
|
mail: ServiceConfig
|
||||||
|
storage: StorageConfig
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
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",
|
||||||
|
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")
|
||||||
|
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},
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
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"}, "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"}, "components.storage")
|
||||||
|
storage = StorageConfig(mode=_choice(storage_raw, "mode", {"local", "s3"}))
|
||||||
|
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 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 _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
|
||||||
579
tools/deployment/govoplan_deploy/planning.py
Normal file
579
tools/deployment/govoplan_deploy/planning.py
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
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 == "s3":
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
366
tools/inventory/extract-webui-structure.mjs
Normal file
366
tools/inventory/extract-webui-structure.mjs
Normal file
@@ -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("")
|
||||||
|
);
|
||||||
|
}
|
||||||
462
tools/inventory/platform-interface-inventory.py
Normal file
462
tools/inventory/platform-interface-inventory.py
Normal file
@@ -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)
|
||||||
|
|||||||
397
tools/release/govoplan_release/artifact_identity.py
Normal file
397
tools/release/govoplan_release/artifact_identity.py
Normal file
@@ -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
|
||||||
254
tools/release/govoplan_release/candidate_artifact.py
Normal file
254
tools/release/govoplan_release/candidate_artifact.py
Normal file
@@ -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:
|
||||||
|
|||||||
@@ -334,6 +334,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 +344,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:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
2534
tools/release/govoplan_release/release_execution.py
Normal file
2534
tools/release/govoplan_release/release_execution.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user