Compare commits
81
Commits
568ea6059a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be8ba10ae3 | ||
|
|
d78b13f9d3 | ||
|
|
3c658fa32d | ||
|
|
efd734fad2 | ||
|
|
186aa104ce | ||
|
|
ff9d2404ab | ||
|
|
ba82a85547 | ||
|
|
f1fd143ef5 | ||
|
|
b4248a849e | ||
|
|
ff47659899 | ||
|
|
908090dd0f | ||
|
|
3864ce28b1 | ||
|
|
857dbe55f7 | ||
|
|
c9fcdc90c1 | ||
|
|
82e836b720 | ||
|
|
fcb8296812 | ||
|
|
f2e2eb5517 | ||
|
|
1aea3e7c4f | ||
|
|
3f9567af18 | ||
|
|
de16f11ce8 | ||
|
|
9d6cdff4b8 | ||
|
|
aa4050c0ca | ||
|
|
d3cdbd8c7a | ||
|
|
7115c4711d | ||
|
|
a3beca6fc5 | ||
|
|
11d45bce25 | ||
|
|
7b6135b89b | ||
|
|
5b79e7d377 | ||
|
|
6afb8fea76 | ||
|
|
163b35c0af | ||
|
|
603e07cec5 | ||
|
|
97dfd333c6 | ||
|
|
ba88c574b9 | ||
|
|
8d292184d4 | ||
|
|
52bd3527cd | ||
|
|
ff49dabf8f | ||
|
|
cd15aa514e | ||
|
|
a042baa1d3 | ||
|
|
e80de00f29 | ||
|
|
c69acf0dee | ||
|
|
8b93bbc6b6 | ||
|
|
3fc17701df | ||
|
|
9788bdde0c | ||
|
|
a10a01c903 | ||
|
|
e005e54333 | ||
|
|
76e4baa76e | ||
|
|
b6e9a9bd81 | ||
|
|
dc46bda224 | ||
|
|
b00d4e55ee | ||
|
|
76f19dc602 | ||
|
|
5381e37a9e | ||
|
|
7ecf1f17b0 | ||
|
|
349a099e5a | ||
|
|
fdee766993 | ||
|
|
47b9c05bde | ||
|
|
4e42911477 | ||
|
|
9c0650b2ed | ||
|
|
4dc0c8d013 | ||
|
|
35c346a1fa | ||
|
|
4edbc11fe5 | ||
|
|
ddee5c00dc | ||
|
|
4c16069f88 | ||
|
|
22f5c2ff4e | ||
|
|
19c4b63ade | ||
|
|
1dc9148ec3 | ||
|
|
ead697d049 | ||
|
|
65aa8e0b7c | ||
|
|
6c0b003dee | ||
|
|
d0dc916837 | ||
|
|
bd715a8473 | ||
|
|
ad8dd4f319 | ||
|
|
753dd2a3ee | ||
|
|
b6bef410d6 | ||
|
|
72f697c341 | ||
|
|
36f662c56f | ||
|
|
25fbbaf5ad | ||
|
|
bcab7095c3 | ||
|
|
99534c0251 | ||
|
|
71e74601cf | ||
|
|
c29f1e9977 | ||
|
|
5447299289 |
+13
-2
@@ -2,17 +2,24 @@
|
||||
# Copy to a deployment-local .env or secret store. Do not commit populated secrets.
|
||||
|
||||
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
|
||||
MASTER_KEY_B64=<generate-with-govoplan-config-env-template-generate-secrets>
|
||||
|
||||
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_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_engine,workflow,views,search,risk_compliance,postbox,notifications,services,parties,mandates,decisions,portal,cases,committee,docs,ops
|
||||
|
||||
CELERY_ENABLED=true
|
||||
REDIS_URL=redis://127.0.0.1:6379/0
|
||||
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
|
||||
|
||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
|
||||
@@ -24,10 +31,14 @@ GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
||||
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
|
||||
|
||||
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||
AUTH_ACTIVITY_TOUCH_INTERVAL_SECONDS=300
|
||||
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||
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
|
||||
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
|
||||
|
||||
@@ -21,23 +21,13 @@ jobs:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Configure SSH for release dependencies
|
||||
env:
|
||||
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
|
||||
- name: Use anonymous HTTPS for public GovOPlaN repositories
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
if [ -z "${GOVOPLAN_RELEASE_SSH_KEY_B64:-}" ]; then
|
||||
echo "GOVOPLAN_RELEASE_SSH_KEY_B64 secret is required for git+ssh 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
|
||||
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan"
|
||||
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan"
|
||||
- name: Bootstrap GovOPlaN repositories
|
||||
working-directory: govoplan
|
||||
run: python tools/repo/bootstrap-repositories.py --parent ..
|
||||
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website
|
||||
- name: Install backend dev audit dependencies
|
||||
working-directory: govoplan
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Deployment Installer
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deployment-installer:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
path: govoplan
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Compile deployment tooling
|
||||
working-directory: govoplan
|
||||
run: python -m py_compile tools/deployment/govoplan-deploy.py tools/deployment/govoplan_deploy/*.py
|
||||
- name: Test declarative deployment bundle
|
||||
working-directory: govoplan
|
||||
run: python -m unittest -v tests.test_deployment_installer
|
||||
- name: Build single-file deployer artifact
|
||||
working-directory: govoplan
|
||||
run: |
|
||||
python tools/deployment/build-deployer-zipapp.py --output /tmp/govoplan-deploy.pyz
|
||||
python /tmp/govoplan-deploy.pyz --help
|
||||
@@ -7,6 +7,18 @@ on:
|
||||
jobs:
|
||||
module-matrix:
|
||||
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:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
@@ -17,32 +29,38 @@ jobs:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Configure SSH for release dependencies
|
||||
env:
|
||||
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
|
||||
- name: Use anonymous HTTPS for public GovOPlaN repositories
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
if [ -z "${GOVOPLAN_RELEASE_SSH_KEY_B64:-}" ]; then
|
||||
echo "GOVOPLAN_RELEASE_SSH_KEY_B64 secret is required for git+ssh 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
|
||||
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan"
|
||||
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan"
|
||||
- name: Bootstrap GovOPlaN repositories
|
||||
working-directory: govoplan
|
||||
run: python tools/repo/bootstrap-repositories.py --parent ..
|
||||
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website
|
||||
- name: Install backend release dependencies
|
||||
working-directory: govoplan
|
||||
run: |
|
||||
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 -m pip install '../govoplan-core[dev]'
|
||||
.venv/bin/python -m pip install --no-deps ../govoplan-search
|
||||
- name: Install WebUI release dependencies with test scripts
|
||||
working-directory: govoplan
|
||||
run: bash tools/release/install-webui-release-dependencies.sh ../govoplan-core/webui
|
||||
- name: Validate 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
|
||||
working-directory: govoplan
|
||||
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
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Configure SSH for release dependencies
|
||||
env:
|
||||
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
|
||||
- name: Use anonymous HTTPS for public GovOPlaN repositories
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
if [ -z "${GOVOPLAN_RELEASE_SSH_KEY_B64:-}" ]; then
|
||||
echo "GOVOPLAN_RELEASE_SSH_KEY_B64 secret is required for git+ssh 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
|
||||
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan"
|
||||
git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan"
|
||||
- name: Bootstrap GovOPlaN repositories
|
||||
working-directory: govoplan
|
||||
run: python tools/repo/bootstrap-repositories.py --parent ..
|
||||
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website
|
||||
- name: Install backend release integration dependencies
|
||||
working-directory: govoplan
|
||||
run: |
|
||||
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 -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
|
||||
working-directory: govoplan
|
||||
run: bash tools/release/install-webui-release-dependencies.sh ../govoplan-core/webui
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
name: Security Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -13,37 +12,31 @@ jobs:
|
||||
security-audit:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SECURITY_AUDIT_MODE: ci
|
||||
SECURITY_AUDIT_MODE: full
|
||||
SECURITY_AUDIT_SCOPE: govoplan
|
||||
SECURITY_AUDIT_FAIL_ON_FINDINGS: "0"
|
||||
SECURITY_AUDIT_REQUIRE_TOOLS: "1"
|
||||
GIT_CONFIG_COUNT: "2"
|
||||
GIT_CONFIG_KEY_0: url.https://git.add-ideas.de/GovOPlaN/govoplan.insteadOf
|
||||
GIT_CONFIG_VALUE_0: git@git.add-ideas.de:GovOPlaN/govoplan
|
||||
GIT_CONFIG_KEY_1: url.https://git.add-ideas.de/GovOPlaN/govoplan.insteadOf
|
||||
GIT_CONFIG_VALUE_1: ssh://git@git.add-ideas.de/GovOPlaN/govoplan
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
path: govoplan
|
||||
- name: Configure SSH for repository bootstrap
|
||||
env:
|
||||
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
if [ -z "${GOVOPLAN_RELEASE_SSH_KEY_B64:-}" ]; then
|
||||
echo "GOVOPLAN_RELEASE_SSH_KEY_B64 secret is required for git+ssh repository bootstrap."
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$GOVOPLAN_RELEASE_SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
echo 'git.add-ideas.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDe48IOof2fJS1dTbJtLWQnWnr+JorZXKIFdOAM9ct8G' > ~/.ssh/known_hosts
|
||||
chmod 600 ~/.ssh/known_hosts
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Bootstrap GovOPlaN repositories
|
||||
working-directory: govoplan
|
||||
run: python tools/repo/bootstrap-repositories.py --parent ..
|
||||
- name: Run security audit
|
||||
run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website
|
||||
- name: Run whole-system security audit
|
||||
working-directory: govoplan
|
||||
run: tools/checks/security-audit/run.sh --mode "$SECURITY_AUDIT_MODE" --scope "$SECURITY_AUDIT_SCOPE" --reports-dir audit-reports
|
||||
- name: Upload audit reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||
with:
|
||||
name: security-audit-reports
|
||||
path: govoplan/audit-reports
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# GovOPlaN Workspace Codex Guide
|
||||
|
||||
## Scope
|
||||
|
||||
This repository coordinates the GovOPlaN workspace, release catalog, shared
|
||||
checks, development environment, and cross-repository automation. Business and
|
||||
platform behavior remains owned by the corresponding module repository.
|
||||
|
||||
## Documentation Contract
|
||||
|
||||
- Treat documentation as part of every behavior change. Update the owning module's manifest-driven `DocumentationTopic` contributions for each affected user and administrator workflow, setting, permission, limitation, and operational consequence.
|
||||
- Keep feature content in the owning module. The optional `govoplan-docs` module projects module contributions and must not import feature internals.
|
||||
- Every module manifest must provide a static user and administrator baseline, even when `documentation_providers` add configured-state details.
|
||||
- Run `tools/checks/check-manifest-shapes.py` after module behavior or manifest changes. Run `tools/checks/check-focused.sh` for cross-module changes.
|
||||
|
||||
## Working Rules
|
||||
|
||||
- Treat Gitea issues as the canonical backlog and state log.
|
||||
- Preserve optional module boundaries and use Core contracts or capabilities for integrations.
|
||||
- Prefer targeted checks before full workspace scans.
|
||||
- Do not start persistent development servers unless requested.
|
||||
@@ -4,6 +4,12 @@
|
||||
**Repository type:** system (meta).
|
||||
<!-- 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
|
||||
whole-product development, release orchestration, repository bootstrap, and
|
||||
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
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
@@ -126,6 +142,23 @@ Start the local release console:
|
||||
./.venv/bin/python tools/release/release-console.py
|
||||
```
|
||||
|
||||
Create and validate a private, declarative installation bundle:
|
||||
|
||||
```sh
|
||||
./.venv/bin/python tools/deployment/govoplan-deploy.py init \
|
||||
--directory ~/.local/share/govoplan/installations/default
|
||||
./.venv/bin/python tools/deployment/govoplan-deploy.py doctor \
|
||||
--directory ~/.local/share/govoplan/installations/default
|
||||
```
|
||||
|
||||
The current executable slice and remaining production gates are documented in
|
||||
[Installation and Deployment Architecture](docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md).
|
||||
Same-host replica balancing and the multi-host promotion boundary are documented
|
||||
in [Scaling and Multi-Host Deployment](docs/SCALING_AND_MULTI_HOST_DEPLOYMENT.md).
|
||||
The recovery state machine, migration rollback boundary, and required restore
|
||||
drills are documented in
|
||||
[Recovery and Rollback Guarantees](docs/RECOVERY_AND_ROLLBACK_GUARANTEES.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
The repository root `.env.example` is the self-hosted operator template for a
|
||||
@@ -146,17 +179,36 @@ Meta ownership and module install/contract boundaries are documented in
|
||||
`docs/META_REPO_SCAN.md` and `docs/MODULE_CONTRACTS_AND_INSTALLS.md`.
|
||||
Frontend layout principles for module pages are documented in
|
||||
`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,
|
||||
connected outcome stories, and capability horizons are documented in
|
||||
the [Connected Governance Platform Roadmap](docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md).
|
||||
The reconciled institutional semantics, source-authority modes, module layers,
|
||||
candidate Mandates/Services/Parties/Decisions boundaries, and migration
|
||||
sequence are documented in the
|
||||
[Institutional Governance Target Architecture](docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||
The selected Campaign-to-Postbox-to-data-to-collaboration implementation path,
|
||||
including stage gates and shared documentation expectations, is in the
|
||||
[Reference Journey Program](docs/REFERENCE_JOURNEY_PROGRAM.md).
|
||||
The administrator journey from Core-only bootstrap through online module
|
||||
installation, scale-out, and reversible environment promotion is defined in
|
||||
[System Administrator Lifecycle User Story](docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md).
|
||||
The corresponding host deployment compiler, managed/external component choices,
|
||||
reconfiguration semantics, and safe Web update boundary are defined in
|
||||
[Installation and Deployment Architecture](docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md).
|
||||
The concrete replica, worker-node, load-balancer, and shared-state topology is
|
||||
defined in [Scaling and Multi-Host Deployment](docs/SCALING_AND_MULTI_HOST_DEPLOYMENT.md).
|
||||
Durable deployment journals, Core recovery evidence, and the distinction
|
||||
between pre-migration configuration restore and post-migration forward recovery
|
||||
are defined in
|
||||
[Recovery and Rollback Guarantees](docs/RECOVERY_AND_ROLLBACK_GUARANTEES.md).
|
||||
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
|
||||
|
||||
|
||||
@@ -117,7 +117,12 @@ tools/checks/postgres-integration-check.py \
|
||||
```
|
||||
|
||||
`--reset-schema` drops and recreates the `public` schema before every module
|
||||
set. Use it only against this disposable database.
|
||||
set. Use it only against this disposable database. Before those permutations,
|
||||
the check runs the Files credential-retirement atomicity proof in random,
|
||||
test-owned schemas. The proof does not modify `public` and fails the release
|
||||
gate if its PostgreSQL or full-stack dependencies are unavailable. The
|
||||
`--skip-retirement-atomicity` option is only for bounded migration/smoke
|
||||
diagnosis; do not use it for release evidence.
|
||||
|
||||
Stop the testbed:
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ Current shared profiles:
|
||||
- `govoplan/dev/postgres`
|
||||
- `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:
|
||||
|
||||
- `govoplan-campaign/dev/mail-testbed`
|
||||
|
||||
@@ -4,27 +4,35 @@
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Assessment ID | `campaign-reference-2026-07-20` |
|
||||
| Assessment ID | `campaign-reference-2026-07-22` |
|
||||
| Schema | `govoplan.fit-assessment/0.1.0` |
|
||||
| Assessed on | 2026-07-20 |
|
||||
| Product snapshot | Untagged workspace at meta commit `05ae81d64195`; this is not a release artifact |
|
||||
| Assessed on | 2026-07-22 |
|
||||
| Product snapshot | Live Ed25519-signed stable catalog sequence `202607220843`: Core `v0.1.13` and Campaign `v0.1.10` |
|
||||
| Configuration basis | Root `.env.example` and the production-like development profile |
|
||||
| Scope | Campaign-centric internal pilot and small-production candidate |
|
||||
| Explicitly postponed | Workflow and workflow-driven user stories |
|
||||
| Machine-readable companion | [`capability-fit-current.json`](capability-fit-current.json) |
|
||||
| Input schema | [`capability-fit.schema.json`](capability-fit.schema.json) |
|
||||
|
||||
**Snapshot notice:** this assessment remains valid only for the pinned
|
||||
2026-07-22 composition above. Workflow Engine, the optional Workflow editor,
|
||||
Datasources, Dataflow, Search, encryption contracts, and other later main-branch
|
||||
work must not be inferred into this evidence record. The current product
|
||||
direction and implemented-state reconciliation are documented separately in
|
||||
the
|
||||
[Institutional Governance Target Architecture](INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||
|
||||
This is a fit assessment, not a production approval or security certification.
|
||||
It deliberately does not infer implementation from a repository, issue, or
|
||||
manifest existing. A conclusion needs code plus a route/contract, test,
|
||||
operational drill, configured example, or explicit evidence that the capability
|
||||
is absent.
|
||||
|
||||
The checkout contained unrelated uncommitted work when this assessment was
|
||||
made. A dirty repository is recorded below, but local-only changes do not count
|
||||
as an integrated release capability. Tests run against the dirty workspace are
|
||||
useful implementation evidence and are labelled as such; they do not turn the
|
||||
workspace into a reproducible release.
|
||||
The release baseline below is reproducible at its tagged source and package
|
||||
references. Later main-branch commits do not become release capabilities merely
|
||||
because they are committed, pushed, or code-tested. This assessment therefore
|
||||
distinguishes code-tested, package-integrated, target-tested, and
|
||||
production-approved evidence explicitly.
|
||||
|
||||
## Controlled status vocabulary
|
||||
|
||||
@@ -51,12 +59,13 @@ non-production SMTP/IMAP service. The module contract, Campaign authoring and
|
||||
validation paths, managed attachments, mail-profile boundary, local audit
|
||||
records, and operator status surface all have direct code/test evidence.
|
||||
|
||||
It is not yet possible to call the current workspace a supported small-production
|
||||
distribution. The production-like profile intentionally runs only PostgreSQL
|
||||
and Redis in containers; API, WebUI, worker, and scheduler processes still run
|
||||
from editable source trees. A target deployment must supply TLS termination,
|
||||
process supervision, secret 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)
|
||||
The signed catalog resolves the earlier source/package reproducibility gap, but
|
||||
it does not make the composition production-approved. The production-like
|
||||
profile intentionally runs only PostgreSQL and Redis in containers; API,
|
||||
WebUI, worker, and scheduler processes still run from editable source trees. A
|
||||
target deployment must supply TLS termination, process supervision, secret
|
||||
injection, monitoring, backup storage, and recovery procedures. The open
|
||||
[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
|
||||
disaster-recovery plan are also not complete.
|
||||
|
||||
@@ -64,43 +73,48 @@ The current recommendation is therefore:
|
||||
|
||||
- **Pilot:** fit with conditions and a bounded proof of concept.
|
||||
- **Small production:** partial fit; do not approve until the proof checks and
|
||||
operational gates below pass against a clean, pinned release candidate.
|
||||
operational gates below pass against the signed release and target
|
||||
environment.
|
||||
- **Workflow:** planned and postponed; it is not part of either recommended
|
||||
composition in this assessment.
|
||||
|
||||
## Pinned composition
|
||||
|
||||
The configured reference composition comes from the meta repository's
|
||||
`ENABLED_MODULES`. Module versions come from the actual manifests. `Dirty`
|
||||
means that repository had uncommitted changes at assessment time.
|
||||
`ENABLED_MODULES`. Versions and refs below are pinned by the live stable catalog;
|
||||
commits are the commits peeled from those annotated tags.
|
||||
|
||||
| Module | Repository commit | Manifest version | Dirty | Role in this assessment |
|
||||
| --- | --- | ---: | :---: | --- |
|
||||
| Core runtime | `govoplan-core@e6f7c45f0a95` | server `0.3.0` | yes | API, registry, migration orchestration, shared WebUI, sessions and kernel contracts |
|
||||
| `tenancy` | `govoplan-tenancy@1dde03854733` | 0.1.8 | no | tenant context and lifecycle |
|
||||
| `organizations` | `govoplan-organizations@45cda1a33f18` | 0.1.8 | no | organization model |
|
||||
| `identity` | `govoplan-identity@7a1710af896f` | 0.1.8 | yes | normalized internal identity directory |
|
||||
| `access` | `govoplan-access@ab07075a679b` | 0.1.8 | yes | local authentication, sessions, API keys and RBAC |
|
||||
| `admin` | `govoplan-admin@c9e1fb287f50` | 0.1.8 | no | administration surfaces |
|
||||
| `dashboard` | `govoplan-dashboard@a315a7c62b1d` | 0.1.8 | no | module-aware home surface |
|
||||
| `policy` | `govoplan-policy@2511fbb5a864` | 0.1.8 | no | policy explanation/configuration boundary |
|
||||
| `audit` | `govoplan-audit@5e4f84a789ea` | 0.1.8 | no | database audit records and retrying audit outbox |
|
||||
| `campaigns` | `govoplan-campaign@2e593b7fa412` | 0.1.8 | yes | Campaign authoring, build, delivery control and reporting |
|
||||
| `files` | `govoplan-files@f3210234d35a` | 0.1.8 | yes | managed files and Campaign attachments |
|
||||
| `mail` | `govoplan-mail@b0337b2d261a` | 0.1.8 | yes | SMTP/IMAP profiles and transports |
|
||||
| `calendar` | `govoplan-calendar@371a00aff51c` | 0.1.8 | yes | optional calendar; not needed by the Campaign pilot |
|
||||
| `docs` | `govoplan-docs@f2ade1d62475` | 0.1.8 | yes | configured-system documentation |
|
||||
| `ops` | `govoplan-ops@341773a4ff8a` | 0.1.8 | no | readiness and deployment-profile visibility |
|
||||
| Module | Tagged source commit | Catalog version/ref | Role in this assessment |
|
||||
| --- | --- | --- | --- |
|
||||
| Core runtime | `govoplan-core@d487726f4d2c` | `0.1.13` / `v0.1.13` | API, registry, migration orchestration, shared WebUI, sessions and kernel contracts |
|
||||
| `tenancy` | `govoplan-tenancy@efbec827616b` | `0.1.8` / `v0.1.8` | tenant context and lifecycle |
|
||||
| `organizations` | `govoplan-organizations@39c081c4fb8f` | `0.1.8` / `v0.1.8` | organization model |
|
||||
| `identity` | `govoplan-identity@7a1710af896f` | `0.1.8` / `v0.1.8` | normalized internal identity directory |
|
||||
| `access` | `govoplan-access@f1d64d247e12` | `0.1.11` / `v0.1.11` | local authentication, sessions, API keys and RBAC |
|
||||
| `admin` | `govoplan-admin@11ecf362a36d` | `0.1.8` / `v0.1.8` | administration surfaces |
|
||||
| `dashboard` | `govoplan-dashboard@4b960ad37f0d` | `0.1.8` / `v0.1.8` | module-aware home surface |
|
||||
| `policy` | `govoplan-policy@1063622d311a` | `0.1.9` / `v0.1.9` | policy explanation/configuration boundary |
|
||||
| `audit` | `govoplan-audit@d3d2c60d7dc1` | `0.1.8` / `v0.1.8` | database audit records and retrying audit outbox |
|
||||
| `campaigns` | `govoplan-campaign@735e874bd03c` | `0.1.10` / `v0.1.10` | Campaign authoring, build, delivery control and reporting |
|
||||
| `files` | `govoplan-files@2b34f6e30578` | `0.1.9` / `v0.1.9` | managed files and Campaign attachments |
|
||||
| `mail` | `govoplan-mail@3e2302909022` | `0.1.10` / `v0.1.10` | SMTP/IMAP profiles and transports |
|
||||
| `calendar` | `govoplan-calendar@9bcf41bb1fbb` | `0.1.8` / `v0.1.8` | optional calendar; not needed by the Campaign pilot |
|
||||
| `docs` | `govoplan-docs@be52b716caed` | `0.1.10` / `v0.1.10` | configured-system documentation |
|
||||
| `ops` | `govoplan-ops@341773a4ff8a` | `0.1.8` / `v0.1.8` | readiness and deployment-profile visibility |
|
||||
|
||||
No tagged release or configuration package is pinned. That is a reproducibility
|
||||
gap, not a harmless omission. The static contract scan found 43 interface
|
||||
contracts, 29 providers, and no contract errors in this workspace. This proves
|
||||
the scanned manifest graph is internally consistent; it does not prove the
|
||||
behavior of every repository in that graph.
|
||||
Stable catalog sequence `202607220843` is valid, signed by trusted key
|
||||
`release-key-1`, and pins matching Python and WebUI refs where applicable. This
|
||||
proves release source/package selection and provenance, not behavior in a target
|
||||
environment. No configuration revision/package is pinned yet, so deployment
|
||||
configuration remains a separate reproducibility and promotion gate. The static
|
||||
contract scan found 43 interface contracts, 33 providers, and no contract errors
|
||||
in the current workspace. That proves the scanned manifest graph is internally
|
||||
consistent; it does not prove every provider or production journey.
|
||||
|
||||
`govoplan-addresses@f19350e65d76` (manifest 0.1.8, dirty) is an optional extension
|
||||
for reusable recipient sources. It is not enabled in the pinned root profile and
|
||||
must be added explicitly when the pilot needs address lists or CardDAV.
|
||||
`govoplan-addresses@93dddbb8c52a` (`0.1.9` / `v0.1.9`) is an optional catalogued
|
||||
extension for reusable recipient sources. It is not enabled in the pinned root
|
||||
profile and must be added explicitly when the pilot needs address lists or
|
||||
CardDAV.
|
||||
|
||||
## Recommended scenarios
|
||||
|
||||
@@ -128,36 +142,36 @@ persistence, and durable shared or object storage. Run one scheduler only when a
|
||||
selected module needs scheduled recovery. Multiple scheduler replicas require
|
||||
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.
|
||||
|
||||
## Functional capability matrix
|
||||
|
||||
| Capability | Status | Evidence and scope | Conditions, gaps, or exclusions |
|
||||
| --- | --- | --- | --- |
|
||||
| Module-aware API/WebUI composition | `verified` | Core registry tests cover installed manifests, route contribution, missing modules, and module permutations; the static contract scan passed. | The pinned workspace is dirty and untagged; repeat on a clean release candidate. |
|
||||
| Module-aware API/WebUI composition | `verified` | Core registry tests cover installed manifests, route contribution, missing modules, and module permutations; the static contract scan passed; stable catalog sequence `202607220843` has a valid trusted signature and matching package refs. | Package integration is verified; repeat the module-permutation checks on the installed target composition. |
|
||||
| Local tenants, accounts, sessions, API keys and RBAC | `verified` | Access/tenancy manifests, auth dependency tests, cookie/CSRF API smoke tests, and role/permission contracts. | External identity providers are not included in this conclusion. |
|
||||
| Campaign draft authoring, validation, recipient import, build, execution snapshot and mock send | `verified` | Core API smoke tests exercise create/validate/build/mock-send, frozen delivery configuration, policy gates, job deltas and reconciliation; Campaign-focused tests passed in this workspace. | Current Campaign UI/code also contains substantial unintegrated WIP; usability and release integration need separate acceptance. |
|
||||
| Campaign draft authoring, validation, recipient import, build, execution snapshot and mock send | `verified` | Core API smoke tests exercise create/validate/build/mock-send, frozen delivery configuration, policy gates, job deltas and reconciliation; Campaign-focused tests passed; Campaign `v0.1.10` and Core `v0.1.13` are package-integrated in the signed stable catalog. | Code and package integration are verified; usability and target-provider acceptance remain separate. |
|
||||
| Managed Campaign attachments and local file storage | `verified` | Files capability/route tests, Campaign attachment-build tests, and local-storage backend code. | The deployment must provide a durable path and include files in database/storage backup plans. |
|
||||
| SMTP send, IMAP append and mailbox/profile policy | `available_unconfigured` | Mail adapters, encrypted profile model, helper tests, Campaign mail-policy smoke tests, and a GreenMail-oriented runbook/testbed. | No target mail server, TLS chain, throttling, sender policy, bounce/reply process, or real delivery receipt was exercised here. |
|
||||
| Delivery retry, uncertainty and reconciliation | `partial` | Campaign API smoke tests include worker-loss/outcome-unknown reconciliation and frozen job evidence. | A target-provider failure drill and operational alerting are still required; local Campaign audit-proof work is not release-integrated. |
|
||||
| Delivery retry, uncertainty and reconciliation | `partial` | Campaign API smoke tests include worker-loss/outcome-unknown reconciliation and frozen job evidence; those paths are present in catalogued Campaign `v0.1.10`. | A target-provider failure drill and operational alerting are still required; package integration does not prove provider behavior. |
|
||||
| Reusable address lists and Campaign recipient-source integration | `available_unconfigured` | Address list/recipient capability and Campaign optional lookup tests passed; manifests expose optional interfaces. | `addresses` is not in the pinned profile. Configure permissions and data governance before use. |
|
||||
| CardDAV address synchronization | `available_unconfigured` | Discovery, preview/conflict, two-way create/update/delete and disconnect tests passed in the current Addresses workspace. | Requires target-server interoperability, credentials, CA/TLS, rate-limit, conflict and restore drills. |
|
||||
| File connectors (WebDAV/Nextcloud/Seafile/SMB/S3 import) | `partial` | Provider descriptors and browse/import helper tests exist; S3 connector tests passed. | Coverage varies by provider and optional dependency. No target content system was exercised; connector egress and provenance policy need review. |
|
||||
| Local audit log and governed-event retry outbox | `verified` | Audit module contract plus enqueue/dispatch/failure-for-retry tests passed. | Central audit/SIEM export, retention enforcement and operational monitoring are not verified. |
|
||||
| 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 storage/sync tests exist; durable external-write outbox, worker recovery and retention are local WIP in this snapshot. | Not required for the Campaign pilot and must not be treated as release-integrated yet. 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. |
|
||||
| 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. |
|
||||
|
||||
## Infrastructure matrix
|
||||
|
||||
| Component | Status | Current evidence | Target requirement / gap |
|
||||
| --- | --- | --- | --- |
|
||||
| WebUI and API | `verified` | FastAPI app, module routes, shared WebUI host, health endpoint, dev smoke and module-permutation tests. | Build immutable, matching frontend/backend artifacts and supervise them; no production image/service bundle is supplied here. |
|
||||
| WebUI and API | `verified` | FastAPI app, module routes, shared WebUI host, health endpoint, dev smoke and module-permutation tests; the catalog pins matching Core/Campaign backend and WebUI refs. | Materialize and supervise immutable artifacts in the target; no production image/service bundle is supplied here. |
|
||||
| Background workers | `available_unconfigured` | Celery app, Campaign queues, Redis configuration and production-like launcher. | Run target broker/worker heartbeat and failure drills; split queues when provider limits or load justify it. |
|
||||
| Scheduler | `partial` | A separately launched Celery beat process and Calendar recovery schedule exist in local WIP. | Keep single-instance until leader election/locking is proved; add process supervision and missed-schedule alerts. |
|
||||
| Scheduler | `partial` | A separately launched Celery beat process exists; Calendar recovery scheduling is committed and pushed after the catalogued `v0.1.8` tag. | The Calendar recovery slice is not stable-package-integrated. Keep single-instance until leader election/locking is proved; add process supervision and missed-schedule alerts. |
|
||||
| PostgreSQL | `verified` | Production target in settings/operator docs, migration tracks and a disposable integration-check harness. | Target HA, patching, connection limits, WAL/archive policy and restore time are external deployment decisions. |
|
||||
| Redis/queues | `available_unconfigured` | Redis-backed Celery configuration, health ping and append-only production-like container. | Target availability, persistence, eviction, authentication/TLS and queue-loss behavior were not assessed. |
|
||||
| Local file storage | `verified` | Local backend, configured root, fallback-root tests and Ops writability check. | Suitable only for a durable single-node/shared path with backup; it blocks independent API scaling when node-local. |
|
||||
@@ -171,13 +185,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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
The scalability and sizing documentation delivered the documentation portions
|
||||
of [Core #217](https://git.add-ideas.de/add-ideas/govoplan-core/issues/217) and
|
||||
[Core #219](https://git.add-ideas.de/add-ideas/govoplan-core/issues/219).
|
||||
[Core #28](https://git.add-ideas.de/add-ideas/govoplan-core/issues/28) records the
|
||||
of [Core #217](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/217) and
|
||||
[Core #219](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/219).
|
||||
[Core #28](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/28) records the
|
||||
operator-documentation slice. These closed tickets are evidence of documented
|
||||
models, not evidence that an organization's production environment has passed
|
||||
them.
|
||||
@@ -209,7 +223,8 @@ Facts:
|
||||
|
||||
- The production-like profile is a development validation composition, not a
|
||||
production deployment artifact.
|
||||
- The assessed workspace is untagged and several key repositories are dirty.
|
||||
- The assessed package baseline is the trusted, signed stable catalog sequence
|
||||
`202607220843`; it pins Core `v0.1.13` and Campaign `v0.1.10`.
|
||||
- Workflow is postponed.
|
||||
- The default composition does not enable Addresses.
|
||||
- Health/readiness checks exist; an external monitoring stack does not.
|
||||
@@ -225,22 +240,25 @@ Assumptions that require confirmation:
|
||||
|
||||
Highest risks:
|
||||
|
||||
1. A dirty, untagged workspace cannot be reproduced or promoted safely.
|
||||
1. A signed, reproducible package selection can still be installed or configured
|
||||
incorrectly and has not been accepted in the target environment.
|
||||
2. Real SMTP/IMAP behavior, throttling and ambiguous outcomes have not been
|
||||
proven against the target provider.
|
||||
3. Backup/restore and disaster recovery are not demonstrated across database,
|
||||
files, keys and deployment configuration.
|
||||
4. External identity, monitoring, secret-store and reverse-proxy controls are
|
||||
deployment gaps rather than GovOPlaN-delivered components.
|
||||
5. Local Campaign and Calendar WIP can look complete in the checkout while
|
||||
remaining absent from the remote release baseline.
|
||||
5. Post-tag Calendar work is committed and pushed but remains outside the signed
|
||||
stable package baseline; branch integration must not be mistaken for a
|
||||
release.
|
||||
6. Data classification, retention, recipient consent/legal basis and external
|
||||
disclosure rules are not assessed for a concrete organization.
|
||||
|
||||
## Proof checks before promotion
|
||||
|
||||
1. Create a clean release candidate that pins every module/package and WebUI
|
||||
artifact; rerun the contract, module-permutation and focused test gates.
|
||||
1. Materialize the signed catalog into an isolated installation and rerun the
|
||||
contract, migration, module-permutation, and focused acceptance gates against
|
||||
the installed artifacts.
|
||||
2. Run a target-like Campaign from import through validation, attachment build,
|
||||
queue, SMTP acceptance, IMAP append, reporting and audit using safe data.
|
||||
3. Drill transient SMTP failure, accepted-but-local-commit-lost uncertainty,
|
||||
@@ -320,20 +338,284 @@ assumptions, gaps, risks, recommendation and proof check. Reassessment must pin
|
||||
new composition and review any row whose code, evidence, configuration or target
|
||||
requirement changed.
|
||||
|
||||
### Repeatable release rerun
|
||||
|
||||
The release-aware rerun tool validates the machine-readable assessment against
|
||||
its JSON Schema, verifies the catalog's Ed25519 signature against an
|
||||
independently pinned local trust keyring, and separately verifies the canonical
|
||||
hash of the published or candidate keyring pinned by the signed catalog. The
|
||||
downloaded keyring is therefore never its own sole trust root. The tool compares
|
||||
catalog sequence and module versions and—when local checkouts are available—checks
|
||||
annotated release tags against assessed commits. For repositories represented in
|
||||
signed `release.selected_units`, both the peeled commit and annotated tag-object
|
||||
ID must match exactly; re-annotating an unchanged commit requires review.
|
||||
Release drift produces a machine-readable list of affected capability and
|
||||
infrastructure conclusions instead of silently carrying their prior status
|
||||
forward:
|
||||
|
||||
```bash
|
||||
./.venv/bin/python tools/assessments/capability-fit.py \
|
||||
--public \
|
||||
--trusted-keyring /srv/govoplan/trust/catalog-keyring.json
|
||||
```
|
||||
|
||||
Use `--catalog CATALOG --keyring PUBLISHED_OR_CANDIDATE_KEYRING
|
||||
--trusted-keyring PINNED_LOCAL_KEYRING` for an offline or release-candidate
|
||||
rerun. `GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE` may provide the
|
||||
independently managed local trust-root path instead. During key rotation this
|
||||
local file may contain both current and `next` keys within their validity
|
||||
windows. Only `active` and `next` entries are trusted; unknown statuses,
|
||||
duplicate key IDs and malformed structured keyrings fail closed, while revoked,
|
||||
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.
|
||||
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
|
||||
explicit review is required, and `1` means the schema or trust checks are
|
||||
blocked. The machine report distinguishes independent signature trust,
|
||||
published-keyring hash agreement, and local-tag proof. Local-tag proof is only
|
||||
marked checked when every composition entry is available for comparison and was
|
||||
attempted. A successful rerun proves only the assessment schema, signed catalog
|
||||
metadata, published-keyring integrity and optional local tag provenance; it does
|
||||
not prove installed artifacts, target providers, target infrastructure, or
|
||||
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.
|
||||
|
||||
### Reference-readiness, provider and production proof boundary
|
||||
|
||||
Installed evidence cannot establish target acceptance, accessibility, privacy,
|
||||
security, operations, recovery, 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,
|
||||
accessibility, privacy, security, operations, recovery, 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.
|
||||
Accessibility, privacy, security, operations, and recovery claims use the same
|
||||
deployment subject. The report emits a cumulative `reference_readiness` verdict
|
||||
only when all six required scopes are checked and positive. This verdict remains
|
||||
separate from production approval and from provider-specific acceptance.
|
||||
|
||||
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. Reference-readiness, 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
|
||||
|
||||
- [Production-like profile](../dev/production-like/README.md) and
|
||||
[Compose dependencies](../dev/production-like/docker-compose.yml)
|
||||
- [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)
|
||||
- [Ops scalability profiles](https://git.add-ideas.de/add-ideas/govoplan-ops/src/branch/main/docs/SCALABILITY_PROFILES.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/GovOPlaN/govoplan-ops/src/branch/main/docs/SCALABILITY_PROFILES.md)
|
||||
- Actual module manifests in the pinned repositories and the static contract
|
||||
checker in this meta repository
|
||||
- Core module-system/API smoke/auth/install-config tests, plus focused Campaign,
|
||||
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)
|
||||
and [published keyring](https://govoplan.add-ideas.de/catalogs/v1/keyring.json),
|
||||
verified against a separately provisioned local trust keyring
|
||||
- Annotated source tags `govoplan-core/v0.1.13` and
|
||||
`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 run for this first slice against the current workspace:
|
||||
Checks retained from the first assessment slice against its then-current
|
||||
workspace:
|
||||
|
||||
- static manifest/interface contract scan: 43 contracts, 29 providers, no issues
|
||||
- selected Core module-composition, auth/CSRF, health, Campaign journey,
|
||||
@@ -345,5 +627,18 @@ Checks run for this first slice against the current workspace:
|
||||
- Addresses tests: 14 passed (one non-failing SQLite resource warning)
|
||||
- JSON Schema validation of the machine-readable assessment: passed
|
||||
|
||||
Refresh checks on 2026-07-22:
|
||||
|
||||
- live stable catalog sequence `202607220843`: valid Ed25519 signature trusted
|
||||
through `release-key-1`, no validator warnings
|
||||
- current static contract scan: 43 modules, 33 providers, 19 requirements, no
|
||||
contract errors
|
||||
- catalog-selected commits: Core `d487726f4d2c` at `v0.1.13` and Campaign
|
||||
`735e874bd03c` at `v0.1.10`; local and remote release refs agree
|
||||
- Campaign's immutable `v0.1.10` tag object and peeled commit exactly match the
|
||||
remote tag and catalog record; Calendar durable outbox work is committed and
|
||||
pushed after catalogued `v0.1.8`
|
||||
- JSON Schema validation of the refreshed machine-readable assessment: passed
|
||||
|
||||
These checks are evidence for the rows above; they are not a substitute for the
|
||||
clean-release and target-environment proof checks.
|
||||
installed-release and target-environment proof checks.
|
||||
|
||||
@@ -10,12 +10,13 @@ baseline to that destination.
|
||||
|
||||
It is a durable direction, not a release promise or a substitute for issue
|
||||
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
|
||||
cross-product vision that sequence serves.
|
||||
|
||||
Read it together with:
|
||||
|
||||
- the [institutional governance target architecture](INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md)
|
||||
- the [selected reference-journey program](REFERENCE_JOURNEY_PROGRAM.md)
|
||||
- the [current capability and infrastructure fit assessment](CAPABILITY_AND_INFRASTRUCTURE_FIT.md)
|
||||
- the [interface pattern language](INTERFACE_PATTERN_LANGUAGE.md)
|
||||
@@ -151,7 +152,7 @@ responsibilities, not a new dependency hierarchy.
|
||||
| Participation and channels | Let internal and external actors enter, receive, discuss, schedule, and respond through suitable channels. | Portal, postbox, mail, notifications, calendar, scheduling, appointments, campaign, consultation, poll |
|
||||
| Work coordination | Turn an input into owned, reviewable work and make exceptions visible. | Forms/runtime, cases, tasks, approvals, workflow, booking, resources, domain modules |
|
||||
| Evidence and institutional memory | Preserve what was known, decided, produced, sent, received, retained, corrected, and disclosed. | Files, templates, DMS, records, audit, search, reporting, transparency |
|
||||
| Institutional governance | Establish who may act, in which capacity, for which organization and tenant, under which policy. | Identity, access, IDM, tenancy, organizations, policy, identity trust, risk/compliance |
|
||||
| Institutional governance | Establish who may act, in which capacity, for which organization and tenant, under which mandate, jurisdiction, and policy. | Identity, access, IDM, tenancy, organizations, policy, identity trust, risk/compliance; candidate mandate and decision contracts |
|
||||
| Integration and operations | Connect sources and destinations, operate them safely, and prove their health and recovery. | Connectors, REST/SOAP and public-sector protocols, mail/calendar/file adapters, ERP handoffs, ops, release and configuration packages |
|
||||
|
||||
Across these planes, GovOPlaN should maintain a connected context graph rather
|
||||
@@ -448,7 +449,7 @@ without creating disconnected ticket systems.
|
||||
- As a manager, I can distinguish demand, backlog, SLA risk, recurring cause,
|
||||
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,
|
||||
reporting, policy, and audit.
|
||||
|
||||
@@ -530,7 +531,7 @@ access, no hidden cross-module coupling, correction rather than fictional undo,
|
||||
and a durable action/effect trail.
|
||||
|
||||
**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
|
||||
not a claim that screening is implemented today.
|
||||
|
||||
@@ -1136,11 +1137,12 @@ boundary, and retain operational, security, and evidence guarantees.
|
||||
| Addresses and directories | Implemented adapters exist but configuration/target proof varies | Directory source, privacy, conflict and lifecycle package | Reusable people/contact source capability |
|
||||
| Docs/admin/ops/dashboard | Useful cross-product surfaces with incomplete rollout | Configured-system inventory, guided config, monitoring/recovery evidence | Explainability and operation of the configured product |
|
||||
| Organizations/identity/IDM/access/postbox | Normalized ownership concepts and uneven runtime slices | Function-bound delivery, reassignment/delegation, vacancy and access-evidence proof | Institutional responsibility and durable communication spine |
|
||||
| Forms/cases/tasks/approvals/search | Concepts and uneven first slices | Extend the proven responsibility path into one manual end-to-end work/evidence journey | Reusable administrative coordination layer |
|
||||
| Templates/reporting/data sources | Boundary concepts or scaffolds | One reproducible data-backed document/report and safe HIS-style launch | Governed document production, reports, dashboards, and analytical consumption |
|
||||
| Analytical data products/dataflow | Selected direction; platform contracts not yet implemented | One bounded university source-to-indicator path with staging, quality, lineage and promotion proof | Transparent institutional BI and cross-process reporting |
|
||||
| Forms/cases/tasks/approvals | Concepts and uneven first slices | Extend the proven responsibility path into one manual end-to-end work/evidence journey, with shared service, party, mandate, and decision semantics | Reusable administrative coordination layer |
|
||||
| Search | PostgreSQL-backed permission-aware provider and global/contextual UI foundation | Complete provider rollout, indexing operations, and target authorization/performance evidence | Optional cross-module discovery with OpenSearch only as an adapter |
|
||||
| Templates/reporting/data sources | Templates and governed Datasources foundations exist; Reporting remains an early semantic-model slice | One reproducible data-backed document/report and safe HIS-style launch | Governed document production, reports, dashboards, and analytical consumption |
|
||||
| Analytical data products/dataflow | Typed graph/runtime foundations, triggers, staging integration, reusable definitions, and golden-flow fixtures exist | One bounded university source-to-indicator path with quality, lineage, promotion, and target-performance proof | Transparent institutional BI and cross-process reporting |
|
||||
| DMS/collaborative editing | Boundary concept and tag-only scaffold | One Files-backed version lifecycle, then one provider-neutral editing session | Collaborative documents, review, approval and records-ready renditions |
|
||||
| Workflow/automation | Concept only; no discoverable Workflow runtime; program postponed | Stable action/effect providers and an explicitly reprioritized bounded journey | Configurable governed process coordination |
|
||||
| Workflow/automation | Headless Workflow Engine and optional editor are implemented foundations with versioned module baselines, BPMN interchange, action/effect execution, and reconciliation | Prove one resumable human/system journey with target recovery and conformance evidence | Configurable governed process coordination without becoming a second domain layer |
|
||||
| Domain modules | Mostly boundary concepts or seeds | Only the modules required by a reference package | Reusable semantics above the shared spine |
|
||||
| Connectors and protocols | Catalogue/strategy plus several module-specific adapters | Profile/runtime, source-of-truth, health and one real landscape | Coexistence with institutional IT |
|
||||
| Records/transparency/risk-compliance/export screening | Planned or early concepts | Evidence contracts and one regulated reference story | Institutional memory, oversight, and assurance |
|
||||
@@ -1216,8 +1218,9 @@ provides all applicable evidence below.
|
||||
## Near-term portfolio order
|
||||
|
||||
This order is now selected. Detailed slices and gates are in the
|
||||
[Reference Journey Program](REFERENCE_JOURNEY_PROGRAM.md). Workflow-driven
|
||||
user-story implementation remains paused.
|
||||
[Reference Journey Program](REFERENCE_JOURNEY_PROGRAM.md). Workflow Engine and
|
||||
the optional editor may support these stages, but Workflow work enters the
|
||||
portfolio only through an explicit bounded package or reference journey.
|
||||
|
||||
0. **Continuous safe foundation.** Keep the composition green and version
|
||||
aligned; close connector destination pinning, response limits, throttling,
|
||||
@@ -1250,8 +1253,9 @@ user-story implementation remains paused.
|
||||
target acceptance for calendar/scheduling/poll and other shipped foundations
|
||||
without displacing the selected reference path; implement new feature
|
||||
programs only when they are required by a stage or explicitly reprioritized.
|
||||
7. **Resume Workflow only by explicit priority decision.** When resumed, start
|
||||
with stable actions from a demonstrated package; do not turn it into a
|
||||
7. **Use Workflow only through bounded journeys.** The headless engine and
|
||||
optional editor now exist. Extend them through stable module-owned actions,
|
||||
versioned baselines, and demonstrated packages; do not turn Workflow into a
|
||||
second domain layer.
|
||||
|
||||
## Product decisions to make progressively
|
||||
@@ -1263,8 +1267,8 @@ them; they do not block the product vision today.
|
||||
reference release?
|
||||
- Which real identity, groupware, file/DMS, and deployment stack should define
|
||||
the first integration profile?
|
||||
- When should the postponed Workflow program resume, and which single package
|
||||
will constrain its first implementation?
|
||||
- Which demonstrated package should provide the first target-accepted Workflow
|
||||
execution and recovery profile?
|
||||
- Which objects and fields remain authoritative in GovOPlaN versus each target
|
||||
system, and which conflict/failure behavior is acceptable?
|
||||
- Which default participant privacy profiles should ship for scheduling,
|
||||
@@ -1279,8 +1283,9 @@ them; they do not block the product vision today.
|
||||
- Which exact HIS/CampusOnline interfaces, student-statistics fields and
|
||||
official keys, accepted calculation, freeze/correction policy, privacy
|
||||
profile, and drill-down level should define the first analytical data product?
|
||||
- After the first source-to-report proof, do repeated source/dataflow contracts
|
||||
justify separate `govoplan-datasources` and `govoplan-dataflow` modules?
|
||||
- Which database, REST, directory, and managed-file providers should follow the
|
||||
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
|
||||
accepted experience emphasize concurrent editing, controlled check-out, or
|
||||
both?
|
||||
@@ -1381,7 +1386,7 @@ integration. Conversely, 30 repositories had no open issue; for many
|
||||
later-wave modules this meant no implementation program had been opened, not
|
||||
that the capability was complete.
|
||||
|
||||
[Poll #2](https://git.add-ideas.de/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
|
||||
matrix/history, idempotent keyed retries, re-decision audit, archive/unarchive,
|
||||
and preservation behavior were implemented and pushed while the issue still
|
||||
@@ -1389,27 +1394,27 @@ reported `needs-info`.
|
||||
|
||||
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;
|
||||
- [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;
|
||||
- [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;
|
||||
- [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;
|
||||
- [Core #263](https://git.add-ideas.de/add-ideas/govoplan-core/issues/263) and
|
||||
[Campaign #63](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/63),
|
||||
[#62](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/62),
|
||||
[#65](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/65), and
|
||||
[#69](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/69) for the
|
||||
- [Core #263](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/263) and
|
||||
[Campaign #63](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/63),
|
||||
[#62](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/62),
|
||||
[#65](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/65), and
|
||||
[#69](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/69) for the
|
||||
reference interface/delivery vocabulary and behavior;
|
||||
- [Poll #1](https://git.add-ideas.de/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;
|
||||
- [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;
|
||||
- [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
|
||||
- [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.
|
||||
|
||||
Live Gitea issue state remains canonical. These dated facts explain the roadmap
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Datasource And Definition Graph Architecture
|
||||
|
||||
## Two-Layer Data Boundary
|
||||
|
||||
GovOPlaN separates governed data identity from external acquisition:
|
||||
|
||||
| Layer | Owner | Responsibilities |
|
||||
| --- | --- | --- |
|
||||
| Datasource layer | `govoplan-datasources` | Governed data/register catalogue, tenant visibility, live/cached/static mode, source authority, staging, immutable materializations, frozen states, schema, quality/freshness policy, institutional provenance, dependencies, and bounded reads |
|
||||
| Connector layer | `govoplan-connectors` and protocol/provider modules | External protocols, endpoints, connection profiles, credentials, discovery, provider maturity/authority support, health, source-side filtering, query pushdown, and effect reconciliation |
|
||||
|
||||
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 Engine provides trigger, activity, review, decision, wait,
|
||||
module-action, Dataflow, and outcome semantics. It permits governed loops and
|
||||
has exactly one trigger plus one or more outcomes. The optional Workflow
|
||||
module supplies the editor over the same native graph/BPMN language.
|
||||
|
||||
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 Engine owns tenant-isolated definitions, immutable revisions,
|
||||
activation pinning, module-contributed versioned baselines, runtime instances,
|
||||
governed action/effect execution, retries, waits, and reconciliation. The
|
||||
optional Workflow module exposes the reusable native BPMN graph editor.
|
||||
- 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 typed graph/IR, registry-driven validation/execution/SQL
|
||||
compilation, expressions and reusable subflows, a pinned run-lifecycle
|
||||
capability, production worker boundary, and Run/Publish surface. Runs record
|
||||
lineage and intermediate artifacts and publish only complete bounded results.
|
||||
- The focused composition check proves Connector origin -> Datasource ->
|
||||
pinned Dataflow run -> frozen published materialization, including replay.
|
||||
|
||||
## Next Slices
|
||||
|
||||
1. Add the provider declaration and source-authority binding used consistently
|
||||
by Connectors, Datasources, configuration packages, Ops, and Docs.
|
||||
2. Add typed datasource owner/steward, legal/purpose, quality/freshness,
|
||||
classification, correction, service/process, and downstream dependency
|
||||
metadata under
|
||||
[Datasources #6](https://git.add-ideas.de/GovOPlaN/govoplan-datasources/issues/6).
|
||||
3. Add SQL database and governed REST origin providers with credential-envelope
|
||||
references and bounded pushdown.
|
||||
4. Add managed-file and directory origins.
|
||||
5. Complete datasource quality rules, schema compatibility policy, retention, and
|
||||
promotion approvals.
|
||||
6. Complete scheduled/event/API/chained Dataflow trigger governance, reusable
|
||||
template inheritance, Reporting publication, human reconciliation transforms,
|
||||
and target resource/recovery evidence for large runs.
|
||||
@@ -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 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
|
||||
export GITEA_URL=https://git.add-ideas.de
|
||||
export GITEA_OWNER=add-ideas
|
||||
export GITEA_OWNER=GovOPlaN
|
||||
export GITEA_REPO=govoplan
|
||||
export GITEA_TOKEN=...
|
||||
```
|
||||
@@ -23,7 +23,7 @@ The API scripts also read `GITEA_*` values from the target repository's `.env` f
|
||||
GITEA_TOKEN=...
|
||||
# Optional if origin inference is not enough:
|
||||
GITEA_URL=https://git.add-ideas.de
|
||||
GITEA_OWNER=add-ideas
|
||||
GITEA_OWNER=GovOPlaN
|
||||
GITEA_REPO=govoplan
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
# Installation And Deployment Architecture
|
||||
|
||||
## Goal
|
||||
|
||||
A supported GovOPlaN installation starts with one downloaded, verified
|
||||
bootstrap artifact. The administrator answers a bounded set of questions and
|
||||
receives a working base system. Re-running the same tool repairs or
|
||||
reconfigures that installation instead of creating unrelated state.
|
||||
|
||||
The canonical product journey remains
|
||||
[System Administrator Lifecycle User Story](SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md).
|
||||
This document defines the deployer boundary and the first executable slice.
|
||||
|
||||
## First Executable Slice
|
||||
|
||||
`tools/deployment/govoplan-deploy.py` is a standard-library-only deployment
|
||||
compiler and reconciler. It can be tested without installing GovOPlaN itself.
|
||||
|
||||
It currently supports:
|
||||
|
||||
- evaluation and self-hosted profiles;
|
||||
- managed or external PostgreSQL;
|
||||
- managed, external, or evaluation-only disabled Redis;
|
||||
- disabled mail, an external relay declaration, or an evaluation-only
|
||||
GreenMail service;
|
||||
- durable local file storage, managed single-node Garage S3, or external
|
||||
S3-compatible storage;
|
||||
- an explicit HAProxy service that load-balances configured WebUI and API
|
||||
replicas without access to the Docker socket;
|
||||
- declarative API, WebUI, and worker replica counts while keeping migrations
|
||||
and the scheduler singleton;
|
||||
- Core, base, or full initial module selections;
|
||||
- deterministic Compose JSON accepted by Compose v2;
|
||||
- generated secrets stored in a private `0600` file;
|
||||
- service-specific environment allowlists so infrastructure containers do not
|
||||
receive unrelated application credentials;
|
||||
- plan, render, doctor, status, apply, Kubernetes export, operation history,
|
||||
and bounded recovery commands;
|
||||
- an installation lock, migration-before-start ordering, readiness polling,
|
||||
and an applied-state receipt;
|
||||
- a durable hash-chained deployment journal captured before runtime mutation;
|
||||
- PostgreSQL advisory serialization for Core and module migrations;
|
||||
- runtime initialization that waits for exact configured migration heads
|
||||
without mutating schema;
|
||||
- runtime node registration, heartbeats, drain state, and a fenced scheduler;
|
||||
- idempotent reconfiguration that preserves generated secrets;
|
||||
- a keyed environment fingerprint that detects private binding changes without
|
||||
writing secret values to plans or receipts;
|
||||
- host CPU, memory, disk, entropy, architecture, Docker daemon, Compose,
|
||||
listen-port, and external endpoint preflight checks;
|
||||
- service removal without implicit data-volume deletion.
|
||||
|
||||
Create a local evaluation bundle:
|
||||
|
||||
```sh
|
||||
./.venv/bin/python tools/deployment/govoplan-deploy.py init \
|
||||
--directory /tmp/govoplan-evaluation \
|
||||
--profile evaluation \
|
||||
--postgres managed \
|
||||
--redis managed \
|
||||
--storage garage \
|
||||
--mail test-mail \
|
||||
--api-replicas 2 \
|
||||
--web-replicas 2 \
|
||||
--worker-replicas 2 \
|
||||
--module-set base
|
||||
```
|
||||
|
||||
Inspect the generated intent and host requirements:
|
||||
|
||||
```sh
|
||||
./.venv/bin/python tools/deployment/govoplan-deploy.py doctor \
|
||||
--directory /tmp/govoplan-evaluation
|
||||
```
|
||||
|
||||
Change a component without rotating existing generated secrets:
|
||||
|
||||
```sh
|
||||
./.venv/bin/python tools/deployment/govoplan-deploy.py configure \
|
||||
--directory /tmp/govoplan-evaluation \
|
||||
--redis external \
|
||||
--redis-url 'rediss://:password@redis.example.org:6379/0'
|
||||
```
|
||||
|
||||
The private installation directory contains:
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `installation.json` | Versioned, non-secret desired state |
|
||||
| `secrets.env` | Deployment-local secrets and external service bindings |
|
||||
| `compose.json` | Deterministic generated Compose definition |
|
||||
| `garage.toml` | Non-secret managed Garage server configuration |
|
||||
| `load-balancer.cfg` | Non-secret HAProxy WebUI/API discovery configuration |
|
||||
| `plan.json` | Latest desired-state diff and readiness findings |
|
||||
| `receipt.json` | Last successfully applied immutable identities |
|
||||
| `applied-state/` | Checksum-verified snapshot of the last healthy deployment bundle |
|
||||
| `operations/<id>/` | Private hash-chained deployment progress and recovery evidence |
|
||||
| `kubernetes.json` | Optional stateless multi-host Kubernetes export |
|
||||
| `.deployment.lock` | Same-host operation exclusion |
|
||||
|
||||
The specification contract is
|
||||
[`installation-spec.schema.json`](installation-spec.schema.json).
|
||||
|
||||
Build the same dependency-free tool as one downloadable artifact:
|
||||
|
||||
```sh
|
||||
./.venv/bin/python tools/deployment/build-deployer-zipapp.py \
|
||||
--output /tmp/govoplan-deploy.pyz
|
||||
python /tmp/govoplan-deploy.pyz --help
|
||||
```
|
||||
|
||||
To exercise reconciliation with locally available evaluation images:
|
||||
|
||||
```sh
|
||||
python /tmp/govoplan-deploy.pyz init \
|
||||
--non-interactive \
|
||||
--directory /tmp/govoplan-evaluation \
|
||||
--profile evaluation \
|
||||
--api-image local/govoplan-api:test \
|
||||
--web-image local/govoplan-web:test
|
||||
python /tmp/govoplan-deploy.pyz apply \
|
||||
--directory /tmp/govoplan-evaluation \
|
||||
--allow-unverified-images \
|
||||
--skip-pull
|
||||
```
|
||||
|
||||
Those images must already contain the selected module set. The override exists
|
||||
only to exercise local orchestration before release artifacts exist; it is
|
||||
rejected for `self-hosted`.
|
||||
|
||||
## Current Production Gates
|
||||
|
||||
The tool deliberately reports blockers instead of pretending the source tree is
|
||||
a production distribution:
|
||||
|
||||
1. **OCI release artifacts.** The release pipeline does not yet publish pinned
|
||||
multi-architecture API and WebUI images.
|
||||
2. **Signed distribution manifest.** A channel manifest must bind exact image
|
||||
digests, Compose compatibility, SBOM/provenance references, and revocation
|
||||
state. Recording a URL and checksum is not signature verification.
|
||||
3. **First administrator.** Production needs a one-time, restricted enrollment
|
||||
identity. The development bootstrap must not be enabled in production.
|
||||
4. **Image/module composition.** The selected module set must be proven present
|
||||
in the exact image or installed from verified offline artifacts before it is
|
||||
enabled.
|
||||
5. **Deployment agent.** Web updates need a separate privileged reconciler with
|
||||
a typed command allowlist. The API and browser must never receive the Docker
|
||||
socket or arbitrary shell access.
|
||||
6. **Ingress and certificates.** The managed HAProxy service provides HTTP
|
||||
load balancing inside the deployment boundary; it does not issue or renew
|
||||
certificates. A self-hosted profile still needs an explicit choice
|
||||
between an existing reverse proxy and a supported managed ingress, including
|
||||
trusted-proxy boundaries, TLS certificate issuance, renewal, and health
|
||||
probing through the public route.
|
||||
|
||||
`apply --allow-unverified-images` is therefore restricted to the evaluation
|
||||
profile. It explicitly acknowledges both mutable image identities and
|
||||
unverified image/module composition. It is a local test escape hatch, not a
|
||||
production setting.
|
||||
|
||||
## Component Choices
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
`managed` creates a persistent PostgreSQL container and private generated
|
||||
credentials. `external` requires an explicit `DATABASE_URL`; switching from
|
||||
managed to external cannot reuse the old `postgres` Docker hostname
|
||||
accidentally.
|
||||
|
||||
Interactive entry hides external URLs because they commonly contain
|
||||
credentials. For unattended automation, provide them through a protected
|
||||
operator mechanism and avoid storing secret-bearing flags in shell history.
|
||||
|
||||
Production policy should support external managed databases and local managed
|
||||
PostgreSQL equally at the application boundary. Backup, point-in-time recovery,
|
||||
high availability, and major-version upgrades remain deployment properties.
|
||||
|
||||
### Redis
|
||||
|
||||
`managed` creates an authenticated, append-only Redis container. `external`
|
||||
requires an explicit `REDIS_URL`. `disabled` is evaluation-only and disables
|
||||
workers while recording the single-process login-throttle risk acknowledgement.
|
||||
|
||||
`doctor` performs a bounded TCP connection check for external PostgreSQL,
|
||||
Redis, and S3 endpoints. This verifies DNS, routing, and that the port accepts a
|
||||
connection; it is not an authentication or semantic health check.
|
||||
|
||||
Production base installations include Redis because durable queues, distributed
|
||||
throttling, notifications, scheduled work, and transactional event delivery
|
||||
must survive API restarts.
|
||||
|
||||
### Mail
|
||||
|
||||
The first slice distinguishes:
|
||||
|
||||
- `disabled`;
|
||||
- `external-relay`, which records the infrastructure decision but leaves Mail
|
||||
server/credential creation as a visible post-install task;
|
||||
- `test-mail`, an evaluation-only GreenMail service.
|
||||
|
||||
A bundled production mail server is intentionally not a default. Operating one
|
||||
requires DNS, reverse DNS, TLS, DKIM, SPF, DMARC, reputation, abuse handling,
|
||||
queue monitoring, and upgrade policy. A later profile may support an
|
||||
operator-selected MTA/relay, but it must expose these requirements rather than
|
||||
presenting a container as a complete mail service.
|
||||
|
||||
### File Storage
|
||||
|
||||
`local` uses a durable Compose volume and is appropriate for one-host
|
||||
installations. `garage` provisions Garage 2.3 in its supported single-node
|
||||
bootstrap mode, generates a private application key and bucket, and connects
|
||||
the Files S3 backend to the exact installer-owned internal endpoint. The
|
||||
managed trust marker cannot authorize another S3 host.
|
||||
Garage metadata and object data use separate persistent volumes. `s3` requires
|
||||
an external endpoint, region, access key, secret key, and bucket values.
|
||||
Self-hosted external S3 endpoints must be clean HTTPS origins. The generated
|
||||
runtime explicitly sets `FILE_STORAGE_S3_ENDPOINT_TRUSTED=true` for that
|
||||
operator-selected endpoint. The trust flag is not accepted for local storage
|
||||
and cannot be combined with installer-managed Garage trust.
|
||||
|
||||
Local storage must be included in backup and restore drills. Horizontal API or
|
||||
worker scale-out requires shared/object storage. The managed Garage profile is
|
||||
persistent but has no data redundancy; availability-sensitive installations
|
||||
must use a tested multi-node Garage cluster or another external S3 service.
|
||||
|
||||
### Load Balancing And Replicas
|
||||
|
||||
The generated Compose topology publishes only `load-balancer`. HAProxy uses
|
||||
Docker DNS service discovery to distribute public traffic across WebUI replicas
|
||||
and WebUI API proxy traffic across API replicas. The WebUI and API services do
|
||||
not publish host ports. HAProxy has no Docker socket and discovers only the
|
||||
bounded replica slots rendered into `load-balancer.cfg`.
|
||||
|
||||
Replica counts are desired state:
|
||||
|
||||
```sh
|
||||
./.venv/bin/python tools/deployment/govoplan-deploy.py configure \
|
||||
--directory /tmp/govoplan-evaluation \
|
||||
--api-replicas 3 \
|
||||
--web-replicas 2 \
|
||||
--worker-replicas 4
|
||||
./.venv/bin/python tools/deployment/govoplan-deploy.py apply \
|
||||
--directory /tmp/govoplan-evaluation
|
||||
```
|
||||
|
||||
Workers are queue consumers, so they are scaled through Redis rather than put
|
||||
behind an HTTP load balancer. Migrations are serialized with a deployment-wide
|
||||
PostgreSQL advisory lock. The Celery scheduler is run under a renewable,
|
||||
fencing-token lease. Multiple API replicas are rejected when Redis is disabled
|
||||
because distributed throttling and queued work cannot then be shared correctly.
|
||||
|
||||
This is same-host scaling. Docker Compose uses a bridge network and does not
|
||||
place containers on another machine. See
|
||||
[Scaling And Multi-Host Deployment](SCALING_AND_MULTI_HOST_DEPLOYMENT.md) for
|
||||
the supported topology and promotion path.
|
||||
|
||||
## Reconfiguration Semantics
|
||||
|
||||
`installation.json` is desired state. `receipt.json` is the last successfully
|
||||
applied state. `plan` compares their canonical hashes and service sets.
|
||||
|
||||
- Adding a managed component creates its service and persistent volume.
|
||||
- Removing a component removes its service container on apply.
|
||||
- Volumes are retained by default; deleting data requires a separate,
|
||||
deliberately destructive workflow.
|
||||
- Existing generated credentials are retained unless an explicit future rotate
|
||||
operation is requested.
|
||||
- Private configuration changes are represented by a keyed fingerprint in the
|
||||
plan and receipt; plaintext values are never copied there.
|
||||
- Managed-to-external transitions require the new endpoint in the same
|
||||
operation.
|
||||
- Migrations run as a one-shot service before API/worker replacement.
|
||||
- API, worker, and scheduler start commands wait for exact configured migration
|
||||
heads; only the migration command is permitted to change schema.
|
||||
- API and worker replicas register their software/module composition and
|
||||
heartbeat in PostgreSQL. Ops can request and cancel a node drain.
|
||||
- The first upgrade from a direct WebUI host port stops that legacy WebUI
|
||||
container immediately before HAProxy claims the same endpoint.
|
||||
- Health must recover before a new receipt and applied-state snapshot are
|
||||
committed.
|
||||
|
||||
Every apply operation is journalled before image pulls or runtime mutation. A
|
||||
failure before migration may restore a verified previous bundle. Once migration
|
||||
starts, recovery is forward-only unless an independently verified database
|
||||
backup is restored. See
|
||||
[Recovery And Rollback Guarantees](RECOVERY_AND_ROLLBACK_GUARANTEES.md).
|
||||
|
||||
Production updates still need an operator-provided database backup/restore
|
||||
gate, database compatibility declaration, image signature verification, and
|
||||
deployment-specific drain policy. The deployment journal proves its own
|
||||
actions; it does not manufacture backup evidence.
|
||||
|
||||
## Stateless Kubernetes Runtime
|
||||
|
||||
`render-kubernetes` exports the application tier for a standard orchestrator.
|
||||
It requires external PostgreSQL, Redis, and S3 and emits no stateful service or
|
||||
secret value:
|
||||
|
||||
```sh
|
||||
python tools/deployment/govoplan-deploy.py render-kubernetes \
|
||||
--directory /srv/govoplan/default \
|
||||
--namespace govoplan \
|
||||
--secret-name govoplan-runtime
|
||||
```
|
||||
|
||||
The output includes a release-specific migration Job, database-head wait init
|
||||
containers, API readiness/liveness probes, rolling Deployments, Services, Pod
|
||||
disruption budgets, a tokenless ServiceAccount, and one fenced scheduler. Apply
|
||||
the named Secret through the cluster's secret manager and review ingress proxy
|
||||
CIDRs before deployment. Detailed rollout and scaling rules live in
|
||||
[Scaling And Multi-Host Deployment](SCALING_AND_MULTI_HOST_DEPLOYMENT.md).
|
||||
|
||||
## Recovery Commands
|
||||
|
||||
List durable deployment operations:
|
||||
|
||||
```sh
|
||||
python tools/deployment/govoplan-deploy.py operations \
|
||||
--directory /srv/govoplan/default
|
||||
```
|
||||
|
||||
Recover a selected failed operation after reviewing its stage evidence:
|
||||
|
||||
```sh
|
||||
python tools/deployment/govoplan-deploy.py recover \
|
||||
--directory /srv/govoplan/default \
|
||||
--operation-id <operation-id>
|
||||
```
|
||||
|
||||
The command reports whether it restored the pre-migration applied bundle,
|
||||
requires forward recovery, or needs manual intervention. Add `--apply` only
|
||||
after that decision has been reviewed.
|
||||
|
||||
## Web Update Boundary
|
||||
|
||||
The intended update path is:
|
||||
|
||||
1. Ops reads the non-secret installation receipt and reports management mode,
|
||||
current release, component health, and update availability.
|
||||
2. An authorized administrator asks Core to create a typed deployment request,
|
||||
for example `reconcile_release` or `rollback_release`.
|
||||
3. Core persists the reviewed immutable plan, actor, expected current receipt,
|
||||
and idempotency key.
|
||||
4. A separately deployed, narrow deployment agent claims the request.
|
||||
5. The agent verifies signatures/digests, acquires a fenced deployment lock,
|
||||
backs up, pulls, migrates, reconciles, probes health, and writes evidence.
|
||||
6. Ops presents durable progress and the resulting receipt.
|
||||
|
||||
The agent owns container-runtime access. It accepts no command strings from the
|
||||
browser and has no domain-data permissions. Installations managed by Kubernetes,
|
||||
systemd, or another external orchestrator expose read-only status and an export
|
||||
of the reviewed update recipe instead of a non-functional update button.
|
||||
|
||||
## Distribution Workflow
|
||||
|
||||
The downloadable entry point should eventually be:
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 --fail --location \
|
||||
https://govoplan.add-ideas.de/install/v1/bootstrap.pyz \
|
||||
--output govoplan-bootstrap.pyz
|
||||
python3 govoplan-bootstrap.pyz init
|
||||
```
|
||||
|
||||
The published documentation must include an independent checksum/signature
|
||||
verification command before execution. The zipapp then downloads only a signed
|
||||
distribution manifest, verifies it against an embedded or explicitly installed
|
||||
keyring, and renders the same installation contract implemented here.
|
||||
|
||||
The source-tree script is the test harness for that future zipapp. It is not yet
|
||||
the internet bootstrap artifact.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the focused tests:
|
||||
|
||||
```sh
|
||||
./.venv/bin/python -m unittest -v tests.test_deployment_installer
|
||||
```
|
||||
|
||||
The tests cover profile restrictions, secret persistence, external endpoint
|
||||
requirements, managed Garage bootstrap, S3 policy, replica validation, HAProxy
|
||||
discovery configuration, Compose service selection, secret non-disclosure,
|
||||
service-specific environment isolation, private file modes, external endpoint
|
||||
preflight, first-plan generation, apply ordering, receipt idempotency,
|
||||
hash-chained recovery journals, migration recovery boundaries, and stateless
|
||||
Kubernetes rendering.
|
||||
@@ -0,0 +1,575 @@
|
||||
# Institutional Governance Target Architecture
|
||||
|
||||
## Status and sources
|
||||
|
||||
This document is the accepted architectural reconciliation of two product
|
||||
concepts prepared outside the repositories:
|
||||
|
||||
- `govoplan_concept_dev.md`
|
||||
- `software_big_picture.md`
|
||||
|
||||
The source concepts describe GovOPlaN as an operational governance platform for
|
||||
public institutions. This document merges that direction with the implemented
|
||||
platform state as of 2026-08-01. It is the canonical repository version of the
|
||||
direction. Gitea issues remain the source of truth for delivery state.
|
||||
|
||||
Read this together with:
|
||||
|
||||
- [Connected Governance Platform Roadmap](CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||
- [Reference Journey Program](REFERENCE_JOURNEY_PROGRAM.md)
|
||||
- [Module Contracts and Install Boundaries](MODULE_CONTRACTS_AND_INSTALLS.md)
|
||||
- [Datasource and Definition Graph Architecture](DATASOURCE_AND_DEFINITION_GRAPH_ARCHITECTURE.md)
|
||||
- [Capability and Infrastructure Fit](CAPABILITY_AND_INFRASTRUCTURE_FIT.md)
|
||||
- [Core Module Architecture](../../govoplan-core/docs/MODULE_ARCHITECTURE.md)
|
||||
- [Core External References and Integration Maturity](../../govoplan-core/docs/EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md)
|
||||
- [Core Action, Effect, and Automation Layer](../../govoplan-core/docs/ACTION_EFFECT_AUTOMATION_LAYER.md)
|
||||
|
||||
## Decision
|
||||
|
||||
GovOPlaN is a configurable **institutional governance and operations layer** for
|
||||
public institutions. It should model the institution, coordinate its work,
|
||||
connect its specialist systems, and preserve why and under whose authority an
|
||||
action occurred.
|
||||
|
||||
GovOPlaN is not intended to become one universal ERP, DMS, groupware suite,
|
||||
workflow editor, or specialist procedure. It should own the governance concepts
|
||||
that must remain understandable across those systems and support native,
|
||||
external, mirrored, synchronized, overlay, and link-only operation explicitly.
|
||||
|
||||
This changes product emphasis, not the modular architecture:
|
||||
|
||||
1. The current kernel and optional-module model remains.
|
||||
2. Existing domain owners keep their data and behavior.
|
||||
3. Cross-module semantics become explicit, versioned contracts.
|
||||
4. Successful compositions become product and sector packages, not forks or
|
||||
monolithic replacement applications.
|
||||
5. Repository creation follows a proof threshold; a noun in the information
|
||||
model does not automatically require a module.
|
||||
|
||||
## What recent work already supersedes
|
||||
|
||||
The source concepts predate several implemented foundations. These items are
|
||||
accepted as the current baseline and must not be reopened as greenfield work.
|
||||
|
||||
| Concept requirement | Reconciled current state |
|
||||
| --- | --- |
|
||||
| Slim kernel plus installable modules | Implemented through entry-point discovery, `ModuleManifest`, migrations, capabilities, interfaces, WebUI contributions, and permutation checks. |
|
||||
| Versioned cross-module contracts | Implemented through named interface ranges, capability protocols, static workspace graph checks, activation validation, and release checks. |
|
||||
| Separate headless workflow runtime and editor | Implemented as `govoplan-workflow-engine` and optional `govoplan-workflow`. Module-owned workflow baselines are versioned and reconciled without replacing local overrides. |
|
||||
| Provider-neutral external references | Implemented in Core with stable external identity and cumulative integration maturity from discovery through replacement. |
|
||||
| Governed asynchronous effects | Implemented foundations include the action/effect contract, transactional platform event outbox, module outboxes, idempotency, outcome-unknown states, reconciliation, and worker health. Coverage still varies by provider. |
|
||||
| Acting identity, function assignment, mandate, and ownership recovery | Implemented foundations span Identity, Organizations, IDM, Access, Mandates, generic ownership transfer/recovery, and audit provenance. Effective competence now resolves through a tenant-bound Mandate capability. |
|
||||
| Governed data foundations | Connectors, Datasources, Dataflow, Reporting, and Search now exist. Datasources already provides live/cached/static modes, staging, immutable materializations, and publication contracts. |
|
||||
| Task-focused projections and configured documentation | Views, view-surface declarations, configurable dashboards, and manifest-driven user/admin documentation exist. Rollout and content depth remain incremental. |
|
||||
| Encryption as an optional capability | Core defines provider-neutral contracts; `govoplan-identity-trust` persists public device keys, key epochs and assurance evidence; and `govoplan-encryption` persists opaque vault/key lifecycle, versioned protection envelopes, quorum recovery authorization, outcome-unknown reconciliation, and disable preflight. No concrete cipher/provider or E2EE claim is bundled. |
|
||||
| Search without mandatory OpenSearch | PostgreSQL-backed, permission-aware search and module provider contracts exist; OpenSearch remains an optional adapter. |
|
||||
| Scale-out and recovery architecture | Stateless API/worker, shared database/object storage, event delivery, deployment, and recovery contracts are documented and partly exercised. Production profiles and drills remain active work. |
|
||||
|
||||
The institutional semantics, provider declaration gate, and first product
|
||||
compositions described here are now implemented. Subsequent work is
|
||||
**product depth and stronger maturity evidence**, not another runtime rewrite
|
||||
or an unimplemented architecture boundary.
|
||||
|
||||
## Implementation status (2026-08-01)
|
||||
|
||||
The architecture contract is implemented as a bounded, executable vertical
|
||||
slice. The portfolio declarations and provider governance gates apply to the
|
||||
whole workspace, while the four semantic domains whose repository thresholds
|
||||
were proven now have independent persistent owners:
|
||||
|
||||
| Area | Implemented state | Remaining rollout |
|
||||
| --- | --- | --- |
|
||||
| Module portfolio metadata | Core validates versioned architecture layer/kind, maturity evidence, known limits, ownership boundaries, authority modes, reference packages, target-tested providers, and migration/upgrade/recovery/security/operations documentation. | Complete for all 62 source manifests. Focused and release checks enforce `--require-architecture`; a new module cannot enter the workspace without truthful declaration and repository-local evidence. |
|
||||
| External providers | Core validates provider objects/field groups, operations, integration maturity, source authority, bounded reads, freshness/health, idempotency, conflicts, outcome-unknown handling, evidence, correction, reconciliation, outage, classification, purpose, retention, and secret handling. Addresses/CardDAV, Files remote storage, Mail SMTP/IMAP, Calendar CalDAV/ICS/Graph/EWS, and Connectors tabular/sanctions providers declare the contract and tenant-bounded secret-free runtime state. | Registry validation rejects any declared external provider without a sanitized state provider. Future adapters must cross the same gate before activation. |
|
||||
| Institutional context | Core provides versioned temporal, actor/representation, institution/unit/function/task/mandate/jurisdiction/service/case/party/work-item/workflow/approval/decision/record, legal-basis, evidence, information-governance, external-source, presentation, and geographic references. Events, automation actions, audit records, and the transactional Audit outbox preserve the envelope. | Owning modules must progressively require the relevant subset for consequential operations. |
|
||||
| Semantic provider contracts | Provider-neutral DTOs and protocols cover Mandate resolution, versioned Service definitions, procedure Parties/representation, and formal Decisions. `govoplan-mandates`, `govoplan-services`, `govoplan-parties`, and `govoplan-decisions` now persist immutable revisions behind those contracts with tenant isolation, bounded reads, replay safety, OCC, migrations, uninstall guards, permissions, APIs, capability documentation, and recovery documentation. | The owners are deliberately headless. Procedure-specific UI remains with consuming modules. |
|
||||
| Formal-outcome proof | Committee persists bodies, meetings, agenda items, minutes, lifecycle events, and an optional protected local Decision projection. Voting separately owns immutable ballot definitions, frozen electorates, recorded casting/replacement, deterministic tally, certification, challenge, annulment, and provider-backed assurance profiles. Committee consumes `voting.ballots` and retains only deliberation linkage and verified aggregate outcome. | Native recorded ballots are reconstructable, not secret. A concrete confidential/secret/certified provider and deployment assurance still require protocol/custody/legal decisions and target evidence. |
|
||||
| Service-to-case proof | Services owns the persistent exact definitions consumed by Portal discovery and Cases intake. Forms owns immutable multi-page/conditional/localized schemas, accessibility assessment and package fragments. Forms Runtime owns definition-aware drafts, validation, submission receipts, status/evidence history, and durable native Case/Workflow handoffs with intent-before-effect and outcome-unknown reconciliation. Portal delegates URL, Case, Form, or Workflow launch to an installed owner while retaining exact Service/Form provenance. | Anonymous intake and concrete attachment/signature providers remain product depth. Portal and Runtime fail closed and explain any absent launcher, target capability, or provider prerequisite. |
|
||||
| Generic approvals and process execution | Approvals persists exact-subject chains, delegation, separation of duties, quorum, signatures-as-evidence, escalation, OCC, and replay-safe decisions. Campaign proves an exact-version delivery gate. Workflow Engine owns immutable definitions/instances plus API, schedule, event and parent triggers, durable timer/event waits, scale-out claims, current-authority rechecks, and idempotent starts independently of the optional editor. | Policy-authored Approval template selection, concrete signature providers, cron adapters, and broader BPMN execution profiles are product/provider depth on explicit contracts. |
|
||||
| Device trust and content protection | Identity Trust separates public device keys, epochs, assurance and key-access decisions from login and Access. Encryption separates resource ownership from opaque provider key custody, versioned envelopes, migration evidence, quorum recovery authorization and uninstall proof. Files/Postbox fixtures prove the optional boundary. | Concrete reviewed KMS/HSM/client providers, feature adapters, backup/restore/key-loss drills, and E2EE interoperability/certification remain required before a deployment protection claim. |
|
||||
| Procedure-party proof | Parties persists effective procedure roles, frozen contact snapshots, and representation powers. Existing powers cannot disappear or be silently rewritten; explicit OCC-guarded revocation is required. Cases resolves the provider capability and excludes expired/revoked authority from downstream delivery. | Procedure modules still decide which contextual fields and actions to present. |
|
||||
| Integrated institutional journey | The executable `product.service-to-decision` fixture uses real SQL-backed Services, Cases, Parties, Mandates, Committee, and Decisions providers. It carries one exact Service version through persisted Case intake, representation and frozen delivery authority, effective Mandate resolution, a body/meeting/agendum/vote/minute sequence, a persisted formal Decision, confirmed Postbox effect, Audit/record evidence, remedy/review, and protected reconstruction. A second executable path proves Portal to exact Form revision, persisted submission, and idempotent replay. | This is architecture and composition evidence. Signed, release-bound target accessibility, privacy, security, operator, delivery-provider, and recovery-drill evidence is still required before the package may claim `reference_ready`. |
|
||||
| Governed data catalogue | Datasources stores typed governance metadata, exposes bounded tenant-scoped filters and update APIs/UI, carries governance through staging, and snapshots it into immutable materializations. Reporting now persists immutable dataset, semantic-model, report, quality-plan, saved-view, and schedule revisions; executes typed semantic queries with quality gates, access checks, replay, pivoting, export/import assessment, and provenance; and exposes the governed analytical WebUI. | Rich dependency/impact traversal, additional expression functions, and policy-specific field visibility can grow on the established contracts without moving connector, transformation, or source ownership. |
|
||||
| Portfolio and change governance | Projects now persists tenant-safe, immutable portfolio/project/milestone revisions with OCC, replay, lifecycle rules, restricted memberships, Search ACL indexing, outcomes, benefits, dependencies, capacity assumptions, change impact, and institutional references. Its WebUI exposes the planning catalogue and core planning fields. | Advanced planning structures already accepted by the API can receive deeper specialized editors without creating a second Policy, Reporting, Resources, or Goals owner. |
|
||||
| Product/package governance | Signed configuration packages distinguish reference, product, sector, deployment, and integration classes; preserve parent/evidence provenance; prevent derived packages from loosening constraints; and preflight provider authority, maturity, exact binding, health, freshness, and recovery expectations. Executable product manifests now exist for governed communication and governed data/assurance and are checked in the module matrix. | Both artifacts deliberately remain product-class until target, accessibility, privacy, security, operations, and recovery evidence justifies reference readiness. |
|
||||
| Projection and release | Platform metadata, signed module catalogs, release synthesis, Ops, and role-aware Docs retain and display architecture/provider declarations. Module-owned state providers add bounded configured/active, authority, health, freshness, conflict, recovery, and observation state; ordinary-user Docs omits binding detail. Static checks validate evidence paths, and the WebUI build verifies consuming types. | Runtime-state adoption and broader portfolio presentation follow truthful provider declaration rollout. |
|
||||
|
||||
The implementation deliberately keeps shared reference contracts in Core and
|
||||
domain tables in their owners. It does not claim unsupported release maturity:
|
||||
the four extracted owners and package remain `vertical_slice`/`product` until
|
||||
target evidence supports a stronger claim. Gitea remains authoritative for
|
||||
feature depth beyond this architecture contract.
|
||||
|
||||
## Target capability layers
|
||||
|
||||
The layers describe ownership and dependency direction. They are not navigation
|
||||
groups and do not imply that every installation exposes every module.
|
||||
|
||||
| Layer | Responsibility | Current owners and declared directions |
|
||||
| --- | --- | --- |
|
||||
| 0. Runtime and meta | Composition, release, migrations, shared contracts, operations, deployment | Core, meta repository, Admin, Ops |
|
||||
| 1. Institutional foundation | Institution, tenant, identity, organization, function, authority, access, trust | Tenancy, Identity, Organizations, IDM, Access, Identity Trust, Encryption, Mandates |
|
||||
| 2. Governance and accountability | Policy, audit, risk, control, explainability, configured projection | Policy, Audit, Risk Compliance, Docs, Views, Search, Decisions |
|
||||
| 3. Human work and procedure | Intake, cases, tasks, approvals, process execution and editing | Services, Forms, Forms Runtime, Cases, Parties, Tasks, Approvals, Workflow Engine, Workflow, Tickets |
|
||||
| 4. Communication and participation | Delivery, participation, scheduling, channels, consultation | Portal, Postbox, Notifications, Mail, Campaign, Calendar, Scheduling, Poll, Appointments, Booking, Consultation, Committee, Addresses, Distribution Lists |
|
||||
| 5. Content, records, and evidence | Managed content, templates, records, knowledge, disclosure | Files, Templates, DMS, Records, Wiki, Transparency, Certificates |
|
||||
| 6. Data, reporting, and integration | Source access, staging, transformation, search, analytics, protocols | Connectors, Datasources, Dataflow, Reporting, Dashboard, REST, SOAP, XOE/V, XTA/OSCI, FIT-Connect, XRechnung, ERP adapters |
|
||||
| 7. Domain capabilities | Reusable public-sector subject matter | Projects, Procurement, Contracts, Grants, Resources, Assets, Facilities, Learning, Payments, Ledger, Permits, Inspections, Evaluation, Helpdesk |
|
||||
| 8. Product and sector packages | Versioned compositions, terminology, forms, processes, controls, reports, integration profiles | Signed configuration packages and reference packages; not runtime modules by default |
|
||||
|
||||
## Canonical institutional semantics
|
||||
|
||||
The connected model must keep these concepts distinct even where one UI
|
||||
combines them.
|
||||
|
||||
| Concept | Canonical answer | Owner or direction |
|
||||
| --- | --- | --- |
|
||||
| Institution and tenant | In which governed installation and tenant does work occur? | Tenancy and Organizations |
|
||||
| Organization and unit | Where is responsibility situated? | Organizations |
|
||||
| Function | Which named organizational responsibility can an incumbent hold? | Organizations |
|
||||
| Identity and account | Who is the person or machine, and through which account do they act? | Identity and Access |
|
||||
| Function assignment | Who holds or represents a function, for which interval and source? | IDM |
|
||||
| Role and permission | What application behavior may the acting principal perform? | Access, constrained by Policy |
|
||||
| Mandate and jurisdiction | Why is an institution, unit, or function competent to act on this subject, territory, population, or interval? | Mandates |
|
||||
| Service | What governed promise can an institution offer, to whom, under which prerequisites, evidence, channel, deadline, and responsibility? | Services; Portal presents it |
|
||||
| Case | Which concrete administrative matter is being handled? | Cases |
|
||||
| Party | In what procedural capacity does a person or organization participate, and who may represent or receive for it? | Parties; Identity/Organizations remain the subject owners |
|
||||
| Work item | What must a responsible actor do next? | Tasks and domain modules |
|
||||
| Workflow | How is work coordinated, including waits, human hand-offs, and governed actions? | Workflow Engine; Workflow is the optional editor |
|
||||
| Approval | Has a proposed action passed a configured review or separation-of-duties gate? | Approvals |
|
||||
| Decision | What formal institutional outcome was reached, by which competent authority, on which facts, rules, evidence, reasoning, and review path? | Decisions |
|
||||
| Evidence and record | What proves the input, state, action, effect, correction, and retained institutional memory? | Domain owner, Files/DMS/Records, and Audit |
|
||||
|
||||
### Extracted semantic modules
|
||||
|
||||
Four horizontal concepts passed the repository proof threshold. Their Core
|
||||
DTOs and provider protocols remain neutral; their persistent data, lifecycle,
|
||||
security, APIs, migrations, and recovery behavior now live in independent
|
||||
repositories.
|
||||
|
||||
#### Mandates
|
||||
|
||||
Mandates should own public or internal tasks, jurisdiction, responsibility,
|
||||
decision/signature authority, legal or organizational basis, and effective
|
||||
history. Organizations continues to own structures and functions; IDM owns
|
||||
incumbency; Access owns permissions; Policy owns constraints.
|
||||
|
||||
`govoplan-mandates` answers: *Was this function competent to act for this case
|
||||
at the relevant time, and on what basis?* Its resolver evaluates effective
|
||||
time, task, authority, unit, function, jurisdiction, subject, conflicts, legal
|
||||
basis, and evidence deterministically. Missing or ambiguous authority fails
|
||||
closed.
|
||||
|
||||
#### Services
|
||||
|
||||
Services should own versioned service definitions: audience, prerequisites,
|
||||
legal basis, evidence, fees, deadlines, channels, responsible unit/function,
|
||||
jurisdiction, forms, case/workflow/result bindings, remedies, service levels,
|
||||
and publication status. Portal presents and starts services but should not own
|
||||
their institutional definition.
|
||||
|
||||
`govoplan-services` now owns those exact versioned definitions. Portal is the
|
||||
first presentation consumer and Cases freezes the selected revision into its
|
||||
intake context. Availability is an independent capability so publication does
|
||||
not imply that all runtime prerequisites are satisfied.
|
||||
|
||||
#### Parties
|
||||
|
||||
Parties should own procedure-local roles and relationships: applicant,
|
||||
respondent, beneficiary, representative, joint applicant, delivery recipient,
|
||||
power or authority to represent, and permitted/preferred channels for the
|
||||
matter. Identity answers who the subject is; Organizations answers which
|
||||
institutional unit it is; Addresses owns contact points; Parties answers how
|
||||
the subject participates here.
|
||||
|
||||
`govoplan-parties` owns the shared effective-dated lifecycle. Cases retains a
|
||||
bounded compatibility projection only when the module is absent; that fallback
|
||||
contains no representation lifecycle and cannot silently become a second
|
||||
authority source.
|
||||
|
||||
#### Decisions
|
||||
|
||||
Decisions should own formal outcomes: subject, type, competent authority,
|
||||
facts, evidence, applicable rule versions, reasoning, operative result,
|
||||
conditions, effect, delivery/publication, remedy/review, correction, revocation,
|
||||
and links to observed effects. Approvals own review gates; Poll owns response
|
||||
collection; Committee owns deliberation, meetings, and votes; Workflow owns
|
||||
coordination.
|
||||
|
||||
`govoplan-decisions` owns the persistent lifecycle and protected reconstruction
|
||||
surface. Committee supplies deliberation context and records through the
|
||||
provider capability. Consumers retain exact Decision references without
|
||||
gaining table access.
|
||||
|
||||
## Source authority and integration maturity
|
||||
|
||||
Two independent dimensions must be recorded. They must not be collapsed into a
|
||||
single `sync` flag.
|
||||
|
||||
### Source-authority mode
|
||||
|
||||
| Mode | Meaning |
|
||||
| --- | --- |
|
||||
| `native_authoritative` | GovOPlaN owns the authoritative object and lifecycle. |
|
||||
| `external_authoritative` | The external system owns the object; GovOPlaN reads or acts through it. |
|
||||
| `external_mirror` | The external system is authoritative and GovOPlaN keeps a governed local projection or immutable snapshots. |
|
||||
| `governed_sync` | Both sides may change supported fields under explicit conflict and reconciliation rules. |
|
||||
| `governance_overlay` | GovOPlaN owns policy, responsibility, evidence, or coordination around an externally executed object. |
|
||||
| `linked_reference` | GovOPlaN keeps only a stable link and minimal display/provenance metadata. |
|
||||
|
||||
Authority may be declared per tenant, organization, service, object type,
|
||||
object, field group, or process step. A broad default must not hide a narrower
|
||||
override.
|
||||
|
||||
### Integration maturity
|
||||
|
||||
The implemented maturity ladder remains `discover`, `link`, `search`, `read`,
|
||||
`publish`, `synchronize`, `migrate`, and `replace`. Maturity says what an
|
||||
adapter can do. Source-authority mode says who owns truth in a particular
|
||||
configuration. For example, a connector may support `synchronize`, while a
|
||||
tenant deliberately configures it as `external_mirror`.
|
||||
|
||||
### Provider declaration
|
||||
|
||||
Every provider that reads or causes external effects must declare:
|
||||
|
||||
- owned object and field groups;
|
||||
- supported source-authority modes and integration maturity;
|
||||
- read, write, delete, search, preview, and dry-run operations;
|
||||
- revision/concurrency tokens, freshness, health, and bounded-read limits;
|
||||
- idempotency, retry, timeout, conflict, and outcome-unknown behavior;
|
||||
- evidence, audit, correction, rollback/compensation, and reconciliation paths;
|
||||
- degraded and outage behavior;
|
||||
- classification, purpose, retention, and secret-handling requirements.
|
||||
|
||||
The common provider declaration composes the external-reference, action/effect,
|
||||
connector-lifecycle, capability, operational-check, and documentation
|
||||
contracts. Core, release tooling, Ops, Docs, and configuration-package
|
||||
preflight validate it; Registry refuses to activate a declared external
|
||||
provider without bounded, sanitized runtime state.
|
||||
|
||||
## Cross-cutting contracts
|
||||
|
||||
The following contracts are mandatory for consequential domain objects. They
|
||||
should be shared reference DTOs and provider protocols, not shared domain
|
||||
tables in Core.
|
||||
|
||||
1. **Time and history:** valid-from/to, recorded-at, superseded-at, revision,
|
||||
change reason, and stable identity.
|
||||
2. **Actor and representation:** real account/identity, system or service
|
||||
account, represented account/function/party, delegation or power, and
|
||||
mandate reference.
|
||||
3. **Institutional context:** tenant, institution, organization unit, function,
|
||||
task/mandate, jurisdiction, service, case, and decision references.
|
||||
4. **Legal and policy basis:** typed, versioned references to rules,
|
||||
obligations, policies, exceptions, and the effective decision source.
|
||||
5. **Requested and observed effect:** intent, approval, dispatch, possible
|
||||
execution, confirmation, reconciliation, correction, and terminal evidence.
|
||||
6. **Evidence and provenance:** source, version, checksum, derivation,
|
||||
responsible actor, timestamps, and inspection links.
|
||||
7. **Information governance:** classification, purpose, legal basis, retention,
|
||||
hold, minimization, and disclosure state.
|
||||
8. **External source:** system/profile/object identity, authority mode,
|
||||
maturity, version, freshness, health, and conflict state.
|
||||
9. **Presentation:** language, accessibility, channel, explanation, and
|
||||
configured availability.
|
||||
|
||||
Existing contracts already cover substantial parts of items 1, 2, 5, 6, 8,
|
||||
and 9. New work should extend those contracts instead of creating parallel DTO
|
||||
families.
|
||||
|
||||
## Existing module direction changes
|
||||
|
||||
### Datasources becomes the governed data and register catalogue
|
||||
|
||||
The implemented live/cached/static, staging, immutable materialization, and
|
||||
publication model includes typed governance metadata for owner/steward,
|
||||
authoritative source and authority mode, legal basis and purpose, semantic
|
||||
definition, quality and freshness policy, classification, transfer agreement,
|
||||
correction process, affected services/processes, and dependent flows,
|
||||
reports, controls, and decisions. Connector credentials and protocol behavior
|
||||
remain outside Datasources.
|
||||
|
||||
### Projects grows into portfolio and change governance
|
||||
|
||||
The Projects boundary already includes portfolios and goals. Extend it through
|
||||
versioned objectives/outcomes, dependencies, capacity, benefits, change impact,
|
||||
and links to mandates, services, risks, contracts, resources, and indicators.
|
||||
Do not create a separate Goals module before more than one domain proves an
|
||||
independent goal lifecycle.
|
||||
|
||||
### Reporting becomes evidence-backed institutional measurement
|
||||
|
||||
Every report, measure, and indicator should explain the institutional question
|
||||
or obligation it serves, owner, source/materialization and flow revision,
|
||||
freshness/quality, calculation version, visibility/purpose limits, publication,
|
||||
and decisions or actions that consumed it. Reporting owns presentation and
|
||||
execution; source and transformation owners retain their domains.
|
||||
|
||||
### Risk Compliance becomes the horizontal assurance model
|
||||
|
||||
Sanctions screening remains a complete vertical slice. The broader reusable
|
||||
model is:
|
||||
|
||||
```text
|
||||
Obligation -> governed object -> risk -> control -> evidence -> finding -> measure -> effectiveness review
|
||||
```
|
||||
|
||||
Risk Compliance now persists that effective-dated, immutable-revision assurance
|
||||
graph, exposes bounded tenant-safe traversal/search/editing, and projects each
|
||||
completed sanctions run into it idempotently. Policy
|
||||
owns enforceable rules and decisions; Audit owns immutable event evidence;
|
||||
domain modules own the governed objects and corrective actions.
|
||||
|
||||
### Connectors exposes authority and effect behavior
|
||||
|
||||
Connector direction (`consume`, `publish`, `bidirectional`) remains useful but
|
||||
is not enough. Profiles and bindings need the source-authority mode and
|
||||
provider declaration above. ERP remains an integration family: finance,
|
||||
workforce, procurement, asset, or other domain modules own semantics while
|
||||
connectors own transport and source interaction.
|
||||
|
||||
### Geography starts as a reference contract
|
||||
|
||||
Before adding a `govoplan-geo` module, define a common reference shape for
|
||||
coordinates, geometry, administrative area, address/location, CRS, source,
|
||||
accuracy, validity, and external GIS identity. Create a repository only when
|
||||
GovOPlaN must own spatial datasets, topology, or independent geospatial
|
||||
lifecycles rather than link to an external GIS.
|
||||
|
||||
## Product and sector packages
|
||||
|
||||
A module says what capability can exist. A product package says how capabilities
|
||||
work together for a bounded outcome. A sector package specializes vocabulary,
|
||||
forms, rules, process baselines, controls, reports, and integration profiles
|
||||
without forking the platform.
|
||||
|
||||
The signed configuration-package mechanism distinguishes:
|
||||
|
||||
- **reference package:** tested composition proving a journey and its recovery
|
||||
behavior;
|
||||
- **product package:** reusable operating capability such as governed
|
||||
communication, service-to-decision, procurement/contracts, or governed BI;
|
||||
- **sector package:** institutional specialization such as municipality,
|
||||
university/research, ministry/program, regulator, grants authority, or
|
||||
committee/council;
|
||||
- **deployment profile:** supported infrastructure and operational topology;
|
||||
- **integration profile:** supported set of external systems, authority modes,
|
||||
bindings, and health expectations.
|
||||
|
||||
Packages may require modules and capabilities, but package definitions remain
|
||||
configuration and evidence. They do not gain access to module-owned tables.
|
||||
|
||||
## Module portfolio metadata
|
||||
|
||||
Repository category is not capability maturity. The runtime manifest, release
|
||||
catalog, Docs projection, and meta repository inventory use one
|
||||
machine-readable declaration with at least:
|
||||
|
||||
- architecture layer and module kind;
|
||||
- lifecycle/maturity claim: `concept`, `scaffold`, `vertical_slice`,
|
||||
`reference_ready`, `supported`, or `lts`;
|
||||
- evidence supporting the claim and known limits;
|
||||
- supported source-authority modes;
|
||||
- owned and explicitly non-owned concepts;
|
||||
- provided/required capabilities and interfaces;
|
||||
- reference packages and target-tested providers;
|
||||
- migration, upgrade, recovery, security, and operations documentation.
|
||||
|
||||
Maturity is a release claim and must be checked against evidence. A manifest
|
||||
must not become “supported” merely because a maintainer changes one string.
|
||||
|
||||
Create a repository only when the capability has distinct data ownership,
|
||||
independent installability, technical assets, a security/lifecycle profile, a
|
||||
release reason, more than one consumer or a proven reference process, and tests
|
||||
that justify the boundary. Otherwise use a shared DTO, provider capability,
|
||||
submodule, configuration fragment, package, or profile.
|
||||
|
||||
## Implemented migration sequence
|
||||
|
||||
### 0. Align the portfolio and contracts - complete
|
||||
|
||||
- This reconciliation is canonical in the meta repository and mirrored to the
|
||||
Gitea wiki.
|
||||
- All 62 source manifests carry validated evidence-based architecture metadata.
|
||||
- External-reference, action/effect, operational-health, ownership, policy,
|
||||
audit, and documentation primitives compose into one enforced provider
|
||||
declaration and sanitized runtime-state contract.
|
||||
- Institutional context, legal basis, evidence, presentation, external source,
|
||||
information governance, temporal revision, and geo references are shared
|
||||
Core DTOs rather than shared domain tables.
|
||||
|
||||
### 1. Prove responsibility and formal outcome - complete
|
||||
|
||||
- Mandate and Decision contracts, deterministic resolution, lifecycle
|
||||
transitions, persistence providers, APIs, permissions, migrations, recovery,
|
||||
and tests are implemented.
|
||||
- Committee and the SQL-backed institutional fixture prove effective-time
|
||||
authority, persisted meeting/agendum/vote/minute context, approval context,
|
||||
reasoning, evidence, observed effect, correction/revision rules, protected
|
||||
reconstruction, and review references.
|
||||
- The independent Mandates and Decisions repositories were created only after
|
||||
persistence and reuse passed the repository threshold.
|
||||
|
||||
### 2. Separate service and party semantics - complete
|
||||
|
||||
- Portal remains the presentation surface while Services owns reusable,
|
||||
versioned definitions and explainable availability.
|
||||
- Parties owns procedure roles, contact snapshots, and append-only
|
||||
representation/revocation authority; Cases consumes the common resolver.
|
||||
- `product.service-to-decision` proves both through a portable administrative
|
||||
service composition.
|
||||
- Forms owns immutable, versioned schemas while Forms Runtime owns drafts,
|
||||
server validation, submission receipts, status/evidence history, and exact
|
||||
Service/Form provenance. Portal delegates Form launch through the runtime
|
||||
capability and fails closed when it is unavailable.
|
||||
|
||||
### 3. Complete governed data, portfolio, and assurance - vertical slices complete
|
||||
|
||||
- Datasources carries typed governance through staging and immutable
|
||||
materializations, with bounded catalogue filters and dependency references.
|
||||
- Reporting owns immutable semantic definitions, safe execution, quality gates,
|
||||
provenance, schedules, saved views, pivoting, and export/import assessment
|
||||
without taking source or transformation ownership.
|
||||
- Risk Compliance persists the horizontal obligation/risk/control/evidence/
|
||||
finding/measure graph and projects sanctions runs idempotently.
|
||||
- Projects persists portfolio/outcome/change-governance revisions as a
|
||||
consuming domain without becoming a second policy or reporting engine.
|
||||
|
||||
### 4. Package repeatable public-sector outcomes - complete at product maturity
|
||||
|
||||
- Governed communication, governed data/assurance, and service-to-decision are
|
||||
portable product package manifests with repository-local evidence.
|
||||
- Package preflight enforces module, capability, provider authority, health,
|
||||
freshness, and recovery expectations without cross-module table access.
|
||||
- Sector and `reference_ready` claims remain gated on target-environment,
|
||||
recovery, accessibility, privacy, security, and operator evidence. This is a
|
||||
maturity gate, not missing architecture implementation.
|
||||
|
||||
## What remains after the executable architecture slice
|
||||
|
||||
The remaining work is not another Core or cross-module architecture rewrite.
|
||||
It falls into two explicitly different categories, neither of which can be
|
||||
truthfully completed by adding generic platform code:
|
||||
|
||||
1. **Concrete provider packages:** the Committee ballot adapter contract is
|
||||
complete, but a real secret/electronic ballot provider requires a selected
|
||||
protocol and product decisions for voter eligibility, custody, secrecy,
|
||||
recount, challenge, retention, and operational assurance. Equivalent future
|
||||
adapters must satisfy the declared provider and recovery gates.
|
||||
Provider selection and certification are tracked in
|
||||
[Committee #1](https://git.add-ideas.de/GovOPlaN/govoplan-committee/issues/1).
|
||||
2. **Target-produced maturity evidence:** `reference_ready`, `supported`, and
|
||||
`lts` cannot be generated from source code. An exact release and deployment
|
||||
must produce signed, expiring accessibility, privacy, security, operator,
|
||||
provider, backup/restore, rollback, and recovery-drill evidence. The verifier
|
||||
and schemas are implemented; the actual claims require those real runs.
|
||||
The pinned-release evidence run is tracked in
|
||||
[GovOPlaN #37](https://git.add-ideas.de/GovOPlaN/govoplan/issues/37).
|
||||
|
||||
Forms and Forms Runtime no longer constitute an architecture gap. Conditional
|
||||
multi-page/localized authoring, package-fragment import, and durable native
|
||||
Case/Workflow handoffs are implemented. Remaining depth is limited to
|
||||
anonymous/public identity profiles, concrete file/signature providers, and
|
||||
additional handoff target adapters. Those use the implemented immutable
|
||||
definition, runtime, policy, evidence, service-launch, and domain-owner
|
||||
boundaries rather than requiring another split. Public/provider decisions stay
|
||||
tracked in Forms Runtime
|
||||
[#2](https://git.add-ideas.de/GovOPlaN/govoplan-forms-runtime/issues/2) and
|
||||
[#3](https://git.add-ideas.de/GovOPlaN/govoplan-forms-runtime/issues/3).
|
||||
|
||||
Approvals, Voting, Workflow trigger/wait dispatch, Identity Trust, and
|
||||
Encryption now likewise have repository owners, neutral Core contracts,
|
||||
persistence, migrations, recovery/disable semantics, documentation and focused
|
||||
tests. Their remaining tickets concern concrete providers, deeper adapters and
|
||||
target evidence, not an unresolved institutional architecture boundary.
|
||||
|
||||
Everything else described as architecture in this document now has a
|
||||
repository owner, versioned contract, bounded implementation, migration and
|
||||
recovery boundary where state exists, documentation, and executable evidence.
|
||||
Further work in those modules is product breadth, UX depth, provider adoption,
|
||||
and evidence renewal.
|
||||
|
||||
## Delivery tracking
|
||||
|
||||
The completed cross-repository architecture epic is
|
||||
[GovOPlaN #29](https://git.add-ideas.de/GovOPlaN/govoplan/issues/29).
|
||||
Its implementation work packages and resulting owners are:
|
||||
|
||||
- [Core #279](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/279):
|
||||
validated module architecture and provider authority declarations;
|
||||
- [Core #280](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/280):
|
||||
shared institutional-context and governed reference primitives;
|
||||
- [GovOPlaN #30](https://git.add-ideas.de/GovOPlaN/govoplan/issues/30) and
|
||||
[govoplan-mandates](https://git.add-ideas.de/GovOPlaN/govoplan-mandates):
|
||||
Mandates semantics and persistent resolver;
|
||||
- [GovOPlaN #31](https://git.add-ideas.de/GovOPlaN/govoplan/issues/31) and
|
||||
[govoplan-services](https://git.add-ideas.de/GovOPlaN/govoplan-services):
|
||||
Services semantics, catalogue, and availability;
|
||||
- [GovOPlaN #32](https://git.add-ideas.de/GovOPlaN/govoplan/issues/32) and
|
||||
[govoplan-parties](https://git.add-ideas.de/GovOPlaN/govoplan-parties):
|
||||
Parties and representation semantics and resolver;
|
||||
- [GovOPlaN #33](https://git.add-ideas.de/GovOPlaN/govoplan/issues/33) and
|
||||
[govoplan-decisions](https://git.add-ideas.de/GovOPlaN/govoplan-decisions):
|
||||
formal Decisions semantics and registry;
|
||||
- [Datasources #6](https://git.add-ideas.de/GovOPlaN/govoplan-datasources/issues/6):
|
||||
governed data/register catalogue;
|
||||
- [Risk Compliance #7](https://git.add-ideas.de/GovOPlaN/govoplan-risk-compliance/issues/7):
|
||||
horizontal assurance graph;
|
||||
- [GovOPlaN #34](https://git.add-ideas.de/GovOPlaN/govoplan/issues/34):
|
||||
product and sector package classes; and
|
||||
- [Docs #19](https://git.add-ideas.de/GovOPlaN/govoplan-docs/issues/19):
|
||||
configured architecture, maturity, and source-authority explanations;
|
||||
- [Forms #2](https://git.add-ideas.de/GovOPlaN/govoplan-forms/issues/2):
|
||||
immutable reusable definitions and the designer surface; and
|
||||
- [Forms Runtime #1](https://git.add-ideas.de/GovOPlaN/govoplan-forms-runtime/issues/1):
|
||||
definition-aware submissions and Portal service launch;
|
||||
- [Forms #3](https://git.add-ideas.de/GovOPlaN/govoplan-forms/issues/3) and
|
||||
[Forms Runtime #4](https://git.add-ideas.de/GovOPlaN/govoplan-forms-runtime/issues/4):
|
||||
conditional/localized definition depth and governed native handoffs;
|
||||
- [Approvals #1](https://git.add-ideas.de/GovOPlaN/govoplan-approvals/issues/1)
|
||||
and [Campaign #22](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/22):
|
||||
generic exact-subject approval chains and one consequential delivery gate;
|
||||
- `govoplan-voting`: governed recorded ballots plus fail-closed provider-backed
|
||||
assurance profiles consumed by Committee; and
|
||||
- Identity Trust #1 and Encryption #1-#3: public device trust, provider-neutral
|
||||
key/protection lifecycle, recovery authorization and disable proof, while
|
||||
concrete provider conformance remains separately gated.
|
||||
|
||||
Existing Projects #1, Reporting #4, Portal #1, Cases #1, Datasources #1,
|
||||
Risk Compliance #2, GovOPlaN #14, and GovOPlaN #19 carry product-depth and
|
||||
reference-readiness work instead of duplicating the completed architecture
|
||||
contract.
|
||||
|
||||
## Completion evidence
|
||||
|
||||
The architecture direction is established by the following executable and
|
||||
machine-enforced evidence:
|
||||
|
||||
- the `product.service-to-decision` composition and SQL-backed golden fixture
|
||||
retain institutional context from service entry through a persisted case,
|
||||
party, authority/work context, committee deliberation, decision, observed
|
||||
communication effect, minute/record, and review references;
|
||||
- the Portal/Form journey retains the exact published Service and Form
|
||||
revisions through persisted draft state, validates on the server, and returns
|
||||
the same submission on an idempotent launch replay;
|
||||
- the system can answer who acted, for whom, in which function, under which
|
||||
mandate and jurisdiction, using which rule and evidence versions;
|
||||
- every implemented external binding declares authority mode, maturity,
|
||||
operations, health, freshness, conflict, and recovery behavior, and Registry
|
||||
rejects a declaration without sanitized runtime state;
|
||||
- every material report or decision can be reconstructed from governed source
|
||||
and transformation versions;
|
||||
- product/package manifests are portable without cross-module table access or
|
||||
code forks, while future sector packages inherit the same signed-package
|
||||
constraints; and
|
||||
- documentation and Ops explain the configured composition and its limits to
|
||||
users, administrators, operators, and auditors.
|
||||
|
||||
These criteria complete the architecture contract at `vertical_slice` and
|
||||
`product` maturity. They do not waive the separately enforced evidence needed
|
||||
for a module or package to claim `reference_ready`, `supported`, or `lts`.
|
||||
The capability-fit verifier now computes that cumulative readiness gate from
|
||||
independently signed, expiring claims bound to the exact assessed release,
|
||||
installed payload, deployment subject, controls, and artifact hashes. Actual
|
||||
target runs and recovery drills remain operator-produced evidence.
|
||||
@@ -444,25 +444,25 @@ reason to infer that a pattern is satisfied.
|
||||
and does not duplicate a central component.
|
||||
- Behavioral/accessibility evidence is linked from the rollout matrix and issue.
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
generic extraction:
|
||||
|
||||
- [#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): stable,
|
||||
- [#59](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/59) and
|
||||
[#73](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/73): stable,
|
||||
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
|
||||
- [#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
|
||||
- [#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
|
||||
- [#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
|
||||
|
||||
These slices do not depend on the Workflow runtime. Campaign's current
|
||||
|
||||
@@ -9,6 +9,12 @@ The applicable design contract is
|
||||
|
||||
## 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.
|
||||
|
||||
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 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `/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 |
|
||||
| `/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 |
|
||||
| `/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 |
|
||||
| `/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 |
|
||||
| `/operator` | `govoplan-campaign` `OperatorQueuePage` | `campaigns:campaign:read` and any of queue, control, retry, or reconcile | Monitor and intervene in campaign jobs through authority-specific controls | Monitoring/work queue | Pilot; durable queue controls delivered in [Campaign #78](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/78); #74 audit remains |
|
||||
| `/reports` | `govoplan-campaign` `AggregateReportsPage` | `campaigns:report:read` | Compare privacy-protected cross-campaign outcome totals without recipient detail, diagnostics, export, or drill-down | Aggregate reporting | Pilot; aggregate-reader surface delivered in [Campaign #80](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/80); #74 audit remains |
|
||||
| `/templates` | `govoplan-campaign` `TemplatesPage` | No route guard declared in `module.ts` | Browse/manage campaign templates | Directory/list-detail | Pilot audit; permission intent must be verified; P2 |
|
||||
| `/dashboard` | `govoplan-dashboard` `DashboardPage` | No route-specific scope | Assemble module-provided actionable widgets | Dashboard | Contributed; unreviewed; P2 |
|
||||
| `/docs` | `govoplan-docs` `DocsPage` | Docs read or system/tenant settings read scopes | Read configured, available, and evidence-aware documentation | Documentation directory/reference | Contributed; unreviewed; P1 [Docs #15](https://git.add-ideas.de/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 |
|
||||
| `/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 |
|
||||
@@ -138,7 +144,7 @@ above; they are not independent routes.
|
||||
|
||||
Campaign is detailed first because it exercises almost every archetype. The
|
||||
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.
|
||||
|
||||
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 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Campaign settings (`GlobalSettingsPage` settings view) | Configure campaign behavior | Adaptive configuration | Can alter validation/build/send behavior | #74 audit |
|
||||
| Mail policy (`MailSettingsPage` policy view) | Inspect/override effective mail policy | Effective policy/provenance editor | Inheritance and locks affect allowed delivery | #74; Core #225 policy pattern |
|
||||
| Campaign policy (`GlobalSettingsPage` policy view) | Inspect/override campaign policy | Effective policy/provenance editor | Inheritance, actor authority, and blocked edits | #74; Core #225 policy pattern |
|
||||
| Review/send (`ReviewSendPage`) | Validate, build, mock-test, confirm/send, inspect results | Guided review/decision plus durable progress | External communication, bounded synchronous execution, persisted queue mode, partial effects, retries, evidence | Bounded synchronous and explicit/persisted queued modes delivered in [#62](https://git.add-ideas.de/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 |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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/GovOPlaN/govoplan-campaign/issues/59) and [#73](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/73) |
|
||||
| Campaign report (`CampaignReportPage`) | Filter and inspect delivery outcomes | Reporting/list-detail | Partial, failed, explicitly excluded/skipped, SMTP/IMAP outcomes and retries | Server-owned filtering and counts delivered in [#65](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/65) with the full-result DataGrid contract from [Core #263](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/263); excluded semantics in [#66](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/66) |
|
||||
| Audit (`CampaignAuditPage`) | Inspect campaign evidence/history | Provenance timeline/report | Actor/action/effect trace | #74 audit |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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/GovOPlaN/govoplan-campaign/issues/80); not parity with the permission-gated per-campaign detail report |
|
||||
| Templates route (`TemplatesPage`) | Browse template records | Directory/list-detail | Template availability and later generated outputs | #74 audit; verify missing route guard intent |
|
||||
|
||||
The five review stages currently named in code are `Validate and inspect`,
|
||||
@@ -186,7 +192,7 @@ The following local repositories contain a backend manifest but no
|
||||
`govoplan-contracts`, `govoplan-dist-lists`, `govoplan-evaluation`,
|
||||
`govoplan-facilities`, `govoplan-forms-runtime`, `govoplan-grants`,
|
||||
`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-resources`, `govoplan-rest`, `govoplan-risk-compliance`,
|
||||
`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 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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 |
|
||||
| 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
|
||||
for this rollout matrix. Focused views can be specified, manually selected, and
|
||||
tested through core composition contracts; a later workflow step may become one
|
||||
activation source without changing the proven surface patterns.
|
||||
Workflow remains outside this rollout matrix because it has its own runtime and
|
||||
editor workstream, not because it is postponed. Focused views can be specified,
|
||||
manually selected, and tested through core composition contracts today.
|
||||
Workflow steps may activate those views through the same contract without
|
||||
changing the proven surface patterns.
|
||||
|
||||
## Inventory Maintenance
|
||||
|
||||
|
||||
@@ -114,7 +114,10 @@ For release validation:
|
||||
|
||||
```sh
|
||||
./.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
|
||||
tagged package refs, not local editable source trees.
|
||||
tagged package refs, not local editable source trees. The second requirements
|
||||
file contains only the harness needed to execute tests from those immutable
|
||||
source tags; it is not part of the deployable release dependency set.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Platform Control Plane And Self-Description
|
||||
|
||||
## Objective
|
||||
|
||||
GovOPlaN should be able to describe its installed structure without becoming a
|
||||
self-modifying application. The platform model is a declarative control plane:
|
||||
module manifests, UI contributions, schemas, policy provenance, runtime
|
||||
capabilities, and generated source evidence describe what can be configured.
|
||||
Ordinary administrators edit validated data through those contracts; they do
|
||||
not edit Python, TypeScript, routes, or database code from the product UI.
|
||||
|
||||
This distinction provides the requested overview while preserving reviewable
|
||||
releases, module boundaries, migrations, and security controls.
|
||||
|
||||
## Canonical Sources
|
||||
|
||||
| Concern | Canonical source |
|
||||
| --- | --- |
|
||||
| Installed modules and dependency graph | Runtime `ModuleManifest` registry |
|
||||
| Backend routes | Registered FastAPI application; Python AST is build-time evidence |
|
||||
| Frontend routes and navigation | `PlatformWebModule` contributions |
|
||||
| View-filterable regions | Versioned `viewSurfaces` declarations |
|
||||
| Admin sections and module settings | `admin.sections`, including `moduleId`, `kind`, scope group, permission guards, and surface ID |
|
||||
| User settings | `settings.sections` and core settings schemas |
|
||||
| Labels and translations | Generated translation catalogs plus source usage |
|
||||
| Fields and help coverage | Shared form components plus generated TypeScript AST inventory |
|
||||
| API use by the WebUI | Typed API clients plus generated static reference inventory |
|
||||
| Effective configuration | Owning module data plus Policy provenance |
|
||||
|
||||
Runtime introspection is authoritative for an installed system. Static source
|
||||
inventory is authoritative evidence for a checkout or release candidate. The
|
||||
two should be compared in CI and by Ops, not conflated.
|
||||
|
||||
## Generated Inventory
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan
|
||||
./.venv/bin/python tools/inventory/platform-interface-inventory.py
|
||||
```
|
||||
|
||||
The command writes:
|
||||
|
||||
- `audit-reports/platform-inventory/platform-interface-inventory.json`
|
||||
- `audit-reports/platform-inventory/platform-interface-inventory.md`
|
||||
|
||||
It combines:
|
||||
|
||||
1. loaded module manifests
|
||||
2. TypeScript AST extraction of fields, label attributes, visible text,
|
||||
translations, frontend routes, navigation, capabilities, and API references
|
||||
3. Python AST extraction of FastAPI route decorators and router prefixes
|
||||
|
||||
The JSON includes exact repository, file, and line evidence. A missing-help
|
||||
entry is a review candidate because dynamic parent components may supply help.
|
||||
A backend route without a static frontend reference is also a review candidate:
|
||||
public APIs, workers, callbacks, health checks, connectors, and dynamic URL
|
||||
assembly are valid explanations.
|
||||
|
||||
`--strict` currently enforces only translation-catalog completeness. Endpoint
|
||||
and help classifications need narrow reviewed baselines before they can become
|
||||
release gates.
|
||||
|
||||
## Admin Information Architecture
|
||||
|
||||
The Admin host uses a tree because system, tenant, group, user, and module
|
||||
settings form a hierarchy rather than one flat list. Every contributed section
|
||||
can identify:
|
||||
|
||||
- its owning `moduleId`
|
||||
- whether it is `management` or `settings`
|
||||
- its system/tenant/group/user scope group
|
||||
- an optional future `parentId`
|
||||
- permission and View visibility requirements
|
||||
|
||||
Existing panels remain their own render owners. The tree only changes discovery
|
||||
and grouping. A later embedded-settings contract may add named slots inside an
|
||||
owning page; it must not allow one module to import another module's private
|
||||
component.
|
||||
|
||||
## Navigation And Workflow
|
||||
|
||||
The intended maximum visible navigation stack is:
|
||||
|
||||
1. global shell context
|
||||
2. one task/object navigation surface
|
||||
3. one workflow stage surface when a workflow is active
|
||||
|
||||
Workflow instance pages should reuse the Campaign stage language: clear stage
|
||||
state, optional/skipped/blocked semantics, partial progress, and a stable current
|
||||
step. Workflow definition pages remain graph editors. Views may activate a
|
||||
focused workflow view that suppresses unrelated shell and module surfaces while
|
||||
retaining an explicit way out.
|
||||
|
||||
Nested module submenus should not be added merely because a data hierarchy
|
||||
exists. Prefer a tree inside configuration/directory surfaces, tabs for sibling
|
||||
views, and the workflow stage rail for ordered work.
|
||||
|
||||
## Safe Meta-Configuration
|
||||
|
||||
The platform can eventually render many configuration editors from versioned
|
||||
JSON Schema and UI Schema supplied by modules. Generated editors remain bounded
|
||||
by:
|
||||
|
||||
- explicit typed schemas and migrations
|
||||
- module-owned validation and preview
|
||||
- Policy locks and provenance
|
||||
- permission and View filtering
|
||||
- preflight, consequence, and rollback information
|
||||
- auditable apply operations
|
||||
|
||||
Custom code, new routes, arbitrary SQL, and executable workflow nodes remain
|
||||
release artifacts. Modeling them as ordinary configuration would create an
|
||||
unreviewed code-execution and migration channel.
|
||||
|
||||
## Next Enforcement Slices
|
||||
|
||||
1. Require every WebUI module route and admin/settings contribution to have
|
||||
matching manifest metadata or a reviewed exception.
|
||||
2. Add stable field IDs and optional help-topic IDs to shared field components.
|
||||
3. Classify each statically unreferenced backend endpoint by consumer type.
|
||||
4. Compare a running installation's OpenAPI and module registry against the
|
||||
release inventory.
|
||||
5. Publish the sanitized installed-system structure through Ops/Docs for
|
||||
authorized administrators.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Recovery And Rollback Guarantees
|
||||
|
||||
## Principle
|
||||
|
||||
GovOPlaN must prove recovery claims with durable state recorded before and
|
||||
after side effects. A failed operation is not automatically rolled back merely
|
||||
because the previous application image still exists. Database schema and
|
||||
external effects may make release rollback unsafe.
|
||||
|
||||
Core therefore distinguishes five recovery modes:
|
||||
|
||||
| Mode | Meaning |
|
||||
| --- | --- |
|
||||
| `atomic` | One database transaction either commits or rolls back. No external effect is claimed. |
|
||||
| `compensation` | Durable evidence identifies explicit inverse actions for completed effects. |
|
||||
| `snapshot_restore` | A separately verified backup reference and restore procedure exist. |
|
||||
| `forward_recovery` | Repair or resume the current version; reverting code/configuration is not claimed safe. |
|
||||
| `irreversible` | No automated recovery is claimed and an approval reference is mandatory. |
|
||||
|
||||
An operation plan must include verification steps. Compensation requires named
|
||||
compensation steps, snapshot restore requires a verified backup reference,
|
||||
forward recovery requires repair steps, and irreversible work requires explicit
|
||||
approval.
|
||||
|
||||
## Core Recovery Ledger
|
||||
|
||||
Core stores recovery operations and append-only, hash-chained checkpoints in
|
||||
PostgreSQL. The contract provides:
|
||||
|
||||
- installation/module/resource identity;
|
||||
- an idempotency key bound to a canonical request hash;
|
||||
- recovery mode, preconditions, verification steps, and references;
|
||||
- optional runtime lease holder and fencing token;
|
||||
- explicit planned, prepared, running, recovery-required, recovering,
|
||||
succeeded, recovered, failed, outcome-unknown, and manual-intervention states;
|
||||
- an evidence-chain head and sequence count;
|
||||
- rejection of plaintext secrets in metadata or evidence.
|
||||
|
||||
Preparation cannot succeed without durable precondition evidence. A non-atomic
|
||||
operation cannot hide a partial effect by transitioning directly from running
|
||||
to failed. Success and recovery require explicit verification evidence with at
|
||||
least one check. The ledger verifies its hash chain before evidence is trusted.
|
||||
|
||||
This is a platform contract, not an assertion that every existing module
|
||||
operation has adopted it. Module operations with external or multi-resource
|
||||
effects must be migrated to the ledger before claiming these guarantees.
|
||||
|
||||
## Deployment Journal
|
||||
|
||||
Every `govoplan-deploy apply` begins an operation journal before it pulls images
|
||||
or mutates runtime state. The private installation directory records:
|
||||
|
||||
```text
|
||||
operations/<operation-id>/operation.json
|
||||
operations/<operation-id>/before/
|
||||
applied-state/
|
||||
```
|
||||
|
||||
Each stage is hash-chained. The previous applied bundle is copied with per-file
|
||||
SHA-256 evidence. Applied state is replaced atomically after health verification;
|
||||
an interrupted replacement restores its previous directory.
|
||||
New journals also bind the complete desired deployment plan, snapshot
|
||||
availability, failure summary, recovery mode, and terminal status to the
|
||||
evidence chain. Recovery verifies every snapshot entry and checksum before it
|
||||
changes any live bundle file, then replaces each live file atomically. A crash
|
||||
between file replacements is recoverable by rerunning the same idempotent
|
||||
recovery command under the deployment lock.
|
||||
|
||||
Inspect operations:
|
||||
|
||||
```sh
|
||||
python tools/deployment/govoplan-deploy.py operations \
|
||||
--directory /srv/govoplan/default
|
||||
```
|
||||
|
||||
Recover the latest failed operation, or provide its identifier:
|
||||
|
||||
```sh
|
||||
python tools/deployment/govoplan-deploy.py recover \
|
||||
--directory /srv/govoplan/default \
|
||||
--operation-id 20260801T120000Z-1234abcd
|
||||
```
|
||||
|
||||
Add `--apply` only after reviewing the reported action.
|
||||
|
||||
## Migration Boundary
|
||||
|
||||
Before database migration starts, a failed deployment with a verified prior
|
||||
applied snapshot may restore its prior release/configuration bundle and
|
||||
reconcile that desired state.
|
||||
|
||||
As soon as migration starts, the journal permanently changes to
|
||||
`forward_recovery`. It will not restore old application configuration because
|
||||
old code may not understand the new schema. Recovery then means one of:
|
||||
|
||||
1. fix and re-run the current release;
|
||||
2. deploy a newer compatible repair release;
|
||||
3. restore a separately verified, coordinated database/object/key backup and
|
||||
then deploy the matching release.
|
||||
|
||||
The deployment tool does not create or validate that database backup. A
|
||||
`backup-required` annotation on the Kubernetes migration Job is an operator
|
||||
gate, not backup evidence. Production automation must provide a backup hook or
|
||||
external backup controller whose artifact, timestamp, scope, encryption key,
|
||||
and restore test can be referenced from the recovery record.
|
||||
|
||||
## Scaled Nodes
|
||||
|
||||
Recovery actions must be safe across replicas:
|
||||
|
||||
- drain affected API and worker nodes before incompatible changes;
|
||||
- use the deployment-wide PostgreSQL advisory lock for schema migration;
|
||||
- use distributed leases and fencing tokens for singleton or externally
|
||||
visible effects;
|
||||
- use idempotency keys for retried commands and jobs;
|
||||
- retain shared object keys and database references until deletion succeeds;
|
||||
- classify uncertain external outcomes instead of retrying blindly;
|
||||
- verify the exact software/module composition after replacement.
|
||||
|
||||
Campaign generated-message objects now follow this model: object writes are
|
||||
compensated when a build fails before database commit, workers verify stored
|
||||
size and digest before delivery, and retention keeps the database reference
|
||||
when storage deletion fails. A hard process loss between object creation and
|
||||
database commit can still leave an orphan object; an inventory reconciler is a
|
||||
separate operational slice and must use the build-specific object prefix.
|
||||
|
||||
## Required Drills
|
||||
|
||||
Record evidence for at least these scenarios before production acceptance:
|
||||
|
||||
1. Kill an API replica and verify traffic continues without session loss.
|
||||
2. Drain and replace a worker while work is queued and while one job is active.
|
||||
3. Start two migration jobs and verify only one mutates schema.
|
||||
4. Kill the fenced scheduler and verify one replacement acquires a higher
|
||||
fencing token.
|
||||
5. Fail deployment before migration and restore the prior applied bundle.
|
||||
6. Fail deployment after migration and verify old configuration is not
|
||||
restored.
|
||||
7. Restore PostgreSQL, object storage, and encryption keys to one coordinated
|
||||
recovery point and verify representative object hashes.
|
||||
8. Interrupt object storage during Campaign build and retention and verify
|
||||
compensation/reference-preservation behavior.
|
||||
9. Tamper with a deployment or Core recovery checkpoint and verify chain
|
||||
validation rejects it.
|
||||
|
||||
No runbook, status badge, or green health endpoint substitutes for a dated,
|
||||
repeatable restore drill against the actual deployment topology.
|
||||
@@ -2,9 +2,16 @@
|
||||
|
||||
## Status
|
||||
|
||||
This is the selected product-development sequence as of 2026-07-21. It turns
|
||||
the long-term connected-platform roadmap into five demonstrable journeys while
|
||||
the Workflow program remains deliberately deferred.
|
||||
This is the selected product-development sequence, originally chosen on
|
||||
2026-07-21 and reconciled with the implemented platform on 2026-07-31. It turns
|
||||
the long-term connected-platform roadmap into five demonstrable journeys.
|
||||
Workflow Engine and the optional Workflow editor now exist, but they are used
|
||||
by a stage only when its package explicitly composes and proves them; Workflow
|
||||
is not an automatic dependency of every journey.
|
||||
|
||||
The institutional semantics and source-authority model applied to these stages
|
||||
are defined in the
|
||||
[Institutional Governance Target Architecture](INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||
|
||||
The stages are ordered, but they are not monolithic releases. Each stage is
|
||||
delivered as small, reviewable, green increments and is complete only when its
|
||||
@@ -445,8 +452,9 @@ that first needs them:
|
||||
- exact HIS/CampusOnline source endpoints, student-statistics keys and accepted
|
||||
calculation, freeze/correction policy, privacy profile, and permitted
|
||||
drill-down level;
|
||||
- whether repeated data-source and dataflow contracts justify separate
|
||||
`govoplan-datasources` and `govoplan-dataflow` modules after the Stage 3 proof;
|
||||
- datasource provider selection and quality/promotion policy; the architecture
|
||||
now separates `govoplan-datasources` lifecycle from `govoplan-connectors`
|
||||
acquisition and `govoplan-dataflow` transformation;
|
||||
- first collaborative editor/provider and whether the first UX is concurrent
|
||||
editing, controlled check-out, or both; and
|
||||
- first Records/archive target and approval/signature assurance level.
|
||||
|
||||
+352
-26
@@ -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,
|
||||
catalog files, Git state, and signing keys.
|
||||
|
||||
The current implementation has a read-only dashboard plus guarded local
|
||||
candidate/publish actions:
|
||||
The current implementation has a read-only dashboard, preview-only legacy
|
||||
controls, and a bounded durable executor for release steps whose inputs and
|
||||
effects can be verified safely:
|
||||
|
||||
- inspect repositories from `repositories.json`
|
||||
- show dirty, ahead, behind, missing, no-HEAD, and tag state
|
||||
@@ -18,10 +19,20 @@ candidate/publish actions:
|
||||
- configure target versions per release unit in the web UI
|
||||
- select the repositories that should advance through checkboxes
|
||||
- generate dry-run selective release plans for independently versioned packages
|
||||
- create annotated source release tags for selected clean, version-aligned
|
||||
repositories and publish each branch/tag pair atomically
|
||||
- generate signed catalog candidates for the selected release units
|
||||
- preview/apply/push reviewed catalog candidates behind explicit confirmations
|
||||
- freeze a selective plan as a durable, resumable local release run
|
||||
- durably preflight a creation-time-bound repository, create its annotated tag,
|
||||
and publish its branch/tag pair atomically
|
||||
- deterministically update recognized package/manifest version declarations and
|
||||
commit only the receipt-bound metadata paths
|
||||
- order selected module providers before consumers, create module tags before
|
||||
Core, regenerate Core's selected WebUI release lock, and re-run alignment
|
||||
before any remote push
|
||||
- build selected Python wheels and generate a private, signed, receipt-bound
|
||||
catalog candidate
|
||||
- publish that exact candidate through a verified website commit and immutable
|
||||
tag after explicit confirmation
|
||||
- install selected candidate wheels into a private no-network/no-dependency
|
||||
target and verify their installed metadata against the frozen plan
|
||||
|
||||
Start it from the meta repository:
|
||||
|
||||
@@ -29,8 +40,32 @@ Start it from the meta repository:
|
||||
./.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
|
||||
API token. Open that URL in a browser on the same machine.
|
||||
The launcher accepts only a numeric loopback address, binds to `127.0.0.1` by
|
||||
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
|
||||
independently and assigned its own target version. Repositories without version
|
||||
@@ -39,6 +74,217 @@ shows the dry-run commands for the selected rows, and `Generate Candidate`
|
||||
creates a signed catalog candidate that advances only selected repositories that
|
||||
already have a catalog entry.
|
||||
|
||||
The full-width **Release Workflow** guide projects the server state into seven
|
||||
operator phases: Inspect, Targets, Validate, Source, Package, Publish, and
|
||||
Verify. It does not maintain a second workflow state. Completed, current,
|
||||
blocked, locked, and unavailable phases are derived from the dashboard,
|
||||
selective plan, and durable run record. The next-action panel opens the exact
|
||||
section or durable step that needs attention. Changing a channel, target
|
||||
version, repository selection, or release gate detaches the browser from the
|
||||
current run and invalidates the draft plan; the persisted run remains available
|
||||
from the saved-run selector. Problems in unselected repositories remain visible
|
||||
as workspace notices but do not lock an unrelated release; the selective plan
|
||||
is the authority for blockers in the selected repository set.
|
||||
|
||||
Installation verification is an explicit durable step after catalog
|
||||
publication. It verifies the exact candidate receipt, installs every selected
|
||||
Python wheel into a temporary target with network and dependency resolution
|
||||
disabled, and compares installed names and versions with the frozen plan.
|
||||
Deployment startup, database upgrades, and module-combination smoke tests remain
|
||||
release-integration CI gates; the console does not represent its local wheel
|
||||
check as a production deployment.
|
||||
|
||||
`Build Plan` also returns structured release-gate findings for each selected
|
||||
repository. The plan names the recommended next action and gives an explicit
|
||||
remediation for source-version, lockfile, Core WebUI composition, Git state, and
|
||||
worktree findings. A target version that differs from internally consistent
|
||||
source metadata becomes a bounded `UPDATE` step. Unsupported, missing, or
|
||||
internally inconsistent declarations remain a blocker with exact remediation.
|
||||
`source_preflight_ready` means that plan-visible source gates pass or have a
|
||||
bounded deterministic mutation; the non-mutating `Preview Tag + Publish`
|
||||
remains mandatory for remote, manifest, and 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, target tag, a SHA-256 over both the fetch and push URLs of `origin`, and
|
||||
a SHA-256 over bounded dirty-path names and bytes. Both URLs must exactly equal
|
||||
the remote registered in `repositories.json`; a changed HEAD, branch, worktree,
|
||||
remote, push URL, or metadata byte requires a new run or explicit
|
||||
interrupted-step reconciliation rather than silently retargeting the frozen
|
||||
compatibility decision.
|
||||
The complete record also has a checksum so a valid-looking manual edit to its
|
||||
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, version, commit, Core-bundle,
|
||||
tag, and push steps persist a bounded repository-state receipt. Version
|
||||
reconciliation requires aligned declarations in the same commit; commit
|
||||
reconciliation requires the expected single-parent release commit and only
|
||||
recognized metadata paths. Tag reconciliation independently requires an
|
||||
annotated local tag at the frozen HEAD; push reconciliation additionally
|
||||
requires both the remote annotated tag object and remote branch to match.
|
||||
Catalog generation persists
|
||||
only its server-issued opaque candidate ID and canonical catalog SHA-256, then
|
||||
re-resolves and re-hashes that private candidate before publication.
|
||||
|
||||
Repository capabilities are frozen into each plan unit (`python-package`,
|
||||
`webui-package`, `module-manifest`, `database-migrations`, `documentation`,
|
||||
`core-release-bundle`, and the universal `git-source`) and determine which
|
||||
steps appear. Internally aligned version changes are rendered deterministically
|
||||
from recognized TOML, JSON, lockfile, manifest, and package declarations.
|
||||
Pre-existing dirty worktrees remain visible but have no commit executor; the
|
||||
console never absorbs unrelated operator changes.
|
||||
|
||||
For mixed releases, module interface providers are ordered before consumers
|
||||
and Core is tagged last. The durable sequence creates and commits module
|
||||
metadata, creates local module tags, updates Core's selected WebUI references,
|
||||
regenerates the release lock against those local tags, commits/tags Core, and
|
||||
runs a receipt-bound alignment gate before exposing any atomic branch/tag push.
|
||||
A failed step stops later steps while preserving prior receipts for explicit
|
||||
retry or reconciliation.
|
||||
|
||||
The browser likewise retains the request identifier for an uncertain
|
||||
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 Release Run section; dashboard
|
||||
and release-preview collection continue and the workflow guide points to 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
|
||||
malformed module contract is returned as a bounded `collection_errors` entry
|
||||
with a remediation, marks the dashboard blocked, and becomes a structured
|
||||
blocker in both full and selective release plans. The console does not silently
|
||||
omit a contract or turn these source errors into an HTTP 500.
|
||||
|
||||
The release-control area above the repository table is read-only and is meant
|
||||
to become the central release cockpit. It shows:
|
||||
|
||||
@@ -58,11 +304,12 @@ Plain repository pushes are separate from catalog publication. `Preview Push`
|
||||
shows the selected repository push commands. `Push Selected` requires `PUSH` in
|
||||
the repository push confirmation field.
|
||||
|
||||
The source release panel closes the former gap between a release plan and a
|
||||
catalog candidate. `Preview Tag + Publish` is non-mutating. `Create Tags`
|
||||
requires `TAG`; `Publish Tags` requires `PUBLISH` and atomically pushes the
|
||||
selected branch and annotated tag. The gate requires an aligned target version,
|
||||
a clean named branch with a HEAD, and a checkout that is not behind. Existing
|
||||
The source release panel retains `Preview Tag + Publish` as a non-mutating
|
||||
inspection. Its legacy `Create Tags` and `Publish Tags` controls stay visible
|
||||
but disabled; the corresponding mutation endpoint rejects apply requests.
|
||||
Creation and atomic branch/tag publication use the `TAG` and `PUBLISH`
|
||||
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.
|
||||
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
|
||||
@@ -70,13 +317,17 @@ the cross-repository module registry. This release gate also rejects every
|
||||
user-facing workflow documentation topic that has no scope condition, or has
|
||||
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
|
||||
website repository.
|
||||
- `Apply + Website Tag` requires `APPLY` in the confirmation field.
|
||||
- `Push Website Release` requires `PUSH` in the confirmation field.
|
||||
- `Apply + Website Tag` remains visible but disabled outside a durable run.
|
||||
- `Push Website Release` remains visible but disabled outside a durable run.
|
||||
|
||||
Source release tags belong to Core or module repositories. Website catalog
|
||||
publication creates a separate catalog tag in the website repository; the UI
|
||||
@@ -101,6 +352,53 @@ cannot be applied or published while any referenced source tag is absent or
|
||||
inconsistent; the preview and API response list each repository and missing or
|
||||
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
|
||||
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
|
||||
@@ -108,7 +406,10 @@ configured Git remote.
|
||||
|
||||
The default 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:
|
||||
|
||||
@@ -129,18 +430,28 @@ Build a signed selective catalog candidate:
|
||||
|
||||
```sh
|
||||
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 \
|
||||
--repo-version govoplan-files=0.1.9 \
|
||||
--python-artifact \
|
||||
"govoplan-files=$ARTIFACT_DIR/govoplan_files-0.1.9-py3-none-any.whl" \
|
||||
--channel stable \
|
||||
--catalog-signing-key "release-key-1=$KEY_DIR/release-key-1.pem"
|
||||
```
|
||||
|
||||
This writes candidate catalog/keyring files below `runtime/release-candidates/`,
|
||||
generates the browsable module directory below `modules/`, validates the signed
|
||||
candidate with the module installer validator, and reports whether the candidate
|
||||
catalog/keyring match the currently published channel. It does not publish to
|
||||
the website repository.
|
||||
This writes candidate catalog/keyring files below
|
||||
`$XDG_STATE_HOME/govoplan/release-candidates/` (or
|
||||
`~/.local/state/govoplan/release-candidates/`), generates the browsable module
|
||||
directory below `modules/`, validates the signed candidate with the module
|
||||
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:
|
||||
|
||||
@@ -155,8 +466,10 @@ Regenerate the browsable module directory from an existing catalog/keyring:
|
||||
Preview publication of a reviewed candidate:
|
||||
|
||||
```sh
|
||||
CANDIDATE_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}/govoplan/release-candidates"
|
||||
|
||||
./.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
|
||||
```
|
||||
|
||||
@@ -164,7 +477,7 @@ Apply the reviewed candidate into the website repository without pushing:
|
||||
|
||||
```sh
|
||||
./.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 \
|
||||
--apply \
|
||||
--commit \
|
||||
@@ -175,7 +488,7 @@ Push is a separate explicit flag:
|
||||
|
||||
```sh
|
||||
./.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 \
|
||||
--apply \
|
||||
--commit \
|
||||
@@ -183,6 +496,13 @@ Push is a separate explicit flag:
|
||||
--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:
|
||||
|
||||
- `https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json`
|
||||
@@ -193,6 +513,12 @@ forcing every repository to the same tag, but channel publication must preserve
|
||||
the unchanged package versions, validate interface compatibility, sign the
|
||||
updated catalog, and keep the published keyring healthy.
|
||||
|
||||
When a selected module exposes a WebUI package, its requested version must also
|
||||
match Core's `webui/package.release.json` input and the resolved
|
||||
`package-lock.release.json` entry. The source-tag preflight, selective plan, and
|
||||
catalog-candidate writer all enforce this composition boundary. Pins for modules
|
||||
that are not part of the selective release remain unchanged.
|
||||
|
||||
Release integration also enforces repository and composition version alignment
|
||||
and generates a CycloneDX SBOM from the resolved Python environment and the
|
||||
release WebUI lockfile. Catalog publication should attach that immutable SBOM
|
||||
|
||||
+77
-65
@@ -6,81 +6,93 @@ Generated from `repositories.json`. Use that JSON file as the machine-readable s
|
||||
|
||||
| Repository | Subtype | Local path | Gitea |
|
||||
| --- | --- | --- | --- |
|
||||
| `govoplan` | `meta` | `../govoplan` | [govoplan](https://git.add-ideas.de/add-ideas/govoplan) |
|
||||
| `govoplan-core` | `kernel` | `../govoplan-core` | [govoplan-core](https://git.add-ideas.de/add-ideas/govoplan-core) |
|
||||
| `govoplan` | `meta` | `../govoplan` | [govoplan](https://git.add-ideas.de/GovOPlaN/govoplan) |
|
||||
| `govoplan-core` | `kernel` | `../govoplan-core` | [govoplan-core](https://git.add-ideas.de/GovOPlaN/govoplan-core) |
|
||||
|
||||
## Module
|
||||
|
||||
| Repository | Subtype | Local path | Gitea |
|
||||
| --- | --- | --- | --- |
|
||||
| `govoplan-access` | `platform` | `../govoplan-access` | [govoplan-access](https://git.add-ideas.de/add-ideas/govoplan-access) |
|
||||
| `govoplan-addresses` | `domain` | `../govoplan-addresses` | [govoplan-addresses](https://git.add-ideas.de/add-ideas/govoplan-addresses) |
|
||||
| `govoplan-admin` | `platform` | `../govoplan-admin` | [govoplan-admin](https://git.add-ideas.de/add-ideas/govoplan-admin) |
|
||||
| `govoplan-appointments` | `domain` | `../govoplan-appointments` | [govoplan-appointments](https://git.add-ideas.de/add-ideas/govoplan-appointments) |
|
||||
| `govoplan-approvals` | `domain` | `../govoplan-approvals` | [govoplan-approvals](https://git.add-ideas.de/add-ideas/govoplan-approvals) |
|
||||
| `govoplan-assets` | `domain` | `../govoplan-assets` | [govoplan-assets](https://git.add-ideas.de/add-ideas/govoplan-assets) |
|
||||
| `govoplan-audit` | `platform` | `../govoplan-audit` | [govoplan-audit](https://git.add-ideas.de/add-ideas/govoplan-audit) |
|
||||
| `govoplan-booking` | `domain` | `../govoplan-booking` | [govoplan-booking](https://git.add-ideas.de/add-ideas/govoplan-booking) |
|
||||
| `govoplan-calendar` | `domain` | `../govoplan-calendar` | [govoplan-calendar](https://git.add-ideas.de/add-ideas/govoplan-calendar) |
|
||||
| `govoplan-campaign` | `domain` | `../govoplan-campaign` | [govoplan-campaign](https://git.add-ideas.de/add-ideas/govoplan-campaign) |
|
||||
| `govoplan-cases` | `domain` | `../govoplan-cases` | [govoplan-cases](https://git.add-ideas.de/add-ideas/govoplan-cases) |
|
||||
| `govoplan-certificates` | `domain` | `../govoplan-certificates` | [govoplan-certificates](https://git.add-ideas.de/add-ideas/govoplan-certificates) |
|
||||
| `govoplan-committee` | `domain` | `../govoplan-committee` | [govoplan-committee](https://git.add-ideas.de/add-ideas/govoplan-committee) |
|
||||
| `govoplan-consultation` | `domain` | `../govoplan-consultation` | [govoplan-consultation](https://git.add-ideas.de/add-ideas/govoplan-consultation) |
|
||||
| `govoplan-contracts` | `domain` | `../govoplan-contracts` | [govoplan-contracts](https://git.add-ideas.de/add-ideas/govoplan-contracts) |
|
||||
| `govoplan-dashboard` | `platform` | `../govoplan-dashboard` | [govoplan-dashboard](https://git.add-ideas.de/add-ideas/govoplan-dashboard) |
|
||||
| `govoplan-dms` | `domain` | `../govoplan-dms` | [govoplan-dms](https://git.add-ideas.de/add-ideas/govoplan-dms) |
|
||||
| `govoplan-dist-lists` | `domain` | `../govoplan-dist-lists` | [govoplan-dist-lists](https://git.add-ideas.de/add-ideas/govoplan-dist-lists) |
|
||||
| `govoplan-docs` | `platform` | `../govoplan-docs` | [govoplan-docs](https://git.add-ideas.de/add-ideas/govoplan-docs) |
|
||||
| `govoplan-erp` | `domain` | `../govoplan-erp` | [govoplan-erp](https://git.add-ideas.de/add-ideas/govoplan-erp) |
|
||||
| `govoplan-evaluation` | `domain` | `../govoplan-evaluation` | [govoplan-evaluation](https://git.add-ideas.de/add-ideas/govoplan-evaluation) |
|
||||
| `govoplan-facilities` | `domain` | `../govoplan-facilities` | [govoplan-facilities](https://git.add-ideas.de/add-ideas/govoplan-facilities) |
|
||||
| `govoplan-files` | `domain` | `../govoplan-files` | [govoplan-files](https://git.add-ideas.de/add-ideas/govoplan-files) |
|
||||
| `govoplan-forms` | `domain` | `../govoplan-forms` | [govoplan-forms](https://git.add-ideas.de/add-ideas/govoplan-forms) |
|
||||
| `govoplan-forms-runtime` | `platform` | `../govoplan-forms-runtime` | [govoplan-forms-runtime](https://git.add-ideas.de/add-ideas/govoplan-forms-runtime) |
|
||||
| `govoplan-grants` | `domain` | `../govoplan-grants` | [govoplan-grants](https://git.add-ideas.de/add-ideas/govoplan-grants) |
|
||||
| `govoplan-helpdesk` | `domain` | `../govoplan-helpdesk` | [govoplan-helpdesk](https://git.add-ideas.de/add-ideas/govoplan-helpdesk) |
|
||||
| `govoplan-identity` | `platform` | `../govoplan-identity` | [govoplan-identity](https://git.add-ideas.de/add-ideas/govoplan-identity) |
|
||||
| `govoplan-identity-trust` | `platform` | `../govoplan-identity-trust` | [govoplan-identity-trust](https://git.add-ideas.de/add-ideas/govoplan-identity-trust) |
|
||||
| `govoplan-idm` | `platform` | `../govoplan-idm` | [govoplan-idm](https://git.add-ideas.de/add-ideas/govoplan-idm) |
|
||||
| `govoplan-inspections` | `domain` | `../govoplan-inspections` | [govoplan-inspections](https://git.add-ideas.de/add-ideas/govoplan-inspections) |
|
||||
| `govoplan-issue-reporting` | `domain` | `../govoplan-issue-reporting` | [govoplan-issue-reporting](https://git.add-ideas.de/add-ideas/govoplan-issue-reporting) |
|
||||
| `govoplan-learning` | `domain` | `../govoplan-learning` | [govoplan-learning](https://git.add-ideas.de/add-ideas/govoplan-learning) |
|
||||
| `govoplan-ledger` | `domain` | `../govoplan-ledger` | [govoplan-ledger](https://git.add-ideas.de/add-ideas/govoplan-ledger) |
|
||||
| `govoplan-mail` | `domain` | `../govoplan-mail` | [govoplan-mail](https://git.add-ideas.de/add-ideas/govoplan-mail) |
|
||||
| `govoplan-notifications` | `platform` | `../govoplan-notifications` | [govoplan-notifications](https://git.add-ideas.de/add-ideas/govoplan-notifications) |
|
||||
| `govoplan-ops` | `platform` | `../govoplan-ops` | [govoplan-ops](https://git.add-ideas.de/add-ideas/govoplan-ops) |
|
||||
| `govoplan-organizations` | `platform` | `../govoplan-organizations` | [govoplan-organizations](https://git.add-ideas.de/add-ideas/govoplan-organizations) |
|
||||
| `govoplan-payments` | `domain` | `../govoplan-payments` | [govoplan-payments](https://git.add-ideas.de/add-ideas/govoplan-payments) |
|
||||
| `govoplan-permits` | `domain` | `../govoplan-permits` | [govoplan-permits](https://git.add-ideas.de/add-ideas/govoplan-permits) |
|
||||
| `govoplan-policy` | `platform` | `../govoplan-policy` | [govoplan-policy](https://git.add-ideas.de/add-ideas/govoplan-policy) |
|
||||
| `govoplan-poll` | `domain` | `../govoplan-poll` | [govoplan-poll](https://git.add-ideas.de/add-ideas/govoplan-poll) |
|
||||
| `govoplan-portal` | `domain` | `../govoplan-portal` | [govoplan-portal](https://git.add-ideas.de/add-ideas/govoplan-portal) |
|
||||
| `govoplan-postbox` | `domain` | `../govoplan-postbox` | [govoplan-postbox](https://git.add-ideas.de/add-ideas/govoplan-postbox) |
|
||||
| `govoplan-procurement` | `domain` | `../govoplan-procurement` | [govoplan-procurement](https://git.add-ideas.de/add-ideas/govoplan-procurement) |
|
||||
| `govoplan-records` | `domain` | `../govoplan-records` | [govoplan-records](https://git.add-ideas.de/add-ideas/govoplan-records) |
|
||||
| `govoplan-reporting` | `domain` | `../govoplan-reporting` | [govoplan-reporting](https://git.add-ideas.de/add-ideas/govoplan-reporting) |
|
||||
| `govoplan-resources` | `domain` | `../govoplan-resources` | [govoplan-resources](https://git.add-ideas.de/add-ideas/govoplan-resources) |
|
||||
| `govoplan-risk-compliance` | `domain` | `../govoplan-risk-compliance` | [govoplan-risk-compliance](https://git.add-ideas.de/add-ideas/govoplan-risk-compliance) |
|
||||
| `govoplan-scheduling` | `domain` | `../govoplan-scheduling` | [govoplan-scheduling](https://git.add-ideas.de/add-ideas/govoplan-scheduling) |
|
||||
| `govoplan-search` | `platform` | `../govoplan-search` | [govoplan-search](https://git.add-ideas.de/add-ideas/govoplan-search) |
|
||||
| `govoplan-tasks` | `domain` | `../govoplan-tasks` | [govoplan-tasks](https://git.add-ideas.de/add-ideas/govoplan-tasks) |
|
||||
| `govoplan-templates` | `domain` | `../govoplan-templates` | [govoplan-templates](https://git.add-ideas.de/add-ideas/govoplan-templates) |
|
||||
| `govoplan-tenancy` | `platform` | `../govoplan-tenancy` | [govoplan-tenancy](https://git.add-ideas.de/add-ideas/govoplan-tenancy) |
|
||||
| `govoplan-transparency` | `domain` | `../govoplan-transparency` | [govoplan-transparency](https://git.add-ideas.de/add-ideas/govoplan-transparency) |
|
||||
| `govoplan-workflow` | `platform` | `../govoplan-workflow` | [govoplan-workflow](https://git.add-ideas.de/add-ideas/govoplan-workflow) |
|
||||
| `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/GovOPlaN/govoplan-addresses) |
|
||||
| `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/GovOPlaN/govoplan-appointments) |
|
||||
| `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/GovOPlaN/govoplan-assets) |
|
||||
| `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/GovOPlaN/govoplan-booking) |
|
||||
| `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/GovOPlaN/govoplan-campaign) |
|
||||
| `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/GovOPlaN/govoplan-certificates) |
|
||||
| `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/GovOPlaN/govoplan-consultation) |
|
||||
| `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/GovOPlaN/govoplan-dashboard) |
|
||||
| `govoplan-dataflow` | `platform` | `../govoplan-dataflow` | [govoplan-dataflow](https://git.add-ideas.de/GovOPlaN/govoplan-dataflow) |
|
||||
| `govoplan-datasources` | `platform` | `../govoplan-datasources` | [govoplan-datasources](https://git.add-ideas.de/GovOPlaN/govoplan-datasources) |
|
||||
| `govoplan-decisions` | `domain` | `../govoplan-decisions` | [govoplan-decisions](https://git.add-ideas.de/GovOPlaN/govoplan-decisions) |
|
||||
| `govoplan-dms` | `domain` | `../govoplan-dms` | [govoplan-dms](https://git.add-ideas.de/GovOPlaN/govoplan-dms) |
|
||||
| `govoplan-dist-lists` | `domain` | `../govoplan-dist-lists` | [govoplan-dist-lists](https://git.add-ideas.de/GovOPlaN/govoplan-dist-lists) |
|
||||
| `govoplan-docs` | `platform` | `../govoplan-docs` | [govoplan-docs](https://git.add-ideas.de/GovOPlaN/govoplan-docs) |
|
||||
| `govoplan-encryption` | `platform` | `../govoplan-encryption` | [govoplan-encryption](https://git.add-ideas.de/GovOPlaN/govoplan-encryption) |
|
||||
| `govoplan-erp` | `domain` | `../govoplan-erp` | [govoplan-erp](https://git.add-ideas.de/GovOPlaN/govoplan-erp) |
|
||||
| `govoplan-evaluation` | `domain` | `../govoplan-evaluation` | [govoplan-evaluation](https://git.add-ideas.de/GovOPlaN/govoplan-evaluation) |
|
||||
| `govoplan-facilities` | `domain` | `../govoplan-facilities` | [govoplan-facilities](https://git.add-ideas.de/GovOPlaN/govoplan-facilities) |
|
||||
| `govoplan-files` | `domain` | `../govoplan-files` | [govoplan-files](https://git.add-ideas.de/GovOPlaN/govoplan-files) |
|
||||
| `govoplan-forms` | `domain` | `../govoplan-forms` | [govoplan-forms](https://git.add-ideas.de/GovOPlaN/govoplan-forms) |
|
||||
| `govoplan-forms-runtime` | `platform` | `../govoplan-forms-runtime` | [govoplan-forms-runtime](https://git.add-ideas.de/GovOPlaN/govoplan-forms-runtime) |
|
||||
| `govoplan-grants` | `domain` | `../govoplan-grants` | [govoplan-grants](https://git.add-ideas.de/GovOPlaN/govoplan-grants) |
|
||||
| `govoplan-helpdesk` | `domain` | `../govoplan-helpdesk` | [govoplan-helpdesk](https://git.add-ideas.de/GovOPlaN/govoplan-helpdesk) |
|
||||
| `govoplan-identity` | `platform` | `../govoplan-identity` | [govoplan-identity](https://git.add-ideas.de/GovOPlaN/govoplan-identity) |
|
||||
| `govoplan-identity-trust` | `platform` | `../govoplan-identity-trust` | [govoplan-identity-trust](https://git.add-ideas.de/GovOPlaN/govoplan-identity-trust) |
|
||||
| `govoplan-idm` | `platform` | `../govoplan-idm` | [govoplan-idm](https://git.add-ideas.de/GovOPlaN/govoplan-idm) |
|
||||
| `govoplan-inspections` | `domain` | `../govoplan-inspections` | [govoplan-inspections](https://git.add-ideas.de/GovOPlaN/govoplan-inspections) |
|
||||
| `govoplan-learning` | `domain` | `../govoplan-learning` | [govoplan-learning](https://git.add-ideas.de/GovOPlaN/govoplan-learning) |
|
||||
| `govoplan-ledger` | `domain` | `../govoplan-ledger` | [govoplan-ledger](https://git.add-ideas.de/GovOPlaN/govoplan-ledger) |
|
||||
| `govoplan-mail` | `domain` | `../govoplan-mail` | [govoplan-mail](https://git.add-ideas.de/GovOPlaN/govoplan-mail) |
|
||||
| `govoplan-mandates` | `domain` | `../govoplan-mandates` | [govoplan-mandates](https://git.add-ideas.de/GovOPlaN/govoplan-mandates) |
|
||||
| `govoplan-notifications` | `platform` | `../govoplan-notifications` | [govoplan-notifications](https://git.add-ideas.de/GovOPlaN/govoplan-notifications) |
|
||||
| `govoplan-ops` | `platform` | `../govoplan-ops` | [govoplan-ops](https://git.add-ideas.de/GovOPlaN/govoplan-ops) |
|
||||
| `govoplan-organizations` | `platform` | `../govoplan-organizations` | [govoplan-organizations](https://git.add-ideas.de/GovOPlaN/govoplan-organizations) |
|
||||
| `govoplan-payments` | `domain` | `../govoplan-payments` | [govoplan-payments](https://git.add-ideas.de/GovOPlaN/govoplan-payments) |
|
||||
| `govoplan-parties` | `domain` | `../govoplan-parties` | [govoplan-parties](https://git.add-ideas.de/GovOPlaN/govoplan-parties) |
|
||||
| `govoplan-permits` | `domain` | `../govoplan-permits` | [govoplan-permits](https://git.add-ideas.de/GovOPlaN/govoplan-permits) |
|
||||
| `govoplan-policy` | `platform` | `../govoplan-policy` | [govoplan-policy](https://git.add-ideas.de/GovOPlaN/govoplan-policy) |
|
||||
| `govoplan-poll` | `domain` | `../govoplan-poll` | [govoplan-poll](https://git.add-ideas.de/GovOPlaN/govoplan-poll) |
|
||||
| `govoplan-portal` | `domain` | `../govoplan-portal` | [govoplan-portal](https://git.add-ideas.de/GovOPlaN/govoplan-portal) |
|
||||
| `govoplan-postbox` | `domain` | `../govoplan-postbox` | [govoplan-postbox](https://git.add-ideas.de/GovOPlaN/govoplan-postbox) |
|
||||
| `govoplan-procurement` | `domain` | `../govoplan-procurement` | [govoplan-procurement](https://git.add-ideas.de/GovOPlaN/govoplan-procurement) |
|
||||
| `govoplan-projects` | `domain` | `../govoplan-projects` | [govoplan-projects](https://git.add-ideas.de/GovOPlaN/govoplan-projects) |
|
||||
| `govoplan-records` | `domain` | `../govoplan-records` | [govoplan-records](https://git.add-ideas.de/GovOPlaN/govoplan-records) |
|
||||
| `govoplan-reporting` | `domain` | `../govoplan-reporting` | [govoplan-reporting](https://git.add-ideas.de/GovOPlaN/govoplan-reporting) |
|
||||
| `govoplan-resources` | `domain` | `../govoplan-resources` | [govoplan-resources](https://git.add-ideas.de/GovOPlaN/govoplan-resources) |
|
||||
| `govoplan-risk-compliance` | `domain` | `../govoplan-risk-compliance` | [govoplan-risk-compliance](https://git.add-ideas.de/GovOPlaN/govoplan-risk-compliance) |
|
||||
| `govoplan-scheduling` | `domain` | `../govoplan-scheduling` | [govoplan-scheduling](https://git.add-ideas.de/GovOPlaN/govoplan-scheduling) |
|
||||
| `govoplan-search` | `platform` | `../govoplan-search` | [govoplan-search](https://git.add-ideas.de/GovOPlaN/govoplan-search) |
|
||||
| `govoplan-services` | `domain` | `../govoplan-services` | [govoplan-services](https://git.add-ideas.de/GovOPlaN/govoplan-services) |
|
||||
| `govoplan-tasks` | `domain` | `../govoplan-tasks` | [govoplan-tasks](https://git.add-ideas.de/GovOPlaN/govoplan-tasks) |
|
||||
| `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-voting` | `domain` | `../govoplan-voting` | [govoplan-voting](https://git.add-ideas.de/GovOPlaN/govoplan-voting) |
|
||||
| `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) |
|
||||
| `govoplan-workflow-engine` | `platform` | `../govoplan-workflow-engine` | [govoplan-workflow-engine](https://git.add-ideas.de/GovOPlaN/govoplan-workflow-engine) |
|
||||
|
||||
## Connector
|
||||
|
||||
| Repository | Subtype | Local path | Gitea |
|
||||
| --- | --- | --- | --- |
|
||||
| `govoplan-connectors` | `connector-hub` | `../govoplan-connectors` | [govoplan-connectors](https://git.add-ideas.de/add-ideas/govoplan-connectors) |
|
||||
| `govoplan-fit-connect` | `standard` | `../govoplan-fit-connect` | [govoplan-fit-connect](https://git.add-ideas.de/add-ideas/govoplan-fit-connect) |
|
||||
| `govoplan-rest` | `protocol` | `../govoplan-rest` | [govoplan-rest](https://git.add-ideas.de/add-ideas/govoplan-rest) |
|
||||
| `govoplan-soap` | `protocol` | `../govoplan-soap` | [govoplan-soap](https://git.add-ideas.de/add-ideas/govoplan-soap) |
|
||||
| `govoplan-xoev` | `standard` | `../govoplan-xoev` | [govoplan-xoev](https://git.add-ideas.de/add-ideas/govoplan-xoev) |
|
||||
| `govoplan-xrechnung` | `standard` | `../govoplan-xrechnung` | [govoplan-xrechnung](https://git.add-ideas.de/add-ideas/govoplan-xrechnung) |
|
||||
| `govoplan-xta-osci` | `standard` | `../govoplan-xta-osci` | [govoplan-xta-osci](https://git.add-ideas.de/add-ideas/govoplan-xta-osci) |
|
||||
| `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/GovOPlaN/govoplan-fit-connect) |
|
||||
| `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/GovOPlaN/govoplan-soap) |
|
||||
| `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/GovOPlaN/govoplan-xrechnung) |
|
||||
| `govoplan-xta-osci` | `standard` | `../govoplan-xta-osci` | [govoplan-xta-osci](https://git.add-ideas.de/GovOPlaN/govoplan-xta-osci) |
|
||||
|
||||
## Website
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Scaling And Multi-Host Deployment
|
||||
|
||||
## Implemented Contract
|
||||
|
||||
GovOPlaN now supports a stateless application tier backed by logically shared
|
||||
state services. The runtime roles are independently replaceable API, WebUI,
|
||||
worker, and scheduler processes. Every replica in one installation must use the
|
||||
same immutable release composition and the same:
|
||||
|
||||
- `GOVOPLAN_INSTALLATION_ID`;
|
||||
- PostgreSQL database;
|
||||
- Redis broker and coordination service;
|
||||
- `MASTER_KEY_B64` and deployment secret references;
|
||||
- enabled-module graph;
|
||||
- S3-compatible object-storage namespace.
|
||||
|
||||
The application tier must not use node-local durable business data in a
|
||||
multi-host deployment. Files owns managed file metadata while Core provides the
|
||||
storage-backend contract. Campaign build artifacts are stored under opaque
|
||||
object keys and workers read those objects from the shared backend. Temporary
|
||||
build and materialization directories may remain node-local because they are
|
||||
discardable.
|
||||
|
||||
Core validates three explicit state profiles:
|
||||
|
||||
| Profile | Supported shape | Storage rule |
|
||||
| --- | --- | --- |
|
||||
| `local` | One API and one worker process for development | Local filesystem permitted. |
|
||||
| `host-shared` | Multiple processes on one Docker host | A shared host volume is permitted; PostgreSQL and Redis are required. |
|
||||
| `shared` | Multiple independent hosts | PostgreSQL, Redis, and S3-compatible object storage are required. |
|
||||
|
||||
`shared` also requires a stable installation identifier. Module package
|
||||
mutation is blocked in this profile: build and verify a new immutable release,
|
||||
then roll the complete cluster to it.
|
||||
|
||||
## Same-Host Compose
|
||||
|
||||
The generated Compose bundle provides:
|
||||
|
||||
```text
|
||||
client -> TLS proxy -> HAProxy -> WebUI replicas -> HAProxy -> API replicas
|
||||
|
||||
API/worker/scheduler -> PostgreSQL
|
||||
-> Redis
|
||||
-> local volume, managed Garage, or external S3
|
||||
```
|
||||
|
||||
HAProxy discovers Compose replicas through Docker DNS and performs health-aware
|
||||
balancing without mounting the Docker socket. This improves concurrency and
|
||||
permits process replacement, but the Docker host and installer-managed stateful
|
||||
services remain single failure domains. Generated Compose therefore declares
|
||||
the `host-shared` state profile even when its shared storage happens to be an
|
||||
external S3 service. Its API backend checks `/health/ready`, so drain or
|
||||
coordination loss removes a replica from rotation. Container, load-balancer,
|
||||
and Kubernetes probes send the configured public host explicitly, keeping
|
||||
readiness compatible with strict trusted-host validation.
|
||||
|
||||
Managed Garage is a convenient single-node S3-compatible service. It is not a
|
||||
multi-host storage cluster. Use an independently operated Garage cluster or
|
||||
another S3-compatible service for the `shared` profile.
|
||||
|
||||
## Kubernetes Export
|
||||
|
||||
The deployment compiler exports a stateless Kubernetes runtime when PostgreSQL,
|
||||
Redis, and S3 are all external:
|
||||
|
||||
```sh
|
||||
python tools/deployment/govoplan-deploy.py render-kubernetes \
|
||||
--directory /srv/govoplan/default \
|
||||
--namespace govoplan \
|
||||
--secret-name govoplan-runtime \
|
||||
--tls-secret-name govoplan-tls \
|
||||
--ingress-class-name nginx \
|
||||
--output /srv/govoplan/default/kubernetes.json
|
||||
```
|
||||
|
||||
The export contains a Namespace, tokenless ServiceAccount, non-secret
|
||||
ConfigMap, API/WebUI/worker/scheduler Deployments, Services, Pod disruption
|
||||
budgets, Ingress, and a release-specific migration Job. It deliberately emits
|
||||
no Secret values, persistent volume, PostgreSQL, Redis, or object-store
|
||||
deployment. Export is rejected unless both release images use immutable
|
||||
`image@sha256:...` references.
|
||||
|
||||
Create the named Secret through the cluster's secret-management path. The
|
||||
command prints the exact required key contract. Review the generated
|
||||
`FORWARDED_ALLOW_IPS` value and replace it with the exact ingress-proxy network
|
||||
before production use.
|
||||
|
||||
The generated containers run as non-root with a read-only root filesystem and
|
||||
an ephemeral `/tmp`. Runtime Deployments wait for the exact configured database
|
||||
migration heads before starting. The API exposes `/health/ready`, which fails
|
||||
while that API node is draining or cannot prove its runtime-coordination
|
||||
heartbeat.
|
||||
|
||||
## Runtime Coordination
|
||||
|
||||
Each API and worker incarnation registers in PostgreSQL with its role, software
|
||||
version, module-composition hash, queue set, and heartbeat. Ops shows active,
|
||||
draining, stopped, and stale nodes and compares active counts with configured
|
||||
replica expectations.
|
||||
|
||||
An operator may request or cancel drain from Ops:
|
||||
|
||||
- API readiness becomes unavailable on the next heartbeat so the load balancer
|
||||
stops assigning new requests.
|
||||
- A worker stops consuming its configured queues and may finish work already
|
||||
claimed by that process.
|
||||
- A stale process incarnation cannot overwrite a replacement incarnation's
|
||||
heartbeat.
|
||||
- A coordination outage removes API readiness and cancels worker consumers;
|
||||
the existing incarnation must heartbeat successfully before either resumes.
|
||||
|
||||
Singleton work uses PostgreSQL-backed leases with monotonically increasing
|
||||
fencing tokens. The generated scheduler runs Celery beat through
|
||||
`govoplan_core.commands.fenced_run`; loss of its lease terminates the child and
|
||||
returns a distinct failure code. A fenced business operation must validate the
|
||||
same lease token immediately before committing its effect.
|
||||
|
||||
## Release Ordering
|
||||
|
||||
Use this order for every multi-replica rollout:
|
||||
|
||||
1. Verify immutable image identities, module composition, external state
|
||||
reachability, backup evidence, and the generated plan.
|
||||
2. Drain application replicas when the migration compatibility declaration
|
||||
requires it.
|
||||
3. Run the release-specific migration Job exactly once. PostgreSQL advisory
|
||||
locking serializes all Core and module migration tasks across competing
|
||||
deployment jobs.
|
||||
4. Let runtime init containers run `wait_for_database`. They wait for exact
|
||||
configured Alembic heads and never mutate schema.
|
||||
5. Roll API, workers, scheduler, and WebUI using health-aware replacement.
|
||||
6. Verify runtime composition, expected replica counts, queue consumers,
|
||||
object-storage round trips, and recovery status in Ops.
|
||||
|
||||
Applying the complete generated manifest is fail-closed: runtime pods remain in
|
||||
their init phase until the migration Job reaches the expected heads. A second
|
||||
release may be submitted concurrently, but advisory locking prevents concurrent
|
||||
schema mutation and each release has a distinct migration Job name.
|
||||
|
||||
## Storage Trust Boundary
|
||||
|
||||
Installer-managed Garage uses its exact generated endpoint. An arbitrary
|
||||
external S3 endpoint is accepted only when the deployment explicitly sets
|
||||
`FILE_STORAGE_S3_ENDPOINT_TRUSTED=true`; that endpoint must be a clean HTTPS
|
||||
origin without embedded credentials, query, fragment, or path. This is an
|
||||
operator trust declaration, not a user-controlled connector bypass. Operators
|
||||
remain responsible for DNS, certificate, network-egress, bucket-policy,
|
||||
versioning, and lifecycle controls.
|
||||
|
||||
## Capacity
|
||||
|
||||
- Scale API replicas only within the PostgreSQL connection budget.
|
||||
- Scale workers by queue, with upper bounds based on external provider limits.
|
||||
- Keep one fenced scheduler rather than load-balancing schedulers.
|
||||
- Increase WebUI replicas for asset/proxy capacity.
|
||||
- Measure request latency, database query time and locks, active connections,
|
||||
queue age, retry rate, storage latency, and provider throttling before adding
|
||||
replicas.
|
||||
|
||||
Workers compete for Redis-backed work and are not placed behind a load balancer.
|
||||
SMTP, IMAP, directory, connector, workflow, dataflow, and reporting queues often
|
||||
hit external-system limits before host CPU is exhausted.
|
||||
|
||||
## What This Does Not Claim
|
||||
|
||||
The implemented contract provides stateless runtime placement, shared artifact
|
||||
access, node visibility, drain controls, migration serialization, and scheduler
|
||||
fencing. It does not by itself provide:
|
||||
|
||||
- a highly available PostgreSQL, Redis, or object-store deployment;
|
||||
- automatic PostgreSQL backup, point-in-time recovery, or restore verification;
|
||||
- autoscaling policy;
|
||||
- central logs, metrics, traces, or alert routing;
|
||||
- managed ingress certificates;
|
||||
- automatic reconciliation of every possible module side effect;
|
||||
- a service-level availability guarantee.
|
||||
|
||||
Those are deployment and module-adoption requirements. Before claiming high
|
||||
availability, drill replica loss, rolling replacement, session continuity, job
|
||||
redelivery, scheduler failover, migration exclusion, object-store outage, and a
|
||||
coordinated database/object/key restore. Recovery rules and evidence are
|
||||
defined in [Recovery And Rollback Guarantees](RECOVERY_AND_ROLLBACK_GUARANTEES.md).
|
||||
+21
-4
@@ -50,6 +50,10 @@ accept findings, but scanner execution errors and malformed JSON/SARIF reports
|
||||
always fail the run. The manifest lists the expected, present, and missing
|
||||
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.
|
||||
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,
|
||||
`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.
|
||||
- `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
|
||||
is therefore a finding under the wrapper contract; higher exit codes, missing
|
||||
output, invalid JSON/SARIF, and scanner error payloads are execution failures.
|
||||
@@ -103,7 +113,7 @@ baseline.
|
||||
|
||||
## Gating
|
||||
|
||||
The initial Gitea workflow runs in report-only mode:
|
||||
The Gitea workflow currently runs findings in report-only mode:
|
||||
|
||||
```bash
|
||||
SECURITY_AUDIT_FAIL_ON_FINDINGS=0
|
||||
@@ -119,13 +129,13 @@ SECURITY_AUDIT_FAIL_ON_FINDINGS=1
|
||||
or run locally with:
|
||||
|
||||
```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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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_UPDATE=1`; it pulls current base images and re-resolves the
|
||||
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,
|
||||
configuration packages, and release provenance into one administrator journey.
|
||||
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
|
||||
|
||||
@@ -139,6 +139,17 @@ The canonical backlog item is
|
||||
|
||||
## Implementation slices
|
||||
|
||||
Implementation status as of the current source tree:
|
||||
|
||||
- Slice 6 has a working application-tier foundation: state profiles, shared
|
||||
object storage, runtime node registration/heartbeats/drain, fenced scheduler,
|
||||
migration serialization, exact-head startup waiting, Ops visibility, and a
|
||||
Kubernetes export. Production acceptance still requires topology-specific
|
||||
failover and restore drills.
|
||||
- The recovery foundation for slices 4 and 5 is implemented as a Core recovery
|
||||
ledger and deployment operation journal. Automatic database backup and broad
|
||||
adoption by module-owned external effects remain open work.
|
||||
|
||||
1. **Reproducible Core-only distribution.** Publish pinned multi-architecture
|
||||
images, signed distribution manifest, Core-only Compose profile, bootstrap
|
||||
preflight, generated secrets, readiness, and idempotent rerun/repair.
|
||||
@@ -155,9 +166,11 @@ The canonical backlog item is
|
||||
5. **Safe module update.** Add drain/maintenance coordination, backup gate,
|
||||
migration compatibility window, reconnectable progress, health verification,
|
||||
retry/recovery, and update notification.
|
||||
6. **Stateless replica profile.** Remove remaining local-runtime assumptions,
|
||||
expose role-specific commands/images, implement worker registration/drain,
|
||||
and prove multiple API and worker replicas against shared dependencies.
|
||||
6. **Stateless replica profile.** Continue module adoption and operational
|
||||
proof for the implemented role commands, shared-state validation, runtime
|
||||
registration/drain, fenced scheduler, and Kubernetes application-tier
|
||||
export. Prove multiple API and worker replicas against the target shared
|
||||
dependencies.
|
||||
7. **Configuration revision model.** Define provider export/import schemas,
|
||||
canonical serialization, secret references, validation/diff, immutable
|
||||
revision storage, audit, apply, and undo-as-new-revision.
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"$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": 8,
|
||||
"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",
|
||||
"accessibility",
|
||||
"privacy",
|
||||
"security",
|
||||
"operations",
|
||||
"recovery",
|
||||
"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}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "./capability-fit.schema.json",
|
||||
"schema_version": "0.1.0",
|
||||
"assessment_id": "campaign-reference-2026-07-20",
|
||||
"assessed_at": "2026-07-20",
|
||||
"assessment_id": "campaign-reference-2026-07-22",
|
||||
"assessed_at": "2026-07-22",
|
||||
"scope": {
|
||||
"title": "Campaign-centric internal pilot and small-production candidate",
|
||||
"reference_journeys": [
|
||||
@@ -14,30 +14,31 @@
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"kind": "workspace_snapshot",
|
||||
"ref": "workspace-2026-07-20",
|
||||
"meta_commit": "05ae81d64195",
|
||||
"reproducible": false,
|
||||
"kind": "tagged_release",
|
||||
"ref": "stable-catalog-202607220843",
|
||||
"meta_commit": "5447299289a1",
|
||||
"reproducible": true,
|
||||
"configuration_packages": [],
|
||||
"notes": [
|
||||
"The snapshot is untagged.",
|
||||
"Dirty repositories are recorded and local-only work is not treated as release-integrated."
|
||||
"The live stable catalog has a valid Ed25519 signature trusted through release-key-1.",
|
||||
"Core v0.1.13 and Campaign v0.1.10 are tagged and package-integrated; this is not target-environment or production approval.",
|
||||
"No configuration revision or configuration package is pinned yet."
|
||||
]
|
||||
},
|
||||
"composition": [
|
||||
{
|
||||
"module_id": "core",
|
||||
"repository": "govoplan-core",
|
||||
"commit": "e6f7c45f0a95",
|
||||
"manifest_version": "server-0.3.0",
|
||||
"commit": "d487726f4d2c",
|
||||
"manifest_version": "0.1.13",
|
||||
"enabled": true,
|
||||
"dirty": true,
|
||||
"dirty": false,
|
||||
"role": "API, registry, migrations, sessions, kernel contracts and shared WebUI"
|
||||
},
|
||||
{
|
||||
"module_id": "tenancy",
|
||||
"repository": "govoplan-tenancy",
|
||||
"commit": "1dde03854733",
|
||||
"commit": "efbec827616b",
|
||||
"manifest_version": "0.1.8",
|
||||
"enabled": true,
|
||||
"dirty": false,
|
||||
@@ -46,7 +47,7 @@
|
||||
{
|
||||
"module_id": "organizations",
|
||||
"repository": "govoplan-organizations",
|
||||
"commit": "45cda1a33f18",
|
||||
"commit": "39c081c4fb8f",
|
||||
"manifest_version": "0.1.8",
|
||||
"enabled": true,
|
||||
"dirty": false,
|
||||
@@ -58,22 +59,22 @@
|
||||
"commit": "7a1710af896f",
|
||||
"manifest_version": "0.1.8",
|
||||
"enabled": true,
|
||||
"dirty": true,
|
||||
"dirty": false,
|
||||
"role": "Normalized internal identity directory"
|
||||
},
|
||||
{
|
||||
"module_id": "access",
|
||||
"repository": "govoplan-access",
|
||||
"commit": "ab07075a679b",
|
||||
"manifest_version": "0.1.8",
|
||||
"commit": "f1d64d247e12",
|
||||
"manifest_version": "0.1.11",
|
||||
"enabled": true,
|
||||
"dirty": true,
|
||||
"dirty": false,
|
||||
"role": "Local authentication, sessions, API keys and RBAC"
|
||||
},
|
||||
{
|
||||
"module_id": "admin",
|
||||
"repository": "govoplan-admin",
|
||||
"commit": "c9e1fb287f50",
|
||||
"commit": "11ecf362a36d",
|
||||
"manifest_version": "0.1.8",
|
||||
"enabled": true,
|
||||
"dirty": false,
|
||||
@@ -82,7 +83,7 @@
|
||||
{
|
||||
"module_id": "dashboard",
|
||||
"repository": "govoplan-dashboard",
|
||||
"commit": "a315a7c62b1d",
|
||||
"commit": "4b960ad37f0d",
|
||||
"manifest_version": "0.1.8",
|
||||
"enabled": true,
|
||||
"dirty": false,
|
||||
@@ -91,8 +92,8 @@
|
||||
{
|
||||
"module_id": "policy",
|
||||
"repository": "govoplan-policy",
|
||||
"commit": "2511fbb5a864",
|
||||
"manifest_version": "0.1.8",
|
||||
"commit": "1063622d311a",
|
||||
"manifest_version": "0.1.9",
|
||||
"enabled": true,
|
||||
"dirty": false,
|
||||
"role": "Policy explanation and configuration boundary"
|
||||
@@ -100,7 +101,7 @@
|
||||
{
|
||||
"module_id": "audit",
|
||||
"repository": "govoplan-audit",
|
||||
"commit": "5e4f84a789ea",
|
||||
"commit": "d3d2c60d7dc1",
|
||||
"manifest_version": "0.1.8",
|
||||
"enabled": true,
|
||||
"dirty": false,
|
||||
@@ -109,46 +110,46 @@
|
||||
{
|
||||
"module_id": "campaigns",
|
||||
"repository": "govoplan-campaign",
|
||||
"commit": "2e593b7fa412",
|
||||
"manifest_version": "0.1.8",
|
||||
"commit": "735e874bd03c",
|
||||
"manifest_version": "0.1.10",
|
||||
"enabled": true,
|
||||
"dirty": true,
|
||||
"dirty": false,
|
||||
"role": "Campaign authoring, build, delivery control and reporting"
|
||||
},
|
||||
{
|
||||
"module_id": "files",
|
||||
"repository": "govoplan-files",
|
||||
"commit": "f3210234d35a",
|
||||
"manifest_version": "0.1.8",
|
||||
"commit": "2b34f6e30578",
|
||||
"manifest_version": "0.1.9",
|
||||
"enabled": true,
|
||||
"dirty": true,
|
||||
"dirty": false,
|
||||
"role": "Managed files and Campaign attachments"
|
||||
},
|
||||
{
|
||||
"module_id": "mail",
|
||||
"repository": "govoplan-mail",
|
||||
"commit": "b0337b2d261a",
|
||||
"manifest_version": "0.1.8",
|
||||
"commit": "3e2302909022",
|
||||
"manifest_version": "0.1.10",
|
||||
"enabled": true,
|
||||
"dirty": true,
|
||||
"dirty": false,
|
||||
"role": "SMTP and IMAP profiles and transports"
|
||||
},
|
||||
{
|
||||
"module_id": "calendar",
|
||||
"repository": "govoplan-calendar",
|
||||
"commit": "371a00aff51c",
|
||||
"commit": "9bcf41bb1fbb",
|
||||
"manifest_version": "0.1.8",
|
||||
"enabled": true,
|
||||
"dirty": true,
|
||||
"dirty": false,
|
||||
"role": "Optional calendar outside the Campaign pilot minimum"
|
||||
},
|
||||
{
|
||||
"module_id": "docs",
|
||||
"repository": "govoplan-docs",
|
||||
"commit": "f2ade1d62475",
|
||||
"manifest_version": "0.1.8",
|
||||
"commit": "be52b716caed",
|
||||
"manifest_version": "0.1.10",
|
||||
"enabled": true,
|
||||
"dirty": true,
|
||||
"dirty": false,
|
||||
"role": "Configured-system documentation"
|
||||
},
|
||||
{
|
||||
@@ -163,10 +164,10 @@
|
||||
{
|
||||
"module_id": "addresses",
|
||||
"repository": "govoplan-addresses",
|
||||
"commit": "f19350e65d76",
|
||||
"manifest_version": "0.1.8",
|
||||
"commit": "93dddbb8c52a",
|
||||
"manifest_version": "0.1.9",
|
||||
"enabled": false,
|
||||
"dirty": true,
|
||||
"dirty": false,
|
||||
"role": "Optional reusable recipient sources and CardDAV"
|
||||
}
|
||||
],
|
||||
@@ -293,20 +294,20 @@
|
||||
"kind": "contract",
|
||||
"scope": "current_workspace",
|
||||
"locator": "govoplan/tools/checks/check-contracts.py",
|
||||
"note": "43 contracts, 29 providers, no issues"
|
||||
"note": "43 modules, 33 providers, 19 requirements, no issues"
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
"Repeat checks on a clean pinned release candidate."
|
||||
"Package integration is verified; repeat checks on the installed target composition."
|
||||
],
|
||||
"gaps": [
|
||||
"The current snapshot is dirty and untagged."
|
||||
"No target deployment acceptance is recorded."
|
||||
],
|
||||
"risks": [
|
||||
"A working module graph can still be unreproducible."
|
||||
"A reproducible module graph can still be installed or configured incorrectly."
|
||||
],
|
||||
"recommendation": "Use the minimal Campaign composition and pin all artifacts before promotion.",
|
||||
"proof_check": "Run contract, migration, API and WebUI module-permutation gates on the release candidate."
|
||||
"recommendation": "Use the signed stable catalog and verify the minimal Campaign composition after installation.",
|
||||
"proof_check": "Run contract, migration, API and WebUI module-permutation gates on the installed release."
|
||||
},
|
||||
{
|
||||
"id": "access.local",
|
||||
@@ -348,21 +349,27 @@
|
||||
},
|
||||
{
|
||||
"kind": "test",
|
||||
"scope": "current_workspace",
|
||||
"scope": "committed_source",
|
||||
"locator": "govoplan-campaign/tests",
|
||||
"note": "14 tests passed"
|
||||
"note": "Campaign v0.1.10 is exactly the catalog-selected tagged source"
|
||||
},
|
||||
{
|
||||
"kind": "configuration",
|
||||
"scope": "committed_source",
|
||||
"locator": "https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json#sequence-202607220843",
|
||||
"note": "Core v0.1.13 and Campaign v0.1.10 have matching catalogued Python and WebUI refs"
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
"This verifies implementation paths, not target-provider delivery."
|
||||
],
|
||||
"gaps": [
|
||||
"Substantial Campaign UI/code WIP is not release-integrated."
|
||||
"Usability and target-provider acceptance remain separate."
|
||||
],
|
||||
"risks": [
|
||||
"Local WIP can make the checkout look more complete than the release baseline."
|
||||
"Package integration does not prove provider behavior or production operations."
|
||||
],
|
||||
"recommendation": "Integrate and retest Campaign work before usability or production acceptance.",
|
||||
"recommendation": "Use the catalogued Campaign release for usability and target-provider acceptance.",
|
||||
"proof_check": "Run the complete journey with safe data and the target-like mail service."
|
||||
},
|
||||
{
|
||||
@@ -500,7 +507,7 @@
|
||||
{
|
||||
"kind": "issue",
|
||||
"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": [],
|
||||
@@ -554,7 +561,7 @@
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
"Build immutable matching artifacts for promotion."
|
||||
"Materialize the matching catalogued artifacts in the target."
|
||||
],
|
||||
"gaps": [
|
||||
"No production image or service bundle is supplied by the profile."
|
||||
@@ -562,7 +569,7 @@
|
||||
"risks": [
|
||||
"Editable source processes are unsuitable as a production artifact."
|
||||
],
|
||||
"recommendation": "Package and supervise WebUI/API from one release candidate.",
|
||||
"recommendation": "Install matching catalogued WebUI/API refs and supervise them as immutable artifacts.",
|
||||
"proof_check": "Deploy the built artifacts and run health/module-route checks."
|
||||
},
|
||||
{
|
||||
@@ -595,12 +602,13 @@
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "test",
|
||||
"scope": "current_workspace",
|
||||
"locator": "govoplan-core/tests/test_calendar_outbox_worker.py"
|
||||
"scope": "committed_source",
|
||||
"locator": "govoplan-calendar/tests/test_outbox.py",
|
||||
"note": "Committed and pushed after the catalogued Calendar v0.1.8 tag"
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
"Relevant Calendar work is local WIP."
|
||||
"Calendar outbox and recovery work is remote-integrated source but not stable-package-integrated."
|
||||
],
|
||||
"gaps": [
|
||||
"No distributed leader election or target supervision is established."
|
||||
@@ -785,7 +793,7 @@
|
||||
{
|
||||
"kind": "issue",
|
||||
"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": [],
|
||||
@@ -928,9 +936,9 @@
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk.reproducibility",
|
||||
"statement": "The dirty, untagged workspace cannot be reproduced as a release.",
|
||||
"impact": "Uncertain deployed behavior and unsafe promotion or rollback.",
|
||||
"treatment": "Integrate scoped work and create a clean pinned release candidate.",
|
||||
"statement": "The signed package selection is reproducible but has not been accepted as an installed target composition.",
|
||||
"impact": "Installation or configuration drift can still produce uncertain deployed behavior.",
|
||||
"treatment": "Materialize the signed catalog in an isolated target and run installed-artifact acceptance gates.",
|
||||
"owner": null,
|
||||
"residual_risk": "Module and environment differences still require release-environment verification."
|
||||
},
|
||||
@@ -955,14 +963,16 @@
|
||||
"Proceed only with a controlled internal Campaign pilot after the bounded proof checks pass.",
|
||||
"Use the minimal composition and enable Addresses only for an explicit reusable-recipient journey.",
|
||||
"Do not claim Workflow, export-control screening, identity federation or production DR as implemented.",
|
||||
"Treat a clean release candidate, target mail proof, monitoring and coherent restore drill as production gates."
|
||||
"Treat installed-release acceptance, target mail proof, monitoring and a coherent restore drill as production gates."
|
||||
],
|
||||
"proof_checks": [
|
||||
"Pin and build a clean matching backend/WebUI release candidate and rerun cross-repository gates.",
|
||||
"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.",
|
||||
"Drill worker, Redis and ambiguous-delivery failures without duplicate sends.",
|
||||
"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.",
|
||||
"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."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"$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": 8,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"enum": [
|
||||
"target_environment",
|
||||
"external_providers",
|
||||
"accessibility",
|
||||
"privacy",
|
||||
"security",
|
||||
"operations",
|
||||
"recovery",
|
||||
"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",
|
||||
"$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",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -113,6 +113,12 @@
|
||||
"description": "GovOPlaN Appointments module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/approvals",
|
||||
"color": "d93f0b",
|
||||
"description": "GovOPlaN Approvals module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/audit",
|
||||
"color": "0e8a16",
|
||||
@@ -143,30 +149,72 @@
|
||||
"description": "GovOPlaN Connectors module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/committee",
|
||||
"color": "5319e7",
|
||||
"description": "GovOPlaN Committee module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/core",
|
||||
"color": "0052cc",
|
||||
"description": "GovOPlaN core runner, shared primitives, shell, or extension points.",
|
||||
"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",
|
||||
"color": "c5def5",
|
||||
"description": "GovOPlaN Dms module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/docs",
|
||||
"color": "c5def5",
|
||||
"description": "GovOPlaN Docs module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/dist-lists",
|
||||
"color": "0e8a16",
|
||||
"description": "GovOPlaN Distribution Lists module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/decisions",
|
||||
"color": "d93f0b",
|
||||
"description": "GovOPlaN formal Decisions module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/erp",
|
||||
"color": "fef2c0",
|
||||
"description": "GovOPlaN Erp module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/encryption",
|
||||
"color": "b60205",
|
||||
"description": "GovOPlaN Encryption key custody, cryptographic policy, and E2EE integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/evaluation",
|
||||
"color": "bfdadc",
|
||||
@@ -191,6 +239,18 @@
|
||||
"description": "GovOPlaN Forms module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/forms-runtime",
|
||||
"color": "f9d0c4",
|
||||
"description": "GovOPlaN Forms Runtime module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/helpdesk",
|
||||
"color": "c2e0c6",
|
||||
"description": "GovOPlaN Helpdesk module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/identity-trust",
|
||||
"color": "d4c5f9",
|
||||
@@ -221,6 +281,12 @@
|
||||
"description": "GovOPlaN mail module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/mandates",
|
||||
"color": "006b75",
|
||||
"description": "GovOPlaN Mandates, jurisdiction, responsibility, and authority behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/notifications",
|
||||
"color": "d876e3",
|
||||
@@ -245,6 +311,12 @@
|
||||
"description": "GovOPlaN Payments module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/parties",
|
||||
"color": "bfd4f2",
|
||||
"description": "GovOPlaN procedure Parties, representation, and delivery-authority behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/permits",
|
||||
"color": "fbca04",
|
||||
@@ -275,12 +347,24 @@
|
||||
"description": "GovOPlaN Postbox module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/projects",
|
||||
"color": "5319e7",
|
||||
"description": "GovOPlaN Projects module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/reporting",
|
||||
"color": "c2e0c6",
|
||||
"description": "GovOPlaN Reporting module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/risk-compliance",
|
||||
"color": "b60205",
|
||||
"description": "GovOPlaN Risk Compliance module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/search",
|
||||
"color": "bfdadc",
|
||||
@@ -293,6 +377,12 @@
|
||||
"description": "GovOPlaN Scheduling module behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/services",
|
||||
"color": "1d76db",
|
||||
"description": "GovOPlaN versioned institutional Services behavior or integration.",
|
||||
"exclusive": false
|
||||
},
|
||||
{
|
||||
"name": "module/tasks",
|
||||
"color": "c5def5",
|
||||
@@ -311,6 +401,24 @@
|
||||
"description": "GovOPlaN Tenancy module behavior or integration.",
|
||||
"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",
|
||||
"color": "f9d0c4",
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://govoplan.add-ideas.de/schemas/installation-spec-v1.json",
|
||||
"title": "GovOPlaN installation specification",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"installation_id",
|
||||
"profile",
|
||||
"public_url",
|
||||
"listen",
|
||||
"network_subnet",
|
||||
"release",
|
||||
"components",
|
||||
"enabled_modules"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": 1
|
||||
},
|
||||
"installation_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,47}$"
|
||||
},
|
||||
"profile": {
|
||||
"enum": [
|
||||
"evaluation",
|
||||
"self-hosted"
|
||||
]
|
||||
},
|
||||
"public_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"pattern": "^https?://"
|
||||
},
|
||||
"listen": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"address",
|
||||
"port"
|
||||
],
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
}
|
||||
}
|
||||
},
|
||||
"network_subnet": {
|
||||
"type": "string"
|
||||
},
|
||||
"release": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"channel",
|
||||
"version",
|
||||
"manifest_url",
|
||||
"manifest_sha256",
|
||||
"api_image",
|
||||
"web_image"
|
||||
],
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,31}$"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
},
|
||||
"manifest_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"manifest_sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^$|^[0-9a-f]{64}$"
|
||||
},
|
||||
"api_image": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 300
|
||||
},
|
||||
"web_image": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 300
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"postgres",
|
||||
"redis",
|
||||
"mail",
|
||||
"storage"
|
||||
],
|
||||
"properties": {
|
||||
"postgres": {
|
||||
"$ref": "#/$defs/postgres"
|
||||
},
|
||||
"redis": {
|
||||
"$ref": "#/$defs/redis"
|
||||
},
|
||||
"mail": {
|
||||
"$ref": "#/$defs/mail"
|
||||
},
|
||||
"storage": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"mode"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"enum": [
|
||||
"local",
|
||||
"garage",
|
||||
"s3"
|
||||
]
|
||||
},
|
||||
"image": {
|
||||
"type": "string",
|
||||
"maxLength": 300
|
||||
}
|
||||
}
|
||||
},
|
||||
"load_balancer": {
|
||||
"$ref": "#/$defs/load_balancer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"replicas": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"api",
|
||||
"web",
|
||||
"worker"
|
||||
],
|
||||
"properties": {
|
||||
"api": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 64
|
||||
},
|
||||
"web": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 64
|
||||
},
|
||||
"worker": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 128
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabled_modules": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]{1,63}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"service": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"mode",
|
||||
"image",
|
||||
"url_env"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string"
|
||||
},
|
||||
"image": {
|
||||
"type": "string"
|
||||
},
|
||||
"url_env": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"postgres": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/service"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"mode": {
|
||||
"enum": [
|
||||
"managed",
|
||||
"external"
|
||||
]
|
||||
},
|
||||
"url_env": {
|
||||
"const": "DATABASE_URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"redis": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/service"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"mode": {
|
||||
"enum": [
|
||||
"managed",
|
||||
"external",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"url_env": {
|
||||
"const": "REDIS_URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"mail": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/service"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"mode": {
|
||||
"enum": [
|
||||
"disabled",
|
||||
"external-relay",
|
||||
"test-mail"
|
||||
]
|
||||
},
|
||||
"url_env": {
|
||||
"const": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"load_balancer": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/service"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"mode": {
|
||||
"const": "managed"
|
||||
},
|
||||
"url_env": {
|
||||
"const": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"profile": {
|
||||
"const": "self-hosted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"public_url": {
|
||||
"pattern": "^https://"
|
||||
},
|
||||
"components": {
|
||||
"properties": {
|
||||
"redis": {
|
||||
"properties": {
|
||||
"mode": {
|
||||
"enum": [
|
||||
"managed",
|
||||
"external"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"mail": {
|
||||
"properties": {
|
||||
"mode": {
|
||||
"enum": [
|
||||
"disabled",
|
||||
"external-relay"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/installer-receipt-authority-keyring.schema.json",
|
||||
"title": "GovOPlaN installer receipt authority keyring",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "purpose", "keys"],
|
||||
"properties": {
|
||||
"$schema": { "type": "string", "format": "uri-reference" },
|
||||
"schema_version": { "const": "0.1.0" },
|
||||
"purpose": { "const": "govoplan.installer-receipt-authorities" },
|
||||
"keys": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 64,
|
||||
"items": { "$ref": "#/$defs/key" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"opaque_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
},
|
||||
"key": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["key_id", "status", "public_key", "allowed_scopes"],
|
||||
"properties": {
|
||||
"key_id": { "$ref": "#/$defs/opaque_id" },
|
||||
"status": {
|
||||
"enum": ["active", "next", "revoked", "disabled", "retired"]
|
||||
},
|
||||
"public_key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256,
|
||||
"contentEncoding": "base64"
|
||||
},
|
||||
"allowed_scopes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "const": "installed_release_origin" }
|
||||
},
|
||||
"not_before": { "type": "string", "format": "date-time" },
|
||||
"not_after": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/installer-receipt.schema.json",
|
||||
"title": "GovOPlaN signed installer receipt",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"evidence_kind",
|
||||
"receipt_id",
|
||||
"assessment_id",
|
||||
"assessment_release",
|
||||
"installed_evidence_sha256",
|
||||
"catalog",
|
||||
"issued_at",
|
||||
"artifacts",
|
||||
"signatures"
|
||||
],
|
||||
"properties": {
|
||||
"$schema": { "type": "string", "format": "uri-reference" },
|
||||
"schema_version": { "const": "0.1.0" },
|
||||
"evidence_kind": { "const": "govoplan.installer-receipt" },
|
||||
"receipt_id": { "$ref": "#/$defs/opaque_id" },
|
||||
"assessment_id": { "$ref": "#/$defs/opaque_id" },
|
||||
"assessment_release": { "$ref": "#/$defs/opaque_id" },
|
||||
"installed_evidence_sha256": { "$ref": "#/$defs/sha256" },
|
||||
"catalog": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["channel", "sequence", "sha256"],
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_-]{0,63}$"
|
||||
},
|
||||
"sequence": { "type": "integer", "minimum": 1 },
|
||||
"sha256": { "$ref": "#/$defs/sha256" }
|
||||
}
|
||||
},
|
||||
"issued_at": { "type": "string", "format": "date-time" },
|
||||
"artifacts": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 256,
|
||||
"items": { "$ref": "#/$defs/artifact" }
|
||||
},
|
||||
"signatures": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 16,
|
||||
"items": { "$ref": "#/$defs/signature" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"opaque_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
},
|
||||
"package_name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._+!-]*$"
|
||||
},
|
||||
"artifact": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"package_name",
|
||||
"package_version",
|
||||
"catalog_archive_sha256",
|
||||
"installed_payload"
|
||||
],
|
||||
"properties": {
|
||||
"package_name": { "$ref": "#/$defs/package_name" },
|
||||
"package_version": { "$ref": "#/$defs/version" },
|
||||
"catalog_archive_sha256": { "$ref": "#/$defs/sha256" },
|
||||
"installed_payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["algorithm", "sha256", "file_count"],
|
||||
"properties": {
|
||||
"algorithm": { "const": "govoplan-installed-record-payload-v1" },
|
||||
"sha256": { "$ref": "#/$defs/sha256" },
|
||||
"file_count": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"signature": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["algorithm", "key_id", "value"],
|
||||
"properties": {
|
||||
"algorithm": { "const": "ed25519" },
|
||||
"key_id": { "$ref": "#/$defs/opaque_id" },
|
||||
"value": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256,
|
||||
"contentEncoding": "base64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Governed Communication Product Package
|
||||
|
||||
This package composes Campaign, Mail, Postbox, Files, Templates, Policy, Audit,
|
||||
and Access into one governed-delivery capability. Modules retain their own
|
||||
tables and lifecycle. The package declares installation and configuration
|
||||
expectations; it does not import module internals.
|
||||
|
||||
## Reference journey
|
||||
|
||||
1. Select governed recipient and attachment data.
|
||||
2. Build and review an immutable Campaign version.
|
||||
3. Apply attachment, access, and delivery policy.
|
||||
4. Deliver through Mail and/or function-bound Postbox targets.
|
||||
5. Reconcile outcomes, acknowledgements, correction, and reports against Audit
|
||||
evidence.
|
||||
|
||||
## Reference-readiness gates
|
||||
|
||||
The artifact remains a `product` package. Promotion to `reference` requires:
|
||||
|
||||
- target-environment delivery and reconciliation tests, including an
|
||||
outcome-unknown transport result;
|
||||
- backup/restore and interrupted-dispatch recovery evidence;
|
||||
- security evidence for recipient, attachment, and postbox isolation;
|
||||
- operator runbooks for queue health, retry, reconciliation, and retirement;
|
||||
- keyboard, focus, screen-reader, and responsive workflow evidence;
|
||||
- privacy evidence for minimization, purpose, retention, redaction, and report
|
||||
access; and
|
||||
- version-pinned user and administrator documentation.
|
||||
|
||||
Optional Notifications, Portal, Reporting, and Workflow Engine integrations do
|
||||
not change the package boundary when absent.
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"package_id": "product.governed-communication",
|
||||
"name": "Governed Communication",
|
||||
"version": "0.1.0",
|
||||
"package_class": "product",
|
||||
"description": "Compose evidence-backed campaigns, mail and function-bound postboxes without merging their domain ownership.",
|
||||
"publisher": "GovOPlaN",
|
||||
"category": "communication",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"required_modules": [
|
||||
{"module_id": "access"},
|
||||
{"module_id": "audit"},
|
||||
{"module_id": "campaign"},
|
||||
{"module_id": "files"},
|
||||
{"module_id": "mail"},
|
||||
{"module_id": "policy"},
|
||||
{"module_id": "postbox"},
|
||||
{"module_id": "templates"}
|
||||
],
|
||||
"optional_modules": [
|
||||
{"module_id": "notifications"},
|
||||
{"module_id": "portal"},
|
||||
{"module_id": "reporting"},
|
||||
{"module_id": "workflow_engine"}
|
||||
],
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "documentation",
|
||||
"reference": "packages/product/governed-communication/README.md",
|
||||
"summary": "Defines the package boundary, journey, and reference-readiness gates."
|
||||
}
|
||||
],
|
||||
"tags": ["campaign", "mail", "postbox", "public-sector"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Governed Data and Assurance Product Package
|
||||
|
||||
This package composes Datasources, Dataflow, Reporting, Search, Risk
|
||||
Compliance, Policy, Audit, and Access. Connectors may acquire external data,
|
||||
but Datasources owns the governed catalogue and immutable snapshots; Dataflow
|
||||
owns transformation definitions and runs; Reporting owns measures and
|
||||
presentation; Risk Compliance owns controls, findings, and effectiveness
|
||||
review.
|
||||
|
||||
## Reference journey
|
||||
|
||||
1. Register a typed datasource with source authority, purpose, classification,
|
||||
owner, freshness, and correction policy.
|
||||
2. Acquire or upload an immutable source state.
|
||||
3. execute a versioned flow and retain intermediate materializations and
|
||||
provenance;
|
||||
4. publish a report or decision input against exact source and flow revisions;
|
||||
5. link obligation, governed object, risk, control, evidence, finding,
|
||||
corrective measure, and effectiveness review; and
|
||||
6. search current authorized objects while preserving ownership and access
|
||||
rechecks.
|
||||
|
||||
## Reference-readiness gates
|
||||
|
||||
The artifact remains a `product` package. Promotion to `reference` requires:
|
||||
|
||||
- a target-tested monthly-data and sanctions-screening fixture with expected
|
||||
outputs and complete provenance;
|
||||
- database/object-storage backup and restore plus interrupted-run recovery;
|
||||
- security evidence for datasource credentials, staged data, intermediate
|
||||
states, search documents, and assurance references;
|
||||
- operator runbooks for stale sources, failed runs, index rebuilds, and graph
|
||||
reconciliation;
|
||||
- accessibility evidence for the catalogue, graph editors, result inspection,
|
||||
reports, and assurance graph;
|
||||
- privacy evidence for minimization, purpose, retention, field visibility, and
|
||||
aggregate disclosure; and
|
||||
- version-pinned user and administrator documentation.
|
||||
|
||||
Optional Connectors, Files, Notifications, and Workflow Engine integrations
|
||||
must remain capability-based and absence-safe.
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"package_id": "product.governed-data-assurance",
|
||||
"name": "Governed Data and Assurance",
|
||||
"version": "0.1.0",
|
||||
"package_class": "product",
|
||||
"description": "Compose governed sources, transformations, reports, search, controls and assurance evidence through stable contracts.",
|
||||
"publisher": "GovOPlaN",
|
||||
"category": "data-governance",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"required_modules": [
|
||||
{"module_id": "access"},
|
||||
{"module_id": "audit"},
|
||||
{"module_id": "dataflow"},
|
||||
{"module_id": "datasources"},
|
||||
{"module_id": "policy"},
|
||||
{"module_id": "reporting"},
|
||||
{"module_id": "risk_compliance"},
|
||||
{"module_id": "search"}
|
||||
],
|
||||
"optional_modules": [
|
||||
{"module_id": "connectors"},
|
||||
{"module_id": "files"},
|
||||
{"module_id": "notifications"},
|
||||
{"module_id": "workflow_engine"}
|
||||
],
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "documentation",
|
||||
"reference": "packages/product/governed-data-assurance/README.md",
|
||||
"summary": "Defines the package boundary, provenance chain, and reference-readiness gates."
|
||||
}
|
||||
],
|
||||
"tags": ["datasources", "dataflow", "reporting", "assurance"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Governed Service To Decision
|
||||
|
||||
This product package composes independently owned institutional semantics into
|
||||
one reconstructable administrative journey:
|
||||
|
||||
```text
|
||||
Service discovery -> Case intake -> Party and representation -> Mandate
|
||||
resolution -> approval/deliberation -> formal Decision -> observed delivery
|
||||
effect -> record and review references
|
||||
```
|
||||
|
||||
An installed Forms and Forms Runtime pair adds an alternative governed entry
|
||||
path before case/workflow handoff:
|
||||
|
||||
```text
|
||||
Service discovery -> exact Form revision -> validated draft/submission
|
||||
-> receipt and handoff evidence -> Case or Workflow owner
|
||||
```
|
||||
|
||||
Services, Cases, Parties, Mandates, Committee, and Decisions retain immutable
|
||||
provider-owned revisions for the parts they own. Portal, Cases, and Committee
|
||||
consume capabilities for cross-module semantics only. The package does not
|
||||
grant cross-module table access and can omit optional presentation, work,
|
||||
deliberation, delivery, or records modules while retaining explicit references
|
||||
to externally performed steps.
|
||||
|
||||
## Security And Recovery
|
||||
|
||||
Every provider is tenant-bound. Missing or conflicting authority fails closed.
|
||||
Protected Decision content has a separate permission. Writes are replay-safe
|
||||
and OCC-guarded. Database restore is the semantic-state recovery unit; file and
|
||||
communication effects remain governed by their owning providers and are linked
|
||||
through requested/observed effect, evidence, and audit references.
|
||||
|
||||
The executable fixture in
|
||||
`tests/test_institutional_governance_journey.py` proves SQL-backed Service,
|
||||
Case, Party, Mandate, Committee meeting/agendum/vote/minute, and Decision state.
|
||||
`tests/test_institutional_service_journey.py` separately proves exact Portal
|
||||
Form launch, persisted submission provenance, and idempotent replay.
|
||||
Target-environment accessibility, security, operator, privacy,
|
||||
delivery-provider, and recovery evidence are still required before this product
|
||||
package may claim `reference_ready` maturity.
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"package_id": "product.service-to-decision",
|
||||
"name": "Governed Service To Decision",
|
||||
"version": "0.1.0",
|
||||
"package_class": "product",
|
||||
"description": "Carry one exact institutional context from service discovery and case intake through parties, authority, formal outcome, communication evidence, and review.",
|
||||
"publisher": "GovOPlaN",
|
||||
"category": "institutional-governance",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"required_modules": [
|
||||
{"module_id": "audit"},
|
||||
{"module_id": "cases"},
|
||||
{"module_id": "decisions"},
|
||||
{"module_id": "mandates"},
|
||||
{"module_id": "parties"},
|
||||
{"module_id": "policy"},
|
||||
{"module_id": "portal"},
|
||||
{"module_id": "services"}
|
||||
],
|
||||
"required_capabilities": [
|
||||
"cases.party_context",
|
||||
"cases.service_intake",
|
||||
"decisions.registry",
|
||||
"mandates.resolver",
|
||||
"parties.resolver",
|
||||
"portal.service_directory",
|
||||
"services.availability",
|
||||
"services.definitions"
|
||||
],
|
||||
"optional_modules": [
|
||||
{"module_id": "approvals"},
|
||||
{"module_id": "committee"},
|
||||
{"module_id": "files"},
|
||||
{"module_id": "forms"},
|
||||
{"module_id": "forms_runtime"},
|
||||
{"module_id": "postbox"},
|
||||
{"module_id": "records"},
|
||||
{"module_id": "tasks"},
|
||||
{"module_id": "workflow_engine"}
|
||||
],
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "documentation",
|
||||
"reference": "packages/product/service-to-decision/README.md",
|
||||
"summary": "Defines the package boundary, authority path, recovery contract, and known operational limits."
|
||||
},
|
||||
{
|
||||
"kind": "target_test",
|
||||
"reference": "tests/test_institutional_governance_journey.py",
|
||||
"summary": "Executes the SQL-backed provider composition from service discovery through persisted formal Decision reconstruction."
|
||||
},
|
||||
{
|
||||
"kind": "target_test",
|
||||
"reference": "tests/test_institutional_service_journey.py",
|
||||
"summary": "Executes Portal delegation from an exact Service revision through an exact immutable Form revision to a replay-safe persisted submission."
|
||||
}
|
||||
],
|
||||
"tags": ["service", "case", "mandate", "party", "decision", "public-sector"]
|
||||
}
|
||||
+79
-67
@@ -1,73 +1,85 @@
|
||||
{
|
||||
"version": 1,
|
||||
"organization": "add-ideas",
|
||||
"organization": "GovOPlaN",
|
||||
"default_parent": "/mnt/DATA/git",
|
||||
"repositories": [
|
||||
{"name": "govoplan", "category": "system", "subtype": "meta", "remote": "git@git.add-ideas.de:add-ideas/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-access", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/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-admin", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/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-approvals", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/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-audit", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/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-calendar", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/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-cases", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/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-committee", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/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-consultation", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/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-dashboard", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/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-dist-lists", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-dist-lists.git", "path": "govoplan-dist-lists"},
|
||||
{"name": "govoplan-docs", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-docs.git", "path": "govoplan-docs"},
|
||||
{"name": "govoplan-erp", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-erp.git", "path": "govoplan-erp"},
|
||||
{"name": "govoplan-evaluation", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-evaluation.git", "path": "govoplan-evaluation"},
|
||||
{"name": "govoplan-facilities", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-facilities.git", "path": "govoplan-facilities"},
|
||||
{"name": "govoplan-files", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-files.git", "path": "govoplan-files"},
|
||||
{"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-forms", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-forms.git", "path": "govoplan-forms"},
|
||||
{"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-grants", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-grants.git", "path": "govoplan-grants"},
|
||||
{"name": "govoplan-helpdesk", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-helpdesk.git", "path": "govoplan-helpdesk"},
|
||||
{"name": "govoplan-identity", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-identity.git", "path": "govoplan-identity"},
|
||||
{"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-idm", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-idm.git", "path": "govoplan-idm"},
|
||||
{"name": "govoplan-inspections", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-inspections.git", "path": "govoplan-inspections"},
|
||||
{"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-learning", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-learning.git", "path": "govoplan-learning"},
|
||||
{"name": "govoplan-ledger", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-ledger.git", "path": "govoplan-ledger"},
|
||||
{"name": "govoplan-mail", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-mail.git", "path": "govoplan-mail"},
|
||||
{"name": "govoplan-notifications", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-notifications.git", "path": "govoplan-notifications"},
|
||||
{"name": "govoplan-ops", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-ops.git", "path": "govoplan-ops"},
|
||||
{"name": "govoplan-organizations", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-organizations.git", "path": "govoplan-organizations"},
|
||||
{"name": "govoplan-payments", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-payments.git", "path": "govoplan-payments"},
|
||||
{"name": "govoplan-permits", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-permits.git", "path": "govoplan-permits"},
|
||||
{"name": "govoplan-policy", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-policy.git", "path": "govoplan-policy"},
|
||||
{"name": "govoplan-poll", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-poll.git", "path": "govoplan-poll"},
|
||||
{"name": "govoplan-portal", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-portal.git", "path": "govoplan-portal"},
|
||||
{"name": "govoplan-postbox", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-postbox.git", "path": "govoplan-postbox"},
|
||||
{"name": "govoplan-procurement", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-procurement.git", "path": "govoplan-procurement"},
|
||||
{"name": "govoplan-records", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-records.git", "path": "govoplan-records"},
|
||||
{"name": "govoplan-reporting", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-reporting.git", "path": "govoplan-reporting"},
|
||||
{"name": "govoplan-resources", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-resources.git", "path": "govoplan-resources"},
|
||||
{"name": "govoplan-rest", "category": "connector", "subtype": "protocol", "remote": "git@git.add-ideas.de:add-ideas/govoplan-rest.git", "path": "govoplan-rest"},
|
||||
{"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-scheduling", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-scheduling.git", "path": "govoplan-scheduling"},
|
||||
{"name": "govoplan-search", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-search.git", "path": "govoplan-search"},
|
||||
{"name": "govoplan-soap", "category": "connector", "subtype": "protocol", "remote": "git@git.add-ideas.de:add-ideas/govoplan-soap.git", "path": "govoplan-soap"},
|
||||
{"name": "govoplan-tasks", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-tasks.git", "path": "govoplan-tasks"},
|
||||
{"name": "govoplan-templates", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-templates.git", "path": "govoplan-templates"},
|
||||
{"name": "govoplan-tenancy", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-tenancy.git", "path": "govoplan-tenancy"},
|
||||
{"name": "govoplan-transparency", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-transparency.git", "path": "govoplan-transparency"},
|
||||
{"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-workflow", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-workflow.git", "path": "govoplan-workflow"},
|
||||
{"name": "govoplan-xoev", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:add-ideas/govoplan-xoev.git", "path": "govoplan-xoev"},
|
||||
{"name": "govoplan-xrechnung", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:add-ideas/govoplan-xrechnung.git", "path": "govoplan-xrechnung"},
|
||||
{"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", "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:GovOPlaN/govoplan-core.git", "path": "govoplan-core"},
|
||||
{"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:GovOPlaN/govoplan-addresses.git", "path": "govoplan-addresses"},
|
||||
{"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:GovOPlaN/govoplan-appointments.git", "path": "govoplan-appointments"},
|
||||
{"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:GovOPlaN/govoplan-assets.git", "path": "govoplan-assets"},
|
||||
{"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:GovOPlaN/govoplan-booking.git", "path": "govoplan-booking"},
|
||||
{"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:GovOPlaN/govoplan-campaign.git", "path": "govoplan-campaign"},
|
||||
{"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:GovOPlaN/govoplan-certificates.git", "path": "govoplan-certificates"},
|
||||
{"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:GovOPlaN/govoplan-connectors.git", "path": "govoplan-connectors"},
|
||||
{"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:GovOPlaN/govoplan-contracts.git", "path": "govoplan-contracts"},
|
||||
{"name": "govoplan-dashboard", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-dashboard.git", "path": "govoplan-dashboard"},
|
||||
{"name": "govoplan-dataflow", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-dataflow.git", "path": "govoplan-dataflow"},
|
||||
{"name": "govoplan-datasources", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-datasources.git", "path": "govoplan-datasources"},
|
||||
{"name": "govoplan-decisions", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-decisions.git", "path": "govoplan-decisions"},
|
||||
{"name": "govoplan-dms", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-dms.git", "path": "govoplan-dms"},
|
||||
{"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-docs", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-docs.git", "path": "govoplan-docs"},
|
||||
{"name": "govoplan-encryption", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-encryption.git", "path": "govoplan-encryption"},
|
||||
{"name": "govoplan-erp", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-erp.git", "path": "govoplan-erp"},
|
||||
{"name": "govoplan-evaluation", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-evaluation.git", "path": "govoplan-evaluation"},
|
||||
{"name": "govoplan-facilities", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-facilities.git", "path": "govoplan-facilities"},
|
||||
{"name": "govoplan-files", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-files.git", "path": "govoplan-files"},
|
||||
{"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-forms", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-forms.git", "path": "govoplan-forms"},
|
||||
{"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-grants", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-grants.git", "path": "govoplan-grants"},
|
||||
{"name": "govoplan-helpdesk", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-helpdesk.git", "path": "govoplan-helpdesk"},
|
||||
{"name": "govoplan-identity", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-identity.git", "path": "govoplan-identity"},
|
||||
{"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-idm", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-idm.git", "path": "govoplan-idm"},
|
||||
{"name": "govoplan-inspections", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-inspections.git", "path": "govoplan-inspections"},
|
||||
{"name": "govoplan-learning", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-learning.git", "path": "govoplan-learning"},
|
||||
{"name": "govoplan-ledger", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-ledger.git", "path": "govoplan-ledger"},
|
||||
{"name": "govoplan-mail", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-mail.git", "path": "govoplan-mail"},
|
||||
{"name": "govoplan-mandates", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-mandates.git", "path": "govoplan-mandates"},
|
||||
{"name": "govoplan-notifications", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-notifications.git", "path": "govoplan-notifications"},
|
||||
{"name": "govoplan-ops", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-ops.git", "path": "govoplan-ops"},
|
||||
{"name": "govoplan-organizations", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-organizations.git", "path": "govoplan-organizations"},
|
||||
{"name": "govoplan-payments", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-payments.git", "path": "govoplan-payments"},
|
||||
{"name": "govoplan-parties", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-parties.git", "path": "govoplan-parties"},
|
||||
{"name": "govoplan-permits", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-permits.git", "path": "govoplan-permits"},
|
||||
{"name": "govoplan-policy", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-policy.git", "path": "govoplan-policy"},
|
||||
{"name": "govoplan-poll", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-poll.git", "path": "govoplan-poll"},
|
||||
{"name": "govoplan-portal", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-portal.git", "path": "govoplan-portal"},
|
||||
{"name": "govoplan-postbox", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-postbox.git", "path": "govoplan-postbox"},
|
||||
{"name": "govoplan-procurement", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-procurement.git", "path": "govoplan-procurement"},
|
||||
{"name": "govoplan-projects", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-projects.git", "path": "govoplan-projects"},
|
||||
{"name": "govoplan-records", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-records.git", "path": "govoplan-records"},
|
||||
{"name": "govoplan-reporting", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-reporting.git", "path": "govoplan-reporting"},
|
||||
{"name": "govoplan-resources", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-resources.git", "path": "govoplan-resources"},
|
||||
{"name": "govoplan-rest", "category": "connector", "subtype": "protocol", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-rest.git", "path": "govoplan-rest"},
|
||||
{"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-scheduling", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-scheduling.git", "path": "govoplan-scheduling"},
|
||||
{"name": "govoplan-search", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-search.git", "path": "govoplan-search"},
|
||||
{"name": "govoplan-services", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-services.git", "path": "govoplan-services"},
|
||||
{"name": "govoplan-soap", "category": "connector", "subtype": "protocol", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-soap.git", "path": "govoplan-soap"},
|
||||
{"name": "govoplan-tasks", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-tasks.git", "path": "govoplan-tasks"},
|
||||
{"name": "govoplan-templates", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-templates.git", "path": "govoplan-templates"},
|
||||
{"name": "govoplan-tenancy", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git", "path": "govoplan-tenancy"},
|
||||
{"name": "govoplan-tickets", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-tickets.git", "path": "govoplan-tickets"},
|
||||
{"name": "govoplan-transparency", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-transparency.git", "path": "govoplan-transparency"},
|
||||
{"name": "govoplan-views", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-views.git", "path": "govoplan-views"},
|
||||
{"name": "govoplan-voting", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-voting.git", "path": "govoplan-voting"},
|
||||
{"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-workflow-engine", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:GovOPlaN/govoplan-workflow-engine.git", "path": "govoplan-workflow-engine"},
|
||||
{"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"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,24 +12,49 @@
|
||||
-e ../govoplan-admin
|
||||
-e ../govoplan-policy
|
||||
-e ../govoplan-audit
|
||||
-e ../govoplan-approvals
|
||||
-e ../govoplan-dashboard
|
||||
-e ../govoplan-addresses
|
||||
-e ../govoplan-dist-lists
|
||||
-e ../govoplan-files
|
||||
-e ../govoplan-forms
|
||||
-e ../govoplan-forms-runtime
|
||||
-e ../govoplan-mail
|
||||
-e ../govoplan-campaign
|
||||
-e ../govoplan-calendar
|
||||
-e ../govoplan-committee
|
||||
-e ../govoplan-cases
|
||||
-e ../govoplan-portal
|
||||
-e ../govoplan-services
|
||||
-e ../govoplan-parties
|
||||
-e ../govoplan-mandates
|
||||
-e ../govoplan-decisions
|
||||
-e ../govoplan-connectors
|
||||
-e ../govoplan-datasources
|
||||
-e ../govoplan-dataflow
|
||||
-e ../govoplan-workflow-engine
|
||||
-e ../govoplan-workflow
|
||||
-e ../govoplan-views
|
||||
-e ../govoplan-voting
|
||||
-e ../govoplan-search
|
||||
-e ../govoplan-risk-compliance
|
||||
-e ../govoplan-postbox
|
||||
-e ../govoplan-poll
|
||||
-e ../govoplan-scheduling
|
||||
-e ../govoplan-notifications
|
||||
-e ../govoplan-evaluation
|
||||
-e ../govoplan-docs
|
||||
-e ../govoplan-encryption
|
||||
-e ../govoplan-identity-trust
|
||||
-e ../govoplan-ops
|
||||
httpx==0.28.1
|
||||
httpx2>=2.5,<3
|
||||
filelock>=3.20.3
|
||||
idna>=3.15
|
||||
jsonschema>=4,<5
|
||||
pip>=26.1.2
|
||||
pip-audit>=2.9,<3
|
||||
pytest>=9.0.3,<10
|
||||
pygments>=2.20,<3
|
||||
python-multipart>=0.0.31
|
||||
ruff>=0.14,<1
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test-harness dependencies used against immutable release source tags.
|
||||
# Keep these separate from requirements-release.txt so they are not part of the
|
||||
# deployable product dependency set.
|
||||
pytest>=9.0.3,<10
|
||||
pygments>=2.20,<3
|
||||
+15
-15
@@ -1,18 +1,18 @@
|
||||
# Whole-product release install from immutable, independently versioned module tags.
|
||||
# Only add a module after its referenced tag has been published.
|
||||
../govoplan-core[server]
|
||||
govoplan-tenancy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-tenancy.git@v0.1.8
|
||||
govoplan-organizations @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git@v0.1.8
|
||||
govoplan-identity @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-identity.git@v0.1.8
|
||||
govoplan-idm @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git@v0.1.8
|
||||
govoplan-access @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git@v0.1.8
|
||||
govoplan-admin @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git@v0.1.8
|
||||
govoplan-policy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git@v0.1.8
|
||||
govoplan-audit @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git@v0.1.8
|
||||
govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.8
|
||||
govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.8
|
||||
govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.8
|
||||
govoplan-campaign @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git@v0.1.8
|
||||
govoplan-calendar @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git@v0.1.8
|
||||
govoplan-docs @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git@v0.1.8
|
||||
govoplan-ops @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.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/GovOPlaN/govoplan-organizations.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/GovOPlaN/govoplan-idm.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/GovOPlaN/govoplan-admin.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/GovOPlaN/govoplan-audit.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/GovOPlaN/govoplan-files.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/GovOPlaN/govoplan-campaign.git@v0.1.11
|
||||
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/GovOPlaN/govoplan-docs.git@v0.1.8
|
||||
govoplan-ops @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git@v0.1.8
|
||||
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,700 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
import unittest
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
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.capability_fit import ( # noqa: E402
|
||||
local_tag_provenance,
|
||||
render_review,
|
||||
review_capability_fit,
|
||||
trusted_keys_from_keyring,
|
||||
)
|
||||
|
||||
|
||||
class CapabilityFitReviewTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.assessment = json.loads(
|
||||
(META_ROOT / "docs" / "capability-fit-current.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
cls.schema = json.loads(
|
||||
(META_ROOT / "docs" / "capability-fit.schema.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
|
||||
def test_matching_signed_catalog_is_current(self) -> None:
|
||||
catalog, keyring = signed_catalog(self.assessment)
|
||||
|
||||
report = review_capability_fit(
|
||||
assessment=deepcopy(self.assessment),
|
||||
schema=self.schema,
|
||||
catalog=catalog,
|
||||
published_keyring=keyring,
|
||||
trusted_keyring=keyring,
|
||||
)
|
||||
|
||||
self.assertEqual("current", report["status"])
|
||||
self.assertEqual([], report["changes"])
|
||||
self.assertFalse(report["proof_scope"]["installed_artifacts"]["checked"])
|
||||
self.assertFalse(report["proof_scope"]["target_environment"]["checked"])
|
||||
self.assertFalse(report["proof_scope"]["local_tag_provenance"]["checked"])
|
||||
repeated = review_capability_fit(
|
||||
assessment=deepcopy(self.assessment),
|
||||
schema=self.schema,
|
||||
catalog=catalog,
|
||||
published_keyring=keyring,
|
||||
trusted_keyring=keyring,
|
||||
)
|
||||
self.assertEqual(
|
||||
json.dumps(report, sort_keys=True), json.dumps(repeated, sort_keys=True)
|
||||
)
|
||||
|
||||
def test_release_drift_identifies_affected_conclusions(self) -> None:
|
||||
catalog, keyring = signed_catalog(
|
||||
self.assessment, versions={"campaigns": "0.1.11"}, sequence=202607230001
|
||||
)
|
||||
|
||||
report = review_capability_fit(
|
||||
assessment=deepcopy(self.assessment),
|
||||
schema=self.schema,
|
||||
catalog=catalog,
|
||||
published_keyring=keyring,
|
||||
trusted_keyring=keyring,
|
||||
)
|
||||
|
||||
self.assertEqual("review_required", report["status"])
|
||||
target_ids = {item["id"] for item in report["review_targets"]}
|
||||
self.assertIn("campaign.journey", target_ids)
|
||||
self.assertIn("composition.campaigns", target_ids)
|
||||
self.assertIn("assessment.release", target_ids)
|
||||
self.assertIn(
|
||||
"composition_version_changed", {item["code"] for item in report["findings"]}
|
||||
)
|
||||
|
||||
def test_contradictory_signed_selected_unit_metadata_blocks_rerun(self) -> None:
|
||||
core = next(
|
||||
item
|
||||
for item in self.assessment["composition"]
|
||||
if item["module_id"] == "core"
|
||||
)
|
||||
catalog, keyring = signed_catalog(
|
||||
self.assessment,
|
||||
selected_units=[
|
||||
{
|
||||
"repo": core["repository"],
|
||||
"version": core["manifest_version"],
|
||||
"tag": "v999.0.0",
|
||||
"commit_sha": "0" * 40,
|
||||
"tag_object_sha": "1" * 40,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
report = review_capability_fit(
|
||||
assessment=deepcopy(self.assessment),
|
||||
schema=self.schema,
|
||||
catalog=catalog,
|
||||
published_keyring=keyring,
|
||||
trusted_keyring=keyring,
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", report["status"])
|
||||
codes = {item["code"] for item in report["findings"]}
|
||||
self.assertIn("catalog_release_metadata", codes)
|
||||
self.assertIn("composition_commit_changed", codes)
|
||||
self.assertTrue(report["proof_scope"]["catalog_signature_and_keyring"]["valid"])
|
||||
self.assertFalse(report["proof_scope"]["release_metadata"]["valid"])
|
||||
|
||||
def test_signed_selected_unit_commit_drift_requires_review(self) -> None:
|
||||
core = next(
|
||||
item
|
||||
for item in self.assessment["composition"]
|
||||
if item["module_id"] == "core"
|
||||
)
|
||||
catalog, keyring = signed_catalog(
|
||||
self.assessment,
|
||||
selected_units=[
|
||||
{
|
||||
"repo": core["repository"],
|
||||
"version": core["manifest_version"],
|
||||
"tag": f"v{core['manifest_version']}",
|
||||
"commit_sha": "0" * 40,
|
||||
"tag_object_sha": "1" * 40,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
report = review_capability_fit(
|
||||
assessment=deepcopy(self.assessment),
|
||||
schema=self.schema,
|
||||
catalog=catalog,
|
||||
published_keyring=keyring,
|
||||
trusted_keyring=keyring,
|
||||
)
|
||||
|
||||
self.assertEqual("review_required", report["status"])
|
||||
self.assertIn(
|
||||
"composition_commit_changed", {item["code"] for item in report["findings"]}
|
||||
)
|
||||
self.assertIn(
|
||||
"composition.core", {item["id"] for item in report["review_targets"]}
|
||||
)
|
||||
self.assertFalse(report["proof_scope"]["release_metadata"]["valid"])
|
||||
|
||||
def test_missing_duplicate_and_malformed_selected_unit_provenance_blocks(
|
||||
self,
|
||||
) -> None:
|
||||
core = next(
|
||||
item
|
||||
for item in self.assessment["composition"]
|
||||
if item["module_id"] == "core"
|
||||
)
|
||||
valid_unit = {
|
||||
"repo": core["repository"],
|
||||
"version": core["manifest_version"],
|
||||
"tag": f"v{core['manifest_version']}",
|
||||
"commit_sha": (str(core["commit"]) + "0" * 40)[:40],
|
||||
"tag_object_sha": "1" * 40,
|
||||
}
|
||||
cases = (
|
||||
("missing", None, False, "catalog_source_provenance"),
|
||||
(
|
||||
"duplicate",
|
||||
[valid_unit, deepcopy(valid_unit)],
|
||||
True,
|
||||
"catalog_release_metadata",
|
||||
),
|
||||
(
|
||||
"malformed",
|
||||
[{**valid_unit, "commit_sha": "not-a-git-object"}],
|
||||
True,
|
||||
"catalog_source_provenance",
|
||||
),
|
||||
)
|
||||
for label, selected_units, include_selected_units, expected_code in cases:
|
||||
with self.subTest(label=label):
|
||||
catalog, keyring = signed_catalog(
|
||||
self.assessment,
|
||||
selected_units=selected_units,
|
||||
include_selected_units=include_selected_units,
|
||||
)
|
||||
|
||||
report = review_capability_fit(
|
||||
assessment=deepcopy(self.assessment),
|
||||
schema=self.schema,
|
||||
catalog=catalog,
|
||||
published_keyring=keyring,
|
||||
trusted_keyring=keyring,
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", report["status"])
|
||||
self.assertIn(
|
||||
expected_code, {item["code"] for item in report["findings"]}
|
||||
)
|
||||
self.assertFalse(report["proof_scope"]["release_metadata"]["valid"])
|
||||
|
||||
def test_catalog_validation_ignores_and_preserves_installer_replay_state(
|
||||
self,
|
||||
) -> None:
|
||||
catalog, keyring = signed_catalog(self.assessment)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
state_path = Path(temp_dir) / "catalog-sequence.json"
|
||||
state_path.write_text(
|
||||
json.dumps(
|
||||
{"channels": {"stable": {"last_sequence": catalog["sequence"]}}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
replay_environment = {
|
||||
"GOVOPLAN_MODULE_PACKAGE_CATALOG_SEQUENCE_STATE": str(state_path),
|
||||
"GOVOPLAN_MODULE_PACKAGE_CATALOG_ENFORCE_SEQUENCE": "true",
|
||||
}
|
||||
with mock.patch.dict(os.environ, replay_environment, clear=False):
|
||||
report = review_capability_fit(
|
||||
assessment=deepcopy(self.assessment),
|
||||
schema=self.schema,
|
||||
catalog=catalog,
|
||||
published_keyring=keyring,
|
||||
trusted_keyring=keyring,
|
||||
)
|
||||
self.assertEqual(
|
||||
replay_environment,
|
||||
{key: os.environ[key] for key in replay_environment},
|
||||
)
|
||||
|
||||
self.assertEqual("current", report["status"])
|
||||
self.assertTrue(report["proof_scope"]["catalog_signature_and_keyring"]["valid"])
|
||||
|
||||
def test_self_consistent_substitution_fails_independent_trust_root(self) -> None:
|
||||
_, trusted_keyring = signed_catalog(self.assessment)
|
||||
catalog, published_keyring = signed_catalog(self.assessment)
|
||||
|
||||
report = review_capability_fit(
|
||||
assessment=deepcopy(self.assessment),
|
||||
schema=self.schema,
|
||||
catalog=catalog,
|
||||
published_keyring=published_keyring,
|
||||
trusted_keyring=trusted_keyring,
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", report["status"])
|
||||
codes = {item["code"] for item in report["findings"]}
|
||||
self.assertIn("catalog_trust", codes)
|
||||
self.assertNotIn("catalog_keyring_hash_mismatch", codes)
|
||||
self.assertTrue(report["proof_scope"]["assessment_schema"]["valid"])
|
||||
self.assertFalse(
|
||||
report["proof_scope"]["catalog_signature_and_trusted_keyring"]["valid"]
|
||||
)
|
||||
self.assertTrue(report["proof_scope"]["published_keyring_hash"]["valid"])
|
||||
|
||||
def test_locally_pinned_rotation_keys_fail_closed(self) -> None:
|
||||
keys = trusted_keys_from_keyring(
|
||||
{
|
||||
"keys": [
|
||||
{"key_id": "active", "status": "active", "public_key": "a"},
|
||||
{"key_id": "next", "status": "next", "public_key": "b"},
|
||||
{"key_id": "retired", "status": "retired", "public_key": "c"},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual({"active": "a", "next": "b"}, keys)
|
||||
self.assertEqual(
|
||||
{"legacy": "base64-key"},
|
||||
trusted_keys_from_keyring({"legacy": "base64-key"}),
|
||||
)
|
||||
self.assertEqual(
|
||||
{},
|
||||
trusted_keys_from_keyring(
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"key_id": "typo",
|
||||
"status": "retierd",
|
||||
"public_key": "unsafe",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
{},
|
||||
trusted_keys_from_keyring(
|
||||
{
|
||||
"keys": [
|
||||
{"key_id": "duplicate", "public_key": "a"},
|
||||
{"key_id": "duplicate", "public_key": "b"},
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
self.assertEqual({}, trusted_keys_from_keyring({"keys": "invalid"}))
|
||||
self.assertEqual(
|
||||
{},
|
||||
trusted_keys_from_keyring(
|
||||
{"keys": [{"key_id": " ", "public_key": "unsafe"}]}
|
||||
),
|
||||
)
|
||||
|
||||
def test_schema_error_blocks_before_comparison(self) -> None:
|
||||
assessment = deepcopy(self.assessment)
|
||||
assessment.pop("scope")
|
||||
catalog, keyring = signed_catalog(self.assessment)
|
||||
|
||||
report = review_capability_fit(
|
||||
assessment=assessment,
|
||||
schema=self.schema,
|
||||
catalog=catalog,
|
||||
published_keyring=keyring,
|
||||
trusted_keyring=keyring,
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", report["status"])
|
||||
self.assertEqual(
|
||||
{"assessment_schema"}, {item["code"] for item in report["findings"]}
|
||||
)
|
||||
self.assertFalse(report["proof_scope"]["assessment_schema"]["valid"])
|
||||
self.assertFalse(
|
||||
report["proof_scope"]["catalog_signature_and_keyring"]["checked"]
|
||||
)
|
||||
self.assertIsNone(
|
||||
report["proof_scope"]["catalog_signature_and_keyring"]["valid"]
|
||||
)
|
||||
|
||||
def test_human_report_states_proof_limit(self) -> None:
|
||||
catalog, keyring = signed_catalog(self.assessment)
|
||||
report = review_capability_fit(
|
||||
assessment=deepcopy(self.assessment),
|
||||
schema=self.schema,
|
||||
catalog=catalog,
|
||||
published_keyring=keyring,
|
||||
trusted_keyring=keyring,
|
||||
)
|
||||
|
||||
rendered = render_review(report)
|
||||
|
||||
self.assertIn("Capability fit rerun: current", rendered)
|
||||
self.assertIn(
|
||||
"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:
|
||||
script = META_ROOT / "tools" / "assessments" / "capability-fit.py"
|
||||
spec = importlib.util.spec_from_file_location("capability_fit_cli", script)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
trusted_path = Path(temp_dir) / "trusted-keyring.json"
|
||||
trusted_path.write_text('{"keys": []}\n', encoding="utf-8")
|
||||
with mock.patch.object(
|
||||
module,
|
||||
"fetch_json",
|
||||
return_value={
|
||||
"ok": False,
|
||||
"error": "Authorization: secret at https://internal.invalid",
|
||||
},
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
SystemExit, r"^Could not fetch fixed public catalog endpoint[.]$"
|
||||
):
|
||||
module.main(
|
||||
[
|
||||
"--public",
|
||||
"--trusted-keyring",
|
||||
str(trusted_path),
|
||||
"--skip-tag-provenance",
|
||||
]
|
||||
)
|
||||
|
||||
def test_cli_requires_an_independent_local_trust_root(self) -> None:
|
||||
script = META_ROOT / "tools" / "assessments" / "capability-fit.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"capability_fit_cli_trust", script
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{module.TRUSTED_KEYRING_FILE_ENV: ""},
|
||||
clear=False,
|
||||
):
|
||||
with self.assertRaisesRegex(SystemExit, r"--trusted-keyring .* required"):
|
||||
module.main(["--public", "--skip-tag-provenance"])
|
||||
|
||||
def test_local_selected_tag_commit_and_object_must_match_exactly(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
commit_sha, tag_object_sha = create_tagged_repository(
|
||||
workspace=workspace,
|
||||
repository="govoplan-core",
|
||||
version="0.1.0",
|
||||
)
|
||||
assessment = deepcopy(self.assessment)
|
||||
core = next(
|
||||
deepcopy(item)
|
||||
for item in assessment["composition"]
|
||||
if item["module_id"] == "core"
|
||||
)
|
||||
core["manifest_version"] = "0.1.0"
|
||||
core["commit"] = commit_sha[:12]
|
||||
assessment["composition"] = [core]
|
||||
selected_unit = {
|
||||
"repo": "govoplan-core",
|
||||
"version": "0.1.0",
|
||||
"tag": "v0.1.0",
|
||||
"commit_sha": commit_sha,
|
||||
"tag_object_sha": tag_object_sha,
|
||||
}
|
||||
catalog, keyring = signed_catalog(
|
||||
assessment,
|
||||
selected_units=[selected_unit],
|
||||
)
|
||||
|
||||
baseline = review_capability_fit(
|
||||
assessment=deepcopy(assessment),
|
||||
schema=self.schema,
|
||||
catalog=catalog,
|
||||
published_keyring=keyring,
|
||||
trusted_keyring=keyring,
|
||||
workspace_root=workspace,
|
||||
)
|
||||
|
||||
self.assertEqual("current", baseline["status"])
|
||||
self.assertTrue(baseline["proof_scope"]["local_tag_provenance"]["valid"])
|
||||
|
||||
forged_commit = commit_sha[:12] + ("0" * 28)
|
||||
if forged_commit == commit_sha:
|
||||
forged_commit = commit_sha[:12] + ("f" * 28)
|
||||
forged_catalog, forged_keyring = signed_catalog(
|
||||
assessment,
|
||||
selected_units=[{**selected_unit, "commit_sha": forged_commit}],
|
||||
)
|
||||
commit_mismatch = review_capability_fit(
|
||||
assessment=deepcopy(assessment),
|
||||
schema=self.schema,
|
||||
catalog=forged_catalog,
|
||||
published_keyring=forged_keyring,
|
||||
trusted_keyring=forged_keyring,
|
||||
workspace_root=workspace,
|
||||
)
|
||||
|
||||
self.assertEqual("review_required", commit_mismatch["status"])
|
||||
self.assertIn(
|
||||
"tag_signed_commit_changed",
|
||||
{item["kind"] for item in commit_mismatch["changes"]},
|
||||
)
|
||||
|
||||
repo = workspace / "govoplan-core"
|
||||
git_text(repo, "tag", "-d", "v0.1.0")
|
||||
git_text(repo, "tag", "-a", "v0.1.0", "-m", "Re-annotated release")
|
||||
reannotated = review_capability_fit(
|
||||
assessment=deepcopy(assessment),
|
||||
schema=self.schema,
|
||||
catalog=catalog,
|
||||
published_keyring=keyring,
|
||||
trusted_keyring=keyring,
|
||||
workspace_root=workspace,
|
||||
)
|
||||
|
||||
self.assertEqual("review_required", reannotated["status"])
|
||||
self.assertIn(
|
||||
"tag_object_changed",
|
||||
{item["kind"] for item in reannotated["changes"]},
|
||||
)
|
||||
self.assertTrue(reannotated["proof_scope"]["local_tag_provenance"]["checked"])
|
||||
self.assertFalse(reannotated["proof_scope"]["local_tag_provenance"]["valid"])
|
||||
|
||||
def test_missing_composition_entry_does_not_claim_complete_tag_proof(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
core_commit, core_tag_object = create_tagged_repository(
|
||||
workspace=workspace,
|
||||
repository="govoplan-core",
|
||||
version="0.1.0",
|
||||
)
|
||||
tenancy_commit, _ = create_tagged_repository(
|
||||
workspace=workspace,
|
||||
repository="govoplan-tenancy",
|
||||
version="0.1.0",
|
||||
)
|
||||
reviewed_assessment = deepcopy(self.assessment)
|
||||
core = next(
|
||||
deepcopy(item)
|
||||
for item in reviewed_assessment["composition"]
|
||||
if item["module_id"] == "core"
|
||||
)
|
||||
tenancy = next(
|
||||
deepcopy(item)
|
||||
for item in reviewed_assessment["composition"]
|
||||
if item["module_id"] == "tenancy"
|
||||
)
|
||||
core.update(manifest_version="0.1.0", commit=core_commit[:12])
|
||||
tenancy.update(manifest_version="0.1.0", commit=tenancy_commit[:12])
|
||||
catalog_assessment = deepcopy(reviewed_assessment)
|
||||
catalog_assessment["composition"] = [core]
|
||||
reviewed_assessment["composition"] = [core, tenancy]
|
||||
catalog, keyring = signed_catalog(
|
||||
catalog_assessment,
|
||||
selected_units=[
|
||||
{
|
||||
"repo": "govoplan-core",
|
||||
"version": "0.1.0",
|
||||
"tag": "v0.1.0",
|
||||
"commit_sha": core_commit,
|
||||
"tag_object_sha": core_tag_object,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
report = review_capability_fit(
|
||||
assessment=reviewed_assessment,
|
||||
schema=self.schema,
|
||||
catalog=catalog,
|
||||
published_keyring=keyring,
|
||||
trusted_keyring=keyring,
|
||||
workspace_root=workspace,
|
||||
)
|
||||
|
||||
proof = report["proof_scope"]["local_tag_provenance"]
|
||||
self.assertEqual("review_required", report["status"])
|
||||
self.assertFalse(proof["checked"])
|
||||
self.assertIsNone(proof["valid"])
|
||||
self.assertEqual(2, proof["attempted_count"])
|
||||
self.assertEqual(2, proof["expected_count"])
|
||||
self.assertNotIn(
|
||||
"tag_provenance_changed", {item["code"] for item in report["findings"]}
|
||||
)
|
||||
|
||||
def test_repository_path_traversal_is_not_resolved(self) -> None:
|
||||
with mock.patch("govoplan_assessment.capability_fit.subprocess.run") as run:
|
||||
result = local_tag_provenance(
|
||||
workspace_root=META_ROOT.parent,
|
||||
repository="../outside",
|
||||
version="0.1.0",
|
||||
assessed_commit="0123456",
|
||||
)
|
||||
|
||||
self.assertEqual("tag_provenance_unavailable", result["kind"])
|
||||
self.assertIn("invalid repository identifier", result["message"])
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
def create_tagged_repository(
|
||||
*, workspace: Path, repository: str, version: str
|
||||
) -> tuple[str, str]:
|
||||
repo = workspace / repository
|
||||
git_text(workspace, "init", "-b", "main", str(repo))
|
||||
git_text(repo, "config", "user.name", "Capability Fit Test")
|
||||
git_text(repo, "config", "user.email", "capability-fit@example.invalid")
|
||||
(repo / "README.md").write_text(f"{repository}\n", encoding="utf-8")
|
||||
git_text(repo, "add", "README.md")
|
||||
git_text(repo, "commit", "-m", "Initial release fixture")
|
||||
git_text(repo, "tag", "-a", f"v{version}", "-m", f"Release v{version}")
|
||||
return (
|
||||
git_text(repo, "rev-parse", "HEAD"),
|
||||
git_text(repo, "rev-parse", f"refs/tags/v{version}"),
|
||||
)
|
||||
|
||||
|
||||
def git_text(cwd: Path, *args: str) -> str:
|
||||
result = subprocess.run(
|
||||
("git", *args),
|
||||
cwd=cwd,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(result.stderr or result.stdout)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def signed_catalog(
|
||||
assessment: dict[str, object],
|
||||
*,
|
||||
versions: dict[str, str] | None = None,
|
||||
sequence: int = 202607220843,
|
||||
selected_units: list[dict[str, object]] | None = None,
|
||||
include_selected_units: bool = True,
|
||||
release_artifacts: list[dict[str, object]] | None = None,
|
||||
) -> tuple[dict[str, object], dict[str, object]]:
|
||||
versions = versions or {}
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
public_key = base64.b64encode(
|
||||
private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
).decode("ascii")
|
||||
keyring: dict[str, object] = {
|
||||
"keyring_version": "1",
|
||||
"purpose": "test",
|
||||
"generated_at": "2026-01-01T00:00:00Z",
|
||||
"keys": [{"key_id": "test-key", "status": "active", "public_key": public_key}],
|
||||
}
|
||||
now = datetime.now(tz=UTC)
|
||||
modules: list[dict[str, object]] = []
|
||||
core_release: dict[str, object] | None = None
|
||||
for component in assessment["composition"]:
|
||||
module_id = str(component["module_id"])
|
||||
repository = str(component["repository"])
|
||||
version = versions.get(module_id, str(component["manifest_version"]))
|
||||
entry: dict[str, object] = {
|
||||
"module_id": module_id,
|
||||
"name": module_id,
|
||||
"version": version,
|
||||
"python_package": repository,
|
||||
"python_ref": f"{repository} @ git+ssh://git@example.invalid/example/{repository}.git@v{version}",
|
||||
}
|
||||
if module_id == "core":
|
||||
core_release = entry
|
||||
else:
|
||||
modules.append(entry)
|
||||
assert core_release is not None
|
||||
if selected_units is None:
|
||||
selected_units = [
|
||||
{
|
||||
"repo": component["repository"],
|
||||
"version": versions.get(
|
||||
str(component["module_id"]), str(component["manifest_version"])
|
||||
),
|
||||
"tag": "v"
|
||||
+ versions.get(
|
||||
str(component["module_id"]), str(component["manifest_version"])
|
||||
),
|
||||
"commit_sha": (str(component["commit"]) + "0" * 40)[:40],
|
||||
"tag_object_sha": hashlib.sha256(
|
||||
f"{component['repository']}:{component['manifest_version']}:tag".encode()
|
||||
).hexdigest()[:40],
|
||||
}
|
||||
for component in assessment["composition"]
|
||||
]
|
||||
release: dict[str, object] = {"keyring_sha256": canonical_hash(keyring)}
|
||||
if include_selected_units:
|
||||
release["selected_units"] = selected_units
|
||||
if release_artifacts is not None:
|
||||
release["artifacts"] = release_artifacts
|
||||
catalog: dict[str, object] = {
|
||||
"catalog_version": "1",
|
||||
"channel": "stable",
|
||||
"sequence": sequence,
|
||||
"generated_at": now.isoformat().replace("+00:00", "Z"),
|
||||
"expires_at": (now + timedelta(days=30)).isoformat().replace("+00:00", "Z"),
|
||||
"core_release": core_release,
|
||||
"modules": modules,
|
||||
"release": release,
|
||||
}
|
||||
signature_payload = json.dumps(
|
||||
catalog, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
).encode("utf-8")
|
||||
catalog["signatures"] = [
|
||||
{
|
||||
"algorithm": "ed25519",
|
||||
"key_id": "test-key",
|
||||
"value": base64.b64encode(private_key.sign(signature_payload)).decode(
|
||||
"ascii"
|
||||
),
|
||||
}
|
||||
]
|
||||
return catalog, keyring
|
||||
|
||||
|
||||
def canonical_hash(payload: object) -> str:
|
||||
encoded = json.dumps(
|
||||
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationPackageManifest,
|
||||
ConfigurationPreflightContext,
|
||||
configuration_package_claim_issues,
|
||||
dry_run_configuration_package,
|
||||
)
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PACKAGE_ROOT = ROOT / "packages"
|
||||
|
||||
|
||||
class ConfigurationPackageArtifactTests(unittest.TestCase):
|
||||
def test_product_package_manifests_are_portable_and_evidence_backed(self) -> None:
|
||||
paths = tuple(sorted(PACKAGE_ROOT.glob("*/*/package.json")))
|
||||
self.assertGreaterEqual(len(paths), 2)
|
||||
package_ids: set[str] = set()
|
||||
|
||||
for path in paths:
|
||||
manifest = ConfigurationPackageManifest.from_mapping(
|
||||
json.loads(path.read_text(encoding="utf-8"))
|
||||
)
|
||||
self.assertNotIn(manifest.package_id, package_ids)
|
||||
package_ids.add(manifest.package_id)
|
||||
self.assertEqual((), configuration_package_claim_issues(manifest))
|
||||
for evidence in manifest.evidence:
|
||||
evidence_path = ROOT / evidence.reference
|
||||
self.assertTrue(
|
||||
evidence_path.is_file(),
|
||||
f"Missing evidence {evidence.reference} for {manifest.package_id}",
|
||||
)
|
||||
if evidence.checksum is not None:
|
||||
self.assertEqual(
|
||||
evidence.checksum,
|
||||
"sha256:" + hashlib.sha256(evidence_path.read_bytes()).hexdigest(),
|
||||
f"Stale evidence checksum for {manifest.package_id}: {evidence.reference}",
|
||||
)
|
||||
for requirement in (
|
||||
*manifest.required_modules,
|
||||
*manifest.optional_modules,
|
||||
):
|
||||
repository = ROOT.parent / (
|
||||
"govoplan-" + requirement.module_id.replace("_", "-")
|
||||
)
|
||||
self.assertTrue(
|
||||
repository.is_dir(),
|
||||
f"Missing repository for {requirement.module_id}",
|
||||
)
|
||||
|
||||
result = dry_run_configuration_package(
|
||||
manifest,
|
||||
(),
|
||||
ConfigurationPreflightContext(
|
||||
installed_modules={
|
||||
item.module_id: item.version or "workspace"
|
||||
for item in manifest.required_modules
|
||||
},
|
||||
capabilities=frozenset(manifest.required_capabilities),
|
||||
),
|
||||
)
|
||||
self.assertFalse(
|
||||
any(item.severity == "blocker" for item in result.diagnostics),
|
||||
tuple(item.to_dict() for item in result.diagnostics),
|
||||
)
|
||||
|
||||
self.assertIn("product.governed-communication", package_ids)
|
||||
self.assertIn("product.governed-data-assurance", package_ids)
|
||||
self.assertIn("product.service-to-decision", package_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -88,9 +88,17 @@ class DependencyBoundaryDiscoveryTests(unittest.TestCase):
|
||||
"govoplan-calendar",
|
||||
"govoplan-poll",
|
||||
"govoplan-scheduling",
|
||||
"govoplan-workflow-engine",
|
||||
):
|
||||
self.assertIn(owner, boundary.REPOS)
|
||||
|
||||
def test_transitional_allowlist_entries_have_a_removal_target(self) -> None:
|
||||
boundary = load_boundary_module()
|
||||
|
||||
for item in boundary.ALLOWLIST:
|
||||
self.assertIn("remove", item.reason.casefold())
|
||||
self.assertRegex(item.reason, r"#[0-9]+")
|
||||
|
||||
def test_webui_discovery_is_fail_closed(self) -> None:
|
||||
boundary = load_boundary_module()
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-boundary-webui-") as directory:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "tools" / "gitea" / "gitea-sync-wiki.py"
|
||||
TOOLS = SCRIPT.parent
|
||||
if str(TOOLS) not in sys.path:
|
||||
sys.path.insert(0, str(TOOLS))
|
||||
SPEC = importlib.util.spec_from_file_location("gitea_sync_wiki", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
wiki_sync = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = wiki_sync
|
||||
SPEC.loader.exec_module(wiki_sync)
|
||||
|
||||
|
||||
class WikiSourceDiscoveryTests(unittest.TestCase):
|
||||
def test_generated_and_incidental_govoplan_files_are_not_docs(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = pathlib.Path(temporary)
|
||||
paths = {
|
||||
"readme": root / "README.md",
|
||||
"architecture": root / "docs" / "DATASOURCE_ARCHITECTURE.md",
|
||||
"plan": root / "workflow-plan.md",
|
||||
"audit": root / "audit-reports" / "full" / "manifest.json",
|
||||
"runtime": root / "runtime" / "release" / "manifest.json",
|
||||
"incidental": root / "tools" / "semgrep" / "govoplan.yml",
|
||||
"manifest": root / "manifest.json",
|
||||
}
|
||||
for path in paths.values():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("content\n", encoding="utf-8")
|
||||
|
||||
self.assertTrue(wiki_sync.is_repo_doc(root, paths["readme"]))
|
||||
self.assertTrue(wiki_sync.is_repo_doc(root, paths["architecture"]))
|
||||
self.assertTrue(wiki_sync.is_repo_doc(root, paths["plan"]))
|
||||
self.assertFalse(wiki_sync.is_repo_doc(root, paths["audit"]))
|
||||
self.assertFalse(wiki_sync.is_repo_doc(root, paths["runtime"]))
|
||||
self.assertFalse(wiki_sync.is_repo_doc(root, paths["incidental"]))
|
||||
self.assertFalse(wiki_sync.is_repo_doc(root, paths["manifest"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,591 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
ActorRepresentationReference,
|
||||
DecisionEffectReference,
|
||||
EvidenceReference,
|
||||
InformationGovernanceReference,
|
||||
InstitutionalReference,
|
||||
LegalBasisReference,
|
||||
MandateDefinition,
|
||||
PartyRepresentation,
|
||||
PartySubjectReference,
|
||||
ProcedureParty,
|
||||
ServiceBinding,
|
||||
ServiceDefinition,
|
||||
TemporalRevision,
|
||||
service_launch_capability,
|
||||
)
|
||||
from govoplan_cases.backend.party_context import CasePartyContext
|
||||
from govoplan_cases.backend.db.models import (
|
||||
CaseAccessGrant,
|
||||
CaseIdentity,
|
||||
CaseRecordRevision,
|
||||
CaseStatusDefinition,
|
||||
CaseTimelineEntry,
|
||||
CaseTypeDefinition,
|
||||
)
|
||||
from govoplan_cases.backend.service import (
|
||||
create_case_from_intake,
|
||||
get_case,
|
||||
upsert_case_status,
|
||||
upsert_case_type,
|
||||
)
|
||||
from govoplan_cases.backend.service_intake import CaseServiceIntake
|
||||
from govoplan_committee.backend.db.models import (
|
||||
CommitteeDecisionProjection,
|
||||
CommitteeWorkspaceEvent,
|
||||
CommitteeWorkspaceRevision,
|
||||
)
|
||||
from govoplan_committee.backend.decision_path import (
|
||||
CommitteeDecisionPath,
|
||||
CommitteeDecisionProposal,
|
||||
)
|
||||
from govoplan_committee.backend.workspace import (
|
||||
CommitteeWorkspaceRecord,
|
||||
SqlCommitteeWorkspace,
|
||||
get_workspace_object,
|
||||
record_workspace_object,
|
||||
)
|
||||
from govoplan_portal.backend.service_directory import PortalServiceDirectory
|
||||
from govoplan_decisions.backend.db.models import FormalDecisionRevision
|
||||
from govoplan_decisions.backend.service import SqlDecisionRegistry
|
||||
from govoplan_mandates.backend.db.models import MandateRevision
|
||||
from govoplan_mandates.backend.service import SqlMandateResolver, record_mandate
|
||||
from govoplan_parties.backend.db.models import ProcedurePartyRevision
|
||||
from govoplan_parties.backend.service import SqlPartyResolver, record_procedure_party
|
||||
from govoplan_services.backend.db.models import ServiceDefinitionRevision
|
||||
from govoplan_services.backend.service import SqlServiceDefinitionProvider, record_service_definition
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _reference(
|
||||
kind: str,
|
||||
object_id: str,
|
||||
*,
|
||||
owner: str,
|
||||
version: str = "1",
|
||||
) -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=owner,
|
||||
object_id=object_id,
|
||||
tenant_id="tenant-1",
|
||||
version=version,
|
||||
valid_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def _evidence(kind: str, evidence_id: str, owner: str) -> EvidenceReference:
|
||||
return EvidenceReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=owner,
|
||||
evidence_id=evidence_id,
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
checksum=f"sha256:{evidence_id}",
|
||||
captured_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Principal:
|
||||
tenant_id: str = "tenant-1"
|
||||
account_id: str = "account-1"
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capabilities: dict[str, object]) -> None:
|
||||
self.capabilities = capabilities
|
||||
|
||||
def has(self, module_id: str) -> bool:
|
||||
return module_id in {
|
||||
"portal",
|
||||
"cases",
|
||||
"committee",
|
||||
"postbox",
|
||||
"services",
|
||||
"parties",
|
||||
"mandates",
|
||||
"decisions",
|
||||
}
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self.capabilities
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
return self.capabilities[name]
|
||||
|
||||
|
||||
class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
||||
def test_service_to_formal_outcome_retains_governed_context(self) -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for table in (
|
||||
ServiceDefinitionRevision.__table__,
|
||||
CaseStatusDefinition.__table__,
|
||||
CaseTypeDefinition.__table__,
|
||||
CaseIdentity.__table__,
|
||||
CaseRecordRevision.__table__,
|
||||
CaseAccessGrant.__table__,
|
||||
CaseTimelineEntry.__table__,
|
||||
ProcedurePartyRevision.__table__,
|
||||
MandateRevision.__table__,
|
||||
FormalDecisionRevision.__table__,
|
||||
CommitteeWorkspaceRevision.__table__,
|
||||
CommitteeWorkspaceEvent.__table__,
|
||||
CommitteeDecisionProjection.__table__,
|
||||
):
|
||||
table.create(engine)
|
||||
session = Session(engine)
|
||||
principal = _Principal()
|
||||
self.addCleanup(engine.dispose)
|
||||
self.addCleanup(session.close)
|
||||
|
||||
organization = _reference(
|
||||
"organization_unit",
|
||||
"permit-office",
|
||||
owner="organizations",
|
||||
)
|
||||
function = _reference("function", "permit-officer", owner="organizations")
|
||||
jurisdiction = _reference(
|
||||
"jurisdiction",
|
||||
"city-1",
|
||||
owner="organizations",
|
||||
)
|
||||
mandate_ref = _reference(
|
||||
"mandate",
|
||||
"permit-mandate",
|
||||
owner="mandates",
|
||||
version="4",
|
||||
)
|
||||
legal_basis = LegalBasisReference(
|
||||
kind="law",
|
||||
authority="Example legislature",
|
||||
reference="permit-law:3",
|
||||
version="2026-01",
|
||||
effective_from=NOW - timedelta(days=100),
|
||||
)
|
||||
service = ServiceDefinition(
|
||||
reference=_reference(
|
||||
"service",
|
||||
"permit-service",
|
||||
owner="services",
|
||||
version="5",
|
||||
),
|
||||
key="permit.apply",
|
||||
temporal=TemporalRevision(
|
||||
revision="5",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(days=30),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
change_reason="Service published.",
|
||||
),
|
||||
title="Apply for a permit",
|
||||
audience=("resident",),
|
||||
legal_bases=(legal_basis,),
|
||||
required_evidence_types=("application", "identity"),
|
||||
channels=("portal", "postbox"),
|
||||
responsible_organization_ref=organization,
|
||||
responsible_function_ref=function,
|
||||
mandate_ref=mandate_ref,
|
||||
jurisdiction_refs=(jurisdiction,),
|
||||
bindings=(
|
||||
ServiceBinding("case", "permit-application"),
|
||||
ServiceBinding("workflow", "workflow:permit-review"),
|
||||
ServiceBinding("result", "decision:permit"),
|
||||
),
|
||||
remedy_refs=("review:administrative-court",),
|
||||
publication_state="published",
|
||||
)
|
||||
record_service_definition(session, principal, definition=service)
|
||||
service_registry = _Registry(
|
||||
{
|
||||
"services.definitions": SqlServiceDefinitionProvider(),
|
||||
service_launch_capability("case"): object(),
|
||||
}
|
||||
)
|
||||
entry = PortalServiceDirectory(service_registry).list_entries(
|
||||
session,
|
||||
principal,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
audiences=("resident",),
|
||||
)[0]
|
||||
self.assertTrue(entry.available)
|
||||
intake = CaseServiceIntake().plan(
|
||||
entry.definition,
|
||||
case_id="case-1",
|
||||
effective_at=NOW,
|
||||
)
|
||||
application_evidence = _evidence("document", "application-1", "files")
|
||||
upsert_case_status(
|
||||
session,
|
||||
principal,
|
||||
status_key="intake",
|
||||
label="Intake",
|
||||
)
|
||||
upsert_case_status(
|
||||
session,
|
||||
principal,
|
||||
status_key="decided",
|
||||
label="Decided",
|
||||
category="decided",
|
||||
)
|
||||
upsert_case_type(
|
||||
session,
|
||||
principal,
|
||||
type_key="permit-application",
|
||||
label="Permit application",
|
||||
initial_status_key="intake",
|
||||
allowed_status_keys=("intake", "decided"),
|
||||
)
|
||||
case_record = create_case_from_intake(
|
||||
session,
|
||||
principal,
|
||||
plan=intake,
|
||||
case_number="PERMIT-2026-0001",
|
||||
title="Permit application",
|
||||
status_key=None,
|
||||
opened_at=NOW,
|
||||
recorded_at=NOW,
|
||||
change_reason="Portal application received.",
|
||||
idempotency_key="journey-case-create",
|
||||
evidence_refs=(application_evidence,),
|
||||
deadline_at=NOW + timedelta(days=30),
|
||||
)
|
||||
|
||||
applicant = _reference("party", "applicant", owner="parties")
|
||||
representative = _reference("party", "representative", owner="parties")
|
||||
address_evidence = _evidence(
|
||||
"snapshot",
|
||||
"address-snapshot-1",
|
||||
"addresses",
|
||||
)
|
||||
representative_party = ProcedureParty(
|
||||
reference=representative,
|
||||
procedure_ref=case_record.reference,
|
||||
role="representative",
|
||||
subject=PartySubjectReference(
|
||||
kind="identity",
|
||||
provider="identity",
|
||||
subject_id="identity-2",
|
||||
tenant_id="tenant-1",
|
||||
version="2",
|
||||
),
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=1),
|
||||
change_reason="Representative added to the procedure.",
|
||||
),
|
||||
permitted_channels=("postbox",),
|
||||
preferred_channels=("postbox",),
|
||||
delivery_recipient=True,
|
||||
representations=(
|
||||
PartyRepresentation(
|
||||
representative_party_ref=representative,
|
||||
represented_party_ref=applicant,
|
||||
power_ref="power-1",
|
||||
permitted_actions=("submit", "receive"),
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=1),
|
||||
change_reason="Representation power recorded.",
|
||||
),
|
||||
evidence=(address_evidence,),
|
||||
),
|
||||
),
|
||||
contact_snapshot_refs=("addresses:snapshot-1",),
|
||||
evidence=(address_evidence,),
|
||||
)
|
||||
record_procedure_party(session, principal, party=representative_party)
|
||||
party_context = CasePartyContext(
|
||||
_Registry({"parties.resolver": SqlPartyResolver()})
|
||||
)
|
||||
parties = party_context.resolve(
|
||||
session,
|
||||
principal,
|
||||
case_ref=case_record.reference,
|
||||
effective_at=NOW,
|
||||
)
|
||||
delivery_target = party_context.delivery_targets(
|
||||
parties,
|
||||
channel="postbox",
|
||||
)[0]
|
||||
|
||||
mandate = MandateDefinition(
|
||||
reference=mandate_ref,
|
||||
temporal=TemporalRevision(
|
||||
revision="4",
|
||||
valid_from=NOW - timedelta(days=30),
|
||||
valid_to=NOW + timedelta(days=30),
|
||||
recorded_at=NOW - timedelta(days=31),
|
||||
change_reason="Permit authority delegated.",
|
||||
),
|
||||
task_types=("committee.formal_decision",),
|
||||
authority_types=("permit",),
|
||||
organization_unit_refs=(organization,),
|
||||
function_refs=(function,),
|
||||
jurisdiction_refs=(jurisdiction,),
|
||||
legal_bases=(legal_basis,),
|
||||
evidence=(_evidence("record", "mandate-record-4", "mandates"),),
|
||||
authority_ceiling="permit:standard",
|
||||
)
|
||||
record_mandate(session, principal, definition=mandate)
|
||||
workspace = SqlCommitteeWorkspace()
|
||||
body = CommitteeWorkspaceRecord(
|
||||
tenant_id="tenant-1",
|
||||
object_kind="body",
|
||||
object_id="permit-board",
|
||||
revision=1,
|
||||
state="active",
|
||||
title="Permit board",
|
||||
recorded_at=NOW,
|
||||
change_reason="Permit board configured.",
|
||||
attributes={
|
||||
"organization_unit_ref": organization.to_dict(),
|
||||
"function_refs": [function.to_dict()],
|
||||
"quorum": {"minimum_count": 1},
|
||||
},
|
||||
)
|
||||
meeting = CommitteeWorkspaceRecord(
|
||||
tenant_id="tenant-1",
|
||||
object_kind="meeting",
|
||||
object_id="meeting-1",
|
||||
revision=1,
|
||||
state="open",
|
||||
title="Permit board meeting",
|
||||
parent_id=body.object_id,
|
||||
recorded_at=NOW,
|
||||
change_reason="Meeting opened.",
|
||||
attributes={
|
||||
"starts_at": NOW.isoformat(),
|
||||
"ends_at": (NOW + timedelta(hours=1)).isoformat(),
|
||||
},
|
||||
context=case_record.context,
|
||||
)
|
||||
agenda = CommitteeWorkspaceRecord(
|
||||
tenant_id="tenant-1",
|
||||
object_kind="agenda_item",
|
||||
object_id="item-1",
|
||||
revision=1,
|
||||
state="deliberating",
|
||||
title="Permit application",
|
||||
parent_id=meeting.object_id,
|
||||
recorded_at=NOW,
|
||||
change_reason="Agenda item entered deliberation.",
|
||||
attributes={
|
||||
"position": 1,
|
||||
"subject_refs": [case_record.reference.to_dict()],
|
||||
},
|
||||
context=case_record.context,
|
||||
evidence=(application_evidence,),
|
||||
)
|
||||
approval_ref = _reference("approval", "approval-1", owner="approvals")
|
||||
vote = CommitteeWorkspaceRecord(
|
||||
tenant_id="tenant-1",
|
||||
object_kind="vote",
|
||||
object_id="vote-1",
|
||||
revision=1,
|
||||
state="closed",
|
||||
title="Vote on permit application",
|
||||
parent_id=agenda.object_id,
|
||||
recorded_at=NOW,
|
||||
change_reason="Vote result accepted.",
|
||||
attributes={
|
||||
"method": "recorded",
|
||||
"choices": ["yes", "no"],
|
||||
"eligible_count": 3,
|
||||
"cast_count": 3,
|
||||
"counts": {"yes": 3, "no": 0},
|
||||
"quorum_met": True,
|
||||
"approval_ref": approval_ref.to_dict(),
|
||||
},
|
||||
context=case_record.context,
|
||||
evidence=(_evidence("record", "vote-result-1", "committee"),),
|
||||
)
|
||||
for record, key in (
|
||||
(body, "journey-body"),
|
||||
(meeting, "journey-meeting"),
|
||||
(agenda, "journey-agenda"),
|
||||
(vote, "journey-vote"),
|
||||
):
|
||||
record_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
record=record,
|
||||
idempotency_key=key,
|
||||
)
|
||||
decision_registry = SqlDecisionRegistry()
|
||||
decision_result = CommitteeDecisionPath(
|
||||
_Registry(
|
||||
{
|
||||
"mandates.resolver": SqlMandateResolver(),
|
||||
"decisions.registry": decision_registry,
|
||||
"committee.workspace": workspace,
|
||||
}
|
||||
)
|
||||
).decide(
|
||||
session,
|
||||
principal,
|
||||
proposal=CommitteeDecisionProposal(
|
||||
tenant_id="tenant-1",
|
||||
decision_id="decision-1",
|
||||
revision="1",
|
||||
effective_at=NOW,
|
||||
meeting_ref="meeting-1",
|
||||
agenda_item_ref="item-1",
|
||||
decision_type="permit",
|
||||
subject_refs=(case_record.reference,),
|
||||
organization_unit_ref=organization,
|
||||
function_ref=function,
|
||||
actor=ActorRepresentationReference(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
represented_function_ref=function,
|
||||
mandate_ref=mandate_ref,
|
||||
),
|
||||
approval_refs=(
|
||||
approval_ref,
|
||||
),
|
||||
fact_evidence=(application_evidence, address_evidence),
|
||||
legal_bases=(legal_basis,),
|
||||
operative_result="Permit granted.",
|
||||
reasoning="The application satisfies the effective rule.",
|
||||
case_ref=case_record.reference,
|
||||
jurisdiction_refs=(jurisdiction,),
|
||||
party_refs=(applicant, representative),
|
||||
record_refs=(
|
||||
_reference("record", "record-1", owner="audit"),
|
||||
),
|
||||
remedy_refs=service.remedy_refs,
|
||||
review_refs=("review:administrative-court",),
|
||||
information_governance=InformationGovernanceReference(
|
||||
classification="restricted",
|
||||
purposes=("permit-decision", "party-delivery"),
|
||||
legal_basis_refs=("permit-law:3@2026-01",),
|
||||
retention_policy_ref="records:permit",
|
||||
disclosure_state="partly_disclosable",
|
||||
),
|
||||
),
|
||||
observed_effects=(
|
||||
DecisionEffectReference(
|
||||
effect_key="postbox.deliver_decision",
|
||||
state="confirmed",
|
||||
resource_refs=(
|
||||
f"postbox:{delivery_target.party_ref.object_id}",
|
||||
),
|
||||
audit_event_refs=("audit:delivery-1",),
|
||||
evidence_refs=(address_evidence.evidence_id,),
|
||||
),
|
||||
),
|
||||
)
|
||||
decided_agenda = replace(
|
||||
agenda,
|
||||
revision=2,
|
||||
state="decided",
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
change_reason="Formal Decision recorded.",
|
||||
attributes={
|
||||
**dict(agenda.attributes),
|
||||
"decision_ref": decision_result.decision.reference.to_dict(),
|
||||
},
|
||||
)
|
||||
record_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
record=decided_agenda,
|
||||
expected_revision=1,
|
||||
idempotency_key="journey-agenda-decided",
|
||||
)
|
||||
closed_meeting = replace(
|
||||
meeting,
|
||||
revision=2,
|
||||
state="closed",
|
||||
recorded_at=NOW + timedelta(hours=1),
|
||||
change_reason="All agenda items completed.",
|
||||
)
|
||||
record_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
record=closed_meeting,
|
||||
expected_revision=1,
|
||||
idempotency_key="journey-meeting-closed",
|
||||
)
|
||||
minute = CommitteeWorkspaceRecord(
|
||||
tenant_id="tenant-1",
|
||||
object_kind="minute",
|
||||
object_id="minute-1",
|
||||
revision=1,
|
||||
state="accepted",
|
||||
title="Accepted permit board minutes",
|
||||
parent_id=meeting.object_id,
|
||||
recorded_at=NOW + timedelta(hours=2),
|
||||
change_reason="Minutes approved.",
|
||||
attributes={
|
||||
"content_ref": _reference(
|
||||
"record",
|
||||
"meeting-minutes-1",
|
||||
owner="records",
|
||||
).to_dict(),
|
||||
"approval_ref": _reference(
|
||||
"approval",
|
||||
"minutes-approval-1",
|
||||
owner="approvals",
|
||||
).to_dict(),
|
||||
},
|
||||
context=case_record.context,
|
||||
evidence=(_evidence("record", "minutes-proof-1", "records"),),
|
||||
)
|
||||
record_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
record=minute,
|
||||
idempotency_key="journey-minute",
|
||||
)
|
||||
reconstruction = decision_result.reconstruction_payload()["decision"]
|
||||
persisted = decision_registry.get_decision(
|
||||
session,
|
||||
principal,
|
||||
reference=decision_result.decision.reference,
|
||||
)
|
||||
|
||||
self.assertTrue(decision_result.persisted_by_decision_registry)
|
||||
self.assertEqual(decision_result.decision, persisted)
|
||||
self.assertEqual(
|
||||
case_record,
|
||||
get_case(session, principal, case_id="case-1"),
|
||||
)
|
||||
self.assertEqual(
|
||||
"decided",
|
||||
get_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind="agenda_item",
|
||||
object_id="item-1",
|
||||
).state,
|
||||
)
|
||||
self.assertEqual(service.reference, intake.context.service_ref)
|
||||
self.assertEqual("applicant", delivery_target.represented_party_refs[0].object_id)
|
||||
self.assertEqual("permit-mandate", reconstruction["authority_context"]["mandate_ref"]["object_id"])
|
||||
self.assertEqual("city-1", reconstruction["authority_context"]["jurisdiction_refs"][0]["object_id"])
|
||||
self.assertEqual("confirmed", reconstruction["observed_effects"][0]["state"])
|
||||
self.assertEqual("audit:delivery-1", reconstruction["observed_effects"][0]["audit_event_refs"][0])
|
||||
self.assertEqual("application-1", reconstruction["fact_evidence"][0]["evidence_id"])
|
||||
self.assertEqual("The application satisfies the effective rule.", reconstruction["reasoning"])
|
||||
self.assertEqual("review:administrative-court", reconstruction["review_refs"][0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_FORM_DEFINITIONS,
|
||||
CAPABILITY_SERVICE_DEFINITIONS,
|
||||
FormDefinition,
|
||||
FormFieldDefinition,
|
||||
InstitutionalReference,
|
||||
ServiceBinding,
|
||||
ServiceDefinition,
|
||||
TemporalRevision,
|
||||
service_launch_capability,
|
||||
)
|
||||
from govoplan_cases.backend.service_intake import (
|
||||
CAPABILITY_CASES_SERVICE_INTAKE,
|
||||
CaseServiceIntake,
|
||||
)
|
||||
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
||||
from govoplan_forms.backend.service import (
|
||||
SqlFormDefinitionProvider,
|
||||
record_form_definition,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.db.models import (
|
||||
FormInstanceEvent,
|
||||
FormInstanceIdentity,
|
||||
FormInstanceRevision,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.service import (
|
||||
FormRuntimeService,
|
||||
FormsServiceLauncher,
|
||||
)
|
||||
from govoplan_portal.backend.service_directory import PortalServiceDirectory
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _service() -> ServiceDefinition:
|
||||
return ServiceDefinition(
|
||||
reference=InstitutionalReference(
|
||||
kind="service",
|
||||
owner_module="portal",
|
||||
object_id="permit",
|
||||
tenant_id="tenant-1",
|
||||
version="5",
|
||||
),
|
||||
key="permit.apply",
|
||||
temporal=TemporalRevision(
|
||||
revision="5",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
),
|
||||
title="Apply for a permit",
|
||||
audience=("resident",),
|
||||
required_evidence_types=("application",),
|
||||
bindings=(
|
||||
ServiceBinding("capability", CAPABILITY_CASES_SERVICE_INTAKE),
|
||||
ServiceBinding("case", "permit-application"),
|
||||
ServiceBinding("workflow", "workflow:permit-review"),
|
||||
),
|
||||
publication_state="published",
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
def __init__(self, definition: ServiceDefinition) -> None:
|
||||
self.definition = definition
|
||||
|
||||
def get_service_definition(self, session, principal, *, reference, effective_at=None):
|
||||
return self.definition
|
||||
|
||||
def list_service_definitions(self, session, principal, *, tenant_id, query="", limit=100):
|
||||
return (self.definition,)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, definition: ServiceDefinition) -> None:
|
||||
self.capabilities = {
|
||||
CAPABILITY_SERVICE_DEFINITIONS: _Provider(definition),
|
||||
CAPABILITY_CASES_SERVICE_INTAKE: CaseServiceIntake(),
|
||||
service_launch_capability("case"): object(),
|
||||
}
|
||||
|
||||
def has(self, module_id: str) -> bool:
|
||||
return module_id in {"portal", "cases"}
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self.capabilities
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
return self.capabilities[name]
|
||||
|
||||
def require_capability(self, name: str) -> object:
|
||||
return self.capabilities[name]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Principal:
|
||||
tenant_id: str = "tenant-1"
|
||||
account_id: str = "account-1"
|
||||
|
||||
|
||||
class _FormRegistry(_Registry):
|
||||
def __init__(self, definition: ServiceDefinition) -> None:
|
||||
super().__init__(definition)
|
||||
self.capabilities[CAPABILITY_FORM_DEFINITIONS] = (
|
||||
SqlFormDefinitionProvider()
|
||||
)
|
||||
self.capabilities[service_launch_capability("form")] = (
|
||||
FormsServiceLauncher(self)
|
||||
)
|
||||
|
||||
def has(self, module_id: str) -> bool:
|
||||
return module_id in {"portal", "forms", "forms_runtime"}
|
||||
|
||||
|
||||
class InstitutionalServiceJourneyTests(unittest.TestCase):
|
||||
def test_one_service_version_drives_portal_and_case_intake(self) -> None:
|
||||
definition = _service()
|
||||
registry = _Registry(definition)
|
||||
|
||||
entries = PortalServiceDirectory(registry).list_entries(
|
||||
None,
|
||||
None,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=NOW,
|
||||
audiences=("resident",),
|
||||
)
|
||||
plan = registry.capability(CAPABILITY_CASES_SERVICE_INTAKE).plan(
|
||||
entries[0].definition,
|
||||
case_id="case-1",
|
||||
effective_at=NOW,
|
||||
)
|
||||
|
||||
self.assertTrue(entries[0].available)
|
||||
self.assertIs(definition, entries[0].definition)
|
||||
self.assertEqual(definition.reference, plan.service_ref)
|
||||
self.assertEqual("5", plan.context.service_ref.version)
|
||||
self.assertEqual("workflow:permit-review", plan.workflow_refs[0])
|
||||
|
||||
def test_portal_launches_exact_form_revision_and_persists_submission(self) -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for table in (
|
||||
FormDefinitionRevision.__table__,
|
||||
FormInstanceIdentity.__table__,
|
||||
FormInstanceRevision.__table__,
|
||||
FormInstanceEvent.__table__,
|
||||
):
|
||||
table.create(engine)
|
||||
session = Session(engine)
|
||||
principal = _Principal()
|
||||
try:
|
||||
form = record_form_definition(
|
||||
session,
|
||||
principal,
|
||||
definition=FormDefinition(
|
||||
reference=InstitutionalReference(
|
||||
kind="form",
|
||||
owner_module="forms",
|
||||
object_id="permit-application",
|
||||
tenant_id="tenant-1",
|
||||
version="3",
|
||||
),
|
||||
key="permit-application",
|
||||
temporal=TemporalRevision(
|
||||
revision="3",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
change_reason="Publish the permit application.",
|
||||
),
|
||||
title="Permit application",
|
||||
fields=(
|
||||
FormFieldDefinition(
|
||||
key="applicant_name",
|
||||
label="Applicant name",
|
||||
required=True,
|
||||
constraints={"min_length": 2},
|
||||
),
|
||||
),
|
||||
publication_state="published",
|
||||
allow_drafts=True,
|
||||
handoff_kinds=("case",),
|
||||
),
|
||||
)
|
||||
binding = ServiceBinding(
|
||||
"form",
|
||||
f"{form.reference.object_id}/{form.reference.version}",
|
||||
)
|
||||
service = ServiceDefinition(
|
||||
reference=InstitutionalReference(
|
||||
kind="service",
|
||||
owner_module="services",
|
||||
object_id="permit",
|
||||
tenant_id="tenant-1",
|
||||
version="6",
|
||||
),
|
||||
key="permit.apply",
|
||||
temporal=TemporalRevision(
|
||||
revision="6",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
),
|
||||
title="Apply for a permit",
|
||||
audience=("public",),
|
||||
bindings=(binding,),
|
||||
publication_state="published",
|
||||
)
|
||||
registry = _FormRegistry(service)
|
||||
directory = PortalServiceDirectory(registry)
|
||||
|
||||
launched = directory.launch_service(
|
||||
session,
|
||||
principal,
|
||||
reference=service.reference,
|
||||
requested_at=NOW,
|
||||
idempotency_key="portal-form-launch-1",
|
||||
parameters={"applicant_name": "Ada Lovelace"},
|
||||
)
|
||||
replay = directory.launch_service(
|
||||
session,
|
||||
principal,
|
||||
reference=service.reference,
|
||||
requested_at=NOW,
|
||||
idempotency_key="portal-form-launch-1",
|
||||
parameters={"applicant_name": "Ada Lovelace"},
|
||||
)
|
||||
instance = FormRuntimeService(registry).get_instance(
|
||||
session,
|
||||
principal,
|
||||
instance_id=str(launched.metadata["form_instance_id"]),
|
||||
)
|
||||
|
||||
self.assertEqual("form_submission", launched.target_ref.kind)
|
||||
self.assertEqual(service.reference, launched.service_ref)
|
||||
self.assertEqual("3", launched.metadata["form_definition_revision"])
|
||||
self.assertTrue(replay.replayed)
|
||||
self.assertEqual(launched.target_ref, replay.target_ref)
|
||||
self.assertEqual(
|
||||
(
|
||||
form.reference.owner_module,
|
||||
form.reference.object_id,
|
||||
form.reference.tenant_id,
|
||||
form.reference.version,
|
||||
),
|
||||
(
|
||||
instance.definition_ref.owner_module,
|
||||
instance.definition_ref.object_id,
|
||||
instance.definition_ref.tenant_id,
|
||||
instance.definition_ref.version,
|
||||
),
|
||||
)
|
||||
self.assertEqual(NOW, instance.definition_ref.valid_at)
|
||||
self.assertEqual(service.reference, instance.service_ref)
|
||||
self.assertEqual("Ada Lovelace", instance.values["applicant_name"])
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "tools"
|
||||
/ "inventory"
|
||||
/ "platform-interface-inventory.py"
|
||||
)
|
||||
SPEC = importlib.util.spec_from_file_location("platform_interface_inventory", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
inventory = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(inventory)
|
||||
|
||||
|
||||
class PlatformInterfaceInventoryTests(unittest.TestCase):
|
||||
def test_canonical_api_path_normalizes_versions_and_parameters(self) -> None:
|
||||
self.assertEqual(
|
||||
inventory.canonical_api_path(
|
||||
"http://localhost/api/v1/campaigns/${campaignId}?limit=10"
|
||||
),
|
||||
"/campaigns/{}",
|
||||
)
|
||||
self.assertEqual(
|
||||
inventory.canonical_api_path("/api/v2/campaigns/{campaign_id}"),
|
||||
"/campaigns/{}",
|
||||
)
|
||||
|
||||
def test_fastapi_route_scanner_includes_router_prefix(self) -> None:
|
||||
tree = ast.parse(
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
router = APIRouter(prefix="/api/v1/items")
|
||||
|
||||
@router.get("/{item_id}")
|
||||
def read_item(item_id: str):
|
||||
return item_id
|
||||
"""
|
||||
)
|
||||
prefixes = inventory._router_prefixes(tree)
|
||||
function = next(
|
||||
node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)
|
||||
)
|
||||
|
||||
route = inventory._endpoint_from_decorator(
|
||||
function.decorator_list[0],
|
||||
prefixes=prefixes,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
route,
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/items/{item_id}",
|
||||
"router": "router",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = META_ROOT / "tools" / "checks" / "postgres-integration-check.py"
|
||||
|
||||
|
||||
def _load_script():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"postgres_integration_check",
|
||||
SCRIPT,
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load {SCRIPT}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class PostgresIntegrationCheckTests(unittest.TestCase):
|
||||
def test_staging_check_has_explicit_runtime_safety_settings(self) -> None:
|
||||
module = _load_script()
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
env = module._check_env(
|
||||
database_url="postgresql+psycopg://user:password@postgres/db",
|
||||
modules="tenancy,access,search",
|
||||
app_env="staging",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
env["CORS_ORIGINS"],
|
||||
"http://127.0.0.1:5173,http://localhost:5173",
|
||||
)
|
||||
self.assertEqual(
|
||||
env["GOVOPLAN_TRUSTED_HOSTS"],
|
||||
"127.0.0.1,localhost,testserver",
|
||||
)
|
||||
self.assertEqual(
|
||||
env["GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS"],
|
||||
"false",
|
||||
)
|
||||
self.assertEqual(
|
||||
env["GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE"],
|
||||
"true",
|
||||
)
|
||||
|
||||
def test_existing_runtime_safety_settings_are_preserved(self) -> None:
|
||||
module = _load_script()
|
||||
configured = {
|
||||
"CORS_ORIGINS": "https://govoplan.example.test",
|
||||
"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.test",
|
||||
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true",
|
||||
"GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE": "false",
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, configured, clear=True):
|
||||
env = module._check_env(
|
||||
database_url="postgresql+psycopg://user:password@postgres/db",
|
||||
modules="tenancy,access,search",
|
||||
app_env="staging",
|
||||
)
|
||||
|
||||
for key, value in configured.items():
|
||||
self.assertEqual(env[key], value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -77,6 +77,76 @@ class PythonEnvironmentSyncTests(unittest.TestCase):
|
||||
self.assertIn(str(root / "govoplan-core"), command)
|
||||
self.assertIn(str(root / "govoplan-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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from datetime import UTC, datetime
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
import zipfile
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||
|
||||
from govoplan_release.artifact_identity import ( # noqa: E402
|
||||
ArtifactIdentityError,
|
||||
inspect_python_wheel,
|
||||
selected_artifact_identity_issues,
|
||||
)
|
||||
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||
apply_python_artifact_identities,
|
||||
)
|
||||
ASSESSMENT_TOOLS_ROOT = META_ROOT / "tools" / "assessments"
|
||||
if str(ASSESSMENT_TOOLS_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ASSESSMENT_TOOLS_ROOT))
|
||||
from govoplan_assessment.installer_receipt import issue_installer_receipt # noqa: E402
|
||||
|
||||
|
||||
class ReleaseArtifactIdentityTests(unittest.TestCase):
|
||||
def test_built_wheel_bytes_become_catalog_identity(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
wheel = self._wheel(Path(temp_dir), console_script=True)
|
||||
payload = self._catalog_payload()
|
||||
|
||||
changes = apply_python_artifact_identities(
|
||||
payload,
|
||||
repo_versions={"govoplan-demo": "1.2.3"},
|
||||
python_artifacts={"govoplan-demo": wheel},
|
||||
)
|
||||
|
||||
identity = payload["release"]["artifacts"][0]
|
||||
self.assertEqual("govoplan-demo", identity["package_name"])
|
||||
self.assertEqual("1.2.3", identity["package_version"])
|
||||
self.assertRegex(identity["archive_sha256"], r"^[0-9a-f]{64}$")
|
||||
self.assertTrue(identity["requires_installer_receipt"])
|
||||
self.assertEqual("release.artifact", changes[0].field)
|
||||
self.assertEqual((), selected_artifact_identity_issues(payload))
|
||||
|
||||
def test_version_update_without_wheel_removes_stale_identity_and_blocks_publish(self) -> None:
|
||||
payload = self._catalog_payload()
|
||||
payload["release"]["artifacts"] = [
|
||||
{
|
||||
"artifact_kind": "python-wheel",
|
||||
"package_name": "govoplan-demo",
|
||||
"package_version": "1.2.2",
|
||||
"archive_sha256": "a" * 64,
|
||||
"archive_size": 10,
|
||||
"installed_payload": {
|
||||
"algorithm": "govoplan-wheel-declared-payload-v1",
|
||||
"sha256": "b" * 64,
|
||||
"file_count": 1,
|
||||
},
|
||||
"requires_installer_receipt": False,
|
||||
}
|
||||
]
|
||||
|
||||
apply_python_artifact_identities(
|
||||
payload,
|
||||
repo_versions={"govoplan-demo": "1.2.3"},
|
||||
python_artifacts={},
|
||||
)
|
||||
|
||||
self.assertNotIn("artifacts", payload["release"])
|
||||
self.assertIn(
|
||||
"no built artifact identity",
|
||||
" ".join(selected_artifact_identity_issues(payload)),
|
||||
)
|
||||
|
||||
def test_tag_only_selection_needs_no_python_artifact_mapping(self) -> None:
|
||||
payload = self._catalog_payload()
|
||||
payload["release"]["selected_units"].append(
|
||||
{"repo": "govoplan", "version": "1.2.3"}
|
||||
)
|
||||
|
||||
apply_python_artifact_identities(
|
||||
payload,
|
||||
repo_versions={"govoplan": "1.2.3"},
|
||||
python_artifacts={},
|
||||
)
|
||||
|
||||
self.assertIn("selected_units", payload["release"])
|
||||
|
||||
def test_unsafe_or_mismatched_wheel_fails_closed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
unsafe = root / "govoplan_demo-1.2.3-py3-none-any.whl"
|
||||
with zipfile.ZipFile(unsafe, "w") as archive:
|
||||
archive.writestr("../escape", b"bad")
|
||||
archive.writestr(
|
||||
"govoplan_demo-1.2.3.dist-info/METADATA",
|
||||
"Metadata-Version: 2.1\nName: govoplan-demo\nVersion: 1.2.3\n",
|
||||
)
|
||||
wrong = self._wheel(root, version="9.9.9")
|
||||
|
||||
with self.assertRaisesRegex(ArtifactIdentityError, "unsafe"):
|
||||
inspect_python_wheel(unsafe)
|
||||
with self.assertRaisesRegex(ValueError, "expected govoplan-demo 1.2.3"):
|
||||
apply_python_artifact_identities(
|
||||
self._catalog_payload(),
|
||||
repo_versions={"govoplan-demo": "1.2.3"},
|
||||
python_artifacts={"govoplan-demo": wrong},
|
||||
)
|
||||
|
||||
def test_receipt_issuer_hashes_exact_consumed_wheel(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
original = self._wheel(root / "original", value=b"VALUE = 1\n")
|
||||
different = self._wheel(root / "different", value=b"VALUE = 2\n")
|
||||
identity = inspect_python_wheel(original)
|
||||
catalog = self._catalog_payload()
|
||||
catalog["channel"] = "stable"
|
||||
catalog["sequence"] = 1
|
||||
catalog["release"]["artifacts"] = [identity.catalog_payload()]
|
||||
assessment = {
|
||||
"assessment_id": "assessment:test",
|
||||
"release": {"ref": "stable-catalog-1"},
|
||||
"composition": [{"module_id": "demo", "enabled": True}],
|
||||
}
|
||||
installed_payload = {
|
||||
"algorithm": "govoplan-installed-record-payload-v1",
|
||||
"sha256": "c" * 64,
|
||||
"file_count": identity.payload_file_count,
|
||||
}
|
||||
evidence = {
|
||||
"assessment_id": "assessment:test",
|
||||
"assessment_release": "stable-catalog-1",
|
||||
"collected_at": "2026-07-22T12:00:00Z",
|
||||
"artifacts": [
|
||||
{
|
||||
"package_name": "govoplan-demo",
|
||||
"package_version": "1.2.3",
|
||||
"record_integrity": {
|
||||
"status": "verified",
|
||||
"hashed_file_count": identity.payload_file_count,
|
||||
"permitted_unhashed_file_count": 1,
|
||||
"generated_unhashed_file_count": 0,
|
||||
"unverifiable_file_count": 0,
|
||||
"missing_file_count": 0,
|
||||
"mismatched_file_count": 0,
|
||||
"artifact_payload_identity": catalog["release"]["artifacts"][0]["installed_payload"],
|
||||
"installed_payload_identity": installed_payload,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
key = Ed25519PrivateKey.generate()
|
||||
|
||||
receipt = issue_installer_receipt(
|
||||
assessment=assessment,
|
||||
catalog=catalog,
|
||||
installed_evidence=evidence,
|
||||
receipt_id="install:test",
|
||||
key_id="installer:test",
|
||||
private_key=key,
|
||||
consumed_artifacts={"govoplan-demo": original},
|
||||
issued_at=datetime(2026, 7, 22, 12, 1, tzinfo=UTC),
|
||||
same_process_observation=True,
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "differs from the signed"):
|
||||
issue_installer_receipt(
|
||||
assessment=assessment,
|
||||
catalog=catalog,
|
||||
installed_evidence=evidence,
|
||||
receipt_id="install:test",
|
||||
key_id="installer:test",
|
||||
private_key=key,
|
||||
consumed_artifacts={"govoplan-demo": different},
|
||||
issued_at=datetime(2026, 7, 22, 12, 1, tzinfo=UTC),
|
||||
same_process_observation=True,
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "within five minutes"):
|
||||
issue_installer_receipt(
|
||||
assessment=assessment,
|
||||
catalog=catalog,
|
||||
installed_evidence=evidence,
|
||||
receipt_id="install:test",
|
||||
key_id="installer:test",
|
||||
private_key=key,
|
||||
consumed_artifacts={"govoplan-demo": original},
|
||||
issued_at=datetime(2026, 7, 22, 12, 6, tzinfo=UTC),
|
||||
same_process_observation=True,
|
||||
)
|
||||
|
||||
self.assertEqual(identity.archive_sha256, receipt["artifacts"][0]["catalog_archive_sha256"])
|
||||
|
||||
def test_path_replacement_during_one_descriptor_inspection_fails_closed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
target = self._wheel(root / "target", value=b"VALUE = 1\n")
|
||||
replacement = self._wheel(root / "replacement", value=b"VALUE = 2\n")
|
||||
real_zip = zipfile.ZipFile
|
||||
|
||||
def swap_path(file_or_path, *args, **kwargs):
|
||||
target.unlink()
|
||||
replacement.rename(target)
|
||||
return real_zip(file_or_path, *args, **kwargs)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"govoplan_release.artifact_identity.zipfile.ZipFile",
|
||||
side_effect=swap_path,
|
||||
),
|
||||
self.assertRaisesRegex(ArtifactIdentityError, "changed"),
|
||||
):
|
||||
inspect_python_wheel(target)
|
||||
|
||||
@staticmethod
|
||||
def _catalog_payload() -> dict[str, object]:
|
||||
return {
|
||||
"core_release": {},
|
||||
"modules": [
|
||||
{
|
||||
"module_id": "demo",
|
||||
"version": "1.2.3",
|
||||
"python_package": "govoplan-demo",
|
||||
"python_ref": "govoplan-demo @ git+ssh://git@example.test/acme/govoplan-demo.git@v1.2.3",
|
||||
}
|
||||
],
|
||||
"release": {
|
||||
"selected_units": [
|
||||
{"repo": "govoplan-demo", "version": "1.2.3"}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _wheel(
|
||||
root: Path,
|
||||
*,
|
||||
version: str = "1.2.3",
|
||||
console_script: bool = False,
|
||||
value: bytes = b"VALUE = 1\n",
|
||||
) -> Path:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
wheel = root / f"govoplan_demo-{version}-py3-none-any.whl"
|
||||
dist_info = f"govoplan_demo-{version}.dist-info"
|
||||
files: dict[str, bytes] = {
|
||||
"govoplan_demo.py": value,
|
||||
f"{dist_info}/METADATA": (
|
||||
f"Metadata-Version: 2.1\nName: govoplan-demo\nVersion: {version}\n"
|
||||
).encode(),
|
||||
f"{dist_info}/WHEEL": b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
}
|
||||
if console_script:
|
||||
files[f"{dist_info}/entry_points.txt"] = (
|
||||
b"[console_scripts]\ngovoplan-demo = govoplan_demo:main\n"
|
||||
)
|
||||
record_buffer = io.StringIO()
|
||||
writer = csv.writer(record_buffer, lineterminator="\n")
|
||||
for name, encoded in files.items():
|
||||
writer.writerow((name, "", len(encoded)))
|
||||
writer.writerow((f"{dist_info}/RECORD", "", ""))
|
||||
files[f"{dist_info}/RECORD"] = record_buffer.getvalue().encode()
|
||||
with zipfile.ZipFile(wheel, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, encoded in files.items():
|
||||
archive.writestr(name, encoded)
|
||||
return wheel
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||
|
||||
from govoplan_release.catalog import canonical_hash # noqa: E402
|
||||
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||
candidate_keyring_from_authenticated_base,
|
||||
load_authenticated_catalog_base,
|
||||
public_key_base64,
|
||||
signature,
|
||||
)
|
||||
|
||||
|
||||
class ReleaseBaseCatalogTrustTests(unittest.TestCase):
|
||||
def test_exact_signed_base_pair_is_loaded_once_and_carried_in_memory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
catalog, keyring = self._write_pair(root, private_key=private_key)
|
||||
|
||||
authenticated = load_authenticated_catalog_base(
|
||||
base_catalog=catalog,
|
||||
base_keyring=keyring,
|
||||
web_root=root,
|
||||
channel="stable",
|
||||
public_base_url="https://invalid.example",
|
||||
signer_public_keys={"release-key": public_key_base64(private_key)},
|
||||
)
|
||||
|
||||
keyring.unlink()
|
||||
|
||||
self.assertEqual("release-key", authenticated.keyring["keys"][0]["key_id"])
|
||||
self.assertEqual(
|
||||
canonical_hash(authenticated.keyring),
|
||||
authenticated.catalog["release"]["keyring_sha256"],
|
||||
)
|
||||
|
||||
def test_deliberate_old_to_new_signer_rotation_is_carried_forward(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
new_key = Ed25519PrivateKey.generate()
|
||||
catalog, keyring = self._write_pair(root, private_key=private_key)
|
||||
configured = {
|
||||
"release-key": public_key_base64(private_key),
|
||||
"release-key-next": public_key_base64(new_key),
|
||||
}
|
||||
|
||||
authenticated = load_authenticated_catalog_base(
|
||||
base_catalog=catalog,
|
||||
base_keyring=keyring,
|
||||
web_root=root,
|
||||
channel="stable",
|
||||
public_base_url="https://invalid.example",
|
||||
signer_public_keys=configured,
|
||||
)
|
||||
candidate = candidate_keyring_from_authenticated_base(
|
||||
authenticated.keyring,
|
||||
signer_public_keys=configured,
|
||||
generated_at=datetime(2026, 7, 22, 12, tzinfo=UTC),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{"release-key", "release-key-next"},
|
||||
{item["key_id"] for item in candidate["keys"]},
|
||||
)
|
||||
|
||||
def test_injected_extra_key_without_catalog_authorization_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
extra_key = Ed25519PrivateKey.generate()
|
||||
catalog, keyring = self._write_pair(root, private_key=private_key)
|
||||
payload = json.loads(keyring.read_text(encoding="utf-8"))
|
||||
payload["keys"].append(
|
||||
{
|
||||
"key_id": "injected-key",
|
||||
"status": "active",
|
||||
"public_key": public_key_base64(extra_key),
|
||||
}
|
||||
)
|
||||
keyring.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "does not pin"):
|
||||
load_authenticated_catalog_base(
|
||||
base_catalog=catalog,
|
||||
base_keyring=keyring,
|
||||
web_root=root,
|
||||
channel="stable",
|
||||
public_base_url="https://invalid.example",
|
||||
signer_public_keys={
|
||||
"release-key": public_key_base64(private_key)
|
||||
},
|
||||
)
|
||||
|
||||
def test_unpinned_or_symlinked_base_keyring_fails_closed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
catalog, keyring = self._write_pair(root, private_key=private_key)
|
||||
payload = json.loads(keyring.read_text(encoding="utf-8"))
|
||||
payload["generated_at"] = "changed"
|
||||
keyring.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "does not pin"):
|
||||
load_authenticated_catalog_base(
|
||||
base_catalog=catalog,
|
||||
base_keyring=keyring,
|
||||
web_root=root,
|
||||
channel="stable",
|
||||
public_base_url="https://invalid.example",
|
||||
signer_public_keys={
|
||||
"release-key": public_key_base64(private_key)
|
||||
},
|
||||
)
|
||||
|
||||
keyring.unlink()
|
||||
outside = root / "outside.json"
|
||||
outside.write_text("{}", encoding="utf-8")
|
||||
keyring.symlink_to(outside)
|
||||
with self.assertRaisesRegex(ValueError, "opened safely"):
|
||||
load_authenticated_catalog_base(
|
||||
base_catalog=catalog,
|
||||
base_keyring=keyring,
|
||||
web_root=root,
|
||||
channel="stable",
|
||||
public_base_url="https://invalid.example",
|
||||
signer_public_keys={
|
||||
"release-key": public_key_base64(private_key)
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _write_pair(
|
||||
root: Path, *, private_key: Ed25519PrivateKey
|
||||
) -> tuple[Path, Path]:
|
||||
keys = [
|
||||
{
|
||||
"key_id": "release-key",
|
||||
"status": "active",
|
||||
"public_key": public_key_base64(private_key),
|
||||
}
|
||||
]
|
||||
keyring_payload = {
|
||||
"keyring_version": "1",
|
||||
"purpose": "govoplan module package catalog signatures",
|
||||
"keys": keys,
|
||||
}
|
||||
catalog_payload = {
|
||||
"channel": "stable",
|
||||
"sequence": 1,
|
||||
"core_release": {"version": "1.0.0"},
|
||||
"modules": [],
|
||||
"release": {"keyring_sha256": canonical_hash(keyring_payload)},
|
||||
}
|
||||
catalog_payload["signatures"] = [
|
||||
signature(
|
||||
catalog_payload,
|
||||
key_id="release-key",
|
||||
private_key=private_key,
|
||||
)
|
||||
]
|
||||
catalog = root / "stable.json"
|
||||
keyring = root / "keyring.json"
|
||||
catalog.write_text(json.dumps(catalog_payload), encoding="utf-8")
|
||||
keyring.write_text(json.dumps(keyring_payload), encoding="utf-8")
|
||||
return catalog, keyring
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||
|
||||
from govoplan_release.candidate_artifact import ( # noqa: E402
|
||||
CandidateArtifactError,
|
||||
candidate_output_path,
|
||||
issue_candidate_id,
|
||||
issue_candidate_receipt,
|
||||
verify_candidate_receipt,
|
||||
validate_release_channel,
|
||||
)
|
||||
|
||||
|
||||
class CandidateArtifactTests(unittest.TestCase):
|
||||
def test_channel_is_one_canonical_basename(self) -> None:
|
||||
self.assertEqual("stable_1", validate_release_channel("stable_1"))
|
||||
for value in ("../stable", "/stable", ".", "..", "Stable", "stable/name"):
|
||||
with self.subTest(value=value), self.assertRaises(CandidateArtifactError):
|
||||
validate_release_channel(value)
|
||||
|
||||
def test_handle_is_deterministic_and_can_precede_output(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "not-created" / "release-candidates"
|
||||
candidate_id = issue_candidate_id("rr-123", "attempt-456")
|
||||
|
||||
first = candidate_output_path(root, candidate_id)
|
||||
second = candidate_output_path(root, candidate_id)
|
||||
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(candidate_id, first.name)
|
||||
self.assertRegex(candidate_id, r"^candidate-[0-9a-f]{32}$")
|
||||
|
||||
def test_receipt_rejects_catalog_drift(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "release-candidates"
|
||||
candidate_id = issue_candidate_id("run", "attempt")
|
||||
catalog = self._write_candidate(root / candidate_id)
|
||||
receipt = issue_candidate_receipt(
|
||||
root=root, candidate_id=candidate_id, channel="stable"
|
||||
)
|
||||
|
||||
resolved = verify_candidate_receipt(
|
||||
root=root,
|
||||
candidate_id=receipt.candidate_id,
|
||||
catalog_sha256=receipt.catalog_sha256,
|
||||
channel="stable",
|
||||
)
|
||||
catalog.write_text(
|
||||
json.dumps({"channel": "stable", "sequence": 2, "signatures": [{}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self.assertEqual(root / candidate_id, resolved)
|
||||
with self.assertRaisesRegex(CandidateArtifactError, "no longer matches"):
|
||||
verify_candidate_receipt(
|
||||
root=root,
|
||||
candidate_id=receipt.candidate_id,
|
||||
catalog_sha256=receipt.catalog_sha256,
|
||||
channel="stable",
|
||||
)
|
||||
|
||||
def test_unissued_ids_traversal_and_symlinks_fail_closed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "release-candidates"
|
||||
root.mkdir(mode=0o700)
|
||||
candidate_id = issue_candidate_id("run", "attempt")
|
||||
outside = Path(temp_dir) / "outside"
|
||||
outside.mkdir()
|
||||
(root / candidate_id).symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with self.assertRaises(CandidateArtifactError):
|
||||
candidate_output_path(root, "../candidate-" + "0" * 32)
|
||||
with self.assertRaisesRegex(CandidateArtifactError, "real directory"):
|
||||
candidate_output_path(root, candidate_id)
|
||||
|
||||
def test_catalog_and_channel_components_must_not_be_symlinks(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "release-candidates"
|
||||
candidate_id = issue_candidate_id("run", "attempt")
|
||||
candidate = root / candidate_id
|
||||
target = Path(temp_dir) / "target"
|
||||
target.mkdir()
|
||||
(target / "stable.json").write_text(
|
||||
json.dumps({"signatures": [{}]}), encoding="utf-8"
|
||||
)
|
||||
root.mkdir(mode=0o700)
|
||||
candidate.mkdir(mode=0o700)
|
||||
(candidate / "channels").symlink_to(target, target_is_directory=True)
|
||||
|
||||
with self.assertRaisesRegex(CandidateArtifactError, "real directory"):
|
||||
issue_candidate_receipt(
|
||||
root=root, candidate_id=candidate_id, channel="stable"
|
||||
)
|
||||
|
||||
def test_shared_candidate_roots_and_catalog_files_fail_closed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "release-candidates"
|
||||
root.mkdir(mode=0o755)
|
||||
candidate_id = issue_candidate_id("run", "attempt")
|
||||
with self.assertRaisesRegex(CandidateArtifactError, "another user"):
|
||||
candidate_output_path(root, candidate_id)
|
||||
|
||||
root.chmod(0o700)
|
||||
catalog = self._write_candidate(root / candidate_id)
|
||||
catalog.chmod(0o640)
|
||||
with self.assertRaisesRegex(CandidateArtifactError, "another user"):
|
||||
issue_candidate_receipt(
|
||||
root=root, candidate_id=candidate_id, channel="stable"
|
||||
)
|
||||
|
||||
def test_receipt_rejects_catalog_with_inconsistent_channel(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "release-candidates"
|
||||
candidate_id = issue_candidate_id("run", "attempt")
|
||||
catalog = self._write_candidate(root / candidate_id)
|
||||
catalog.write_text(
|
||||
json.dumps({"channel": "next", "signatures": [{}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(CandidateArtifactError, "does not match"):
|
||||
issue_candidate_receipt(
|
||||
root=root, candidate_id=candidate_id, channel="stable"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _write_candidate(candidate: Path) -> Path:
|
||||
candidate.parent.mkdir(mode=0o700, exist_ok=True)
|
||||
candidate.mkdir(mode=0o700)
|
||||
channels = candidate / "channels"
|
||||
channels.mkdir(mode=0o700)
|
||||
catalog = channels / "stable.json"
|
||||
catalog.write_text(
|
||||
json.dumps({"channel": "stable", "sequence": 1, "signatures": [{}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
catalog.chmod(0o600)
|
||||
return catalog
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,17 +4,101 @@ import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ExternalProviderDeclaration,
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleMaturityEvidence,
|
||||
ProviderBehaviorDeclaration,
|
||||
ProviderObjectDeclaration,
|
||||
)
|
||||
|
||||
|
||||
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.catalog_entry_synthesis import validate_initial_entry_closure # noqa: E402
|
||||
from govoplan_release.catalog_entry_synthesis import ( # noqa: E402
|
||||
manifest_catalog_entry,
|
||||
validate_initial_entry_closure,
|
||||
)
|
||||
from govoplan_release.selective_catalog import apply_repo_updates # noqa: E402
|
||||
|
||||
|
||||
class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
||||
def test_catalog_entry_preserves_architecture_and_provider_declarations(
|
||||
self,
|
||||
) -> None:
|
||||
provider = ExternalProviderDeclaration(
|
||||
id="example.records",
|
||||
module_id="example",
|
||||
label="Example records",
|
||||
maturity="read",
|
||||
operations=("read",),
|
||||
objects=(
|
||||
ProviderObjectDeclaration(
|
||||
object_type="record",
|
||||
field_groups=("identity", "content"),
|
||||
authority_modes=("external_mirror",),
|
||||
default_authority_mode="external_mirror",
|
||||
),
|
||||
),
|
||||
behavior=ProviderBehaviorDeclaration(
|
||||
freshness="Reports the acquisition timestamp.",
|
||||
health="Reports source and parser health.",
|
||||
max_read_items=100,
|
||||
classifications=("internal",),
|
||||
purposes=("release contract test",),
|
||||
retention="The owning package retention policy applies.",
|
||||
),
|
||||
)
|
||||
architecture = ModuleArchitectureDeclaration(
|
||||
layer="data_reporting_integration",
|
||||
kind="integration",
|
||||
maturity="vertical_slice",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_release_catalog_entry_synthesis.py",
|
||||
summary="Proves catalog metadata preservation.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md",
|
||||
summary="Defines the provider declaration contract.",
|
||||
),
|
||||
),
|
||||
known_limits=("Test-only provider.",),
|
||||
supported_authority_modes=("external_mirror",),
|
||||
owned_concepts=("example transport",),
|
||||
target_tested_providers=(provider.id,),
|
||||
)
|
||||
|
||||
entry = manifest_catalog_entry(
|
||||
manifest=ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.2.3",
|
||||
architecture=architecture,
|
||||
external_providers=(provider,),
|
||||
),
|
||||
repo="govoplan-example",
|
||||
package="govoplan-example",
|
||||
version="1.2.3",
|
||||
description=None,
|
||||
root=META_ROOT,
|
||||
repository_base="git+ssh://git@git.add-ideas.de/GovOPlaN",
|
||||
)
|
||||
|
||||
self.assertEqual("vertical_slice", entry["architecture"]["maturity"])
|
||||
self.assertEqual(
|
||||
"external_mirror",
|
||||
entry["external_providers"][0]["objects"][0][
|
||||
"default_authority_mode"
|
||||
],
|
||||
)
|
||||
|
||||
def test_selective_update_synthesizes_initial_entries_from_package_manifests(self) -> None:
|
||||
payload: dict[str, object] = {
|
||||
"core_release": {},
|
||||
@@ -24,7 +108,7 @@ class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
||||
"module_id": "access",
|
||||
"version": "0.1.11",
|
||||
"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 +121,7 @@ class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
||||
"govoplan-scheduling": "0.1.11",
|
||||
},
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -13,10 +16,691 @@ 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.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):
|
||||
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:
|
||||
with tempfile.TemporaryDirectory() as 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
|
||||
|
||||
@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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -36,6 +37,30 @@ class ReleaseConsoleSecurityTests(unittest.TestCase):
|
||||
self.assertIn("window.location.hash.slice(1)", 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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
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 server.app import create_app # noqa: E402
|
||||
|
||||
|
||||
class ReleaseDashboardDiagnosticsTests(unittest.TestCase):
|
||||
def test_malformed_core_version_is_bounded_and_blocks_api_plans(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
core = workspace / "govoplan-core"
|
||||
core.mkdir()
|
||||
(core / "pyproject.toml").write_text("[project\n", encoding="utf-8")
|
||||
|
||||
with TestClient(create_app(workspace_root=workspace)) as client:
|
||||
dashboard_response = client.get(
|
||||
"/api/dashboard", params={"public_catalog": "false"}
|
||||
)
|
||||
selective_response = client.get(
|
||||
"/api/selective-plan",
|
||||
params={
|
||||
"repos": "govoplan-core",
|
||||
"repo_versions": "govoplan-core:1.2.4",
|
||||
"public_catalog": "false",
|
||||
},
|
||||
)
|
||||
full_plan_response = client.get(
|
||||
"/api/plan", params={"public_catalog": "false"}
|
||||
)
|
||||
|
||||
self.assertEqual(200, dashboard_response.status_code)
|
||||
dashboard = dashboard_response.json()
|
||||
self.assertEqual("blocked", dashboard["summary"]["status"])
|
||||
error = next(
|
||||
item
|
||||
for item in dashboard["collection_errors"]
|
||||
if item["code"] == "core_version_metadata_unreadable"
|
||||
)
|
||||
self.assertEqual("govoplan-core/pyproject.toml", error["source"])
|
||||
self.assertIn("TOMLDecodeError", error["message"])
|
||||
self.assertIn("Repair govoplan-core/pyproject.toml", error["remediation"])
|
||||
self.assertLess(len(error["message"]), 200)
|
||||
self.assertNotIn(str(workspace), error["message"])
|
||||
|
||||
self.assertEqual(200, selective_response.status_code)
|
||||
selective_plan = selective_response.json()
|
||||
self.assertEqual("blocked", selective_plan["status"])
|
||||
self.assertFalse(selective_plan["source_preflight_ready"])
|
||||
finding = next(
|
||||
item
|
||||
for item in selective_plan["gate_findings"]
|
||||
if item["code"] == "core_version_metadata_unreadable"
|
||||
)
|
||||
self.assertEqual(error["remediation"], finding["remediation"])
|
||||
self.assertEqual(
|
||||
"resolve_release_gate", selective_plan["recommended_action"]["id"]
|
||||
)
|
||||
|
||||
self.assertEqual(200, full_plan_response.status_code)
|
||||
full_plan = full_plan_response.json()
|
||||
self.assertEqual("blocked", full_plan["status"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
action["id"].startswith("dashboard:core_version_metadata_unreadable:")
|
||||
for action in full_plan["actions"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_malformed_manifest_is_not_silently_omitted_from_selected_plan(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
core = workspace / "govoplan-core"
|
||||
core.mkdir()
|
||||
(core / "pyproject.toml").write_text(
|
||||
'[project]\nname = "govoplan-core"\nversion = "0.1.13"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
files = workspace / "govoplan-files"
|
||||
manifest = files / "src" / "govoplan_files" / "backend" / "manifest.py"
|
||||
manifest.parent.mkdir(parents=True)
|
||||
(files / "pyproject.toml").write_text(
|
||||
'[project]\nname = "govoplan-files"\nversion = "1.2.4"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
manifest.write_text("def broken(:\n", encoding="utf-8")
|
||||
initialize_repository(files)
|
||||
|
||||
with TestClient(create_app(workspace_root=workspace)) as client:
|
||||
dashboard_response = client.get(
|
||||
"/api/dashboard", params={"public_catalog": "false"}
|
||||
)
|
||||
selective_response = client.get(
|
||||
"/api/selective-plan",
|
||||
params={
|
||||
"repos": "govoplan-files",
|
||||
"repo_versions": "govoplan-files:1.2.4",
|
||||
"public_catalog": "false",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(200, dashboard_response.status_code)
|
||||
dashboard = dashboard_response.json()
|
||||
error = next(
|
||||
item
|
||||
for item in dashboard["collection_errors"]
|
||||
if item["code"] == "module_contract_unreadable"
|
||||
)
|
||||
self.assertEqual("govoplan-files", error["repo"])
|
||||
self.assertEqual(
|
||||
"govoplan-files/src/govoplan_files/backend/manifest.py",
|
||||
error["source"],
|
||||
)
|
||||
self.assertIn("SyntaxError", error["message"])
|
||||
self.assertIn("Repair the manifest", error["remediation"])
|
||||
|
||||
self.assertEqual(200, selective_response.status_code)
|
||||
plan = selective_response.json()
|
||||
self.assertEqual("blocked", plan["status"])
|
||||
self.assertFalse(plan["source_preflight_ready"])
|
||||
self.assertIn(
|
||||
"module_contract_unreadable",
|
||||
{finding["code"] for finding in plan["gate_findings"]},
|
||||
)
|
||||
self.assertEqual("resolve_release_gate", plan["recommended_action"]["id"])
|
||||
|
||||
|
||||
def initialize_repository(path: Path) -> None:
|
||||
subprocess.run(
|
||||
["git", "init", "--initial-branch=main", str(path)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "-C", str(path), "add", "."],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(path),
|
||||
"-c",
|
||||
"user.name=Release Test",
|
||||
"-c",
|
||||
"user.email=release@example.invalid",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-m",
|
||||
"Initial fixture",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__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=publication_trust", 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)
|
||||
|
||||
def test_release_integration_honors_independent_module_versions(self) -> None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
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_manifest_version_does_not_confuse_interface_versions(self) -> None:
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "govoplan-example"
|
||||
manifest = root / "src" / "govoplan_example" / "backend" / "manifest.py"
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text(
|
||||
"\n".join(
|
||||
(
|
||||
'MODULE_VERSION = "1.2.3"',
|
||||
"manifest = ModuleManifest(",
|
||||
' id="example",',
|
||||
" version=MODULE_VERSION,",
|
||||
" provides_interfaces=(",
|
||||
' ModuleInterfaceProvider(name="example.items", version="9.8.7"),',
|
||||
" ),",
|
||||
")",
|
||||
"",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
versions = git_state.read_manifest_versions(root)
|
||||
|
||||
self.assertEqual(("1.2.3",), versions)
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
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.http_fetch import ( # noqa: E402
|
||||
DEFAULT_MAX_RESPONSE_BYTES,
|
||||
fetch_http,
|
||||
)
|
||||
|
||||
|
||||
class ReleaseHttpFetchTests(unittest.TestCase):
|
||||
def test_content_length_over_default_limit_blocks_before_read(self) -> None:
|
||||
response = FakeResponse(
|
||||
body=b"ignored",
|
||||
headers={"Content-Length": str(DEFAULT_MAX_RESPONSE_BYTES + 1)},
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"govoplan_release.http_fetch.urllib.request.urlopen",
|
||||
return_value=response,
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "exceeds the configured size"):
|
||||
fetch_http("https://example.invalid/catalog.json", timeout=1)
|
||||
|
||||
self.assertEqual([], response.read_sizes)
|
||||
|
||||
def test_streamed_body_is_capped_when_length_is_absent(self) -> None:
|
||||
response = FakeResponse(body=b"x" * 9, headers={})
|
||||
|
||||
with mock.patch(
|
||||
"govoplan_release.http_fetch.urllib.request.urlopen",
|
||||
return_value=response,
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "exceeds the configured size"):
|
||||
fetch_http(
|
||||
"https://example.invalid/catalog.json",
|
||||
timeout=1,
|
||||
max_response_bytes=8,
|
||||
)
|
||||
|
||||
self.assertEqual([9], response.read_sizes)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, *, body: bytes, headers: dict[str, str]) -> None:
|
||||
self.status = 200
|
||||
self.headers = headers
|
||||
self.body = body
|
||||
self.read_sizes: list[int] = []
|
||||
|
||||
def __enter__(self) -> FakeResponse:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
def read(self, size: int) -> bytes:
|
||||
self.read_sizes.append(size)
|
||||
return self.body[:size]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from tools.checks.release_integration import (
|
||||
SOURCE_COUPLED_CORE_TESTS,
|
||||
@@ -10,6 +12,7 @@ from tools.checks.release_integration import (
|
||||
artifact_contract_issues,
|
||||
filter_test_suite,
|
||||
release_package_names,
|
||||
run_module_release_tests,
|
||||
)
|
||||
|
||||
|
||||
@@ -32,9 +35,9 @@ class ReleaseIntegrationTests(unittest.TestCase):
|
||||
"\n".join(
|
||||
(
|
||||
"../govoplan-core[server]",
|
||||
"govoplan-idm @ git+ssh://git@example.test/add-ideas/govoplan-idm.git@v0.1.8",
|
||||
"govoplan-campaign @ git+ssh://git@example.test/add-ideas/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",
|
||||
"govoplan-campaign @ git+ssh://git@example.test/GovOPlaN/govoplan-campaign.git@v0.1.8",
|
||||
"govoplan-idm @ git+ssh://git@example.test/GovOPlaN/govoplan-idm.git@v0.1.8",
|
||||
"other-package==1.0",
|
||||
)
|
||||
),
|
||||
@@ -77,7 +80,7 @@ class ReleaseIntegrationTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(set(removed), set(SOURCE_COUPLED_CORE_TESTS))
|
||||
self.assertEqual([test.id() for test in filtered], [retained_id])
|
||||
self.assertEqual(len(SOURCE_COUPLED_CORE_TESTS), 4)
|
||||
self.assertEqual(len(SOURCE_COUPLED_CORE_TESTS), 5)
|
||||
|
||||
def test_release_entrypoint_uses_artifact_checks_and_filtered_core_suite(self) -> None:
|
||||
meta_root = Path(__file__).resolve().parents[1]
|
||||
@@ -89,8 +92,76 @@ class ReleaseIntegrationTests(unittest.TestCase):
|
||||
|
||||
self.assertLess(artifact_check, core_tests)
|
||||
self.assertLess(core_tests, module_tests)
|
||||
self.assertNotIn("unittest discover", script)
|
||||
self.assertNotIn('"$PYTHON" -m unittest \\\n tests.test_module_system', script)
|
||||
|
||||
def test_tagged_module_runner_executes_pytest_functions_from_module_root(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-release-module-tests-") as temp_dir:
|
||||
module_root = Path(temp_dir)
|
||||
tests_root = module_root / "tests"
|
||||
tests_root.mkdir()
|
||||
marker = module_root / "pytest-function-ran"
|
||||
(tests_root / "test_probe.py").write_text(
|
||||
"""from pathlib import Path
|
||||
import os
|
||||
|
||||
|
||||
def test_pytest_style_function():
|
||||
expected_root = Path(os.environ[\"GOVOPLAN_TEST_MODULE_ROOT\"])
|
||||
assert Path.cwd() == expected_root
|
||||
Path(os.environ[\"GOVOPLAN_TEST_MARKER\"]).write_text(\"ran\", encoding=\"utf-8\")
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GOVOPLAN_TEST_MODULE_ROOT": str(module_root),
|
||||
"GOVOPLAN_TEST_MARKER": str(marker),
|
||||
},
|
||||
):
|
||||
status = run_module_release_tests(module_root)
|
||||
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(marker.read_text(encoding="utf-8"), "ran")
|
||||
|
||||
def test_tagged_module_runner_skips_missing_test_tree(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-release-module-tests-") as temp_dir:
|
||||
self.assertEqual(run_module_release_tests(Path(temp_dir)), 0)
|
||||
|
||||
def test_module_matrix_uses_artifact_aware_core_suite(self) -> None:
|
||||
meta_root = Path(__file__).resolve().parents[1]
|
||||
script = (meta_root / "tools" / "checks" / "check-module-matrix.sh").read_text(encoding="utf-8")
|
||||
|
||||
artifact_check = script.index('"$META_ROOT/tools/checks/release_integration.py" artifacts')
|
||||
core_tests = script.index('"$META_ROOT/tools/checks/release_integration.py" core-tests')
|
||||
|
||||
self.assertLess(artifact_check, core_tests)
|
||||
self.assertIn('--requirements "$META_ROOT/requirements-release.txt"', script)
|
||||
self.assertIn('--core-root "$ROOT"', script)
|
||||
self.assertNotIn('"$PYTHON" -m unittest tests.test_module_system', script)
|
||||
|
||||
def test_module_matrix_installs_search_before_postgres_validation(self) -> None:
|
||||
meta_root = Path(__file__).resolve().parents[1]
|
||||
workflow = (meta_root / ".gitea" / "workflows" / "module-matrix.yml").read_text(encoding="utf-8")
|
||||
|
||||
install = workflow.index("pip install --no-deps ../govoplan-search")
|
||||
validation = workflow.index("Validate Search against PostgreSQL")
|
||||
|
||||
self.assertLess(install, validation)
|
||||
|
||||
def test_release_workflow_installs_tagged_source_test_harness(self) -> None:
|
||||
meta_root = Path(__file__).resolve().parents[1]
|
||||
workflow = (meta_root / ".gitea" / "workflows" / "release-integration.yml").read_text(encoding="utf-8")
|
||||
requirements = (meta_root / "requirements-release-tests.txt").read_text(encoding="utf-8")
|
||||
|
||||
install = workflow.index("pip install -r requirements-release-tests.txt")
|
||||
checks = workflow.index("bash tools/checks/check-release-integration.sh")
|
||||
|
||||
self.assertLess(install, checks)
|
||||
self.assertIn("pytest>=9.0.3,<10", requirements)
|
||||
self.assertNotIn("requirements-release-tests.txt", (meta_root / "requirements-release.txt").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -47,6 +47,38 @@ branch_labels: Union[str, Sequence[str], None] = None
|
||||
self.assertEqual(migration.depends_on, ("core",))
|
||||
self.assertEqual(migration.branch_labels, ())
|
||||
|
||||
def test_development_wrapper_uses_release_revision_metadata(self) -> None:
|
||||
audit = load_audit_module()
|
||||
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
|
||||
root = Path(directory)
|
||||
versions = root / "versions"
|
||||
development = root / "dev_versions"
|
||||
versions.mkdir()
|
||||
development.mkdir()
|
||||
release = versions / "1234_example.py"
|
||||
release.write_text(
|
||||
'revision = "1234"\n'
|
||||
'down_revision = "base"\n'
|
||||
'depends_on = "core"\n'
|
||||
"branch_labels = None\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
wrapper = development / release.name
|
||||
wrapper.write_text(
|
||||
"revision = _migration.revision\n"
|
||||
"down_revision = _migration.down_revision\n"
|
||||
"depends_on = _migration.depends_on\n"
|
||||
"branch_labels = _migration.branch_labels\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
migration = audit.parse_migration_file("govoplan-core", wrapper)
|
||||
|
||||
self.assertIsNotNone(migration)
|
||||
self.assertEqual("1234", migration.revision)
|
||||
self.assertEqual(("base",), migration.down_revisions)
|
||||
self.assertEqual(("core",), migration.depends_on)
|
||||
|
||||
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
|
||||
audit = load_audit_module()
|
||||
migrations = [
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||
|
||||
from govoplan_release.module_directory import ( # noqa: E402
|
||||
module_directory_payloads,
|
||||
safe_path_part,
|
||||
)
|
||||
|
||||
|
||||
class ReleaseModuleDirectoryTests(unittest.TestCase):
|
||||
def test_signed_catalog_timestamp_makes_derived_files_reproducible(self) -> None:
|
||||
catalog = {
|
||||
"generated_at": "2026-07-22T12:00:00Z",
|
||||
"sequence": 7,
|
||||
"modules": [],
|
||||
}
|
||||
first = module_directory_payloads(
|
||||
catalog_payload=catalog,
|
||||
keyring_payload={},
|
||||
channel="stable",
|
||||
)
|
||||
second = module_directory_payloads(
|
||||
catalog_payload=catalog,
|
||||
keyring_payload={},
|
||||
channel="stable",
|
||||
)
|
||||
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(
|
||||
"2026-07-22T12:00:00Z",
|
||||
first[-1][1]["generated_at"],
|
||||
)
|
||||
|
||||
def test_dot_segments_and_noncanonical_ids_never_become_paths(self) -> None:
|
||||
for value in (".", "..", "../escape", "/absolute", "module/child"):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
safe_path_part(value)
|
||||
|
||||
for module_id in ("..", "../escape", "UPPER", "bad.id"):
|
||||
with self.subTest(module_id=module_id), mock.patch(
|
||||
"govoplan_release.module_directory.module_rows",
|
||||
return_value=[
|
||||
{
|
||||
"module_id": module_id,
|
||||
"version": "1.2.3",
|
||||
}
|
||||
],
|
||||
):
|
||||
with self.assertRaises(ValueError):
|
||||
module_directory_payloads(
|
||||
catalog_payload={},
|
||||
keyring_payload={},
|
||||
channel="stable",
|
||||
)
|
||||
|
||||
def test_noncanonical_version_and_channel_fail_before_paths(self) -> None:
|
||||
with mock.patch(
|
||||
"govoplan_release.module_directory.module_rows",
|
||||
return_value=[{"module_id": "demo", "version": "../1.2.3"}],
|
||||
):
|
||||
with self.assertRaises(ValueError):
|
||||
module_directory_payloads(
|
||||
catalog_payload={}, keyring_payload={}, channel="stable"
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
module_directory_payloads(
|
||||
catalog_payload={}, keyring_payload={}, channel="../stable"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,316 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
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 ( # noqa: E402
|
||||
CatalogSnapshot,
|
||||
DashboardSummary,
|
||||
ReleaseDashboard,
|
||||
RepositorySnapshot,
|
||||
RepositorySpec,
|
||||
VersionSnapshot,
|
||||
)
|
||||
from govoplan_release.selective_planner import build_selective_release_plan # noqa: E402
|
||||
|
||||
|
||||
class ReleasePlanGuidanceTests(unittest.TestCase):
|
||||
def test_unprepared_target_gets_bounded_version_and_commit_steps(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
repo_path = workspace / "govoplan-files"
|
||||
repo_path.mkdir()
|
||||
(repo_path / "pyproject.toml").write_text(
|
||||
'[project]\nname = "govoplan-files"\nversion = "1.2.3"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
plan = build_selective_release_plan(
|
||||
dashboard(workspace=workspace, version="1.2.3"),
|
||||
selected_repos=("govoplan-files",),
|
||||
repo_versions={"govoplan-files": "1.2.4"},
|
||||
)
|
||||
|
||||
self.assertEqual("attention", plan.status)
|
||||
self.assertTrue(plan.source_preflight_ready)
|
||||
self.assertFalse(
|
||||
any(
|
||||
item.code == "repository_version_alignment"
|
||||
for item in plan.gate_findings
|
||||
)
|
||||
)
|
||||
steps = {step.id: step for step in plan.dry_run_steps}
|
||||
self.assertEqual("planned", steps["govoplan-files:version"].status)
|
||||
self.assertEqual("planned", steps["govoplan-files:commit"].status)
|
||||
self.assertIn("pyproject.toml", steps["govoplan-files:version"].detail)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"version metadata will be updated" in warning
|
||||
for warning in plan.units[0].warnings
|
||||
)
|
||||
)
|
||||
self.assertIn("python-package", plan.units[0].capabilities)
|
||||
|
||||
def test_aligned_target_recommends_non_mutating_tag_preview(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
repo_path = workspace / "govoplan-files"
|
||||
repo_path.mkdir()
|
||||
(repo_path / "pyproject.toml").write_text(
|
||||
'[project]\nname = "govoplan-files"\nversion = "1.2.4"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
plan = build_selective_release_plan(
|
||||
dashboard(workspace=workspace, version="1.2.4"),
|
||||
selected_repos=("govoplan-files",),
|
||||
repo_versions={"govoplan-files": "1.2.4"},
|
||||
)
|
||||
|
||||
self.assertTrue(plan.source_preflight_ready)
|
||||
self.assertEqual("preview_source_release", plan.recommended_action.id)
|
||||
self.assertIn("Preview Tag + Publish", plan.recommended_action.remediation)
|
||||
|
||||
def test_mixed_plan_tags_modules_before_core_and_aligns_before_push(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
for repo_name in ("govoplan-core", "govoplan-files"):
|
||||
repo_path = workspace / repo_name
|
||||
repo_path.mkdir()
|
||||
(repo_path / "pyproject.toml").write_text(
|
||||
f'[project]\nname = "{repo_name}"\nversion = "1.2.3"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
repositories = tuple(
|
||||
RepositorySnapshot(
|
||||
spec=RepositorySpec(
|
||||
name=repo_name,
|
||||
category="core" if repo_name == "govoplan-core" else "module",
|
||||
subtype="platform" if repo_name == "govoplan-core" else "infrastructure",
|
||||
remote=f"git@example.test:GovOPlaN/{repo_name}.git",
|
||||
path=repo_name,
|
||||
),
|
||||
absolute_path=str(workspace / repo_name),
|
||||
exists=True,
|
||||
is_git=True,
|
||||
has_head=True,
|
||||
branch="main",
|
||||
versions=VersionSnapshot(pyproject="1.2.3"),
|
||||
local_target_tag_exists=False,
|
||||
)
|
||||
for repo_name in ("govoplan-core", "govoplan-files")
|
||||
)
|
||||
base = dashboard(workspace=workspace, version="1.2.3")
|
||||
mixed = ReleaseDashboard(
|
||||
generated_at=base.generated_at,
|
||||
meta_root=base.meta_root,
|
||||
workspace_root=base.workspace_root,
|
||||
target_version=None,
|
||||
target_tag=None,
|
||||
online=False,
|
||||
include_migrations=False,
|
||||
summary=DashboardSummary(
|
||||
repository_count=2,
|
||||
missing_count=0,
|
||||
dirty_count=0,
|
||||
ahead_count=0,
|
||||
behind_count=0,
|
||||
no_head_count=0,
|
||||
error_count=0,
|
||||
safe_directory_count=0,
|
||||
local_target_tag_missing_count=0,
|
||||
status="ready",
|
||||
),
|
||||
repositories=repositories,
|
||||
catalog=base.catalog,
|
||||
)
|
||||
plan = build_selective_release_plan(
|
||||
mixed,
|
||||
selected_repos=("govoplan-core", "govoplan-files"),
|
||||
repo_versions={
|
||||
"govoplan-core": "1.2.3",
|
||||
"govoplan-files": "1.2.3",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["govoplan-files", "govoplan-core"],
|
||||
[unit.repo for unit in plan.units],
|
||||
)
|
||||
step_ids = [step.id for step in plan.dry_run_steps]
|
||||
self.assertLess(
|
||||
step_ids.index("govoplan-files:tag"),
|
||||
step_ids.index("govoplan-core:tag"),
|
||||
)
|
||||
self.assertLess(
|
||||
step_ids.index("govoplan-core:tag"),
|
||||
step_ids.index("release:alignment"),
|
||||
)
|
||||
self.assertLess(
|
||||
step_ids.index("release:alignment"),
|
||||
step_ids.index("govoplan-files:push"),
|
||||
)
|
||||
self.assertEqual("release:install-verify", step_ids[-1])
|
||||
core = next(unit for unit in plan.units if unit.repo == "govoplan-core")
|
||||
self.assertIn("core-release-bundle", core.capabilities)
|
||||
|
||||
def test_malformed_version_metadata_is_a_structured_blocker(self) -> None:
|
||||
cases = (
|
||||
("package.json", "{not-json\n", "JSONDecodeError"),
|
||||
("pyproject.toml", "[project\n", "TOMLDecodeError"),
|
||||
)
|
||||
for relative_path, malformed, error_name in cases:
|
||||
with self.subTest(relative_path=relative_path):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
repo_path = workspace / "govoplan-files"
|
||||
repo_path.mkdir()
|
||||
(repo_path / "pyproject.toml").write_text(
|
||||
'[project]\nname = "govoplan-files"\nversion = "1.2.4"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(repo_path / relative_path).write_text(malformed, encoding="utf-8")
|
||||
|
||||
plan = build_selective_release_plan(
|
||||
dashboard(workspace=workspace, version="1.2.4"),
|
||||
selected_repos=("govoplan-files",),
|
||||
repo_versions={"govoplan-files": "1.2.4"},
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", plan.status)
|
||||
finding = next(
|
||||
item
|
||||
for item in plan.gate_findings
|
||||
if item.code == "repository_version_metadata_unreadable"
|
||||
)
|
||||
self.assertEqual("govoplan-files", finding.repo)
|
||||
self.assertIn(error_name, finding.message)
|
||||
self.assertIn("Repair the malformed TOML or JSON", finding.remediation)
|
||||
self.assertEqual("resolve_release_gate", plan.recommended_action.id)
|
||||
|
||||
def test_malformed_webui_metadata_is_a_structured_blocker(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
repo_path = workspace / "govoplan-files"
|
||||
(repo_path / "webui").mkdir(parents=True)
|
||||
(repo_path / "pyproject.toml").write_text(
|
||||
'[project]\nname = "govoplan-files"\nversion = "1.2.4"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(repo_path / "webui" / "package.json").write_text(
|
||||
"{not-json\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
plan = build_selective_release_plan(
|
||||
dashboard(workspace=workspace, version="1.2.4"),
|
||||
selected_repos=("govoplan-files",),
|
||||
repo_versions={"govoplan-files": "1.2.4"},
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", plan.status)
|
||||
codes = {item.code for item in plan.gate_findings}
|
||||
self.assertIn("repository_version_metadata_unreadable", codes)
|
||||
finding = next(
|
||||
item
|
||||
for item in plan.gate_findings
|
||||
if item.code == "release_webui_metadata_unreadable"
|
||||
)
|
||||
self.assertIn("JSONDecodeError", finding.message)
|
||||
self.assertIn("Repair malformed JSON", finding.remediation)
|
||||
self.assertEqual("resolve_release_gate", plan.recommended_action.id)
|
||||
|
||||
def test_webui_renders_recommendation_and_remediation(self) -> None:
|
||||
webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("plan.gate_findings", webui)
|
||||
self.assertIn("plan.recommended_action", webui)
|
||||
self.assertIn("recommended next", webui)
|
||||
self.assertIn('plan.status === "blocked" ? "block"', webui)
|
||||
self.assertIn('pill("recommended next", recommendationKind)', webui)
|
||||
self.assertIn("<strong>Remediation:</strong>", webui)
|
||||
|
||||
def test_webui_projects_release_state_into_a_guided_workflow(self) -> None:
|
||||
webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
for phase in (
|
||||
"Inspect",
|
||||
"Targets",
|
||||
"Validate",
|
||||
"Source",
|
||||
"Package",
|
||||
"Publish",
|
||||
"Verify",
|
||||
):
|
||||
self.assertIn(f'label: "{phase}"', webui)
|
||||
self.assertIn("releaseWorkflowPhases", webui)
|
||||
self.assertIn("workflowPrimaryAction", webui)
|
||||
self.assertIn("data-run-step-id", webui)
|
||||
self.assertIn("invalidateReleaseDraft", webui)
|
||||
self.assertIn("Installation Verification", webui)
|
||||
self.assertIn("receipt-bound local gate", webui)
|
||||
self.assertIn('step.id === "release:install-verify"', webui)
|
||||
self.assertIn('const inspectionNotices = (summary.missing_count || 0)', webui)
|
||||
self.assertIn('(summary.repository_count || 0) === 0', webui)
|
||||
|
||||
|
||||
def dashboard(*, workspace: Path, version: str) -> ReleaseDashboard:
|
||||
repo = RepositorySnapshot(
|
||||
spec=RepositorySpec(
|
||||
name="govoplan-files",
|
||||
category="module",
|
||||
subtype="infrastructure",
|
||||
remote="git@example.test:GovOPlaN/govoplan-files.git",
|
||||
path="govoplan-files",
|
||||
),
|
||||
absolute_path=str(workspace / "govoplan-files"),
|
||||
exists=True,
|
||||
is_git=True,
|
||||
has_head=True,
|
||||
branch="main",
|
||||
versions=VersionSnapshot(pyproject=version),
|
||||
local_target_tag_exists=False,
|
||||
)
|
||||
return ReleaseDashboard(
|
||||
generated_at="2026-07-22T00:00:00Z",
|
||||
meta_root=str(workspace / "govoplan"),
|
||||
workspace_root=str(workspace),
|
||||
target_version=None,
|
||||
target_tag=None,
|
||||
online=False,
|
||||
include_migrations=False,
|
||||
summary=DashboardSummary(
|
||||
repository_count=1,
|
||||
missing_count=0,
|
||||
dirty_count=0,
|
||||
ahead_count=0,
|
||||
behind_count=0,
|
||||
no_head_count=0,
|
||||
error_count=0,
|
||||
safe_directory_count=0,
|
||||
local_target_tag_missing_count=0,
|
||||
status="ready",
|
||||
),
|
||||
repositories=(repo,),
|
||||
catalog=CatalogSnapshot(
|
||||
channel="stable",
|
||||
website_path="",
|
||||
catalog_path="",
|
||||
catalog_exists=False,
|
||||
keyring_path="",
|
||||
keyring_exists=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -117,9 +118,11 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
||||
|
||||
def test_empty_manifest_workspace_blocks_source_tagging(self) -> None:
|
||||
workspace = self.root / "empty-manifest-workspace"
|
||||
remote_root = self.root / "empty-manifest-remotes"
|
||||
workspace.mkdir()
|
||||
remote_root.mkdir()
|
||||
core, _ = create_release_repo(
|
||||
root=self.root,
|
||||
root=remote_root,
|
||||
workspace=workspace,
|
||||
name="govoplan-core",
|
||||
version="0.1.10",
|
||||
@@ -155,10 +158,86 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
||||
for repository in (self.repo, self.remote, access, access_remote):
|
||||
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
||||
|
||||
def test_api_requires_explicit_confirmation_and_ui_exposes_source_release(self) -> None:
|
||||
with TestClient(create_app(workspace_root=self.workspace)) as client:
|
||||
def test_batch_preflight_blocks_selected_webui_version_missing_from_core_bundle(self) -> None:
|
||||
campaign, campaign_remote = create_release_repo(
|
||||
root=self.root,
|
||||
workspace=self.workspace,
|
||||
name="govoplan-campaign",
|
||||
version="0.1.10",
|
||||
)
|
||||
campaign_webui = campaign / "webui"
|
||||
campaign_webui.mkdir()
|
||||
(campaign_webui / "package.json").write_text(
|
||||
'{"name":"@govoplan/campaign-webui","version":"0.1.10"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
git(campaign, "add", "webui/package.json")
|
||||
git(campaign, "commit", "-m", "Add Campaign WebUI package")
|
||||
git(campaign, "tag", "-a", "v0.1.9", "-m", "Prior Campaign release")
|
||||
git(campaign, "push", "origin", "main", "refs/tags/v0.1.9")
|
||||
campaign_head = git_text(campaign, "rev-parse", "HEAD")
|
||||
|
||||
core_webui = self.repo / "webui"
|
||||
core_webui.mkdir()
|
||||
old_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v0.1.9"
|
||||
(core_webui / "package.release.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.10",
|
||||
"dependencies": {"@govoplan/campaign-webui": old_ref},
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(core_webui / "package-lock.release.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.10",
|
||||
"dependencies": {"@govoplan/campaign-webui": old_ref},
|
||||
},
|
||||
"node_modules/@govoplan/campaign-webui": {
|
||||
"version": "0.1.9",
|
||||
"resolved": f"git+ssh://git@example.test/acme/govoplan-campaign.git#{campaign_head}",
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
git(self.repo, "add", "webui/package.release.json", "webui/package-lock.release.json")
|
||||
git(self.repo, "commit", "-m", "Add release WebUI composition")
|
||||
git(self.repo, "push", "origin", "main")
|
||||
|
||||
result = tag_repositories(
|
||||
repos=("govoplan-core", "govoplan-campaign"),
|
||||
repo_versions={"govoplan-core": "0.1.10", "govoplan-campaign": "0.1.10"},
|
||||
workspace_root=self.workspace,
|
||||
apply=True,
|
||||
push=True,
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", result["status"])
|
||||
self.assertIn("no selected repository was mutated", result["detail"])
|
||||
self.assertEqual("skipped", result["repositories"][0]["status"])
|
||||
self.assertEqual("blocked", result["repositories"][1]["status"])
|
||||
self.assertIn("release WebUI composition gate failed", result["repositories"][1]["detail"])
|
||||
for repository in (self.repo, self.remote, campaign, campaign_remote):
|
||||
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
||||
|
||||
def test_api_keeps_legacy_source_release_preview_only(self) -> None:
|
||||
with TestClient(
|
||||
create_app(workspace_root=self.workspace, token="token")
|
||||
) as client:
|
||||
headers = {"X-Release-Console-Token": "token"}
|
||||
rejected = client.post(
|
||||
"/api/repositories/tag",
|
||||
headers=headers,
|
||||
json={
|
||||
"repos": ["govoplan-core"],
|
||||
"repo_versions": {"govoplan-core": "0.1.10"},
|
||||
@@ -169,6 +248,7 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
||||
)
|
||||
preview = client.post(
|
||||
"/api/repositories/tag",
|
||||
headers=headers,
|
||||
json={
|
||||
"repos": ["govoplan-core"],
|
||||
"repo_versions": {"govoplan-core": "0.1.10"},
|
||||
@@ -176,14 +256,47 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
||||
"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("/")
|
||||
|
||||
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(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.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
||||
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(
|
||||
'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("Apply + Website Tag", ui.text)
|
||||
|
||||
|
||||
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))
|
||||
|
||||
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
|
||||
build_selective_catalog_candidate,
|
||||
enforce_selected_source_provenance,
|
||||
parse_signing_key,
|
||||
public_key_base64,
|
||||
signature,
|
||||
)
|
||||
from govoplan_release.source_provenance import ( # noqa: E402
|
||||
catalog_source_selection,
|
||||
@@ -117,10 +121,20 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
||||
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, "push", "origin", "refs/tags/v1.2.3")
|
||||
base_catalog = self.root / "base.json"
|
||||
base_catalog.write_text(
|
||||
json.dumps(
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
keyring = {
|
||||
"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",
|
||||
"sequence": 1,
|
||||
"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",
|
||||
},
|
||||
"modules": [],
|
||||
"release": {"version": "1.2.2", "tag": "v1.2.2"},
|
||||
"release": {
|
||||
"version": "1.2.2",
|
||||
"tag": "v1.2.2",
|
||||
"keyring_sha256": canonical_hash(keyring),
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
base_payload["signatures"] = [
|
||||
signature(base_payload, key_id="test-key", private_key=private_key)
|
||||
]
|
||||
base_catalog.write_text(json.dumps(base_payload), encoding="utf-8")
|
||||
key_path = self.root / "release.pem"
|
||||
key_path.write_bytes(
|
||||
private_key.private_bytes(
|
||||
@@ -142,6 +160,7 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
key_path.chmod(0o600)
|
||||
output = self.root / "candidate"
|
||||
|
||||
with patch(
|
||||
@@ -153,6 +172,7 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
||||
workspace_root=self.workspace,
|
||||
output_dir=output,
|
||||
base_catalog=base_catalog,
|
||||
base_keyring=base_keyring,
|
||||
signing_keys=(f"test-key={key_path}",),
|
||||
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("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:
|
||||
core, _ = make_repo(self.workspace, self.root, "govoplan-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("Validation and provenance details", ui)
|
||||
|
||||
def test_console_returns_actionable_conflict_for_candidate_provenance_gate(self) -> None:
|
||||
with patch(
|
||||
"server.app.build_selective_catalog_candidate",
|
||||
side_effect=ValueError("Complete catalog source provenance gate failed: govoplan-core v1.2.3: missing"),
|
||||
):
|
||||
with TestClient(create_app(workspace_root=self.workspace)) as client:
|
||||
def test_console_disables_unclaimed_legacy_candidate_signing(self) -> None:
|
||||
with TestClient(
|
||||
create_app(workspace_root=self.workspace, token="token")
|
||||
) as client:
|
||||
response = client.post(
|
||||
"/api/catalog-candidates",
|
||||
headers={"X-Release-Console-Token": "token"},
|
||||
json={
|
||||
"repo_versions": {"govoplan-core": "1.2.3"},
|
||||
"signing_keys": ["test=/unused/key.pem"],
|
||||
@@ -232,7 +270,7 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
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:
|
||||
self.assertEqual(
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE_ROOT = META_ROOT / "tools" / "release"
|
||||
if str(RELEASE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(RELEASE_ROOT))
|
||||
|
||||
from govoplan_release.version_metadata import ( # noqa: E402
|
||||
apply_version_metadata_mutations,
|
||||
version_metadata_mutations,
|
||||
)
|
||||
|
||||
|
||||
class ReleaseVersionMetadataTests(unittest.TestCase):
|
||||
def test_updates_recognized_metadata_without_changing_interface_versions(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
package = root / "src" / "govoplan_example"
|
||||
backend = package / "backend"
|
||||
webui = root / "webui"
|
||||
backend.mkdir(parents=True)
|
||||
webui.mkdir()
|
||||
(root / "pyproject.toml").write_text(
|
||||
'[project]\nname = "govoplan-example"\nversion = "1.2.3"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "package.json").write_text(
|
||||
'{"name":"root","version":"1.2.3"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(webui / "package.json").write_text(
|
||||
'{"name":"@govoplan/example-webui","version":"1.2.3"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(webui / "package-lock.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "@govoplan/example-webui",
|
||||
"version": "1.2.3",
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/example-webui",
|
||||
"version": "1.2.3",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(backend / "manifest.py").write_text(
|
||||
"def get_manifest():\n"
|
||||
" return ModuleManifest(\n"
|
||||
' id="example",\n'
|
||||
' version="1.2.3",\n'
|
||||
" provides_interfaces=(\n"
|
||||
' ModuleInterfaceProvider(name="example.api", version="4.0"),\n'
|
||||
" ),\n"
|
||||
" )\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package / "__init__.py").write_text(
|
||||
'__version__ = "1.2.3"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
preview = version_metadata_mutations(
|
||||
root,
|
||||
target_version="1.2.4",
|
||||
)
|
||||
changed = apply_version_metadata_mutations(
|
||||
root,
|
||||
target_version="1.2.4",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{mutation.path for mutation in preview},
|
||||
set(changed),
|
||||
)
|
||||
self.assertEqual(
|
||||
"1.2.4",
|
||||
json.loads((webui / "package-lock.json").read_text())[
|
||||
"packages"
|
||||
][""]["version"],
|
||||
)
|
||||
manifest = (backend / "manifest.py").read_text(encoding="utf-8")
|
||||
self.assertIn('version="1.2.4"', manifest)
|
||||
self.assertIn('version="4.0"', manifest)
|
||||
self.assertEqual(
|
||||
'__version__ = "1.2.4"\n',
|
||||
(package / "__init__.py").read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,393 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = META_ROOT / "tools" / "repo" / "bootstrap-repositories.py"
|
||||
|
||||
|
||||
def load_bootstrap_module():
|
||||
spec = importlib.util.spec_from_file_location("bootstrap_repositories", SCRIPT)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load {SCRIPT}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class RepositoryBootstrapTests(unittest.TestCase):
|
||||
def test_public_https_transport_rewrites_registered_gitea_remotes(self) -> None:
|
||||
bootstrap = load_bootstrap_module()
|
||||
|
||||
self.assertEqual(
|
||||
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||
bootstrap.clone_remote(
|
||||
"git@git.add-ideas.de:GovOPlaN/govoplan-core.git",
|
||||
transport=bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||
bootstrap.clone_remote(
|
||||
"ssh://git@git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||
transport=bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||
bootstrap.clone_remote(
|
||||
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||
transport=bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||
),
|
||||
)
|
||||
|
||||
def test_registered_transport_preserves_the_manifest_remote(self) -> None:
|
||||
bootstrap = load_bootstrap_module()
|
||||
remote = "git@example.test:private/repository.git"
|
||||
|
||||
self.assertEqual(
|
||||
remote,
|
||||
bootstrap.clone_remote(
|
||||
remote,
|
||||
transport=bootstrap.REGISTERED_TRANSPORT,
|
||||
),
|
||||
)
|
||||
|
||||
def test_public_https_transport_fails_closed_for_other_hosts(self) -> None:
|
||||
bootstrap = load_bootstrap_module()
|
||||
|
||||
unsafe_remotes = (
|
||||
"git@example.test:GovOPlaN/govoplan-core.git",
|
||||
"https://token@git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||
"https://git.add-ideas.de:443/GovOPlaN/govoplan-core.git",
|
||||
"https://git.add-ideas.de/add-ideas/../govoplan-core.git",
|
||||
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git?ref=main",
|
||||
"ssh://root@git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||
)
|
||||
for remote in unsafe_remotes:
|
||||
with self.subTest(remote=remote), self.assertRaises(ValueError):
|
||||
bootstrap.clone_remote(
|
||||
remote,
|
||||
transport=bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||
)
|
||||
|
||||
def test_main_clones_missing_repositories_over_public_https(self) -> None:
|
||||
bootstrap = load_bootstrap_module()
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
||||
root = Path(directory)
|
||||
parent = root / "checkouts"
|
||||
parent.mkdir()
|
||||
root.joinpath("repositories.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_parent": str(parent),
|
||||
"repositories": [
|
||||
{
|
||||
"name": "govoplan-core",
|
||||
"path": "govoplan-core",
|
||||
"remote": (
|
||||
"git@git.add-ideas.de:"
|
||||
"GovOPlaN/govoplan-core.git"
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
patch.object(bootstrap, "ROOT", root),
|
||||
patch.object(bootstrap.subprocess, "run") as runner,
|
||||
):
|
||||
status = bootstrap.main(
|
||||
[
|
||||
"--parent",
|
||||
str(parent),
|
||||
"--transport",
|
||||
bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(0, status)
|
||||
runner.assert_called_once_with(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"clone",
|
||||
"--",
|
||||
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||
str(parent / "govoplan-core"),
|
||||
],
|
||||
check=True,
|
||||
env=runner.call_args.kwargs["env"],
|
||||
)
|
||||
environment = runner.call_args.kwargs["env"]
|
||||
self.assertEqual("/bin/false", environment["GIT_ASKPASS"])
|
||||
self.assertEqual("0", environment["GIT_TERMINAL_PROMPT"])
|
||||
|
||||
def test_main_validates_every_remote_before_cloning(self) -> None:
|
||||
bootstrap = load_bootstrap_module()
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
||||
root = Path(directory)
|
||||
parent = root / "checkouts"
|
||||
parent.mkdir()
|
||||
root.joinpath("repositories.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_parent": str(parent),
|
||||
"repositories": [
|
||||
{
|
||||
"name": "govoplan-core",
|
||||
"path": "govoplan-core",
|
||||
"remote": (
|
||||
"git@git.add-ideas.de:"
|
||||
"GovOPlaN/govoplan-core.git"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "unsafe",
|
||||
"path": "unsafe",
|
||||
"remote": "git@example.test:private/unsafe.git",
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
patch.object(bootstrap, "ROOT", root),
|
||||
patch.object(bootstrap.subprocess, "run") as runner,
|
||||
self.assertRaises(ValueError),
|
||||
):
|
||||
bootstrap.main(
|
||||
[
|
||||
"--parent",
|
||||
str(parent),
|
||||
"--transport",
|
||||
bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||
]
|
||||
)
|
||||
|
||||
runner.assert_not_called()
|
||||
|
||||
def test_main_preserves_an_explicit_private_repository_transport(self) -> None:
|
||||
bootstrap = load_bootstrap_module()
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
||||
root = Path(directory)
|
||||
parent = root / "checkouts"
|
||||
parent.mkdir()
|
||||
root.joinpath("repositories.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_parent": str(parent),
|
||||
"repositories": [
|
||||
{
|
||||
"name": "website",
|
||||
"path": "website",
|
||||
"remote": (
|
||||
"git@git.add-ideas.de:"
|
||||
"add-ideas/addideas-govoplan-website.git"
|
||||
),
|
||||
"bootstrap_transport": (
|
||||
bootstrap.REGISTERED_TRANSPORT
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
patch.object(bootstrap, "ROOT", root),
|
||||
patch.object(bootstrap.subprocess, "run") as runner,
|
||||
):
|
||||
status = bootstrap.main(
|
||||
[
|
||||
"--parent",
|
||||
str(parent),
|
||||
"--transport",
|
||||
bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(0, status)
|
||||
command = runner.call_args.args[0]
|
||||
self.assertEqual(
|
||||
"git@git.add-ideas.de:add-ideas/addideas-govoplan-website.git",
|
||||
command[-2],
|
||||
)
|
||||
|
||||
def test_main_limits_bootstrap_to_selected_repositories(self) -> None:
|
||||
bootstrap = load_bootstrap_module()
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
||||
root = Path(directory)
|
||||
parent = root / "checkouts"
|
||||
parent.mkdir()
|
||||
root.joinpath("repositories.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_parent": str(parent),
|
||||
"repositories": [
|
||||
{
|
||||
"name": name,
|
||||
"path": name,
|
||||
"remote": (
|
||||
"git@git.add-ideas.de:GovOPlaN/"
|
||||
f"{name}.git"
|
||||
),
|
||||
}
|
||||
for name in ("govoplan-core", "govoplan-poll")
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
for repository_filter in (
|
||||
["--repo", "govoplan-core"],
|
||||
["--exclude-repo", "govoplan-poll"],
|
||||
):
|
||||
with (
|
||||
self.subTest(repository_filter=repository_filter),
|
||||
patch.object(bootstrap, "ROOT", root),
|
||||
patch.object(bootstrap.subprocess, "run") as runner,
|
||||
):
|
||||
status = bootstrap.main(
|
||||
[
|
||||
"--parent",
|
||||
str(parent),
|
||||
"--transport",
|
||||
bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||
*repository_filter,
|
||||
]
|
||||
)
|
||||
self.assertEqual(0, status)
|
||||
runner.assert_called_once()
|
||||
self.assertEqual(
|
||||
"https://git.add-ideas.de/GovOPlaN/govoplan-core.git",
|
||||
runner.call_args.args[0][-2],
|
||||
)
|
||||
|
||||
def test_main_rejects_unknown_or_conflicting_repository_filters(self) -> None:
|
||||
bootstrap = load_bootstrap_module()
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
||||
root = Path(directory)
|
||||
parent = root / "checkouts"
|
||||
parent.mkdir()
|
||||
root.joinpath("repositories.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_parent": str(parent),
|
||||
"repositories": [
|
||||
{
|
||||
"name": "govoplan-core",
|
||||
"path": "govoplan-core",
|
||||
"remote": (
|
||||
"git@git.add-ideas.de:"
|
||||
"GovOPlaN/govoplan-core.git"
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
patch.object(bootstrap, "ROOT", root),
|
||||
patch.object(bootstrap.subprocess, "run") as runner,
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "unknown registered"):
|
||||
bootstrap.main(["--repo", "govoplan-missing"])
|
||||
with self.assertRaisesRegex(ValueError, "selected and excluded"):
|
||||
bootstrap.main(
|
||||
[
|
||||
"--repo",
|
||||
"govoplan-core",
|
||||
"--exclude-repo",
|
||||
"govoplan-core",
|
||||
]
|
||||
)
|
||||
|
||||
runner.assert_not_called()
|
||||
|
||||
def test_check_reports_missing_without_cloning(self) -> None:
|
||||
bootstrap = load_bootstrap_module()
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
|
||||
root = Path(directory)
|
||||
parent = root / "checkouts"
|
||||
parent.mkdir()
|
||||
root.joinpath("repositories.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_parent": str(parent),
|
||||
"repositories": [
|
||||
{
|
||||
"name": "govoplan-core",
|
||||
"path": "govoplan-core",
|
||||
"remote": (
|
||||
"git@git.add-ideas.de:"
|
||||
"GovOPlaN/govoplan-core.git"
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
patch.object(bootstrap, "ROOT", root),
|
||||
patch.object(bootstrap.subprocess, "run") as runner,
|
||||
):
|
||||
status = bootstrap.main(
|
||||
[
|
||||
"--check",
|
||||
"--parent",
|
||||
str(parent),
|
||||
"--transport",
|
||||
bootstrap.PUBLIC_HTTPS_TRANSPORT,
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(1, status)
|
||||
runner.assert_not_called()
|
||||
|
||||
def test_anonymous_ci_bootstrap_excludes_registered_transport_repositories(
|
||||
self,
|
||||
) -> None:
|
||||
manifest = json.loads(
|
||||
(META_ROOT / "repositories.json").read_text(encoding="utf-8")
|
||||
)
|
||||
registered_only = {
|
||||
entry["name"]
|
||||
for entry in manifest["repositories"]
|
||||
if entry.get("bootstrap_transport") == "registered"
|
||||
}
|
||||
self.assertTrue(registered_only)
|
||||
|
||||
for workflow in sorted(
|
||||
(META_ROOT / ".gitea" / "workflows").glob("*.yml")
|
||||
):
|
||||
contents = workflow.read_text(encoding="utf-8")
|
||||
if (
|
||||
"bootstrap-repositories.py" not in contents
|
||||
or "--transport public-https" not in contents
|
||||
):
|
||||
continue
|
||||
with self.subTest(workflow=workflow.name):
|
||||
for repository in registered_only:
|
||||
self.assertIn(
|
||||
f"--exclude-repo {repository}",
|
||||
contents,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
RESOLVER = (
|
||||
META_ROOT / "tools" / "checks" / "security-audit" / "resolve_workspace_mount.py"
|
||||
)
|
||||
|
||||
|
||||
def load_resolver_module():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"security_audit_mount_resolver",
|
||||
RESOLVER,
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load {RESOLVER}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class SecurityAuditMountResolverTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.resolver = load_resolver_module()
|
||||
self.root = "/workspace/GovOPlaN/govoplan/govoplan"
|
||||
self.workspace = "/workspace/GovOPlaN/govoplan"
|
||||
|
||||
def test_resolves_only_the_named_workspace_volume_for_both_scopes(self) -> None:
|
||||
mounts = [
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/var/run/docker.sock",
|
||||
"Destination": "/var/run/docker.sock",
|
||||
"RW": True,
|
||||
},
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "actions-workspace",
|
||||
"Source": "/var/lib/docker/volumes/actions-workspace/_data",
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
},
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "actions-environment",
|
||||
"Source": "/var/lib/docker/volumes/actions-environment/_data",
|
||||
"Destination": "/var/run/act",
|
||||
"RW": True,
|
||||
},
|
||||
]
|
||||
|
||||
for scope in ("current", "govoplan"):
|
||||
with self.subTest(scope=scope):
|
||||
self.assertEqual(
|
||||
(f"type=volume,source=actions-workspace,target={self.workspace}"),
|
||||
self.resolver.resolve_workspace_mount(
|
||||
mounts,
|
||||
root=self.root,
|
||||
scope=scope,
|
||||
reports_dir="audit-reports",
|
||||
),
|
||||
)
|
||||
|
||||
def test_resolves_a_workspace_bind_without_other_mounts(self) -> None:
|
||||
mounts = [
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/srv/gitea/actions/task-123",
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
},
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/var/run/docker.sock",
|
||||
"Destination": "/var/run/docker.sock",
|
||||
"RW": True,
|
||||
},
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
(f"type=bind,source=/srv/gitea/actions/task-123,target={self.workspace}"),
|
||||
self.resolver.resolve_workspace_mount(
|
||||
mounts,
|
||||
root=self.root,
|
||||
scope="govoplan",
|
||||
reports_dir="audit-reports",
|
||||
),
|
||||
)
|
||||
|
||||
def test_nested_repository_mount_is_valid_only_for_current_scope(self) -> None:
|
||||
mounts = [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "all-repositories",
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
},
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "current-repository",
|
||||
"Destination": self.root,
|
||||
"RW": True,
|
||||
},
|
||||
]
|
||||
|
||||
self.assertIn(
|
||||
"source=current-repository",
|
||||
self.resolver.resolve_workspace_mount(
|
||||
mounts,
|
||||
root=self.root,
|
||||
scope="current",
|
||||
reports_dir="audit-reports",
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
self.resolver.MountResolutionError,
|
||||
"nested job-container mounts",
|
||||
):
|
||||
self.resolver.resolve_workspace_mount(
|
||||
mounts,
|
||||
root=self.root,
|
||||
scope="govoplan",
|
||||
reports_dir="audit-reports",
|
||||
)
|
||||
|
||||
def test_rejects_unsafe_or_unusable_mount_layouts(self) -> None:
|
||||
cases = {
|
||||
"missing": [],
|
||||
"path-boundary": [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "wrong-workspace",
|
||||
"Destination": "/workspace/GovOPlaN/govoplan-other",
|
||||
"RW": True,
|
||||
}
|
||||
],
|
||||
"read-only": [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "actions-workspace",
|
||||
"Destination": self.workspace,
|
||||
"RW": False,
|
||||
}
|
||||
],
|
||||
"ambiguous": [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": name,
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
}
|
||||
for name in ("workspace-one", "workspace-two")
|
||||
],
|
||||
"scope-too-narrow": [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "repository-only",
|
||||
"Destination": self.root,
|
||||
"RW": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
for name, mounts in cases.items():
|
||||
with (
|
||||
self.subTest(name=name),
|
||||
self.assertRaises(self.resolver.MountResolutionError),
|
||||
):
|
||||
self.resolver.resolve_workspace_mount(
|
||||
mounts,
|
||||
root=self.root,
|
||||
scope="govoplan",
|
||||
reports_dir="audit-reports",
|
||||
)
|
||||
|
||||
def test_rejects_reports_outside_the_selected_workspace_mount(self) -> None:
|
||||
mounts = [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "actions-workspace",
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
}
|
||||
]
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
self.resolver.MountResolutionError,
|
||||
"reports path .* outside",
|
||||
):
|
||||
self.resolver.resolve_workspace_mount(
|
||||
mounts,
|
||||
root=self.root,
|
||||
scope="current",
|
||||
reports_dir="/tmp/audit-reports",
|
||||
)
|
||||
|
||||
def test_rejects_broad_or_sensitive_workspace_bind_sources(self) -> None:
|
||||
for source in (
|
||||
"/",
|
||||
"/home",
|
||||
"/var/run/docker.sock",
|
||||
"/srv/../etc/shadow",
|
||||
):
|
||||
with (
|
||||
self.subTest(source=source),
|
||||
self.assertRaisesRegex(
|
||||
self.resolver.MountResolutionError,
|
||||
"too broad or sensitive",
|
||||
),
|
||||
):
|
||||
self.resolver.resolve_workspace_mount(
|
||||
[
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": source,
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
}
|
||||
],
|
||||
root=self.root,
|
||||
scope="govoplan",
|
||||
reports_dir="audit-reports",
|
||||
)
|
||||
|
||||
with self.assertRaises(self.resolver.MountResolutionError):
|
||||
self.resolver.resolve_workspace_mount(
|
||||
[
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "//var/lib/workspace",
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
}
|
||||
],
|
||||
root=self.root,
|
||||
scope="govoplan",
|
||||
reports_dir="audit-reports",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,6 +12,7 @@ import unittest
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
AUDIT_SCRIPT = META_ROOT / "tools" / "checks" / "check-security-audit.sh"
|
||||
CONTAINER_RUNNER = META_ROOT / "tools" / "checks" / "security-audit" / "run.sh"
|
||||
|
||||
|
||||
class SecurityAuditWrapperTests(unittest.TestCase):
|
||||
@@ -252,6 +253,12 @@ class SecurityAuditWrapperTests(unittest.TestCase):
|
||||
self.assertEqual(0, manifest["overall_status"])
|
||||
self.assertEqual(1, manifest["finding_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(
|
||||
"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"])
|
||||
|
||||
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:
|
||||
result, reports = self._run(
|
||||
"--report-only",
|
||||
@@ -354,5 +441,226 @@ class SecurityAuditWrapperTests(unittest.TestCase):
|
||||
self.assertEqual(set(manifest["reports"]), checksummed_paths)
|
||||
|
||||
|
||||
class SecurityAuditContainerRunnerTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._temporary_directory = tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-audit-runner-"
|
||||
)
|
||||
root = Path(self._temporary_directory.name)
|
||||
self.stub_bin = root / "bin"
|
||||
self.stub_bin.mkdir()
|
||||
self.docker_log = root / "docker.jsonl"
|
||||
docker_stub = self.stub_bin / "docker"
|
||||
docker_stub.write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
arguments = sys.argv[1:]
|
||||
with Path(os.environ["DOCKER_STUB_LOG"]).open(
|
||||
"a", encoding="utf-8"
|
||||
) as handle:
|
||||
handle.write(json.dumps(arguments) + "\\n")
|
||||
if arguments and arguments[0] == "version":
|
||||
print("26.1.0")
|
||||
if arguments[:2] == ["container", "inspect"]:
|
||||
print(os.environ["DOCKER_STUB_MOUNTS"])
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
docker_stub.chmod(0o755)
|
||||
self.environment = os.environ.copy()
|
||||
self.environment.update(
|
||||
{
|
||||
"DOCKER_STUB_LOG": str(self.docker_log),
|
||||
"DOCKER_STUB_MOUNTS": "[]",
|
||||
"PATH": f"{self.stub_bin}:{self.environment['PATH']}",
|
||||
}
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._temporary_directory.cleanup()
|
||||
|
||||
def _run(
|
||||
self,
|
||||
*,
|
||||
scope: str,
|
||||
actions_mounts: object | None = None,
|
||||
**environment_overrides: str,
|
||||
) -> tuple[subprocess.CompletedProcess[str], list[list[str]]]:
|
||||
self.docker_log.write_text("", encoding="utf-8")
|
||||
environment = self.environment.copy()
|
||||
environment.update(environment_overrides)
|
||||
if actions_mounts is None:
|
||||
environment.pop("GITEA_ACTIONS", None)
|
||||
else:
|
||||
environment["GITEA_ACTIONS"] = "true"
|
||||
environment["DOCKER_STUB_MOUNTS"] = json.dumps(actions_mounts)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(CONTAINER_RUNNER),
|
||||
"--mode",
|
||||
"quick",
|
||||
"--scope",
|
||||
scope,
|
||||
],
|
||||
cwd=META_ROOT,
|
||||
env=environment,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
commands = [
|
||||
json.loads(line)
|
||||
for line in self.docker_log.read_text(encoding="utf-8").splitlines()
|
||||
]
|
||||
return result, commands
|
||||
|
||||
def test_gitea_job_shares_only_its_workspace_mount(self) -> None:
|
||||
mounts = [
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/var/run/docker.sock",
|
||||
"Destination": "/var/run/docker.sock",
|
||||
"RW": True,
|
||||
},
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "govoplan-actions-workspace",
|
||||
"Destination": str(META_ROOT.parent),
|
||||
"RW": True,
|
||||
},
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "govoplan-actions-environment",
|
||||
"Destination": "/var/run/act",
|
||||
"RW": True,
|
||||
},
|
||||
]
|
||||
container_id = subprocess.run(
|
||||
["hostname"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
for scope in ("current", "govoplan"):
|
||||
with self.subTest(scope=scope):
|
||||
result, commands = self._run(scope=scope, actions_mounts=mounts)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
run_command = next(
|
||||
command for command in commands if command[0] == "run"
|
||||
)
|
||||
inspect_command = next(
|
||||
command
|
||||
for command in commands
|
||||
if command[:2] == ["container", "inspect"]
|
||||
)
|
||||
self.assertEqual(container_id, inspect_command[-1])
|
||||
mount_index = run_command.index("--mount")
|
||||
self.assertEqual(
|
||||
(
|
||||
"type=volume,source=govoplan-actions-workspace,"
|
||||
f"target={META_ROOT.parent}"
|
||||
),
|
||||
run_command[mount_index + 1],
|
||||
)
|
||||
self.assertNotIn("--volumes-from", run_command)
|
||||
self.assertNotIn("-v", run_command)
|
||||
self.assertNotIn("/var/run/docker.sock", " ".join(run_command))
|
||||
self.assertNotIn("/var/run/act", " ".join(run_command))
|
||||
repository_roots = [
|
||||
value
|
||||
for value in run_command
|
||||
if value.startswith("GOVOPLAN_REPOS_ROOT=")
|
||||
]
|
||||
expected_roots = (
|
||||
[f"GOVOPLAN_REPOS_ROOT={META_ROOT.parent}"]
|
||||
if scope == "govoplan"
|
||||
else []
|
||||
)
|
||||
self.assertEqual(expected_roots, repository_roots)
|
||||
self.assertIn("SECURITY_AUDIT_REQUIRE_TOOLS=1", run_command)
|
||||
|
||||
def test_gitea_job_passes_only_public_git_url_rewrites(self) -> None:
|
||||
mounts = [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "govoplan-actions-workspace",
|
||||
"Destination": str(META_ROOT.parent),
|
||||
"RW": True,
|
||||
}
|
||||
]
|
||||
git_config = {
|
||||
"GIT_CONFIG_COUNT": "2",
|
||||
"GIT_CONFIG_KEY_0": (
|
||||
"url.https://git.add-ideas.de/GovOPlaN/govoplan.insteadOf"
|
||||
),
|
||||
"GIT_CONFIG_VALUE_0": "git@git.add-ideas.de:GovOPlaN/govoplan",
|
||||
"GIT_CONFIG_KEY_1": (
|
||||
"url.https://git.add-ideas.de/GovOPlaN/govoplan.insteadOf"
|
||||
),
|
||||
"GIT_CONFIG_VALUE_1": (
|
||||
"ssh://git@git.add-ideas.de/GovOPlaN/govoplan"
|
||||
),
|
||||
}
|
||||
|
||||
result, commands = self._run(
|
||||
scope="govoplan",
|
||||
actions_mounts=mounts,
|
||||
**git_config,
|
||||
)
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
run_command = next(command for command in commands if command[0] == "run")
|
||||
for key, value in git_config.items():
|
||||
self.assertIn(f"{key}={value}", run_command)
|
||||
|
||||
def test_container_runner_rejects_non_url_git_configuration(self) -> None:
|
||||
result, _commands = self._run(
|
||||
scope="current",
|
||||
GIT_CONFIG_COUNT="1",
|
||||
GIT_CONFIG_KEY_0="credential.helper",
|
||||
GIT_CONFIG_VALUE_0="!print-secret",
|
||||
)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn(
|
||||
"Only credential-free HTTPS Git insteadOf rewrites",
|
||||
result.stderr,
|
||||
)
|
||||
|
||||
def test_security_workflow_configures_nested_public_git_resolution(
|
||||
self,
|
||||
) -> None:
|
||||
workflow = (
|
||||
META_ROOT / ".gitea" / "workflows" / "security-audit.yml"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn('GIT_CONFIG_COUNT: "2"', workflow)
|
||||
self.assertIn(
|
||||
"url.https://git.add-ideas.de/GovOPlaN/govoplan.insteadOf",
|
||||
workflow,
|
||||
)
|
||||
|
||||
def test_non_actions_runner_keeps_the_scoped_bind_mount(self) -> None:
|
||||
result, commands = self._run(scope="govoplan")
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
run_command = next(command for command in commands if command[0] == "run")
|
||||
bind_index = run_command.index("-v")
|
||||
self.assertEqual(
|
||||
f"{META_ROOT.parent}:/workspace",
|
||||
run_command[bind_index + 1],
|
||||
)
|
||||
self.assertNotIn("--mount", run_command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -6,6 +6,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -16,11 +17,20 @@ if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||
from govoplan_release.version_alignment import ( # noqa: E402
|
||||
candidate_catalog_version_issues,
|
||||
release_composition_issues,
|
||||
selected_release_webui_bundle_issues,
|
||||
repository_version_issues,
|
||||
selected_repository_version_issues,
|
||||
)
|
||||
from govoplan_release.model import RepositorySnapshot, RepositorySpec, VersionSnapshot # noqa: E402
|
||||
from govoplan_release.selective_planner import build_unit # noqa: E402
|
||||
from govoplan_release.git_state import sanitized_git_environment # noqa: E402
|
||||
from govoplan_release.model import ( # noqa: E402
|
||||
CatalogSnapshot,
|
||||
DashboardSummary,
|
||||
ReleaseDashboard,
|
||||
RepositorySnapshot,
|
||||
RepositorySpec,
|
||||
VersionSnapshot,
|
||||
)
|
||||
from govoplan_release.selective_planner import build_selective_release_plan, build_unit # noqa: E402
|
||||
from govoplan_release.selective_catalog import enforce_selected_version_alignment # noqa: E402
|
||||
|
||||
|
||||
@@ -82,6 +92,176 @@ class VersionAlignmentTests(unittest.TestCase):
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
def test_selected_webui_release_must_match_core_bundle_input_and_lock(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
workspace = Path(tmp)
|
||||
core_webui = workspace / "govoplan-core" / "webui"
|
||||
campaign_webui = workspace / "govoplan-campaign" / "webui"
|
||||
core_webui.mkdir(parents=True)
|
||||
campaign_webui.mkdir(parents=True)
|
||||
dependency = "@govoplan/campaign-webui"
|
||||
old_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v1.2.2"
|
||||
(campaign_webui / "package.json").write_text(
|
||||
json.dumps({"name": dependency, "version": "1.2.3"})
|
||||
)
|
||||
(core_webui / "package.release.json").write_text(
|
||||
json.dumps({"dependencies": {dependency: old_ref}})
|
||||
)
|
||||
(core_webui / "package-lock.release.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"packages": {
|
||||
"": {"dependencies": {dependency: old_ref}},
|
||||
f"node_modules/{dependency}": {"version": "1.2.2"},
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
issues = selected_release_webui_bundle_issues(
|
||||
repo_versions={"govoplan-core": "2.0.0", "govoplan-campaign": "1.2.3"},
|
||||
workspace=workspace,
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "Core release WebUI dependency"):
|
||||
enforce_selected_version_alignment(
|
||||
repo_versions={"govoplan-campaign": "1.2.3"},
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
self.assertEqual(2, len(issues))
|
||||
self.assertTrue(all(issue.repo == "govoplan-campaign" for issue in issues))
|
||||
self.assertEqual(
|
||||
{
|
||||
"Core release WebUI dependency must match the selected module version",
|
||||
"Core release lock must resolve the selected module version",
|
||||
},
|
||||
{issue.message for issue in issues},
|
||||
)
|
||||
|
||||
def test_selected_webui_gate_ignores_unselected_module_pins(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
workspace = Path(tmp)
|
||||
core_webui = workspace / "govoplan-core" / "webui"
|
||||
campaign_webui = workspace / "govoplan-campaign" / "webui"
|
||||
core_webui.mkdir(parents=True)
|
||||
campaign_webui.mkdir(parents=True)
|
||||
campaign_package = "@govoplan/campaign-webui"
|
||||
files_package = "@govoplan/files-webui"
|
||||
campaign_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v1.2.3"
|
||||
files_ref = "git+ssh://git@example.test/acme/govoplan-files.git#v0.9.0"
|
||||
(campaign_webui / "package.json").write_text(
|
||||
json.dumps({"name": campaign_package, "version": "1.2.3"})
|
||||
)
|
||||
dependencies = {campaign_package: campaign_ref, files_package: files_ref}
|
||||
(core_webui / "package.release.json").write_text(json.dumps({"dependencies": dependencies}))
|
||||
(core_webui / "package-lock.release.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"packages": {
|
||||
"": {"dependencies": dependencies},
|
||||
f"node_modules/{campaign_package}": {"version": "1.2.3"},
|
||||
f"node_modules/{files_package}": {"version": "0.9.0"},
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
issues = selected_release_webui_bundle_issues(
|
||||
repo_versions={"govoplan-campaign": "1.2.3"},
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
self.assertEqual((), issues)
|
||||
|
||||
def test_selective_plan_blocks_mismatched_selected_webui_bundle(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
workspace = Path(tmp)
|
||||
core_webui = workspace / "govoplan-core" / "webui"
|
||||
campaign_webui = workspace / "govoplan-campaign" / "webui"
|
||||
core_webui.mkdir(parents=True)
|
||||
campaign_webui.mkdir(parents=True)
|
||||
package_name = "@govoplan/campaign-webui"
|
||||
old_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v1.2.2"
|
||||
(campaign_webui / "package.json").write_text(
|
||||
json.dumps({"name": package_name, "version": "1.2.3"})
|
||||
)
|
||||
(core_webui / "package.release.json").write_text(
|
||||
json.dumps({"dependencies": {package_name: old_ref}})
|
||||
)
|
||||
(core_webui / "package-lock.release.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"packages": {
|
||||
"": {"dependencies": {package_name: old_ref}},
|
||||
f"node_modules/{package_name}": {"version": "1.2.2"},
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
campaign = RepositorySnapshot(
|
||||
spec=RepositorySpec(
|
||||
name="govoplan-campaign",
|
||||
category="module",
|
||||
subtype="domain",
|
||||
remote="git@example.test:acme/govoplan-campaign.git",
|
||||
path="govoplan-campaign",
|
||||
),
|
||||
absolute_path=str(workspace / "govoplan-campaign"),
|
||||
exists=True,
|
||||
is_git=True,
|
||||
has_head=True,
|
||||
branch="main",
|
||||
versions=VersionSnapshot(pyproject="1.2.3", webui_package="1.2.3"),
|
||||
)
|
||||
dashboard = ReleaseDashboard(
|
||||
generated_at="2026-01-01T00:00:00Z",
|
||||
meta_root=str(workspace / "govoplan"),
|
||||
workspace_root=str(workspace),
|
||||
target_version=None,
|
||||
target_tag=None,
|
||||
online=False,
|
||||
include_migrations=False,
|
||||
summary=DashboardSummary(
|
||||
repository_count=1,
|
||||
missing_count=0,
|
||||
dirty_count=0,
|
||||
ahead_count=0,
|
||||
behind_count=0,
|
||||
no_head_count=0,
|
||||
error_count=0,
|
||||
safe_directory_count=0,
|
||||
local_target_tag_missing_count=0,
|
||||
status="ready",
|
||||
),
|
||||
repositories=(campaign,),
|
||||
catalog=CatalogSnapshot(
|
||||
channel="stable",
|
||||
website_path="",
|
||||
catalog_path="",
|
||||
catalog_exists=False,
|
||||
keyring_path="",
|
||||
keyring_exists=False,
|
||||
),
|
||||
)
|
||||
|
||||
plan = build_selective_release_plan(
|
||||
dashboard,
|
||||
selected_repos=("govoplan-campaign",),
|
||||
repo_versions={"govoplan-campaign": "1.2.3"},
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", plan.status)
|
||||
self.assertEqual("blocked", plan.units[0].status)
|
||||
self.assertTrue(any("Core release WebUI dependency" in item for item in plan.units[0].blockers))
|
||||
finding = next(
|
||||
item
|
||||
for item in plan.gate_findings
|
||||
if item.code == "release_webui_composition_alignment"
|
||||
)
|
||||
self.assertIn("package.release.json", finding.source)
|
||||
self.assertIn("regenerate webui/package-lock.release.json", finding.remediation)
|
||||
self.assertEqual("resolve_release_gate", plan.recommended_action.id)
|
||||
|
||||
def test_selected_repository_gate_reports_missing_version_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
workspace = Path(tmp)
|
||||
@@ -220,6 +400,68 @@ class VersionAlignmentTests(unittest.TestCase):
|
||||
)
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
Executable
+451
@@ -0,0 +1,451 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Re-run a capability-fit assessment against a trusted release catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[2]
|
||||
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 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
|
||||
|
||||
|
||||
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:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate a fit assessment and identify conclusions needing review after release drift."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--assessment",
|
||||
type=Path,
|
||||
default=META_ROOT / "docs" / "capability-fit-current.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--schema",
|
||||
type=Path,
|
||||
default=META_ROOT / "docs" / "capability-fit.schema.json",
|
||||
)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument(
|
||||
"--public",
|
||||
action="store_true",
|
||||
help="Use the fixed public GovOPlaN stable catalog and keyring URLs.",
|
||||
)
|
||||
source.add_argument(
|
||||
"--catalog", type=Path, help="Read a catalog from a local file."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keyring",
|
||||
type=Path,
|
||||
help="Published or candidate keyring whose hash is pinned by --catalog.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trusted-keyring",
|
||||
type=Path,
|
||||
help=(
|
||||
"Independently pinned local keyring used as the catalog signature trust "
|
||||
f"root; defaults to ${TRUSTED_KEYRING_FILE_ENV}."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workspace-root",
|
||||
type=Path,
|
||||
default=META_ROOT.parent,
|
||||
help="Workspace containing repository checkouts for local tag provenance.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-tag-provenance",
|
||||
action="store_true",
|
||||
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(
|
||||
"--json", action="store_true", help="Print the machine-readable review report."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
help="Also write the machine-readable review report to this path.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
if args.catalog is not None and args.keyring is None:
|
||||
raise SystemExit("--keyring is required with --catalog")
|
||||
if args.public and args.keyring is not None:
|
||||
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()
|
||||
trusted_keyring_path = args.trusted_keyring or (
|
||||
Path(configured_trusted_keyring) if configured_trusted_keyring else None
|
||||
)
|
||||
if trusted_keyring_path is None:
|
||||
raise SystemExit(
|
||||
f"--trusted-keyring or ${TRUSTED_KEYRING_FILE_ENV} is required"
|
||||
)
|
||||
assessment = read_object(
|
||||
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:
|
||||
catalog = fetch_public_json(
|
||||
f"{DEFAULT_PUBLIC_BASE_URL}/catalogs/v1/channels/stable.json",
|
||||
label="catalog",
|
||||
)
|
||||
published_keyring = fetch_public_json(
|
||||
f"{DEFAULT_PUBLIC_BASE_URL}/catalogs/v1/keyring.json",
|
||||
label="published keyring",
|
||||
)
|
||||
else:
|
||||
catalog = read_object(
|
||||
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_path,
|
||||
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(
|
||||
assessment=assessment,
|
||||
schema=schema,
|
||||
catalog=catalog,
|
||||
published_keyring=published_keyring,
|
||||
trusted_keyring=trusted_keyring,
|
||||
workspace_root=None
|
||||
if args.skip_tag_provenance
|
||||
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"
|
||||
if args.output is not None:
|
||||
write_object(
|
||||
args.output,
|
||||
report,
|
||||
max_bytes=MAX_REPORT_OUTPUT_BYTES,
|
||||
label="review report",
|
||||
)
|
||||
sys.stdout.write(encoded if args.json else render_review(report))
|
||||
return {"current": 0, "blocked": 1, "review_required": 2}[report["status"]]
|
||||
|
||||
|
||||
def read_object(path: Path | None, *, label: str, max_bytes: int) -> dict[str, object]:
|
||||
if path is None:
|
||||
raise SystemExit(f"Missing {label} path")
|
||||
try:
|
||||
if path.stat().st_size > max_bytes:
|
||||
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
|
||||
if not isinstance(payload, dict):
|
||||
raise SystemExit(f"{label.capitalize()} must be a JSON object: {path}")
|
||||
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]:
|
||||
result = fetch_json(url)
|
||||
if result.get("ok") is not True or not isinstance(result.get("payload"), dict):
|
||||
raise SystemExit(f"Could not fetch fixed public {label} endpoint.")
|
||||
return result["payload"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Repeatable GovOPlaN product-assessment tooling."""
|
||||
|
||||
from .capability_fit import review_capability_fit, render_review
|
||||
from .evidence import collect_installed_composition
|
||||
|
||||
__all__ = (
|
||||
"collect_installed_composition",
|
||||
"render_review",
|
||||
"review_capability_fit",
|
||||
)
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
"""Issuance of role-scoped receipts from same-process installed observations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from .capability_fit import canonical_hash
|
||||
from .evidence import (
|
||||
MAX_LOCAL_OBSERVATION_AGE,
|
||||
OPAQUE_ID_PATTERN,
|
||||
_catalog_artifact_identities,
|
||||
_record_integrity_semantics_valid,
|
||||
canonical_bytes,
|
||||
canonical_sha256,
|
||||
normalize_package_name,
|
||||
)
|
||||
from govoplan_release.artifact_identity import inspect_python_wheel
|
||||
|
||||
|
||||
def issue_installer_receipt(
|
||||
*,
|
||||
assessment: Mapping[str, Any],
|
||||
catalog: Mapping[str, Any],
|
||||
installed_evidence: dict[str, Any],
|
||||
receipt_id: str,
|
||||
key_id: str,
|
||||
private_key: Ed25519PrivateKey,
|
||||
consumed_artifacts: Mapping[str, Path | str] | None = None,
|
||||
issued_at: datetime | None = None,
|
||||
same_process_observation: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Sign a receipt only for a fresh observation collected by this process.
|
||||
|
||||
File-based evidence is intentionally not an admitted issuance input. The CLI
|
||||
collector calls this function directly with its in-memory observation.
|
||||
"""
|
||||
|
||||
if same_process_observation is not True:
|
||||
raise ValueError("installer receipts require a same-process observation")
|
||||
if OPAQUE_ID_PATTERN.fullmatch(receipt_id) is None:
|
||||
raise ValueError("receipt ID must be a bounded opaque ID")
|
||||
if OPAQUE_ID_PATTERN.fullmatch(key_id) is None:
|
||||
raise ValueError("installer signing key ID must be a bounded opaque ID")
|
||||
if not isinstance(consumed_artifacts, Mapping) or not consumed_artifacts:
|
||||
raise ValueError("installer receipt issuance requires exact consumed wheels")
|
||||
release = catalog.get("release")
|
||||
raw_artifacts = release.get("artifacts") if isinstance(release, Mapping) else None
|
||||
catalog_artifacts, artifact_findings = _catalog_artifact_identities(raw_artifacts)
|
||||
if artifact_findings:
|
||||
raise ValueError("signed catalog artifact manifest is invalid")
|
||||
evidence_rows = installed_evidence.get("artifacts")
|
||||
if not isinstance(evidence_rows, list) or not evidence_rows:
|
||||
raise ValueError("installed evidence has no artifact observations")
|
||||
expected_packages = _expected_packages(assessment=assessment, catalog=catalog)
|
||||
receipt_rows: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for artifact in evidence_rows:
|
||||
if not isinstance(artifact, dict):
|
||||
raise ValueError("installed evidence contains a malformed artifact")
|
||||
package_name = normalize_package_name(str(artifact.get("package_name") or ""))
|
||||
package_version = str(artifact.get("package_version") or "")
|
||||
if package_name in seen:
|
||||
raise ValueError("installed evidence contains duplicate packages")
|
||||
seen.add(package_name)
|
||||
catalog_artifact = catalog_artifacts.get(package_name)
|
||||
record = artifact.get("record_integrity")
|
||||
installed_payload = (
|
||||
record.get("installed_payload_identity")
|
||||
if isinstance(record, Mapping)
|
||||
else None
|
||||
)
|
||||
if (
|
||||
catalog_artifact is None
|
||||
or catalog_artifact.get("package_version") != package_version
|
||||
or not isinstance(record, Mapping)
|
||||
or record.get("status") != "verified"
|
||||
or not _record_integrity_semantics_valid(record)
|
||||
or not isinstance(installed_payload, Mapping)
|
||||
):
|
||||
raise ValueError(
|
||||
"installed payload is not a verified match for a catalog artifact"
|
||||
)
|
||||
artifact_path = consumed_artifacts.get(package_name)
|
||||
if artifact_path is None:
|
||||
raise ValueError("every installed package requires its exact consumed wheel")
|
||||
consumed = inspect_python_wheel(artifact_path)
|
||||
if (
|
||||
consumed.package_name != package_name
|
||||
or consumed.package_version != package_version
|
||||
or consumed.archive_sha256 != catalog_artifact.get("archive_sha256")
|
||||
or consumed.catalog_payload().get("installed_payload")
|
||||
!= catalog_artifact.get("installed_payload")
|
||||
or record.get("artifact_payload_identity")
|
||||
!= catalog_artifact.get("installed_payload")
|
||||
):
|
||||
raise ValueError(
|
||||
"consumed wheel or observed wheel payload differs from the signed catalog identity"
|
||||
)
|
||||
receipt_rows.append(
|
||||
{
|
||||
"package_name": package_name,
|
||||
"package_version": package_version,
|
||||
"catalog_archive_sha256": catalog_artifact["archive_sha256"],
|
||||
"installed_payload": dict(installed_payload),
|
||||
}
|
||||
)
|
||||
if seen != expected_packages:
|
||||
raise ValueError(
|
||||
"installed evidence must contain exactly the enabled assessed catalog packages"
|
||||
)
|
||||
if set(consumed_artifacts) != expected_packages:
|
||||
raise ValueError("consumed wheel set must exactly match enabled packages")
|
||||
receipt_rows.sort(key=lambda item: (item["package_name"], item["package_version"]))
|
||||
observed_at = issued_at or datetime.now(tz=UTC)
|
||||
if observed_at.tzinfo is None:
|
||||
raise ValueError("installer receipt issuance time must include a timezone")
|
||||
collected_at = _datetime(installed_evidence.get("collected_at"))
|
||||
if (
|
||||
collected_at is None
|
||||
or observed_at < collected_at
|
||||
or observed_at - collected_at > MAX_LOCAL_OBSERVATION_AGE
|
||||
):
|
||||
raise ValueError(
|
||||
"installer receipt issuance must follow live collection within five minutes"
|
||||
)
|
||||
assessment_release = assessment.get("release")
|
||||
assessment_release_ref = (
|
||||
assessment_release.get("ref")
|
||||
if isinstance(assessment_release, Mapping)
|
||||
else None
|
||||
)
|
||||
receipt: dict[str, Any] = {
|
||||
"$schema": "./installer-receipt.schema.json",
|
||||
"schema_version": "0.1.0",
|
||||
"evidence_kind": "govoplan.installer-receipt",
|
||||
"receipt_id": receipt_id,
|
||||
"assessment_id": assessment.get("assessment_id"),
|
||||
"assessment_release": assessment_release_ref,
|
||||
"installed_evidence_sha256": canonical_sha256(installed_evidence),
|
||||
"catalog": {
|
||||
"channel": catalog.get("channel"),
|
||||
"sequence": catalog.get("sequence"),
|
||||
"sha256": canonical_hash(catalog),
|
||||
},
|
||||
"issued_at": observed_at.astimezone(UTC).isoformat().replace("+00:00", "Z"),
|
||||
"artifacts": receipt_rows,
|
||||
}
|
||||
receipt["signatures"] = [
|
||||
{
|
||||
"algorithm": "ed25519",
|
||||
"key_id": key_id,
|
||||
"value": base64.b64encode(
|
||||
private_key.sign(canonical_bytes(receipt))
|
||||
).decode("ascii"),
|
||||
}
|
||||
]
|
||||
return receipt
|
||||
|
||||
|
||||
def _expected_packages(
|
||||
*, assessment: Mapping[str, Any], catalog: Mapping[str, Any]
|
||||
) -> set[str]:
|
||||
entries: dict[str, Mapping[str, Any]] = {}
|
||||
core = catalog.get("core_release")
|
||||
if isinstance(core, Mapping):
|
||||
entries["core"] = core
|
||||
modules = catalog.get("modules")
|
||||
if isinstance(modules, list):
|
||||
for entry in modules:
|
||||
if isinstance(entry, Mapping) and isinstance(entry.get("module_id"), str):
|
||||
entries[str(entry["module_id"])] = entry
|
||||
expected: set[str] = set()
|
||||
composition = assessment.get("composition")
|
||||
if not isinstance(composition, list):
|
||||
raise ValueError("assessment composition is unavailable")
|
||||
for component in composition:
|
||||
if not isinstance(component, Mapping) or component.get("enabled") is not True:
|
||||
continue
|
||||
entry = entries.get(str(component.get("module_id") or ""))
|
||||
package = entry.get("python_package") if isinstance(entry, Mapping) else None
|
||||
if not isinstance(package, str):
|
||||
raise ValueError("enabled assessment component has no catalog package")
|
||||
normalized = normalize_package_name(package)
|
||||
if normalized in expected:
|
||||
raise ValueError("enabled assessment packages are ambiguous")
|
||||
expected.add(normalized)
|
||||
if not expected:
|
||||
raise ValueError("assessment has no enabled catalog packages")
|
||||
return expected
|
||||
|
||||
|
||||
def _datetime(value: object) -> datetime | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed.astimezone(UTC) if parsed.tzinfo is not None else None
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect live installed payloads and issue an independently signed receipt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[2]
|
||||
for tools_root in (META_ROOT / "tools" / "assessments", META_ROOT / "tools" / "release"):
|
||||
if str(tools_root) not in sys.path:
|
||||
sys.path.insert(0, str(tools_root))
|
||||
|
||||
from cryptography.hazmat.primitives import serialization # noqa: E402
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import ( # noqa: E402
|
||||
Ed25519PrivateKey,
|
||||
)
|
||||
|
||||
from govoplan_assessment import collect_installed_composition # noqa: E402
|
||||
from govoplan_assessment.atomic_io import ( # noqa: E402
|
||||
AtomicJsonWriteError,
|
||||
atomic_write_json,
|
||||
)
|
||||
from govoplan_assessment.capability_fit import ( # noqa: E402
|
||||
canonical_hash,
|
||||
review_capability_fit,
|
||||
)
|
||||
from govoplan_assessment.evidence import validate_payload # noqa: E402
|
||||
from govoplan_assessment.installer_receipt import ( # noqa: E402
|
||||
issue_installer_receipt,
|
||||
)
|
||||
|
||||
|
||||
MAX_INPUT_BYTES = 16 * 1024 * 1024
|
||||
MAX_OUTPUT_BYTES = 4 * 1024 * 1024
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--assessment", type=Path, required=True)
|
||||
parser.add_argument("--assessment-schema", type=Path, default=META_ROOT / "docs" / "capability-fit.schema.json")
|
||||
parser.add_argument("--catalog", type=Path, required=True)
|
||||
parser.add_argument("--keyring", type=Path, required=True)
|
||||
parser.add_argument("--trusted-keyring", type=Path, required=True)
|
||||
parser.add_argument("--receipt-id", required=True)
|
||||
parser.add_argument("--signing-key", required=True, metavar="KEY_ID=/PATH/PRIVATE.pem")
|
||||
parser.add_argument(
|
||||
"--python-artifact",
|
||||
action="append",
|
||||
default=[],
|
||||
required=True,
|
||||
metavar="PACKAGE=/PATH/TO/CONSUMED.whl",
|
||||
help="Exact wheel consumed by this installer; repeat for every package.",
|
||||
)
|
||||
parser.add_argument("--evidence-output", type=Path, required=True)
|
||||
parser.add_argument("--receipt-output", type=Path, required=True)
|
||||
parser.add_argument("--installed-evidence-schema", type=Path, default=META_ROOT / "docs" / "installed-composition-evidence.schema.json")
|
||||
parser.add_argument("--receipt-schema", type=Path, default=META_ROOT / "docs" / "installer-receipt.schema.json")
|
||||
args = parser.parse_args(argv)
|
||||
if args.evidence_output.resolve() == args.receipt_output.resolve():
|
||||
parser.error("evidence and receipt output paths must differ")
|
||||
|
||||
assessment = read_object(args.assessment, label="assessment")
|
||||
assessment_schema = read_object(args.assessment_schema, label="assessment schema")
|
||||
catalog = read_object(args.catalog, label="catalog")
|
||||
keyring = read_object(args.keyring, label="published keyring")
|
||||
trusted_keyring = read_object(args.trusted_keyring, label="trusted keyring")
|
||||
evidence_schema = read_object(args.installed_evidence_schema, label="installed evidence schema")
|
||||
receipt_schema = read_object(args.receipt_schema, label="installer receipt schema")
|
||||
release = catalog.get("release")
|
||||
if not isinstance(release, dict) or release.get("keyring_sha256") != canonical_hash(keyring):
|
||||
parser.error("catalog does not pin the supplied published keyring")
|
||||
|
||||
evidence = collect_installed_composition(assessment=assessment)
|
||||
if validate_payload(payload=evidence, schema=evidence_schema):
|
||||
parser.error("live installed observation does not satisfy its bounded schema")
|
||||
report = review_capability_fit(
|
||||
assessment=assessment,
|
||||
schema=assessment_schema,
|
||||
catalog=catalog,
|
||||
published_keyring=keyring,
|
||||
trusted_keyring=trusted_keyring,
|
||||
workspace_root=None,
|
||||
installed_evidence=evidence,
|
||||
installed_evidence_schema=evidence_schema,
|
||||
installed_evidence_mode="direct_local",
|
||||
)
|
||||
required_scopes = (
|
||||
"catalog_signature_and_keyring",
|
||||
"catalog_signature_and_trusted_keyring",
|
||||
"published_keyring_hash",
|
||||
"release_metadata",
|
||||
"installed_artifacts",
|
||||
"installed_record_integrity",
|
||||
)
|
||||
if any(report["proof_scope"].get(scope, {}).get("valid") is not True for scope in required_scopes):
|
||||
parser.error("trusted catalog and live installed-composition checks must pass before receipt issuance")
|
||||
|
||||
key_id, private_key = read_signing_key(args.signing_key)
|
||||
consumed_artifacts = parse_package_paths(tuple(args.python_artifact))
|
||||
receipt = issue_installer_receipt(
|
||||
assessment=assessment,
|
||||
catalog=catalog,
|
||||
installed_evidence=evidence,
|
||||
receipt_id=args.receipt_id,
|
||||
key_id=key_id,
|
||||
private_key=private_key,
|
||||
consumed_artifacts=consumed_artifacts,
|
||||
same_process_observation=True,
|
||||
)
|
||||
if validate_payload(payload=receipt, schema=receipt_schema):
|
||||
parser.error("issued installer receipt does not satisfy its bounded schema")
|
||||
write_object(args.evidence_output, evidence, label="installed evidence")
|
||||
write_object(args.receipt_output, receipt, label="installer receipt")
|
||||
print(json.dumps({"receipt_id": args.receipt_id, "evidence_output": str(args.evidence_output), "receipt_output": str(args.receipt_output)}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
def read_object(path: Path, *, label: str) -> dict[str, object]:
|
||||
try:
|
||||
if path.stat().st_size > MAX_INPUT_BYTES:
|
||||
raise ValueError("input exceeds size limit")
|
||||
encoded = path.read_bytes()
|
||||
payload = json.loads(encoded.decode("utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
||||
raise SystemExit(f"Could not read {label}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise SystemExit(f"{label.capitalize()} must be a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
def read_signing_key(value: str) -> tuple[str, Ed25519PrivateKey]:
|
||||
key_id, separator, path_text = value.partition("=")
|
||||
if not separator or not key_id or not path_text:
|
||||
raise SystemExit("--signing-key must use KEY_ID=/path/to/private.pem")
|
||||
try:
|
||||
key = serialization.load_pem_private_key(Path(path_text).expanduser().read_bytes(), password=None)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise SystemExit("Could not read installer signing key") from exc
|
||||
if not isinstance(key, Ed25519PrivateKey):
|
||||
raise SystemExit("Installer signing key must be Ed25519")
|
||||
return key_id, key
|
||||
|
||||
|
||||
def parse_package_paths(values: tuple[str, ...]) -> dict[str, Path]:
|
||||
result: dict[str, Path] = {}
|
||||
for value in values:
|
||||
package, separator, path_text = value.partition("=")
|
||||
package = package.strip()
|
||||
path_text = path_text.strip()
|
||||
if not separator or not package or not path_text or package in result:
|
||||
raise SystemExit(
|
||||
"--python-artifact must uniquely use PACKAGE=/path/to/consumed.whl"
|
||||
)
|
||||
result[package] = Path(path_text).expanduser()
|
||||
return result
|
||||
|
||||
|
||||
def write_object(path: Path, payload: dict[str, object], *, label: str) -> None:
|
||||
try:
|
||||
atomic_write_json(path, payload, max_bytes=MAX_OUTPUT_BYTES)
|
||||
except AtomicJsonWriteError as exc:
|
||||
raise SystemExit(f"Could not securely write {label}") from exc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,328 @@
|
||||
#!/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_engine",
|
||||
"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())
|
||||
@@ -24,11 +24,28 @@ export NPM_CONFIG_USERCONFIG="$NPM_USERCONFIG"
|
||||
export GOVOPLAN_NPM_USERCONFIG="$NPM_USERCONFIG"
|
||||
unset npm_config_tmp NPM_CONFIG_TMP
|
||||
|
||||
# Validate the current sibling checkouts even when a newly added module has not
|
||||
# yet been installed into an existing development virtualenv.
|
||||
SOURCE_PYTHONPATH=""
|
||||
for source_dir in "$META_ROOT"/../govoplan*/src; do
|
||||
[ -d "$source_dir" ] || continue
|
||||
SOURCE_PYTHONPATH="${SOURCE_PYTHONPATH:+$SOURCE_PYTHONPATH:}$source_dir"
|
||||
done
|
||||
export PYTHONPATH="${SOURCE_PYTHONPATH}${PYTHONPATH:+:$PYTHONPATH}"
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
GOVOPLAN_CORE_ROOT="$ROOT" PYTHON="$PYTHON" CHECK_TESTCLIENT_DEPRECATIONS=1 bash "$META_ROOT/tools/checks/check-dependency-hygiene.sh"
|
||||
"$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" --require-architecture
|
||||
|
||||
cd "$META_ROOT"
|
||||
"$PYTHON" -m unittest tests.test_deployment_installer
|
||||
"$PYTHON" -m unittest tests.test_capability_fit_evidence
|
||||
"$PYTHON" -m unittest tests.test_configuration_package_artifacts
|
||||
"$PYTHON" -m unittest tests.test_institutional_governance_journey
|
||||
"$PYTHON" -m unittest tests.test_institutional_service_journey
|
||||
cd "$ROOT"
|
||||
|
||||
"$PYTHON" - <<'PY'
|
||||
import ast
|
||||
@@ -69,6 +86,25 @@ 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")'
|
||||
"$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
||||
"$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-engine/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" -m unittest discover -s /mnt/DATA/git/govoplan-portal/tests
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-forms/tests
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-forms-runtime/tests
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-cases/tests
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-committee/tests
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-voting/tests
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-approvals/tests
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-identity-trust/tests
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-encryption/tests
|
||||
"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-campaign/tests/test_approval_gate.py
|
||||
"$PYTHON" "$META_ROOT/tools/checks/check-datasource-composition.py"
|
||||
"$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
|
||||
|
||||
@@ -77,6 +113,21 @@ cd "$ROOT/webui"
|
||||
"$NPM" run test:module-capabilities
|
||||
"$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
|
||||
"$NPM" run test:mail-ui
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ META_ROOT = Path(__file__).resolve().parents[2]
|
||||
MODULE_NAME_PATTERN = re.compile(
|
||||
r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
|
||||
)
|
||||
REQUIRED_DOCUMENTATION_TYPES = frozenset({"admin", "user"})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -26,6 +27,14 @@ def main() -> int:
|
||||
default=None,
|
||||
help="Directory containing the GovOPlaN repositories. Defaults to repositories.json default_parent.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--require-architecture",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Reject every module that has not adopted the versioned architecture "
|
||||
"declaration. GovOPlaN release and focused checks enable this gate."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
catalog = json.loads((META_ROOT / "repositories.json").read_text(encoding="utf-8"))
|
||||
@@ -90,6 +99,36 @@ def main() -> int:
|
||||
)
|
||||
continue
|
||||
|
||||
documented_types = {
|
||||
documentation_type
|
||||
for topic in manifest.documentation
|
||||
for documentation_type in topic.documentation_types
|
||||
}
|
||||
missing_documentation_types = sorted(REQUIRED_DOCUMENTATION_TYPES - documented_types)
|
||||
if missing_documentation_types:
|
||||
errors.append(
|
||||
f"{repository_name}: module {manifest.id!r} is missing static documentation for "
|
||||
f"{', '.join(missing_documentation_types)}; add manifest DocumentationTopic entries "
|
||||
"even when runtime documentation providers are registered"
|
||||
)
|
||||
continue
|
||||
|
||||
repository_root = manifest_path.parents[3]
|
||||
if manifest.architecture is None:
|
||||
if args.require_architecture:
|
||||
errors.append(
|
||||
f"{repository_name}: module {manifest.id!r} has no architecture declaration"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
errors.extend(
|
||||
_architecture_evidence_errors(
|
||||
repository_name=repository_name,
|
||||
repository_root=repository_root,
|
||||
manifest=manifest,
|
||||
)
|
||||
)
|
||||
|
||||
manifests.append(manifest)
|
||||
|
||||
if errors:
|
||||
@@ -109,6 +148,11 @@ def main() -> int:
|
||||
f"Manifest registry check passed: {len(snapshot.manifests)} manifests "
|
||||
f"from {len(manifest_sources)} source files."
|
||||
)
|
||||
declared = sum(manifest.architecture is not None for manifest in manifests)
|
||||
print(
|
||||
f"Architecture declaration coverage: {declared}/{len(manifests)} modules "
|
||||
f"({(declared / len(manifests) * 100):.1f}%)."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -120,5 +164,53 @@ def _module_entry_points(pyproject_path: Path) -> dict[str, str]:
|
||||
return {str(name): str(target) for name, target in values.items()}
|
||||
|
||||
|
||||
def _architecture_evidence_errors(
|
||||
*,
|
||||
repository_name: str,
|
||||
repository_root: Path,
|
||||
manifest: object,
|
||||
) -> list[str]:
|
||||
architecture = getattr(manifest, "architecture", None)
|
||||
if architecture is None:
|
||||
return []
|
||||
errors: list[str] = []
|
||||
references = [
|
||||
(f"{item.kind} evidence", item.reference)
|
||||
for item in architecture.evidence
|
||||
]
|
||||
for category in ("migration", "upgrade", "recovery", "security", "operations"):
|
||||
references.extend(
|
||||
(f"{category} documentation", reference)
|
||||
for reference in getattr(architecture.documentation, category)
|
||||
)
|
||||
for label, reference in references:
|
||||
if not _looks_like_repository_reference(reference):
|
||||
continue
|
||||
candidate = (repository_root / reference).resolve()
|
||||
try:
|
||||
candidate.relative_to(repository_root.resolve())
|
||||
except ValueError:
|
||||
errors.append(
|
||||
f"{repository_name}: {label} escapes the repository: {reference!r}"
|
||||
)
|
||||
continue
|
||||
if not candidate.exists():
|
||||
errors.append(
|
||||
f"{repository_name}: {label} does not exist: {reference!r}"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _looks_like_repository_reference(reference: str) -> bool:
|
||||
normalized = reference.strip()
|
||||
if not normalized or "://" in normalized:
|
||||
return False
|
||||
return (
|
||||
"/" in normalized
|
||||
or normalized.startswith("README")
|
||||
or normalized.endswith((".md", ".py", ".json", ".yaml", ".yml"))
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -19,9 +19,19 @@ fi
|
||||
|
||||
cd "$ROOT"
|
||||
"$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"
|
||||
|
||||
cd "$META_ROOT"
|
||||
"$PYTHON" -m unittest tests.test_configuration_package_artifacts
|
||||
PYTHONPATH="$META_ROOT/../govoplan-portal/src:$META_ROOT/../govoplan-forms/src:$META_ROOT/../govoplan-forms-runtime/src:$META_ROOT/../govoplan-cases/src:$ROOT/src${PYTHONPATH:+:$PYTHONPATH}" \
|
||||
"$PYTHON" -m unittest tests.test_institutional_service_journey
|
||||
PYTHONPATH="$META_ROOT/../govoplan-portal/src:$META_ROOT/../govoplan-cases/src:$META_ROOT/../govoplan-committee/src:$META_ROOT/../govoplan-services/src:$META_ROOT/../govoplan-parties/src:$META_ROOT/../govoplan-mandates/src:$META_ROOT/../govoplan-decisions/src:$ROOT/src${PYTHONPATH:+:$PYTHONPATH}" \
|
||||
"$PYTHON" -m unittest tests.test_institutional_governance_journey
|
||||
|
||||
cd "$ROOT/webui"
|
||||
PATH="$ROOT/webui/node_modules/.bin:$NODE_BIN:$PATH" "$NPM" run test:module-capabilities
|
||||
PATH="$ROOT/webui/node_modules/.bin:$NODE_BIN:$PATH" "$NPM" run test:module-permutations
|
||||
|
||||
@@ -60,24 +60,6 @@ retry() {
|
||||
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() {
|
||||
local webui_dir="$1"
|
||||
if [[ ! -f "$webui_dir/package.json" ]]; then
|
||||
@@ -244,7 +226,7 @@ PY
|
||||
run_step "Clone release module test sources"
|
||||
for repo in govoplan-mail govoplan-calendar govoplan-campaign; do
|
||||
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
|
||||
|
||||
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"
|
||||
|
||||
run_step "Run cloned module backend tests"
|
||||
run_discovered_tests "$WORK_ROOT/govoplan-mail/tests"
|
||||
run_discovered_tests "$WORK_ROOT/govoplan-calendar/tests"
|
||||
run_discovered_tests "$WORK_ROOT/govoplan-campaign/tests"
|
||||
for repo in govoplan-mail govoplan-calendar govoplan-campaign; do
|
||||
"$PYTHON" "$META_ROOT/tools/checks/release_integration.py" module-tests \
|
||||
--module-root "$WORK_ROOT/$repo"
|
||||
done
|
||||
|
||||
run_step "Run core WebUI release tests and build"
|
||||
cd "$ROOT/webui"
|
||||
|
||||
@@ -87,6 +87,37 @@ for flag_name in FAIL_ON_FINDINGS REQUIRE_TOOLS; do
|
||||
fi
|
||||
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=()
|
||||
if [[ "$SCOPE" == "govoplan" ]]; then
|
||||
REPOS_ROOT="${GOVOPLAN_REPOS_ROOT:-$(dirname "$ROOT")}"
|
||||
@@ -138,12 +169,14 @@ overall_status=0
|
||||
finding_status=0
|
||||
missing_status=0
|
||||
execution_status=0
|
||||
coverage_complete=true
|
||||
audit_started_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||
audit_completed_at=""
|
||||
workspace_unchanged="unknown"
|
||||
audit_toolbox_fingerprint="${SECURITY_AUDIT_TOOLBOX_FINGERPRINT:-direct-host}"
|
||||
declare -A REPORT_FILES=()
|
||||
declare -A MACHINE_REPORTS=()
|
||||
declare -A SCANNER_TERMINAL_STATUSES=()
|
||||
|
||||
register_report() {
|
||||
local path="$1"
|
||||
@@ -169,7 +202,9 @@ prepare_machine_report() {
|
||||
fi
|
||||
}
|
||||
|
||||
run_step() {
|
||||
run_step_internal() {
|
||||
local scanner_id="$1"
|
||||
shift
|
||||
local name="$1"
|
||||
local exit_contract="$2"
|
||||
shift 2
|
||||
@@ -178,10 +213,12 @@ run_step() {
|
||||
"$@"
|
||||
local status=$?
|
||||
local outcome="passed"
|
||||
local scanner_outcome="no-findings"
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
:
|
||||
elif [[ "$exit_contract" == "findings-exit-one" && "$status" -eq 1 ]]; then
|
||||
outcome="findings"
|
||||
scanner_outcome="findings"
|
||||
echo "Audit step reported findings: $name (exit $status)" >&2
|
||||
finding_status=1
|
||||
if [[ "$FAIL_ON_FINDINGS" == "1" ]]; then
|
||||
@@ -189,21 +226,77 @@ run_step() {
|
||||
fi
|
||||
else
|
||||
outcome="execution-error"
|
||||
scanner_outcome="scanner-failure"
|
||||
echo "Audit step failed to execute successfully: $name (exit $status)" >&2
|
||||
execution_status=1
|
||||
overall_status=1
|
||||
fi
|
||||
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() {
|
||||
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
|
||||
missing_status=1
|
||||
coverage_complete=false
|
||||
SCANNER_TERMINAL_STATUSES["$scanner_id"]="skipped"
|
||||
if [[ "$REQUIRE_TOOLS" == "1" ]]; then
|
||||
overall_status=1
|
||||
fi
|
||||
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() {
|
||||
@@ -229,7 +322,10 @@ write_manifest() {
|
||||
"$finding_status" \
|
||||
"$execution_status" \
|
||||
"$missing_status" \
|
||||
"$coverage_complete" \
|
||||
"$audit_toolbox_fingerprint" \
|
||||
"${#REQUIRED_SCANNERS[@]}" \
|
||||
"${REQUIRED_SCANNERS[@]}" \
|
||||
"${#REPOS[@]}" \
|
||||
"${REPOS[@]}" \
|
||||
"${#present_reports[@]}" \
|
||||
@@ -254,16 +350,51 @@ import sys
|
||||
finding_status,
|
||||
execution_status,
|
||||
missing_status,
|
||||
coverage_complete,
|
||||
toolbox_fingerprint,
|
||||
repository_count,
|
||||
*remaining,
|
||||
) = 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)
|
||||
repositories = remaining[:repo_count]
|
||||
remaining = remaining[repo_count:]
|
||||
report_count = int(remaining[0])
|
||||
reports = remaining[1 : 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 = {
|
||||
"mode": mode,
|
||||
"scope": scope,
|
||||
@@ -276,6 +407,12 @@ payload = {
|
||||
"finding_status": int(finding_status),
|
||||
"execution_error_status": int(execution_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",
|
||||
"workspace_state_start": "workspace-state-start.tsv",
|
||||
"workspace_state_end": "workspace-state-end.tsv",
|
||||
@@ -350,7 +487,10 @@ write_tool_versions() {
|
||||
: > "$destination"
|
||||
local tool
|
||||
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
|
||||
version="$("$tool" version 2>&1)"
|
||||
else
|
||||
@@ -496,7 +636,12 @@ run_bandit() {
|
||||
fi
|
||||
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
|
||||
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=$?
|
||||
[[ "$test_status" -le 1 ]] || status=2
|
||||
fi
|
||||
@@ -512,7 +657,11 @@ run_ruff_security() {
|
||||
fi
|
||||
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
|
||||
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=$?
|
||||
[[ "$test_status" -le 1 ]] || status=2
|
||||
fi
|
||||
@@ -582,14 +731,39 @@ run_trivy() {
|
||||
run_pip_audit_manifests() {
|
||||
local status=0
|
||||
for repo in "${REPOS[@]}"; do
|
||||
[[ -f "$repo/requirements.txt" ]] || continue
|
||||
local name
|
||||
name="$(safe_name "$repo")"
|
||||
prepare_machine_report "$REPORTS_DIR/pip-audit-$name.json" || return 2
|
||||
local repo_name
|
||||
repo_name="$(safe_name "$repo")"
|
||||
while IFS= read -r -d '' requirements_file; do
|
||||
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]`.
|
||||
# Resolve them from the owning repository instead of the meta-repository.
|
||||
(cd "$repo" && pip-audit -r requirements.txt --progress-spinner off --format json --output "$REPORTS_DIR/pip-audit-$name.json")
|
||||
# Resolve them from the directory containing the manifest.
|
||||
(
|
||||
cd "$(dirname "$requirements_file")" &&
|
||||
pip-audit \
|
||||
-r "$(basename "$requirements_file")" \
|
||||
--progress-spinner off \
|
||||
--format json \
|
||||
--output "$report_path"
|
||||
)
|
||||
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
|
||||
return "$status"
|
||||
}
|
||||
@@ -597,12 +771,44 @@ run_pip_audit_manifests() {
|
||||
run_npm_audit_manifests() {
|
||||
local status=0
|
||||
for repo in "${REPOS[@]}"; do
|
||||
[[ -f "$repo/webui/package-lock.json" ]] || continue
|
||||
local name
|
||||
name="$(safe_name "$repo")"
|
||||
prepare_machine_report "$REPORTS_DIR/npm-audit-$name.json" || return 2
|
||||
(cd "$repo/webui" && npm audit --omit=dev --json > "$REPORTS_DIR/npm-audit-$name.json")
|
||||
local repo_name
|
||||
repo_name="$(safe_name "$repo")"
|
||||
while IFS= read -r -d '' lock_file; do
|
||||
local lock_dir lock_name runtime_report all_report
|
||||
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 "$?"
|
||||
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
|
||||
return "$status"
|
||||
}
|
||||
@@ -665,6 +871,19 @@ run_jscpd() {
|
||||
--output "$REPORTS_DIR/jscpd" \
|
||||
--ignore "$ignored_path_pattern" \
|
||||
"${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() {
|
||||
@@ -680,10 +899,12 @@ run_xenon() {
|
||||
}
|
||||
|
||||
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/workspace-state-start.tsv"
|
||||
register_report "$REPORTS_DIR/workspace-state-end.tsv"
|
||||
: > "$REPORTS_DIR/step-status.tsv"
|
||||
: > "$REPORTS_DIR/scanner-status.tsv"
|
||||
if ! write_workspace_state "$REPORTS_DIR/workspace-state-start.tsv"; then
|
||||
echo "Could not capture the repository state before the audit." >&2
|
||||
exit 1
|
||||
@@ -691,75 +912,76 @@ fi
|
||||
run_step "Record audit tool versions" execution-only write_tool_versions
|
||||
|
||||
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
|
||||
skip_or_fail_missing semgrep
|
||||
skip_or_fail_missing semgrep semgrep
|
||||
fi
|
||||
|
||||
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
|
||||
skip_or_fail_missing bandit
|
||||
skip_or_fail_missing bandit bandit
|
||||
fi
|
||||
|
||||
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
|
||||
skip_or_fail_missing ruff
|
||||
skip_or_fail_missing ruff-security ruff
|
||||
fi
|
||||
|
||||
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
|
||||
skip_or_fail_missing gitleaks
|
||||
skip_or_fail_missing gitleaks gitleaks
|
||||
fi
|
||||
|
||||
if [[ "$MODE" != "quick" ]]; 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
|
||||
skip_or_fail_missing trivy
|
||||
skip_or_fail_missing trivy trivy
|
||||
fi
|
||||
|
||||
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
|
||||
skip_or_fail_missing pip-audit
|
||||
skip_or_fail_missing pip-audit pip-audit
|
||||
fi
|
||||
|
||||
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
|
||||
skip_or_fail_missing npm
|
||||
skip_or_fail_missing npm-audit npm
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "full" ]]; 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
|
||||
skip_or_fail_missing osv-scanner
|
||||
skip_or_fail_missing osv-scanner osv-scanner
|
||||
fi
|
||||
|
||||
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
|
||||
skip_or_fail_missing jscpd
|
||||
skip_or_fail_missing jscpd jscpd
|
||||
fi
|
||||
|
||||
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
|
||||
skip_or_fail_missing radon
|
||||
skip_or_fail_missing radon radon
|
||||
fi
|
||||
|
||||
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
|
||||
skip_or_fail_missing xenon
|
||||
skip_or_fail_missing xenon xenon
|
||||
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
|
||||
|
||||
echo
|
||||
|
||||
@@ -109,9 +109,36 @@ class AllowlistedImport:
|
||||
reason: str
|
||||
|
||||
|
||||
# Transitional exceptions. This should remain empty; add entries only with a
|
||||
# dated removal plan and a capability/API contract issue.
|
||||
ALLOWLIST: tuple[AllowlistedImport, ...] = ()
|
||||
# Transitional exceptions. This should remain empty outside explicit,
|
||||
# time-bounded compatibility facades with a tracked removal issue.
|
||||
_WORKFLOW_ENGINE_COMPATIBILITY_FACADES = (
|
||||
"backend/bpmn.py",
|
||||
"backend/bpmn_adapters.py",
|
||||
"backend/bpmn_graph.py",
|
||||
"backend/db/__init__.py",
|
||||
"backend/db/models.py",
|
||||
"backend/governance.py",
|
||||
"backend/instance_service.py",
|
||||
"backend/manifest.py",
|
||||
"backend/node_library.py",
|
||||
"backend/router.py",
|
||||
"backend/runtime.py",
|
||||
"backend/schemas.py",
|
||||
"backend/service.py",
|
||||
"backend/validation.py",
|
||||
)
|
||||
ALLOWLIST: tuple[AllowlistedImport, ...] = tuple(
|
||||
AllowlistedImport(
|
||||
owner="govoplan-workflow",
|
||||
relative_path=relative_path,
|
||||
imported_owner="govoplan-workflow-engine",
|
||||
reason=(
|
||||
"One-release Engine extraction facade; remove for 0.2 under "
|
||||
"govoplan-workflow#14 and govoplan-core#40."
|
||||
),
|
||||
)
|
||||
for relative_path in _WORKFLOW_ENGINE_COMPATIBILITY_FACADES
|
||||
)
|
||||
ALLOWLIST_KEYS = {(item.owner, item.relative_path, item.imported_owner) for item in ALLOWLIST}
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import sys
|
||||
import tempfile
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
@@ -65,6 +66,11 @@ def main() -> int:
|
||||
parser.add_argument("--runtime-root", type=Path, help="Directory for temporary drill runtimes. Defaults to a new temp directory.")
|
||||
parser.add_argument("--keep-runtime", action="store_true", help="Keep the temporary drill runtime after completion.")
|
||||
parser.add_argument("--format", choices=("json", "text"), default="text", help="Output format.")
|
||||
parser.add_argument(
|
||||
"--evidence-path",
|
||||
type=Path,
|
||||
help="Write an atomic machine-readable drill result for the Ops health surface.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
runtime_root = args.runtime_root or Path(tempfile.mkdtemp(prefix="govoplan-installer-drill-"))
|
||||
@@ -88,7 +94,14 @@ def main() -> int:
|
||||
if not args.keep_runtime and args.runtime_root is None:
|
||||
shutil.rmtree(runtime_root, ignore_errors=True)
|
||||
|
||||
payload = {"ok": ok, "runtime_root": str(runtime_root), "scenarios": results}
|
||||
payload = {
|
||||
"ok": ok,
|
||||
"completed_at": datetime.now(tz=UTC).isoformat(),
|
||||
"runtime_root": str(runtime_root),
|
||||
"scenarios": results,
|
||||
}
|
||||
if args.evidence_path:
|
||||
_write_evidence(args.evidence_path, payload)
|
||||
if args.format == "json":
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
else:
|
||||
@@ -105,6 +118,18 @@ def main() -> int:
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def _write_evidence(path: Path, payload: dict[str, object]) -> None:
|
||||
path = path.expanduser()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def package_failure(root: Path) -> dict[str, object]:
|
||||
database_url, database = _sqlite_database(root)
|
||||
plan = _saved_install_plan(database, "package-failure-example")
|
||||
|
||||
@@ -47,6 +47,14 @@ def main() -> int:
|
||||
)
|
||||
parser.add_argument("--skip-migrate", action="store_true", help="Skip govoplan_core.commands.init_db.")
|
||||
parser.add_argument("--skip-smoke", action="store_true", help="Skip govoplan_core.devserver --smoke.")
|
||||
parser.add_argument(
|
||||
"--skip-retirement-atomicity",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Skip the isolated destructive-retirement atomicity proof. "
|
||||
"Do not use this option for a release gate."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--python", default=sys.executable, help="Python executable to use. Defaults to the current interpreter.")
|
||||
parser.add_argument("--app-env", default="staging", help="APP_ENV value to pass to subprocesses. Defaults to staging.")
|
||||
args = parser.parse_args()
|
||||
@@ -59,8 +67,21 @@ def main() -> int:
|
||||
|
||||
module_sets = _parse_module_sets(args.module_set)
|
||||
ok = True
|
||||
if not args.skip_retirement_atomicity:
|
||||
print("\n== destructive retirement atomicity ==", flush=True)
|
||||
retirement_env = _check_env(
|
||||
database_url=database_url,
|
||||
modules="tenancy,access,admin,audit,files",
|
||||
app_env=args.app_env,
|
||||
)
|
||||
retirement_env["GOVOPLAN_POSTGRES_DATABASE_URL"] = database_url
|
||||
retirement_env["GOVOPLAN_REQUIRE_POSTGRES_RETIREMENT_PROOF"] = "1"
|
||||
ok = _run(
|
||||
[args.python, "-m", "unittest", "-v", "tests.test_postgres_retirement_atomicity"],
|
||||
env=retirement_env,
|
||||
) and ok
|
||||
for name, modules in module_sets:
|
||||
print(f"\n== {name}: {modules} ==")
|
||||
print(f"\n== {name}: {modules} ==", flush=True)
|
||||
if args.reset_schema:
|
||||
_reset_public_schema(database_url)
|
||||
env = _check_env(database_url=database_url, modules=modules, app_env=args.app_env)
|
||||
@@ -95,12 +116,16 @@ def _check_env(*, database_url: str, modules: str, app_env: str) -> dict[str, st
|
||||
env.setdefault("FILE_STORAGE_BACKEND", "local")
|
||||
env.setdefault("FILE_STORAGE_LOCAL_ROOT", str(ROOT / "runtime" / "postgres-check-files"))
|
||||
env.setdefault("MOCK_MAILBOX_DIR", str(ROOT / "runtime" / "postgres-check-mock-mailbox"))
|
||||
env.setdefault("CORS_ORIGINS", "http://127.0.0.1:5173,http://localhost:5173")
|
||||
env.setdefault("GOVOPLAN_TRUSTED_HOSTS", "127.0.0.1,localhost,testserver")
|
||||
env.setdefault("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS", "false")
|
||||
env.setdefault("GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE", "true")
|
||||
env["PYTHONPATH"] = os.pathsep.join(part for part in (str(SRC), env.get("PYTHONPATH", "")) if part)
|
||||
return env
|
||||
|
||||
|
||||
def _run(command: list[str], *, env: dict[str, str]) -> bool:
|
||||
print("+ " + " ".join(command))
|
||||
print("+ " + " ".join(command), flush=True)
|
||||
result = subprocess.run(command, cwd=ROOT, env=env, check=False)
|
||||
if result.returncode != 0:
|
||||
print(f"Command failed with exit code {result.returncode}: {' '.join(command)}", file=sys.stderr)
|
||||
|
||||
@@ -5,6 +5,7 @@ import argparse
|
||||
import importlib.metadata as metadata
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -35,6 +36,9 @@ SOURCE_COUPLED_CORE_TESTS: Mapping[str, str] = {
|
||||
"tests.test_module_system.ModuleSystemTests.test_release_catalog_generator_reads_manifest_interface_contracts": (
|
||||
"requires the sibling meta/source workspace and is retained by the normal source gate"
|
||||
),
|
||||
"tests.test_module_system.ModuleSystemTests.test_registry_keeps_optional_auth_modules_access_free": (
|
||||
"requires optional Poll, Evaluation, and Scheduling source packages outside the tagged base release set"
|
||||
),
|
||||
"tests.test_identity_organization_contracts.IdentityOrganizationContractTests."
|
||||
"test_sql_identity_and_organization_directories_return_dtos": (
|
||||
"directly constructs the current IDM source implementation instead of using its public capability factory"
|
||||
@@ -354,6 +358,24 @@ def run_core_release_tests(core_root: Path) -> int:
|
||||
return 0 if result.wasSuccessful() else 1
|
||||
|
||||
|
||||
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:
|
||||
meta_root = Path(__file__).resolve().parents[2]
|
||||
parser = argparse.ArgumentParser(description="Artifact-aware GovOPlaN release integration checks")
|
||||
@@ -366,6 +388,8 @@ def _parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
core_parser = subparsers.add_parser("core-tests", help="Run Core tests that are valid for tagged module artifacts")
|
||||
core_parser.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
|
||||
|
||||
|
||||
@@ -373,7 +397,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
if args.command == "artifacts":
|
||||
return run_installed_artifact_checks(args.requirements)
|
||||
if args.command == "core-tests":
|
||||
return run_core_release_tests(args.core_root)
|
||||
return run_module_release_tests(args.module_root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -48,7 +48,10 @@ RUN python -m pip install --upgrade pip \
|
||||
&& osv-scanner --version \
|
||||
&& trivy --version \
|
||||
&& jscpd --version \
|
||||
&& pip-audit --progress-spinner off
|
||||
&& pip-audit --progress-spinner off \
|
||||
&& npm --version \
|
||||
&& radon --version \
|
||||
&& xenon --version
|
||||
|
||||
RUN mkdir -p /workspace \
|
||||
&& chmod 0777 /workspace
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user