From 6d701a1773595716885d39d2a84fb9edddf429d4 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 11 Jul 2026 02:46:04 +0200 Subject: [PATCH] Release v0.1.7 --- README.md | 1 + docs/EVENTS_AND_AUDIT.md | 13 +- docs/MODULE_ARCHITECTURE.md | 46 +- docs/RELEASE_DEPENDENCIES.md | 85 +- docs/migration-release-baselines.json | 100 +- docs/module-package-catalog.example.json | 23 + pyproject.toml | 2 +- scripts/generate-release-catalog.py | 21 + scripts/generate-release-lock.sh | 26 + scripts/push-release-tag.sh | 119 +-- src/govoplan_core/commands/init_db.py | 64 +- src/govoplan_core/core/migrations.py | 49 +- src/govoplan_core/core/module_installer.py | 876 +++++++++++++++++- .../core/module_package_catalog.py | 86 ++ src/govoplan_core/core/modules.py | 69 +- src/govoplan_core/core/registry.py | 85 +- src/govoplan_core/db/migrations.py | 212 ++++- tests/test_delete_veto_contract.py | 57 ++ tests/test_module_system.py | 657 ++++++++++++- webui/package-lock.json | 94 +- webui/package-lock.release.json | 209 ++++- webui/package.json | 6 +- webui/package.release.json | 26 +- webui/scripts/test-module-permutations.mjs | 7 +- webui/vite.config.ts | 8 +- 25 files changed, 2740 insertions(+), 201 deletions(-) create mode 100644 tests/test_delete_veto_contract.py diff --git a/README.md b/README.md index 57c9aaf..eb8ff53 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ [![Module Matrix](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/module-matrix.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=module-matrix.yml&actor=0&status=0) +[![Release Integration](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/release-integration.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=release-integration.yml&actor=0&status=0) [![Dependency Audit](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/dependency-audit.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=dependency-audit.yml&actor=0&status=0) # govoplan-core diff --git a/docs/EVENTS_AND_AUDIT.md b/docs/EVENTS_AND_AUDIT.md index 13d9101..2c0d61a 100644 --- a/docs/EVENTS_AND_AUDIT.md +++ b/docs/EVENTS_AND_AUDIT.md @@ -133,13 +133,12 @@ Access: Tenancy: -- `tenancy.tenant.created` -- `tenancy.tenant.updated` -- `tenancy.tenant.suspended` -- `tenancy.tenant.reactivated` -- `tenancy.tenant.delete_requested` -- `tenancy.tenant.delete_blocked` -- `tenancy.tenant.deleted` +- `tenant.created` +- `tenant.updated` +- `tenant.suspended` +- `tenant.resumed` +- `tenant.deletion_requested` +- `tenant.erasure_completed` Policy: diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index f8433b1..b0961ae 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -458,6 +458,17 @@ routers and live module activation fail fast if two routers register the same HTTP method and path. That keeps OpenAPI output and FastAPI route order from silently masking a module collision. +Tenant deletion and cleanup use the registry-owned delete-veto contract. A +module that owns tenant-bound data may declare `delete_veto_providers` on its +manifest for resource types such as `tenant` or `group`. Providers receive +`(session, tenant_id, resource_id)` and should return `DeleteVetoIssue`, an +iterable of `DeleteVetoIssue`, or `None`; older exception-based providers are +still treated as blocking vetoes. Core attributes each issue to the provider +module and adds resource context before the tenancy module exposes the issues +through the deletion plan. `blocker` issues prevent destructive or retire +operations, `warning` issues explain retained data, and `info` issues document +non-blocking lifecycle facts. + ## Database And Migrations Core owns the database/session lifecycle. Modules access the database through core session dependencies and register their models/migrations through their manifest. @@ -492,13 +503,34 @@ against the configured offline license before adding the entry to the install plan. Catalog entries may also declare `migration_safety` as `automatic`, `requires_review`, `forward_only`, or `destructive`; forward-only and destructive entries require explicit operator acknowledgement in the install -plan before installer preflight allows activation. +plan before installer preflight allows activation. Forward-only and destructive +catalog entries must also declare a tested recovery path. Catalog update entries +can define direct-update windows with `current_version_min` and +`current_version_max_exclusive`, mark intermediate `bridge_release` targets, and +explicitly opt into reviewed downgrade or same-version package-refresh plans. +Module migration order can be declared with `migration_after` and +`migration_before` in manifests or release catalogs; installer preflight turns +that metadata, module dependencies, and named interface relationships into an +ordered migration plan. + +Modules that need live-data work outside Alembic schema revisions may declare +`migration_tasks` on `MigrationSpec`. This is deliberately narrower than a +general lifecycle hook system. Each task has a stable `task_id`, one of four +phases (`pre_migration_check`, `pre_migration_prepare`, +`post_migration_backfill`, `post_migration_verify`), a short operator-facing +summary, a task version, safety metadata, and an idempotent executor. Installer +preflight blocks non-idempotent tasks, forward-only/destructive tasks without +operator acknowledgement, and installed manifest tasks that have no executor. +Catalog task metadata is surfaced before activation as pending because the +executor can only be verified after the package is installed. Modules should provide: - pinned backend and WebUI package refs for official catalog entries - module dependency metadata for catalog target-state planning - migration-safety metadata for catalog update planning +- migration task metadata when live-data checks, preparation, backfills, or + verification must run around Alembic - compatibility metadata in the module manifest - named interface contracts in the manifest and catalog entry when the module provides or consumes cross-module APIs @@ -984,13 +1016,19 @@ The package install-plan API records operator intent only: - `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves a planned install or update row from a validated catalog entry. Installed modules are planned as updates. Catalog signature and approved-channel policy - are enforced before the row is saved. The saved plan row can also carry a + are enforced before the row is saved. When the selected catalog row requires + companion dependency or interface-provider updates, the endpoint adds those + rows to the plan automatically. The saved plan row can also carry a data-safety acknowledgement used by preflight for forward-only or destructive catalog entries. - Install-plan preflight returns a structured `target_plan` summary so the admin UI can show current version, target version, package refs, - migration-safety level, and acknowledgement state without requiring JSON - editing. + migration-safety level, update-window and bridge metadata, recovery metadata, + and acknowledgement state without requiring JSON editing. +- Install-plan preflight also returns a structured `migration_plan` summary with + target enabled modules and ordered module migration steps. When the installer + runs with migration enabled, the database migration command receives that + target module set and ordered module list. - `POST /api/v1/admin/system/modules/{module_id}/uninstall-plan` saves a planned non-destructive uninstall row for an installed module after it has been disabled. The Python distribution name is resolved from the installed diff --git a/docs/RELEASE_DEPENDENCIES.md b/docs/RELEASE_DEPENDENCIES.md index ce62c79..d780072 100644 --- a/docs/RELEASE_DEPENDENCIES.md +++ b/docs/RELEASE_DEPENDENCIES.md @@ -111,10 +111,12 @@ scripts/push-release-tag.sh --version 0.1.6 `ModuleManifest` objects while writing catalog entries. When a manifest is available, the catalog entry uses the manifest version, points package refs at `v`, and copies `provides_interfaces` / -`requires_interfaces` from the manifest. If a manifest cannot be discovered, -the entry falls back to the release version passed with `--version` and omits -interface metadata. This keeps the catalog aligned with independently -versioned module packages instead of relying on a hardcoded compatibility table. +`requires_interfaces` from the manifest. It also copies module migration order +and `migration_tasks` metadata when present. If a manifest cannot be +discovered, the entry falls back to the release version passed with `--version` +and omits interface and migration-task metadata. This keeps the catalog aligned +with independently versioned module packages instead of relying on a hardcoded +compatibility table. The script also includes GovOPlaN roadmap/scaffold module repositories that do not yet have package metadata. Those repositories are committed, tagged, and @@ -211,6 +213,21 @@ Each module entry can declare: or `destructive` - `migration_notes`, operator-facing data/migration guidance for review, forward-only, or destructive changes +- `migration_after` and `migration_before`, explicit module ids used to order + module-owned migration heads when a release needs a live-data sequencing rule +- `migration_tasks`, constrained live-data tasks that run around Alembic + migration phases. Each task declares `task_id`, `phase`, `summary`, + `task_version`, `safety`, `idempotent`, and optionally `timeout_seconds`. + The allowed phases are `pre_migration_check`, `pre_migration_prepare`, + `post_migration_backfill`, and `post_migration_verify`. +- `current_version_min` and `current_version_max_exclusive`, the installed + version window from which this catalog target may be applied directly +- `bridge_release` and `bridge_notes`, marking a target as an intermediate + compatibility release in a staged update path +- `allow_downgrade` and `allow_same_version`, explicit opt-ins for reviewed + rollback or package-refresh plans +- `recovery_tested` and `recovery_notes`, documenting the rehearsal for + forward-only or destructive data changes - `provides_interfaces`, named interface contracts exported by this module - `requires_interfaces`, named interface contracts and version ranges required by this module @@ -282,8 +299,18 @@ Catalog provenance changes preflight severity: block activation before the installer runs - selected catalog entries whose target dependencies are neither installed nor planned block activation before the installer runs +- catalog update targets older than the installed module version block unless + the catalog entry declares `allow_downgrade: true` +- catalog update targets equal to the installed module version block unless the + catalog entry declares `allow_same_version: true` +- catalog update targets with a `current_version_min` / + `current_version_max_exclusive` window block when the installed version is + outside that window; publish and apply a bridge release instead - catalog entries marked `forward_only` or `destructive` block activation until the plan row has an explicit data-safety acknowledgement +- catalog entries marked `forward_only` or `destructive` also block unless the + catalog entry declares `recovery_tested: true` and either the catalog entry or + operator plan row contains recovery notes - catalog entries marked `destructive` also require catalog migration notes or operator notes describing the cleanup or retirement plan @@ -298,12 +325,37 @@ activate the resulting set together. Install-plan rows support explicit `install`, `update`, and `uninstall` actions. Catalog planning writes `update` when the module is already installed. Preflight resolves the target set from installed manifests plus the planned -catalog entries. Unplanned catalog entries are not treated as installed; when -they would satisfy a missing dependency or named interface, preflight blocks -activation with a companion-update issue so the operator can add them to the -same plan. The preflight response also includes a structured `target_plan` -summary with each planned module's action, current version, catalog target -version, package refs, migration-safety level, and acknowledgement state. +catalog entries. Unplanned catalog entries are not treated as installed. When a +catalog entry would satisfy a missing dependency or named interface, preflight +blocks activation with a companion-update issue; the admin catalog planner adds +those companion rows automatically when it can resolve them from the current +catalog. The preflight response also includes a structured `target_plan` summary +with each planned module's action, current version, catalog target version, +package refs, migration-safety level, current-version update window, bridge +metadata, recovery metadata, and acknowledgement state. + +Database migrations are planned against that same target module set. When the +installer is run with `--migrate`, it calls `govoplan_core.commands.init_db` +with the target enabled modules rather than the pre-update startup module list, +so newly installed module migration directories are discovered before +activation. Preflight also returns a structured migration plan. Its step order is +derived from: + +- manifest and catalog `migration_after` / `migration_before` declarations +- module dependencies and optional dependencies when both modules are in the + target plan +- named interface provider/consumer relationships when both sides are in the + target plan + +Preflight blocks cycles in that ordering graph. It also blocks non-idempotent +module migration tasks, forward-only/destructive tasks without operator +acknowledgement, and installed manifest tasks that declare no executor. +Catalog-only task executors are marked as pending because they can only be +confirmed after the target package is installed. The migrator runs pre-migration +tasks, upgrades the ordered module heads first, finishes with Alembic `heads`, +and then runs post-migration tasks, so Alembic's revision graph remains +authoritative while GovOPlaN still gives operators a module-aware live-data +order. This avoids circular "upgrade A first / upgrade B first" traps: named interface requirements are solved against the target set, not against each intermediate @@ -330,15 +382,20 @@ modules with registered migrations as `requires_review` by default. Release authors should keep that value for ordinary reversible migrations, raise it to `forward_only` when database rollback requires restoring a snapshot, and raise it to `destructive` when the update removes or irreversibly rewrites persisted -data. The admin install-plan UI exposes the safety level and lets operators -record an explicit acknowledgement; preflight keeps acknowledged -forward-only/destructive changes visible as warnings. +data. Forward-only and destructive entries must include `recovery_tested: true` +and recovery notes after a verified restore or forward-recovery rehearsal. The +admin install-plan UI exposes the safety level and lets operators record an +explicit acknowledgement; preflight keeps acknowledged forward-only/destructive +changes visible as warnings. In practice, circular dependencies are avoided by designing interfaces with compatibility windows and by publishing bridge releases. A bridge release keeps the old interface while introducing the new one, allowing dependent modules to move first; a later release can retire the old interface after every dependent -module has a compatible target version. +module has a compatible target version. Use `current_version_min` and +`current_version_max_exclusive` to make those direct-update windows explicit in +the catalog, and set `bridge_release: true` on intermediate targets that exist +primarily to carry installations safely across a compatibility gap. Trusted catalog keys are configured locally: diff --git a/docs/migration-release-baselines.json b/docs/migration-release-baselines.json index 953559c..af49e92 100644 --- a/docs/migration-release-baselines.json +++ b/docs/migration-release-baselines.json @@ -1,4 +1,100 @@ { - "version": 1, - "releases": [] + "releases": [ + { + "heads": [ + { + "owner": "govoplan-campaign", + "revision": "2c3d4e5f7081" + }, + { + "owner": "govoplan-mail", + "revision": "3d4e5f708192" + }, + { + "owner": "govoplan-core", + "revision": "4f2a9c8e7b6d" + }, + { + "owner": "govoplan-identity", + "revision": "5c6d7e8f9a10" + }, + { + "owner": "govoplan-organizations", + "revision": "6d7e8f9a0b1c" + }, + { + "owner": "govoplan-idm", + "revision": "8f9a0b1c2d3e" + }, + { + "owner": "govoplan-calendar", + "revision": "9e0f1a2b3c4d" + }, + { + "owner": "govoplan-files", + "revision": "a7b8c9d0e1f3" + } + ], + "owner_heads": [ + { + "owner": "govoplan-access", + "revisions": [ + "4a5b6c7d8e9f" + ] + }, + { + "owner": "govoplan-calendar", + "revisions": [ + "9e0f1a2b3c4d" + ] + }, + { + "owner": "govoplan-campaign", + "revisions": [ + "2c3d4e5f7081" + ] + }, + { + "owner": "govoplan-core", + "revisions": [ + "4f2a9c8e7b6d" + ] + }, + { + "owner": "govoplan-files", + "revisions": [ + "a7b8c9d0e1f3" + ] + }, + { + "owner": "govoplan-identity", + "revisions": [ + "5c6d7e8f9a10" + ] + }, + { + "owner": "govoplan-idm", + "revisions": [ + "8f9a0b1c2d3e" + ] + }, + { + "owner": "govoplan-mail", + "revisions": [ + "3d4e5f708192" + ] + }, + { + "owner": "govoplan-organizations", + "revisions": [ + "6d7e8f9a0b1c" + ] + } + ], + "recorded_at": "2026-07-11T00:19:07Z", + "release": "0.1.7", + "squash_policy": "reviewed-manual" + } + ], + "version": 1 } diff --git a/docs/module-package-catalog.example.json b/docs/module-package-catalog.example.json index 99e4d63..6d16154 100644 --- a/docs/module-package-catalog.example.json +++ b/docs/module-package-catalog.example.json @@ -15,6 +15,29 @@ "python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4", "webui_package": "@govoplan/files-webui", "webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4", + "migration_safety": "forward_only", + "migration_notes": "Database rollback requires restoring the pre-update snapshot.", + "migration_after": ["access"], + "migration_before": ["campaigns"], + "migration_tasks": [ + { + "task_id": "backfill_spaces", + "phase": "post_migration_backfill", + "summary": "Backfill file spaces after the schema migration.", + "task_version": "1", + "safety": "forward_only", + "idempotent": true, + "timeout_seconds": 300 + } + ], + "current_version_min": "0.1.0", + "current_version_max_exclusive": "0.2.0", + "bridge_release": true, + "bridge_notes": "Keeps the 0.1.x campaign attachment interface while introducing the 0.2.x contract.", + "allow_downgrade": false, + "allow_same_version": false, + "recovery_tested": true, + "recovery_notes": "Snapshot restore and forward recovery were rehearsed on the release candidate dataset.", "provides_interfaces": [ { "name": "files.campaign_attachments", diff --git a/pyproject.toml b/pyproject.toml index 1ee7786..c0e0371 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-core" -version = "0.1.6" +version = "0.1.7" description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/generate-release-catalog.py b/scripts/generate-release-catalog.py index e8e2c0e..1a38860 100644 --- a/scripts/generate-release-catalog.py +++ b/scripts/generate-release-catalog.py @@ -87,6 +87,7 @@ CATALOG_MODULES = ( name="Policy", description="Policy and governance capability module.", tags=("official", "platform-module"), + webui_package="@govoplan/policy-webui", ), CatalogModule( module_id="audit", @@ -95,6 +96,7 @@ CATALOG_MODULES = ( name="Audit", description="Audit-log storage and audit administration routes.", tags=("official", "platform-module"), + webui_package="@govoplan/audit-webui", ), CatalogModule( module_id="dashboard", @@ -303,6 +305,25 @@ def _manifest_catalog_metadata(manifest: ModuleManifest | None) -> dict[str, obj if manifest.migration_spec is not None: payload["migration_safety"] = "requires_review" payload["migration_notes"] = "Module owns database migrations; review release notes and migration output before activation." + if manifest.migration_spec.migration_after: + payload["migration_after"] = list(manifest.migration_spec.migration_after) + if manifest.migration_spec.migration_before: + payload["migration_before"] = list(manifest.migration_spec.migration_before) + if manifest.migration_spec.migration_tasks: + tasks: list[dict[str, object]] = [] + for task in manifest.migration_spec.migration_tasks: + task_payload: dict[str, object] = { + "task_id": task.task_id, + "phase": task.phase, + "summary": task.summary, + "task_version": task.task_version, + "safety": task.safety, + "idempotent": task.idempotent, + } + if task.timeout_seconds is not None: + task_payload["timeout_seconds"] = task.timeout_seconds + tasks.append(task_payload) + payload["migration_tasks"] = tasks if manifest.provides_interfaces: payload["provides_interfaces"] = [ {"name": item.name, "version": item.version} diff --git a/scripts/generate-release-lock.sh b/scripts/generate-release-lock.sh index bbbd705..95086d6 100644 --- a/scripts/generate-release-lock.sh +++ b/scripts/generate-release-lock.sh @@ -28,6 +28,7 @@ fail() { ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WEBUI="$ROOT/webui" NPM_BIN="${NPM:-}" +NODE_BIN="${NODE:-}" if [[ -z "$NPM_BIN" ]]; then if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then @@ -36,6 +37,13 @@ if [[ -z "$NPM_BIN" ]]; then NPM_BIN="npm" fi fi +if [[ -z "$NODE_BIN" ]]; then + if [[ -x "$(dirname "$NPM_BIN")/node" ]]; then + NODE_BIN="$(dirname "$NPM_BIN")/node" + else + NODE_BIN="node" + fi +fi while [[ $# -gt 0 ]]; do case "$1" in @@ -58,6 +66,7 @@ done [[ -f "$WEBUI/package.release.json" ]] || fail "missing $WEBUI/package.release.json" command -v "$NPM_BIN" >/dev/null 2>&1 || fail "npm executable not found: $NPM_BIN" +command -v "$NODE_BIN" >/dev/null 2>&1 || fail "node executable not found: $NODE_BIN" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/govoplan-release-lock.XXXXXXXX")" cleanup() { @@ -75,6 +84,23 @@ echo "Temporary workspace: $TMP_DIR" ( cd "$TMP_DIR" PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" install --package-lock-only --ignore-scripts + mapfile -t GIT_PACKAGES < <( + PATH="$(dirname "$NODE_BIN"):$PATH" "$NODE_BIN" <<'NODE' +const fs = require("fs"); +const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); +for (const group of ["dependencies", "devDependencies", "optionalDependencies"]) { + for (const [name, spec] of Object.entries(pkg[group] || {})) { + if (typeof spec === "string" && spec.startsWith("git+")) { + console.log(name); + } + } +} +NODE + ) + if [[ "${#GIT_PACKAGES[@]}" -gt 0 ]]; then + echo "Refreshing git package lock entries: ${GIT_PACKAGES[*]}" + PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" update --package-lock-only --ignore-scripts "${GIT_PACKAGES[@]}" + fi ) cp "$TMP_DIR/package-lock.json" "$WEBUI/package-lock.release.json" diff --git a/scripts/push-release-tag.sh b/scripts/push-release-tag.sh index cc96730..badf650 100644 --- a/scripts/push-release-tag.sh +++ b/scripts/push-release-tag.sh @@ -92,19 +92,39 @@ PARENT="$(dirname "$ROOT")" PACKAGE_MODULE_REPOS=( "$PARENT/govoplan-access" "$PARENT/govoplan-admin" - "$PARENT/govoplan-tenancy" - "$PARENT/govoplan-organizations" + "$PARENT/govoplan-approvals" + "$PARENT/govoplan-assets" + "$PARENT/govoplan-audit" + "$PARENT/govoplan-booking" + "$PARENT/govoplan-calendar" + "$PARENT/govoplan-campaign" + "$PARENT/govoplan-certificates" + "$PARENT/govoplan-committee" + "$PARENT/govoplan-consultation" + "$PARENT/govoplan-contracts" + "$PARENT/govoplan-dashboard" + "$PARENT/govoplan-docs" + "$PARENT/govoplan-facilities" + "$PARENT/govoplan-files" + "$PARENT/govoplan-forms-runtime" + "$PARENT/govoplan-grants" + "$PARENT/govoplan-helpdesk" "$PARENT/govoplan-identity" "$PARENT/govoplan-idm" - "$PARENT/govoplan-policy" - "$PARENT/govoplan-audit" - "$PARENT/govoplan-dashboard" - "$PARENT/govoplan-files" + "$PARENT/govoplan-inspections" + "$PARENT/govoplan-issue-reporting" + "$PARENT/govoplan-learning" "$PARENT/govoplan-mail" - "$PARENT/govoplan-campaign" - "$PARENT/govoplan-calendar" - "$PARENT/govoplan-docs" "$PARENT/govoplan-ops" + "$PARENT/govoplan-organizations" + "$PARENT/govoplan-permits" + "$PARENT/govoplan-policy" + "$PARENT/govoplan-procurement" + "$PARENT/govoplan-records" + "$PARENT/govoplan-resources" + "$PARENT/govoplan-risk-compliance" + "$PARENT/govoplan-tenancy" + "$PARENT/govoplan-transparency" ) TAG_ONLY_MODULE_REPOS=( "$PARENT/govoplan-addresses" @@ -120,6 +140,7 @@ TAG_ONLY_MODULE_REPOS=( "$PARENT/govoplan-notifications" "$PARENT/govoplan-payments" "$PARENT/govoplan-portal" + "$PARENT/govoplan-postbox" "$PARENT/govoplan-reporting" "$PARENT/govoplan-scheduling" "$PARENT/govoplan-search" @@ -173,6 +194,9 @@ if [[ -z "$NPM_BIN" ]]; then fi fi +export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes -o ConnectTimeout=15}" +GIT_REMOTE_TIMEOUT="${GIT_REMOTE_TIMEOUT:-30}" + normalize_bump() { local value="${1,,}" case "$value" in @@ -282,56 +306,20 @@ update_manifest_version() { local repo="$1" local project_name="$2" local version="$3" - local manifest_path="" + local manifest_paths=() - case "$project_name" in - govoplan-core) - return 0 - ;; - govoplan-access) - manifest_path="$repo/src/govoplan_access/backend/manifest.py" - ;; - govoplan-admin) - manifest_path="$repo/src/govoplan_admin/backend/manifest.py" - ;; - govoplan-tenancy) - manifest_path="$repo/src/govoplan_tenancy/backend/manifest.py" - ;; - govoplan-organizations) - manifest_path="$repo/src/govoplan_organizations/backend/manifest.py" - ;; - govoplan-identity) - manifest_path="$repo/src/govoplan_identity/backend/manifest.py" - ;; - govoplan-idm) - manifest_path="$repo/src/govoplan_idm/backend/manifest.py" - ;; - govoplan-policy) - manifest_path="$repo/src/govoplan_policy/backend/manifest.py" - ;; - govoplan-audit) - manifest_path="$repo/src/govoplan_audit/backend/manifest.py" - ;; - govoplan-files) - manifest_path="$repo/src/govoplan_files/backend/manifest.py" - ;; - govoplan-mail) - manifest_path="$repo/src/govoplan_mail/backend/manifest.py" - ;; - govoplan-campaign) - manifest_path="$repo/src/govoplan_campaign/backend/manifest.py" - ;; - govoplan-calendar) - manifest_path="$repo/src/govoplan_calendar/backend/manifest.py" - ;; - *) - fail "unknown project for manifest version update: $project_name" - ;; - esac + if [[ "$project_name" == "govoplan-core" ]]; then + return 0 + fi - [[ -f "$manifest_path" ]] || fail "missing module manifest: $manifest_path" + while IFS= read -r -d '' manifest_path; do + manifest_paths+=("$manifest_path") + done < <(find "$repo/src" -path "*/backend/manifest.py" -print0 2>/dev/null) - "$PYTHON" - "$manifest_path" "$version" <<'PYCODE' + [[ "${#manifest_paths[@]}" -gt 0 ]] || fail "missing module manifest under $repo/src" + + for manifest_path in "${manifest_paths[@]}"; do + "$PYTHON" - "$manifest_path" "$version" <<'PYCODE' from __future__ import annotations import pathlib @@ -347,10 +335,18 @@ text, count = re.subn( text, count=1, ) +if count == 0: + text, count = re.subn( + r'(?m)^(MODULE_VERSION\s*=\s*)["\'][^"\']+["\'](\s*)$', + rf'\1"{new_version}"\2', + text, + count=1, + ) if count != 1: raise SystemExit(f"could not update ModuleManifest.version in {path}") path.write_text(text) PYCODE + done } update_webui_package() { @@ -729,7 +725,7 @@ for repo in "${REPOS[@]}"; do fi set +e - git -C "$repo" ls-remote --exit-code --tags "$REMOTE" "refs/tags/$TAG" >/dev/null + timeout "$GIT_REMOTE_TIMEOUT" git -C "$repo" ls-remote --exit-code --tags "$REMOTE" "refs/tags/$TAG" >/dev/null ls_remote_status=$? set -e @@ -790,9 +786,14 @@ for repo in "${MODULE_REPOS[@]}"; do if [[ "$DRY_RUN" -eq 0 ]]; then if git -C "$repo" diff --cached --quiet; then if [[ "${REPO_KINDS[$repo]}" == "package" ]]; then - fail "no staged changes for ${PROJECT_NAMES[$repo]} after version bump" + if [[ "$TARGET_VERSION" == "${OLD_VERSIONS[$repo]}" ]]; then + echo "No staged changes for ${PROJECT_NAMES[$repo]}; version is already $TARGET_VERSION, tagging current HEAD." + else + fail "no staged changes for ${PROJECT_NAMES[$repo]} after version bump" + fi + else + echo "No staged changes for ${PROJECT_NAMES[$repo]}; tagging current HEAD." fi - echo "No staged changes for ${PROJECT_NAMES[$repo]}; tagging current HEAD." else run git -C "$repo" commit -m "$COMMIT_MESSAGE" fi diff --git a/src/govoplan_core/commands/init_db.py b/src/govoplan_core/commands/init_db.py index 60b9f5b..59f2afe 100644 --- a/src/govoplan_core/commands/init_db.py +++ b/src/govoplan_core/commands/init_db.py @@ -1,9 +1,17 @@ from __future__ import annotations import argparse +import json +from pathlib import Path from govoplan_core.db.bootstrap import bootstrap_dev_data -from govoplan_core.db.migrations import migrate_database +from govoplan_core.db.migrations import ( + ModuleMigrationTaskExecutionError, + POST_MIGRATION_TASK_PHASES, + PRE_MIGRATION_TASK_PHASES, + migrate_database, + run_registered_module_migration_tasks, +) from govoplan_core.db.session import configure_database, get_database from govoplan_core.settings import settings @@ -11,12 +19,43 @@ from govoplan_core.settings import settings def main() -> None: parser = argparse.ArgumentParser(description="Initialize the GovOPlaN database") parser.add_argument("--database-url", default=settings.database_url, help="Database URL to migrate") + parser.add_argument("--enabled-module", action="append", default=[], help="Target enabled module id used to discover module migrations; may be repeated.") + parser.add_argument("--migration-module", action="append", default=[], help="Module id whose migration heads should be upgraded in this order before final heads.") + parser.add_argument("--migration-task-record-output", type=Path, help="Write executed module migration task records to this JSON file.") parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key") parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create") args = parser.parse_args() configure_database(args.database_url) - migration = migrate_database(database_url=args.database_url) + enabled_modules = tuple(args.enabled_module) if args.enabled_module else None + migration_order = tuple(args.migration_module) if args.migration_module else None + task_records: list[dict[str, object]] = [] + try: + _run_migration_tasks( + task_records, + database_url=args.database_url, + enabled_modules=enabled_modules, + migration_order=migration_order, + phases=PRE_MIGRATION_TASK_PHASES, + ) + migration = migrate_database( + database_url=args.database_url, + enabled_modules=enabled_modules, + migration_module_order=migration_order, + ) + _run_migration_tasks( + task_records, + database_url=args.database_url, + enabled_modules=enabled_modules, + migration_order=migration_order, + phases=POST_MIGRATION_TASK_PHASES, + ) + finally: + if args.migration_task_record_output: + args.migration_task_record_output.parent.mkdir(parents=True, exist_ok=True) + args.migration_task_record_output.write_text(json.dumps(task_records, indent=2, sort_keys=True) + "\n", encoding="utf-8") + if task_records: + print(f"Executed {len(task_records)} module migration task(s).") if migration.reconciled_revision: print(f"Reconciled legacy database marker to {migration.reconciled_revision}.") print(f"Database schema upgraded to {migration.current_revision}.") @@ -33,5 +72,26 @@ def main() -> None: print("Development API key already exists or was not requested.") +def _run_migration_tasks( + target: list[dict[str, object]], + *, + database_url: str, + enabled_modules: tuple[str, ...] | None, + migration_order: tuple[str, ...] | None, + phases: tuple[str, ...], +) -> None: + try: + records = run_registered_module_migration_tasks( + database_url=database_url, + enabled_modules=enabled_modules, + migration_module_order=migration_order, + phases=phases, + ) + except ModuleMigrationTaskExecutionError as exc: + target.extend(exc.records) + raise + target.extend(records) + + if __name__ == "__main__": main() diff --git a/src/govoplan_core/core/migrations.py b/src/govoplan_core/core/migrations.py index 2e5c7ee..0df8aa4 100644 --- a/src/govoplan_core/core/migrations.py +++ b/src/govoplan_core/core/migrations.py @@ -12,6 +12,29 @@ from govoplan_core.core.registry import PlatformRegistry class MigrationMetadataPlan: metadata: tuple[MetaData, ...] script_locations: tuple[str, ...] + modules: tuple[ModuleMigrationMetadata, ...] = () + + +@dataclass(frozen=True, slots=True) +class ModuleMigrationMetadata: + module_id: str + script_location: str | None = None + has_metadata: bool = False + migration_after: tuple[str, ...] = () + migration_before: tuple[str, ...] = () + migration_tasks: tuple[ModuleMigrationTaskMetadata, ...] = () + + +@dataclass(frozen=True, slots=True) +class ModuleMigrationTaskMetadata: + task_id: str + phase: str + summary: str + task_version: str = "1" + safety: str = "automatic" + idempotent: bool = True + timeout_seconds: int | None = None + has_executor: bool = False def migration_metadata_plan(registry: PlatformRegistry, *, extra_metadata: Iterable[MetaData] = ()) -> MigrationMetadataPlan: @@ -28,4 +51,28 @@ def migration_metadata_plan(registry: PlatformRegistry, *, extra_metadata: Itera metadata.append(spec.metadata) if spec.script_location: script_locations.append(spec.script_location) - return MigrationMetadataPlan(metadata=tuple(metadata), script_locations=tuple(script_locations)) + modules = tuple( + ModuleMigrationMetadata( + module_id=manifest.id, + script_location=manifest.migration_spec.script_location, + has_metadata=isinstance(manifest.migration_spec.metadata, MetaData), + migration_after=tuple(manifest.migration_spec.migration_after), + migration_before=tuple(manifest.migration_spec.migration_before), + migration_tasks=tuple( + ModuleMigrationTaskMetadata( + task_id=task.task_id, + phase=task.phase, + summary=task.summary, + task_version=task.task_version, + safety=task.safety, + idempotent=task.idempotent, + timeout_seconds=task.timeout_seconds, + has_executor=task.executor is not None, + ) + for task in manifest.migration_spec.migration_tasks + ), + ) + for manifest in registry.manifests() + if manifest.migration_spec is not None + ) + return MigrationMetadataPlan(metadata=tuple(metadata), script_locations=tuple(script_locations), modules=modules) diff --git a/src/govoplan_core/core/module_installer.py b/src/govoplan_core/core/module_installer.py index f5ed851..ee3e3fc 100644 --- a/src/govoplan_core/core/module_installer.py +++ b/src/govoplan_core/core/module_installer.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Iterable, Mapping from contextlib import AbstractContextManager, closing -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import UTC, datetime from importlib import metadata import hashlib @@ -42,6 +42,15 @@ IssueSeverity = Literal["blocker", "warning", "info"] ChecklistStatus = Literal["done", "pending", "blocked", "warning"] SUPPORTED_MANIFEST_CONTRACT_VERSION = "1" PACKAGE_TARGET_ACTIONS = {"install", "update"} +MIGRATION_TASK_PHASES = ( + "pre_migration_check", + "pre_migration_prepare", + "post_migration_backfill", + "post_migration_verify", +) +MIGRATION_TASK_MUTATING_PHASES = {"pre_migration_prepare", "post_migration_backfill"} +MIGRATION_TASK_REVIEW_SAFETY = {"requires_review", "forward_only", "destructive"} +MIGRATION_TASK_BLOCKING_SAFETY = {"forward_only", "destructive"} @dataclass(frozen=True, slots=True) @@ -93,6 +102,14 @@ class ModuleInstallTargetItem: webui_ref: str | None = None migration_safety: str = "automatic" migration_notes: str | None = None + current_version_min: str | None = None + current_version_max_exclusive: str | None = None + bridge_release: bool = False + bridge_notes: str | None = None + allow_downgrade: bool = False + allow_same_version: bool = False + recovery_tested: bool = False + recovery_notes: str | None = None data_safety_acknowledged: bool = False def as_dict(self) -> dict[str, object]: @@ -101,6 +118,10 @@ class ModuleInstallTargetItem: "action": self.action, "source": self.source, "migration_safety": self.migration_safety, + "bridge_release": self.bridge_release, + "allow_downgrade": self.allow_downgrade, + "allow_same_version": self.allow_same_version, + "recovery_tested": self.recovery_tested, "data_safety_acknowledged": self.data_safety_acknowledged, } for key in ( @@ -111,6 +132,10 @@ class ModuleInstallTargetItem: "webui_package", "webui_ref", "migration_notes", + "current_version_min", + "current_version_max_exclusive", + "bridge_notes", + "recovery_notes", ): value = getattr(self, key) if value: @@ -118,6 +143,87 @@ class ModuleInstallTargetItem: return payload +@dataclass(frozen=True, slots=True) +class ModuleMigrationPlanStep: + module_id: str + action: str + phase: str + source: str + has_migration_metadata: bool = False + metadata_pending: bool = False + migration_safety: str = "automatic" + current_version: str | None = None + target_version: str | None = None + reason: str | None = None + + def as_dict(self) -> dict[str, object]: + payload: dict[str, object] = { + "module_id": self.module_id, + "action": self.action, + "phase": self.phase, + "source": self.source, + "has_migration_metadata": self.has_migration_metadata, + "metadata_pending": self.metadata_pending, + "migration_safety": self.migration_safety, + } + for key in ("current_version", "target_version", "reason"): + value = getattr(self, key) + if value: + payload[key] = value + return payload + + +@dataclass(frozen=True, slots=True) +class ModuleMigrationTaskPlanItem: + module_id: str + task_id: str + phase: str + summary: str + task_version: str = "1" + safety: str = "automatic" + idempotent: bool = True + timeout_seconds: int | None = None + source: str = "manifest" + has_executor: bool = False + metadata_pending: bool = False + + def as_dict(self) -> dict[str, object]: + payload: dict[str, object] = { + "module_id": self.module_id, + "task_id": self.task_id, + "phase": self.phase, + "summary": self.summary, + "task_version": self.task_version, + "safety": self.safety, + "idempotent": self.idempotent, + "source": self.source, + "has_executor": self.has_executor, + "metadata_pending": self.metadata_pending, + } + if self.timeout_seconds is not None: + payload["timeout_seconds"] = self.timeout_seconds + return payload + + +@dataclass(frozen=True, slots=True) +class ModuleMigrationExecutionPlan: + enabled_modules: tuple[str, ...] = () + steps: tuple[ModuleMigrationPlanStep, ...] = () + tasks: tuple[ModuleMigrationTaskPlanItem, ...] = () + + @property + def requires_database_migration(self) -> bool: + return bool(self.steps or self.tasks) + + def as_dict(self) -> dict[str, object]: + return { + "enabled_modules": list(self.enabled_modules), + "requires_database_migration": self.requires_database_migration, + "steps": [step.as_dict() for step in self.steps], + "tasks": [task.as_dict() for task in self.tasks], + } + + @dataclass(frozen=True, slots=True) class ModuleInstallerPreflight: allowed: bool @@ -129,6 +235,7 @@ class ModuleInstallerPreflight: issues: tuple[ModuleInstallerIssue, ...] checklist: tuple[ModuleInstallChecklistItem, ...] = () target_plan: tuple[ModuleInstallTargetItem, ...] = () + migration_plan: ModuleMigrationExecutionPlan = field(default_factory=ModuleMigrationExecutionPlan) artifact_integrity: tuple[dict[str, object], ...] = () def as_dict(self) -> dict[str, object]: @@ -142,6 +249,7 @@ class ModuleInstallerPreflight: "issues": [issue.as_dict() for issue in self.issues], "checklist": [item.as_dict() for item in self.checklist], "target_plan": [item.as_dict() for item in self.target_plan], + "migration_plan": self.migration_plan.as_dict(), "artifact_integrity": list(self.artifact_integrity), } @@ -196,19 +304,26 @@ def module_install_preflight( ) -> ModuleInstallerPreflight: issues: list[ModuleInstallerIssue] = [] current = {item for item in current_enabled} - desired = {item for item in desired_enabled} + desired_sequence = tuple(dict.fromkeys(str(item).strip() for item in desired_enabled if str(item).strip())) + desired = set(desired_sequence) planned_items = tuple(item for item in plan.items if item.status == "planned") dependents = module_dependents(available) target_plan = _module_install_target_plan(plan, available) + migration_plan, migration_issues = _module_migration_execution_plan( + plan=plan, + available=available, + desired_enabled=desired_sequence, + ) if not planned_items: issues.append(ModuleInstallerIssue("warning", "empty_plan", "No planned package changes are present.")) if not maintenance_mode: issues.append(ModuleInstallerIssue("blocker", "maintenance_required", "Package changes require maintenance mode.")) - activation_candidates = desired_modules_after_package_plan(desired, plan) + activation_candidates = desired_modules_after_package_plan(desired_sequence, plan) issues.extend(module_manifest_compatibility_issues(available, module_ids=activation_candidates)) issues.extend(_package_catalog_preflight_issues(plan, available)) + issues.extend(migration_issues) frontend_rebuild_required = False for item in planned_items: @@ -281,6 +396,7 @@ def module_install_preflight( planned_items=planned_items, maintenance_mode=maintenance_mode, frontend_rebuild_required=frontend_rebuild_required, + migration_plan=migration_plan, blocker=blocker, ) return ModuleInstallerPreflight( @@ -293,6 +409,7 @@ def module_install_preflight( issues=tuple(issues), checklist=checklist, target_plan=target_plan, + migration_plan=migration_plan, artifact_integrity=artifact_integrity, ) @@ -343,6 +460,9 @@ def run_module_install_plan( npm_bin=npm_bin, build_webui=build_webui, migrate_database_url=database_url if migrate_database else None, + migration_enabled_modules=preflight.migration_plan.enabled_modules if migrate_database else (), + migration_module_order=tuple(step.module_id for step in preflight.migration_plan.steps if step.phase == "upgrade") if migrate_database else (), + migration_task_record_path=run_dir / "migration-tasks.json" if migrate_database and preflight.migration_plan.tasks else None, verify_modules=True, ) snapshot = _snapshot_environment( @@ -392,6 +512,12 @@ def run_module_install_plan( "stdout": result.stdout[-8000:], "stderr": result.stderr[-8000:], }) + migration_task_record_path = command.get("migration_task_record_path") + if migration_task_record_path: + task_records = _read_json_file(Path(str(migration_task_record_path))) + if isinstance(task_records, list): + executed[-1]["migration_tasks"] = task_records + record["migration_tasks"] = task_records record["commands"] = executed _write_json(record_path, record) if result.returncode != 0: @@ -838,6 +964,9 @@ def structured_install_commands( npm_bin: str = "npm", build_webui: bool = False, migrate_database_url: str | None = None, + migration_enabled_modules: Iterable[str] = (), + migration_module_order: Iterable[str] = (), + migration_task_record_path: Path | None = None, verify_modules: bool = False, ) -> tuple[dict[str, Any], ...]: commands: list[dict[str, Any]] = [] @@ -865,7 +994,17 @@ def structured_install_commands( if verify_command is not None: commands.append(verify_command) if migrate_database_url is not None: - commands.append(_structured_command([sys.executable, "-m", "govoplan_core.commands.init_db", "--database-url", migrate_database_url])) + migrate_command = [sys.executable, "-m", "govoplan_core.commands.init_db", "--database-url", migrate_database_url] + for module_id in dict.fromkeys(str(item).strip() for item in migration_enabled_modules if str(item).strip()): + migrate_command.extend(["--enabled-module", module_id]) + for module_id in dict.fromkeys(str(item).strip() for item in migration_module_order if str(item).strip()): + migrate_command.extend(["--migration-module", module_id]) + command = _structured_command(migrate_command) + if migration_task_record_path is not None: + migrate_command.extend(["--migration-task-record-output", str(migration_task_record_path)]) + command = _structured_command(migrate_command) + command["migration_task_record_path"] = str(migration_task_record_path) + commands.append(command) return tuple(commands) @@ -1197,9 +1336,25 @@ def _module_install_target_plan( target_version = None if item.action == "uninstall" else _catalog_optional_string(catalog_module, "version") migration_safety = "automatic" migration_notes = None + current_version_min = None + current_version_max_exclusive = None + bridge_release = False + bridge_notes = None + allow_downgrade = False + allow_same_version = False + recovery_tested = False + recovery_notes = None if catalog_module is not None and item.action in PACKAGE_TARGET_ACTIONS: migration_safety = _catalog_optional_string(catalog_module, "migration_safety") or "automatic" migration_notes = _catalog_optional_string(catalog_module, "migration_notes") + current_version_min = _catalog_optional_string(catalog_module, "current_version_min") + current_version_max_exclusive = _catalog_optional_string(catalog_module, "current_version_max_exclusive") + bridge_release = _catalog_optional_bool(catalog_module, "bridge_release") + bridge_notes = _catalog_optional_string(catalog_module, "bridge_notes") + allow_downgrade = _catalog_optional_bool(catalog_module, "allow_downgrade") + allow_same_version = _catalog_optional_bool(catalog_module, "allow_same_version") + recovery_tested = _catalog_optional_bool(catalog_module, "recovery_tested") + recovery_notes = _catalog_optional_string(catalog_module, "recovery_notes") targets.append(ModuleInstallTargetItem( module_id=item.module_id, action=item.action, @@ -1212,11 +1367,528 @@ def _module_install_target_plan( webui_ref=item.webui_ref or _catalog_optional_string(catalog_module, "webui_ref"), migration_safety=migration_safety, migration_notes=migration_notes, + current_version_min=current_version_min, + current_version_max_exclusive=current_version_max_exclusive, + bridge_release=bridge_release, + bridge_notes=bridge_notes, + allow_downgrade=allow_downgrade, + allow_same_version=allow_same_version, + recovery_tested=recovery_tested, + recovery_notes=recovery_notes, data_safety_acknowledged=item.data_safety_acknowledged, )) return tuple(targets) +def module_install_catalog_companion_module_ids( + plan: ModuleInstallPlan, + available: Mapping[str, ModuleManifest], + *, + validation: Mapping[str, object] | None = None, +) -> tuple[str, ...]: + planned_items = tuple( + item + for item in plan.items + if item.status == "planned" and item.source == "catalog" and item.action in PACKAGE_TARGET_ACTIONS + ) + if not planned_items: + return () + if validation is None: + try: + from govoplan_core.core.module_package_catalog import validate_module_package_catalog + + validation = validate_module_package_catalog() + except Exception: + return () + if validation.get("valid") is not True: + return () + modules = _catalog_modules_by_id(validation) + original_ids = {item.module_id for item in planned_items} + planned_ids = set(original_ids) + companions: list[str] = [] + for _round in range(20): + target_provider_versions = _catalog_target_provider_versions_for_ids( + planned_ids=planned_ids, + modules=modules, + available=available, + ) + missing: set[str] = set() + for module_id in sorted(planned_ids): + module = modules.get(module_id) + if module is None: + continue + for dependency_id in _catalog_string_list(module.get("dependencies")): + if dependency_id in planned_ids or dependency_id in available: + continue + if dependency_id in modules: + missing.add(dependency_id) + for requirement in _catalog_interface_items(module.get("requires_interfaces")): + if _catalog_requirement_satisfied(requirement, target_provider_versions): + continue + name = requirement.get("name") if isinstance(requirement.get("name"), str) else None + if not name: + continue + missing.update(_catalog_provider_suggestions( + name=name, + version_min=requirement.get("version_min") if isinstance(requirement.get("version_min"), str) else None, + version_max_exclusive=( + requirement.get("version_max_exclusive") + if isinstance(requirement.get("version_max_exclusive"), str) + else None + ), + catalog_modules=modules, + planned_ids=planned_ids, + )) + for manifest in available.values(): + if manifest.id in planned_ids: + continue + requirements = tuple( + { + "name": requirement.name, + "version_min": requirement.version_min, + "version_max_exclusive": requirement.version_max_exclusive, + "optional": requirement.optional, + } + for requirement in manifest.requires_interfaces + ) + if all(_catalog_requirement_satisfied(requirement, target_provider_versions) for requirement in requirements): + continue + candidate_update = modules.get(manifest.id) + if candidate_update is None: + continue + candidate_requirements = _catalog_interface_items(candidate_update.get("requires_interfaces")) + if _catalog_requirements_satisfied(candidate_requirements, target_provider_versions): + missing.add(manifest.id) + missing = {module_id for module_id in missing if module_id not in planned_ids and module_id in modules} + if not missing: + break + for module_id in sorted(missing): + planned_ids.add(module_id) + companions.append(module_id) + return tuple(module_id for module_id in companions if module_id not in original_ids) + + +def _module_migration_execution_plan( + *, + plan: ModuleInstallPlan, + available: Mapping[str, ModuleManifest], + desired_enabled: Iterable[str], +) -> tuple[ModuleMigrationExecutionPlan, tuple[ModuleInstallerIssue, ...]]: + planned_items = tuple(item for item in plan.items if item.status == "planned") + enabled_modules = desired_modules_after_package_plan(desired_enabled, plan) + catalog_modules = _catalog_modules_for_target_plan(planned_items) + nodes = { + item.module_id: _migration_node( + item=item, + manifest=available.get(item.module_id), + catalog_module=catalog_modules.get(item.module_id), + ) + for item in planned_items + if _migration_step_relevant(item, available.get(item.module_id), catalog_modules.get(item.module_id)) + } + if not nodes: + return ModuleMigrationExecutionPlan(enabled_modules=enabled_modules), () + + edges: dict[str, set[str]] = {module_id: set() for module_id in nodes} + issues: list[ModuleInstallerIssue] = [] + target_metadata = { + module_id: _migration_target_metadata( + module_id=module_id, + manifest=available.get(module_id), + catalog_module=catalog_modules.get(module_id), + ) + for module_id in set(enabled_modules) | set(nodes) + } + provider_modules = _migration_provider_modules(target_metadata) + + for module_id, node in nodes.items(): + metadata = target_metadata.get(module_id) + if metadata is None: + continue + for before_id in metadata["migration_before"]: + _add_migration_edge( + edges, + issues, + before=module_id, + after=before_id, + source=module_id, + target_enabled=set(enabled_modules), + available=available, + ) + for after_id in metadata["migration_after"]: + _add_migration_edge( + edges, + issues, + before=after_id, + after=module_id, + source=module_id, + target_enabled=set(enabled_modules), + available=available, + ) + dependency_ids = [*metadata["dependencies"], *metadata["optional_dependencies"]] + for dependency_id in dependency_ids: + if dependency_id not in nodes: + continue + if node["phase"] == "retirement" and nodes[dependency_id]["phase"] == "retirement": + edges[module_id].add(dependency_id) + else: + edges[dependency_id].add(module_id) + for requirement in metadata["requires_interfaces"]: + name = requirement.get("name") if isinstance(requirement.get("name"), str) else None + if not name: + continue + version_min = requirement.get("version_min") if isinstance(requirement.get("version_min"), str) else None + version_max_exclusive = ( + requirement.get("version_max_exclusive") + if isinstance(requirement.get("version_max_exclusive"), str) + else None + ) + for provider_id, version in provider_modules.get(name, ()): + if provider_id not in nodes or provider_id == module_id: + continue + if not version_satisfies_range(version, version_min=version_min, version_max_exclusive=version_max_exclusive): + continue + if node["phase"] == "retirement" and nodes[provider_id]["phase"] == "retirement": + edges[module_id].add(provider_id) + else: + edges[provider_id].add(module_id) + + ordered_ids, cycle_ids = _topological_module_order(edges) + if cycle_ids: + cycle_text = ", ".join(cycle_ids) + issues.append(ModuleInstallerIssue( + "blocker", + "migration_graph_cycle", + f"Module migration ordering has a cycle involving: {cycle_text}. Adjust migration_after/migration_before metadata or publish a bridge release.", + )) + ordered_ids = tuple(sorted(nodes)) + + steps = tuple(_migration_step_from_node(nodes[module_id]) for module_id in ordered_ids if module_id in nodes) + tasks = _migration_task_plan_items(ordered_ids, target_metadata, nodes) + planned_by_id = {item.module_id: item for item in planned_items} + issues.extend(_migration_task_preflight_issues(tasks, planned_by_id)) + pending = [step.module_id for step in steps if step.metadata_pending] + if pending: + issues.append(ModuleInstallerIssue( + "info", + "migration_metadata_pending", + "Migration metadata for newly installed manual package(s) can only be confirmed after package verification: " + ", ".join(pending) + ".", + )) + return ModuleMigrationExecutionPlan(enabled_modules=enabled_modules, steps=steps, tasks=tasks), tuple(issues) + + +def _migration_step_relevant( + item: ModuleInstallPlanItem, + manifest: ModuleManifest | None, + catalog_module: Mapping[str, object] | None, +) -> bool: + if item.action == "uninstall": + return manifest is not None and manifest.migration_spec is not None + if item.action not in PACKAGE_TARGET_ACTIONS: + return False + if manifest is not None and manifest.migration_spec is not None: + return True + if catalog_module is not None: + safety = _catalog_optional_string(catalog_module, "migration_safety") or "automatic" + if safety != "automatic": + return True + if _catalog_string_list(catalog_module.get("migration_after")) or _catalog_string_list(catalog_module.get("migration_before")): + return True + if _catalog_migration_task_items(catalog_module.get("migration_tasks")): + return True + return bool(item.python_ref and item.source == "manual") + + +def _migration_node( + *, + item: ModuleInstallPlanItem, + manifest: ModuleManifest | None, + catalog_module: Mapping[str, object] | None, +) -> dict[str, object]: + has_manifest_metadata = manifest is not None and manifest.migration_spec is not None + has_catalog_metadata = catalog_module is not None and ( + (_catalog_optional_string(catalog_module, "migration_safety") or "automatic") != "automatic" + or bool(_catalog_string_list(catalog_module.get("migration_after"))) + or bool(_catalog_string_list(catalog_module.get("migration_before"))) + or bool(_catalog_migration_task_items(catalog_module.get("migration_tasks"))) + ) + phase = "retirement" if item.action == "uninstall" else "upgrade" + source = "manifest" if has_manifest_metadata else "catalog" if has_catalog_metadata else "pending" + reason = None + if item.action == "uninstall": + reason = "Module owns migration metadata and may leave dormant schema behind." + elif has_manifest_metadata: + reason = "Installed module owns migration metadata." + elif has_catalog_metadata: + reason = "Catalog target declares migration metadata." + else: + reason = "Manual backend package may add migration metadata after installation." + return { + "module_id": item.module_id, + "action": item.action, + "phase": phase, + "source": source, + "has_migration_metadata": has_manifest_metadata or has_catalog_metadata, + "metadata_pending": item.action in PACKAGE_TARGET_ACTIONS and source == "pending", + "migration_safety": _catalog_optional_string(catalog_module, "migration_safety") or "automatic", + "current_version": manifest.version if manifest is not None else None, + "target_version": _catalog_optional_string(catalog_module, "version"), + "reason": reason, + } + + +def _migration_step_from_node(node: Mapping[str, object]) -> ModuleMigrationPlanStep: + return ModuleMigrationPlanStep( + module_id=str(node["module_id"]), + action=str(node["action"]), + phase=str(node["phase"]), + source=str(node["source"]), + has_migration_metadata=node.get("has_migration_metadata") is True, + metadata_pending=node.get("metadata_pending") is True, + migration_safety=str(node.get("migration_safety") or "automatic"), + current_version=node.get("current_version") if isinstance(node.get("current_version"), str) else None, + target_version=node.get("target_version") if isinstance(node.get("target_version"), str) else None, + reason=node.get("reason") if isinstance(node.get("reason"), str) else None, + ) + + +def _migration_target_metadata( + *, + module_id: str, + manifest: ModuleManifest | None, + catalog_module: Mapping[str, object] | None, +) -> dict[str, object]: + dependencies: tuple[str, ...] = () + optional_dependencies: tuple[str, ...] = () + provides_interfaces: tuple[Mapping[str, object], ...] = () + requires_interfaces: tuple[Mapping[str, object], ...] = () + migration_after: tuple[str, ...] = () + migration_before: tuple[str, ...] = () + migration_tasks: tuple[Mapping[str, object], ...] = () + if manifest is not None: + dependencies = tuple(manifest.dependencies) + optional_dependencies = tuple(manifest.optional_dependencies) + provides_interfaces = tuple({"name": item.name, "version": item.version} for item in manifest.provides_interfaces) + requires_interfaces = tuple( + { + "name": item.name, + "version_min": item.version_min, + "version_max_exclusive": item.version_max_exclusive, + "optional": item.optional, + } + for item in manifest.requires_interfaces + ) + if manifest.migration_spec is not None: + migration_after = tuple(manifest.migration_spec.migration_after) + migration_before = tuple(manifest.migration_spec.migration_before) + migration_tasks = tuple( + { + "task_id": task.task_id, + "phase": task.phase, + "summary": task.summary, + "task_version": task.task_version, + "safety": task.safety, + "idempotent": task.idempotent, + "timeout_seconds": task.timeout_seconds, + "source": "manifest", + "has_executor": task.executor is not None, + } + for task in manifest.migration_spec.migration_tasks + ) + if catalog_module is not None: + dependencies = _catalog_string_list(catalog_module.get("dependencies")) or dependencies + optional_dependencies = _catalog_string_list(catalog_module.get("optional_dependencies")) or optional_dependencies + provides_interfaces = _catalog_interface_items(catalog_module.get("provides_interfaces")) or provides_interfaces + requires_interfaces = _catalog_interface_items(catalog_module.get("requires_interfaces")) or requires_interfaces + migration_after = _catalog_string_list(catalog_module.get("migration_after")) or migration_after + migration_before = _catalog_string_list(catalog_module.get("migration_before")) or migration_before + catalog_tasks = _catalog_migration_task_items(catalog_module.get("migration_tasks")) + if catalog_tasks: + migration_tasks = catalog_tasks + return { + "module_id": module_id, + "dependencies": dependencies, + "optional_dependencies": optional_dependencies, + "provides_interfaces": provides_interfaces, + "requires_interfaces": requires_interfaces, + "migration_after": migration_after, + "migration_before": migration_before, + "migration_tasks": migration_tasks, + } + + +def _migration_task_plan_items( + ordered_ids: tuple[str, ...], + target_metadata: Mapping[str, Mapping[str, object]], + nodes: Mapping[str, Mapping[str, object]], +) -> tuple[ModuleMigrationTaskPlanItem, ...]: + tasks: list[ModuleMigrationTaskPlanItem] = [] + for module_id in ordered_ids: + node = nodes.get(module_id) + metadata = target_metadata.get(module_id) + if node is None or metadata is None: + continue + metadata_pending = node.get("metadata_pending") is True + migration_tasks = tuple( + task for task in metadata.get("migration_tasks", ()) + if isinstance(task, Mapping) + ) + for phase in MIGRATION_TASK_PHASES: + for task in migration_tasks: + if str(task.get("phase") or "") != phase: + continue + source = str(task.get("source") or node.get("source") or "manifest") + tasks.append(ModuleMigrationTaskPlanItem( + module_id=module_id, + task_id=str(task.get("task_id") or ""), + phase=phase, + summary=str(task.get("summary") or ""), + task_version=str(task.get("task_version") or "1"), + safety=str(task.get("safety") or "automatic"), + idempotent=task.get("idempotent") is not False, + timeout_seconds=task.get("timeout_seconds") if isinstance(task.get("timeout_seconds"), int) else None, + source=source, + has_executor=task.get("has_executor") is True, + metadata_pending=metadata_pending or source != "manifest", + )) + return tuple(tasks) + + +def _migration_task_preflight_issues( + tasks: tuple[ModuleMigrationTaskPlanItem, ...], + planned_by_id: Mapping[str, ModuleInstallPlanItem], +) -> tuple[ModuleInstallerIssue, ...]: + issues: list[ModuleInstallerIssue] = [] + for task in tasks: + if not task.task_id: + issues.append(ModuleInstallerIssue( + "blocker", + "migration_task_id_required", + "Module migration task is missing a task id.", + task.module_id, + )) + if task.phase not in MIGRATION_TASK_PHASES: + issues.append(ModuleInstallerIssue( + "blocker", + "migration_task_phase_invalid", + f"Module migration task {task.task_id!r} declares unsupported phase {task.phase!r}.", + task.module_id, + )) + if not task.idempotent: + issues.append(ModuleInstallerIssue( + "blocker", + "migration_task_not_idempotent", + f"Module migration task {task.task_id!r} is not idempotent; publish an idempotent task before activation.", + task.module_id, + )) + planned_item = planned_by_id.get(task.module_id) + acknowledged = planned_item.data_safety_acknowledged if planned_item is not None else False + if task.safety in MIGRATION_TASK_REVIEW_SAFETY and not acknowledged: + severity: IssueSeverity = "blocker" if task.safety in MIGRATION_TASK_BLOCKING_SAFETY else "warning" + code = ( + "migration_task_acknowledgement_required" + if task.safety in MIGRATION_TASK_BLOCKING_SAFETY + else "migration_task_review_required" + ) + issues.append(ModuleInstallerIssue( + severity, + code, + f"Module migration task {task.task_id!r} is marked {task.safety}; review and acknowledge the package data-safety step before activation.", + task.module_id, + )) + elif task.safety in MIGRATION_TASK_REVIEW_SAFETY: + issues.append(ModuleInstallerIssue( + "warning", + "migration_task_acknowledged", + f"Module migration task {task.task_id!r} is marked {task.safety} and has been acknowledged.", + task.module_id, + )) + if task.metadata_pending: + issues.append(ModuleInstallerIssue( + "info", + "migration_task_executor_pending", + f"Module migration task {task.task_id!r} executor can only be confirmed after package installation.", + task.module_id, + )) + elif not task.has_executor: + issues.append(ModuleInstallerIssue( + "blocker", + "migration_task_executor_missing", + f"Module migration task {task.task_id!r} is declared in the installed manifest but has no executor.", + task.module_id, + )) + return tuple(issues) + + +def _migration_provider_modules( + target_metadata: Mapping[str, Mapping[str, object]], +) -> dict[str, tuple[tuple[str, str], ...]]: + providers: dict[str, list[tuple[str, str]]] = defaultdict(list) + for module_id, metadata in target_metadata.items(): + for provided in metadata.get("provides_interfaces", ()): + if not isinstance(provided, Mapping): + continue + name = provided.get("name") if isinstance(provided.get("name"), str) else None + version = provided.get("version") if isinstance(provided.get("version"), str) else None + if name and version: + providers[name].append((module_id, version)) + return {name: tuple(values) for name, values in providers.items()} + + +def _add_migration_edge( + edges: dict[str, set[str]], + issues: list[ModuleInstallerIssue], + *, + before: str, + after: str, + source: str, + target_enabled: set[str], + available: Mapping[str, ModuleManifest], +) -> None: + if before == after: + issues.append(ModuleInstallerIssue( + "blocker", + "migration_graph_self_dependency", + f"Module {source!r} declares migration ordering against itself.", + source, + )) + return + if before not in edges or after not in edges: + referenced = before if before not in edges else after + if referenced not in target_enabled and referenced not in available: + issues.append(ModuleInstallerIssue( + "blocker", + "migration_graph_reference_missing", + f"Module {source!r} declares migration ordering against absent module {referenced!r}.", + source, + )) + return + edges[before].add(after) + + +def _topological_module_order(edges: Mapping[str, set[str]]) -> tuple[tuple[str, ...], tuple[str, ...]]: + incoming: dict[str, set[str]] = {module_id: set() for module_id in edges} + outgoing: dict[str, set[str]] = {module_id: set(values) for module_id, values in edges.items()} + for before, after_values in edges.items(): + for after in after_values: + incoming.setdefault(after, set()).add(before) + outgoing.setdefault(before, set()).add(after) + ready = sorted(module_id for module_id, values in incoming.items() if not values) + ordered: list[str] = [] + while ready: + module_id = ready.pop(0) + ordered.append(module_id) + for after in sorted(outgoing.get(module_id, ())): + incoming[after].discard(module_id) + if not incoming[after] and after not in ordered and after not in ready: + ready.append(after) + ready.sort() + if len(ordered) == len(incoming): + return tuple(ordered), () + cycle_ids = tuple(sorted(module_id for module_id, values in incoming.items() if values)) + return tuple(ordered), cycle_ids + + def _catalog_modules_for_target_plan( planned_items: tuple[ModuleInstallPlanItem, ...], ) -> dict[str, Mapping[str, object]]: @@ -1243,6 +1915,12 @@ def _catalog_optional_string(module: Mapping[str, object] | None, key: str) -> s return None +def _catalog_optional_bool(module: Mapping[str, object] | None, key: str) -> bool: + if module is None: + return False + return module.get(key) is True + + def _selected_catalog_interface_issues( catalog_items: tuple[ModuleInstallPlanItem, ...], validation: Mapping[str, object], @@ -1274,6 +1952,7 @@ def _selected_catalog_interface_issues( available=available, modules=modules, )) + issues.extend(_catalog_update_policy_issues(item=item, module=module, available=available)) issues.extend(_catalog_data_safety_issues(item=item, module=module)) issues.extend(_catalog_requirement_issues( module_id=item.module_id, @@ -1345,13 +2024,31 @@ def _catalog_data_safety_issues( message = "Catalog marks this package change as requiring migration review before activation." + detail return (ModuleInstallerIssue(severity, code, message, item.module_id),) if safety == "forward_only": + issues: list[ModuleInstallerIssue] = [] + recovery_notes = _catalog_optional_string(module, "recovery_notes") + if not _catalog_optional_bool(module, "recovery_tested"): + issues.append(ModuleInstallerIssue( + "blocker", + "recovery_validation_required", + "Forward-only catalog updates must declare recovery_tested=true after a verified restore or forward-recovery rehearsal.", + item.module_id, + )) + if not recovery_notes and not item.notes: + issues.append(ModuleInstallerIssue( + "blocker", + "recovery_notes_required", + "Forward-only catalog updates require recovery notes or operator notes describing the tested recovery path.", + item.module_id, + )) if not item.data_safety_acknowledged: - return (ModuleInstallerIssue( + issues.append(ModuleInstallerIssue( "blocker", "forward_only_migration_acknowledgement_required", "Catalog marks this package change as forward-only; acknowledge the data safety review before activation." + detail, item.module_id, - ),) + )) + if issues: + return tuple(issues) return (ModuleInstallerIssue( "warning", "forward_only_migration_acknowledged", @@ -1360,6 +2057,21 @@ def _catalog_data_safety_issues( ),) if safety == "destructive": issues: list[ModuleInstallerIssue] = [] + recovery_notes = _catalog_optional_string(module, "recovery_notes") + if not _catalog_optional_bool(module, "recovery_tested"): + issues.append(ModuleInstallerIssue( + "blocker", + "recovery_validation_required", + "Destructive catalog updates must declare recovery_tested=true after a verified restore or forward-recovery rehearsal.", + item.module_id, + )) + if not recovery_notes and not item.notes: + issues.append(ModuleInstallerIssue( + "blocker", + "recovery_notes_required", + "Destructive catalog updates require recovery notes or operator notes describing the tested recovery path.", + item.module_id, + )) if not notes and not item.notes: issues.append(ModuleInstallerIssue( "blocker", @@ -1390,6 +2102,79 @@ def _catalog_data_safety_issues( ),) +def _catalog_update_policy_issues( + *, + item: ModuleInstallPlanItem, + module: Mapping[str, object], + available: Mapping[str, ModuleManifest], +) -> tuple[ModuleInstallerIssue, ...]: + if item.action != "update": + return () + manifest = available.get(item.module_id) + if manifest is None: + return () + target_version = _catalog_optional_string(module, "version") + issues: list[ModuleInstallerIssue] = [] + current_version_min = _catalog_optional_string(module, "current_version_min") + current_version_max_exclusive = _catalog_optional_string(module, "current_version_max_exclusive") + if target_version: + comparison = compare_versions(target_version, manifest.version) + if comparison < 0 and not _catalog_optional_bool(module, "allow_downgrade"): + issues.append(ModuleInstallerIssue( + "blocker", + "catalog_downgrade_blocked", + ( + f"Catalog target version {target_version} is older than installed version " + f"{manifest.version}; set allow_downgrade only for a reviewed rollback path." + ), + item.module_id, + )) + elif comparison == 0 and not _catalog_optional_bool(module, "allow_same_version"): + issues.append(ModuleInstallerIssue( + "blocker", + "catalog_noop_update_blocked", + ( + f"Catalog target version {target_version} matches the installed version; " + "use an install plan only when package refs or metadata intentionally need refreshing." + ), + item.module_id, + )) + if ( + current_version_min is not None + or current_version_max_exclusive is not None + ) and not version_satisfies_range( + manifest.version, + version_min=current_version_min, + version_max_exclusive=current_version_max_exclusive, + ): + version_range = format_version_range( + version_min=current_version_min, + version_max_exclusive=current_version_max_exclusive, + ) + bridge_note = _catalog_optional_string(module, "bridge_notes") + bridge_detail = f" Bridge note: {bridge_note}" if bridge_note else "" + issues.append(ModuleInstallerIssue( + "blocker", + "catalog_update_bridge_required", + ( + f"Installed version {manifest.version} is outside the supported update window " + f"for catalog target {target_version or item.module_id} ({version_range})." + + bridge_detail + ), + item.module_id, + )) + if _catalog_optional_bool(module, "bridge_release"): + bridge_note = _catalog_optional_string(module, "bridge_notes") + detail = f" Bridge note: {bridge_note}" if bridge_note else "" + issues.append(ModuleInstallerIssue( + "info", + "catalog_bridge_release", + "Catalog marks this target as a bridge release for staged update paths." + detail, + item.module_id, + )) + return tuple(issues) + + def _catalog_modules_by_id(validation: Mapping[str, object]) -> dict[str, Mapping[str, object]]: return { str(item.get("module_id")): item @@ -1423,6 +2208,58 @@ def _catalog_target_provider_versions( return providers +def _catalog_target_provider_versions_for_ids( + *, + planned_ids: set[str], + modules: Mapping[str, Mapping[str, object]], + available: Mapping[str, ModuleManifest], +) -> dict[str, list[tuple[str, str]]]: + providers: dict[str, list[tuple[str, str]]] = defaultdict(list) + for manifest in available.values(): + if manifest.id in planned_ids: + continue + for provided in manifest.provides_interfaces: + providers[provided.name].append((manifest.id, provided.version)) + for module_id in planned_ids: + module = modules.get(module_id) + if module is None: + continue + for provided in _catalog_interface_items(module.get("provides_interfaces")): + name = provided.get("name") if isinstance(provided.get("name"), str) else None + version = provided.get("version") if isinstance(provided.get("version"), str) else None + if name and version: + providers[name].append((module_id, version)) + return providers + + +def _catalog_requirement_satisfied( + requirement: Mapping[str, object], + target_provider_versions: Mapping[str, list[tuple[str, str]]], +) -> bool: + name = requirement.get("name") if isinstance(requirement.get("name"), str) else None + if not name: + return True + version_min = requirement.get("version_min") if isinstance(requirement.get("version_min"), str) else None + version_max_exclusive = ( + requirement.get("version_max_exclusive") + if isinstance(requirement.get("version_max_exclusive"), str) + else None + ) + providers = target_provider_versions.get(name, ()) + matching = [ + version + for _provider_module_id, version in providers + if version_satisfies_range( + version, + version_min=version_min, + version_max_exclusive=version_max_exclusive, + ) + ] + if matching: + return True + return requirement.get("optional") is True and not providers + + def _catalog_dependency_issues( *, module_id: str, @@ -1611,19 +2448,27 @@ def _catalog_interface_items(value: object) -> tuple[Mapping[str, object], ...]: return tuple(item for item in value if isinstance(item, Mapping)) +def _catalog_migration_task_items(value: object) -> tuple[Mapping[str, object], ...]: + if not isinstance(value, list): + return () + return tuple({**item, "source": "catalog", "has_executor": False} for item in value if isinstance(item, Mapping)) + + def _post_install_checklist( *, planned_items: tuple[ModuleInstallPlanItem, ...], maintenance_mode: bool, frontend_rebuild_required: bool, + migration_plan: ModuleMigrationExecutionPlan, blocker: bool, ) -> tuple[ModuleInstallChecklistItem, ...]: has_package_changes = bool(planned_items) - has_backend_changes = any(item.python_ref or item.python_package for item in planned_items) preflight_status: ChecklistStatus = "blocked" if blocker else "done" package_status: ChecklistStatus = "pending" if has_package_changes else "done" restart_status: ChecklistStatus = "pending" if has_package_changes else "done" - migration_status: ChecklistStatus = "pending" if has_backend_changes else "done" + migration_status: ChecklistStatus = "pending" if migration_plan.requires_database_migration else "done" + if blocker and migration_plan.requires_database_migration: + migration_status = "blocked" webui_status: ChecklistStatus = "pending" if frontend_rebuild_required else "done" module_state_status: ChecklistStatus = "pending" if has_package_changes else "done" health_status: ChecklistStatus = "pending" if has_package_changes else "done" @@ -1650,7 +2495,11 @@ def _post_install_checklist( id="migrations", label="Module migrations", status=migration_status, - detail="Run or verify module migrations after backend package changes.", + detail=( + "Run ordered module migrations after backend package changes." + if migration_plan.requires_database_migration + else "No module migration step is expected for this plan." + ), ), ModuleInstallChecklistItem( id="webui-rebuild", @@ -1867,6 +2716,13 @@ def _read_run_record(path: Path) -> dict[str, object] | None: return payload if isinstance(payload, dict) else None +def _read_json_file(path: Path) -> object | None: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + return None + + def _run_summary(path: Path, record: Mapping[str, object]) -> dict[str, object]: supervisor = record.get("supervisor") if isinstance(record.get("supervisor"), Mapping) else {} commands = record.get("commands") if isinstance(record.get("commands"), list) else [] @@ -2380,6 +3236,8 @@ def _command_record(command: Mapping[str, Any]) -> dict[str, object]: } if command.get("cwd"): payload["cwd"] = str(command["cwd"]) + if command.get("migration_task_record_path"): + payload["migration_task_record_path"] = str(command["migration_task_record_path"]) return payload diff --git a/src/govoplan_core/core/module_package_catalog.py b/src/govoplan_core/core/module_package_catalog.py index 67a00d0..d5e57e4 100644 --- a/src/govoplan_core/core/module_package_catalog.py +++ b/src/govoplan_core/core/module_package_catalog.py @@ -20,6 +20,12 @@ from govoplan_core.core.versioning import format_version_range, version_range_is _INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive") +CATALOG_MIGRATION_TASK_PHASES = ( + "pre_migration_check", + "pre_migration_prepare", + "post_migration_backfill", + "post_migration_verify", +) def module_package_catalog( @@ -611,6 +617,17 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]: "optional_dependencies": _string_list(value.get("optional_dependencies")), "migration_safety": _catalog_migration_safety(value.get("migration_safety"), module_id=module_id), "migration_notes": _optional_str(value, "migration_notes"), + "migration_after": _string_list(value.get("migration_after")), + "migration_before": _string_list(value.get("migration_before")), + "migration_tasks": _normalize_catalog_migration_tasks(value.get("migration_tasks"), module_id=module_id), + "current_version_min": _optional_str(value, "current_version_min"), + "current_version_max_exclusive": _optional_str(value, "current_version_max_exclusive"), + "bridge_release": _optional_bool(value, "bridge_release"), + "bridge_notes": _optional_str(value, "bridge_notes"), + "allow_downgrade": _optional_bool(value, "allow_downgrade"), + "allow_same_version": _optional_bool(value, "allow_same_version"), + "recovery_tested": _optional_bool(value, "recovery_tested"), + "recovery_notes": _optional_str(value, "recovery_notes"), "python_package": _optional_str(value, "python_package"), "python_ref": _optional_str(value, "python_ref"), "webui_package": _optional_str(value, "webui_package"), @@ -621,6 +638,15 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]: "notes": _optional_str(value, "notes"), "tags": _string_list(value.get("tags")), } + if not version_range_is_valid( + version_min=item["current_version_min"] if isinstance(item["current_version_min"], str) else None, + version_max_exclusive=item["current_version_max_exclusive"] if isinstance(item["current_version_max_exclusive"], str) else None, + ): + version_range = format_version_range( + version_min=item["current_version_min"] if isinstance(item["current_version_min"], str) else None, + version_max_exclusive=item["current_version_max_exclusive"] if isinstance(item["current_version_max_exclusive"], str) else None, + ) + raise ValueError(f"Module package catalog entry {module_id!r} has invalid current-version range {version_range!r}.") artifact_integrity = _normalize_artifact_integrity(value.get("artifact_integrity")) if artifact_integrity: item["artifact_integrity"] = artifact_integrity @@ -637,6 +663,66 @@ def _catalog_migration_safety(value: Any, *, module_id: str) -> str: return safety +def _normalize_catalog_migration_tasks(value: Any, *, module_id: str) -> list[dict[str, object]]: + if value is None: + return [] + if not isinstance(value, list): + raise ValueError(f"Module package catalog migration_tasks for {module_id!r} must be a list.") + normalized: list[dict[str, object]] = [] + seen: set[str] = set() + for raw in value: + if not isinstance(raw, dict): + raise ValueError(f"Module package catalog migration_tasks entries for {module_id!r} must be objects.") + task_id = _catalog_task_required_str(raw, "task_id", module_id=module_id) + if task_id in seen: + raise ValueError(f"Module package catalog entry {module_id!r} declares migration task {task_id!r} more than once.") + seen.add(task_id) + phase = _catalog_task_required_str(raw, "phase", module_id=module_id) + if phase not in CATALOG_MIGRATION_TASK_PHASES: + allowed = ", ".join(CATALOG_MIGRATION_TASK_PHASES) + raise ValueError(f"Unsupported migration task phase for {module_id!r}/{task_id!r}: {phase!r}; expected one of {allowed}.") + summary = _catalog_task_required_str(raw, "summary", module_id=module_id) + safety = _catalog_migration_safety(raw.get("safety"), module_id=module_id) + item: dict[str, object] = { + "task_id": task_id, + "phase": phase, + "summary": summary, + "task_version": _optional_str(raw, "task_version") or "1", + "safety": safety, + "idempotent": _catalog_optional_bool_default(raw, "idempotent", default=True), + } + timeout_seconds = _catalog_optional_positive_int(raw, "timeout_seconds", module_id=module_id, task_id=task_id) + if timeout_seconds is not None: + item["timeout_seconds"] = timeout_seconds + normalized.append(item) + return normalized + + +def _catalog_task_required_str(value: dict[str, Any], key: str, *, module_id: str) -> str: + item = _optional_str(value, key) + if not item: + raise ValueError(f"Module package catalog migration task for {module_id!r} is missing {key!r}.") + return item + + +def _catalog_optional_bool_default(value: dict[str, Any], key: str, *, default: bool) -> bool: + if key not in value: + return default + return _optional_bool(value, key) + + +def _catalog_optional_positive_int(value: dict[str, Any], key: str, *, module_id: str, task_id: str) -> int | None: + if value.get(key) is None: + return None + try: + integer = int(value[key]) + except (TypeError, ValueError) as exc: + raise ValueError(f"Module package catalog migration task {module_id!r}/{task_id!r} has invalid {key!r}.") from exc + if integer <= 0: + raise ValueError(f"Module package catalog migration task {module_id!r}/{task_id!r} requires positive {key!r}.") + return integer + + def _required_str(value: dict[str, Any], key: str) -> str: item = _optional_str(value, key) if not item: diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index 17ba8ea..31f6298 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -13,6 +13,14 @@ SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1" PermissionLevel = Literal["system", "tenant"] SubjectType = Literal["account", "membership", "group", "service_account", "tenant"] +MigrationTaskPhase = Literal[ + "pre_migration_check", + "pre_migration_prepare", + "post_migration_backfill", + "post_migration_verify", +] +MigrationTaskSafety = Literal["automatic", "requires_review", "forward_only", "destructive"] +MigrationTaskStatus = Literal["ok", "warning", "blocked", "skipped"] @dataclass(frozen=True, slots=True) @@ -91,11 +99,49 @@ MigrationRetirementExecutor = Callable[[object, str], None] MigrationRetirementProvider = Callable[[object | None, str], MigrationRetirementPlan] +@dataclass(frozen=True, slots=True) +class ModuleMigrationTaskContext: + module_id: str + task_id: str + phase: MigrationTaskPhase + database_url: str + target_version: str | None = None + session: object | None = None + dry_run: bool = False + metadata: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class ModuleMigrationTaskResult: + status: MigrationTaskStatus = "ok" + message: str | None = None + details: Mapping[str, Any] = field(default_factory=dict) + + +ModuleMigrationTaskExecutor = Callable[[ModuleMigrationTaskContext], ModuleMigrationTaskResult | None] + + +@dataclass(frozen=True, slots=True) +class ModuleMigrationTask: + task_id: str + phase: MigrationTaskPhase + summary: str + task_version: str = "1" + safety: MigrationTaskSafety = "automatic" + idempotent: bool = True + timeout_seconds: int | None = None + executor: ModuleMigrationTaskExecutor | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + @dataclass(frozen=True, slots=True) class MigrationSpec: module_id: str metadata: object | None = None script_location: str | None = None + migration_after: tuple[str, ...] = () + migration_before: tuple[str, ...] = () + migration_tasks: tuple[ModuleMigrationTask, ...] = () retirement_supported: bool = False retirement_provider: MigrationRetirementProvider | None = None retirement_notes: str | None = None @@ -215,7 +261,28 @@ class ResourceAclProvider(Protocol): TenantSummaryProvider = Callable[[object, str], Mapping[str, int]] -DeleteVetoProvider = Callable[[object, str, str], None] + + +@dataclass(frozen=True, slots=True) +class DeleteVetoIssue: + severity: Literal["blocker", "warning", "info"] + code: str + message: str + module_id: str | None = None + details: Mapping[str, Any] = field(default_factory=dict) + + +DeleteVetoProviderResult = DeleteVetoIssue | Iterable[DeleteVetoIssue] | None +DeleteVetoProvider = Callable[[object, str, str], DeleteVetoProviderResult] + + +@dataclass(frozen=True, slots=True) +class DeleteVetoProviderRegistration: + module_id: str + resource_type: str + provider: DeleteVetoProvider + + RouteFactory = Callable[[ModuleContext], "APIRouter"] CapabilityFactory = Callable[[ModuleContext], object] DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]] diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index f53d8db..026a2a6 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -3,11 +3,13 @@ from __future__ import annotations import re from collections import defaultdict, deque from collections.abc import Iterable, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from govoplan_core.core.modules import ( CapabilityFactory, + DeleteVetoIssue, DeleteVetoProvider, + DeleteVetoProviderRegistration, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleContext, @@ -45,7 +47,7 @@ class PlatformRegistry: def __init__(self) -> None: self._manifests: dict[str, ModuleManifest] = {} self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {} - self._delete_veto_providers: dict[str, list[DeleteVetoProvider]] = defaultdict(list) + self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list) self._capability_factories: dict[str, CapabilityFactory] = {} self._capabilities: dict[str, object] = {} self._capability_context: ModuleContext | None = None @@ -58,7 +60,7 @@ class PlatformRegistry: self.register_tenant_summary_provider(manifest.id, provider) for resource_type, providers in manifest.delete_veto_providers.items(): for provider in providers: - self.register_delete_veto(resource_type, provider) + self.register_delete_veto(manifest.id, resource_type, provider) for name, factory in manifest.capability_factories.items(): self.register_capability_factory(manifest.id, name, factory) return manifest @@ -148,12 +150,39 @@ class PlatformRegistry: def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]: return dict(self._tenant_summary_providers) - def register_delete_veto(self, resource_type: str, provider: DeleteVetoProvider) -> None: - self._delete_veto_providers[resource_type].append(provider) + def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None: + self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration( + module_id=module_id, + resource_type=resource_type, + provider=provider, + )) def delete_veto_providers(self, resource_type: str) -> tuple[DeleteVetoProvider, ...]: + return tuple(registration.provider for registration in self._delete_veto_providers.get(resource_type, ())) + + def delete_veto_provider_registrations(self, resource_type: str) -> tuple[DeleteVetoProviderRegistration, ...]: return tuple(self._delete_veto_providers.get(resource_type, ())) + def collect_delete_veto_issues(self, resource_type: str, session: object, tenant_id: str, resource_id: str) -> tuple[DeleteVetoIssue, ...]: + issues: list[DeleteVetoIssue] = [] + for registration in self.delete_veto_provider_registrations(resource_type): + try: + result = registration.provider(session, tenant_id, resource_id) + except Exception as exc: + issues.append(DeleteVetoIssue( + severity="blocker", + code="module_veto", + message=str(exc), + module_id=registration.module_id, + details={ + "resource_type": resource_type, + "resource_id": resource_id, + }, + )) + continue + issues.extend(_normalize_delete_veto_result(result, registration=registration, resource_id=resource_id)) + return tuple(issues) + def validate(self) -> RegistrySnapshot: ordered = tuple(self._topologically_sorted()) available_capabilities = set(self._capability_factories) @@ -224,6 +253,52 @@ class PlatformRegistry: return (self._manifests[module_id] for module_id in ordered) +def _normalize_delete_veto_result( + result: object, + *, + registration: DeleteVetoProviderRegistration, + resource_id: str, +) -> tuple[DeleteVetoIssue, ...]: + if result is None: + return () + if isinstance(result, DeleteVetoIssue): + return (_attribute_delete_veto_issue(result, registration=registration, resource_id=resource_id),) + if isinstance(result, str): + return (DeleteVetoIssue( + severity="blocker", + code="module_veto", + message=result, + module_id=registration.module_id, + details={ + "resource_type": registration.resource_type, + "resource_id": resource_id, + }, + ),) + if isinstance(result, Iterable): + return tuple( + _attribute_delete_veto_issue(issue, registration=registration, resource_id=resource_id) + for issue in result + if isinstance(issue, DeleteVetoIssue) + ) + return () + + +def _attribute_delete_veto_issue( + issue: DeleteVetoIssue, + *, + registration: DeleteVetoProviderRegistration, + resource_id: str, +) -> DeleteVetoIssue: + details = { + "resource_type": registration.resource_type, + "resource_id": resource_id, + **dict(issue.details), + } + if issue.module_id == registration.module_id and issue.details == details: + return issue + return replace(issue, module_id=issue.module_id or registration.module_id, details=details) + + def _validate_manifest_shape(manifest: ModuleManifest) -> None: if not _MODULE_ID_RE.match(manifest.id): raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}") diff --git a/src/govoplan_core/db/migrations.py b/src/govoplan_core/db/migrations.py index 358382f..5774926 100644 --- a/src/govoplan_core/db/migrations.py +++ b/src/govoplan_core/db/migrations.py @@ -1,19 +1,24 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass +import json import os from pathlib import Path +from typing import Any from alembic import command from alembic.config import Config from alembic.runtime.migration import MigrationContext +from alembic.script import ScriptDirectory from sqlalchemy import create_engine, inspect, text from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids -from govoplan_core.db.session import configure_database +from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult +from govoplan_core.db.session import configure_database, get_database from govoplan_core.server.default_config import get_server_config from govoplan_core.server.config import ManifestFactory from govoplan_core.server.registry import available_module_manifests, build_platform_registry @@ -33,6 +38,9 @@ REVISION_CORE_CHANGE_SEQUENCE = "3f4a5b6c7d8e" REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0" LEGACY_SCOPE_TABLE = "tenancy_tenants" CORE_SCOPE_TABLE = "core_scopes" +PRE_MIGRATION_TASK_PHASES = ("pre_migration_check", "pre_migration_prepare") +POST_MIGRATION_TASK_PHASES = ("post_migration_backfill", "post_migration_verify") +MIGRATION_TASK_PHASES = (*PRE_MIGRATION_TASK_PHASES, *POST_MIGRATION_TASK_PHASES) _NAMESPACE_TABLE_RENAMES = ( ("tenants", "tenancy_tenants"), @@ -160,12 +168,31 @@ class MigrationResult: current_revision: str | None +class ModuleMigrationTaskExecutionError(RuntimeError): + def __init__(self, message: str, *, records: tuple[dict[str, object], ...]) -> None: + super().__init__(message) + self.records = records + + def registered_module_migration_plan( database_url: str | None = None, *, enabled_modules: tuple[str, ...] | list[str] | None = None, manifest_factories: tuple[ManifestFactory, ...] = (), ) -> MigrationMetadataPlan: + return migration_metadata_plan(_registered_module_registry( + database_url=database_url, + enabled_modules=enabled_modules, + manifest_factories=manifest_factories, + )) + + +def _registered_module_registry( + *, + database_url: str | None = None, + enabled_modules: tuple[str, ...] | list[str] | None = None, + manifest_factories: tuple[ManifestFactory, ...] = (), +): server_config = get_server_config() active_database_url = database_url or settings.database_url if active_database_url: @@ -187,7 +214,130 @@ def registered_module_migration_plan( active_enabled_modules, manifest_factories=active_manifest_factories, ) - return migration_metadata_plan(registry) + return registry + + +def run_registered_module_migration_tasks( + *, + database_url: str | None = None, + enabled_modules: tuple[str, ...] | list[str] | None = None, + migration_module_order: tuple[str, ...] | list[str] | None = None, + phases: tuple[str, ...] | list[str] = MIGRATION_TASK_PHASES, + dry_run: bool = False, + manifest_factories: tuple[ManifestFactory, ...] = (), +) -> tuple[dict[str, object], ...]: + active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip())) + invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES) + if invalid_phases: + raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases)) + url = database_url or settings.database_url + registry = _registered_module_registry( + database_url=url, + enabled_modules=enabled_modules, + manifest_factories=manifest_factories, + ) + manifests = {manifest.id: manifest for manifest in registry.manifests()} + ordered_ids = tuple(dict.fromkeys([ + *(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()), + *manifests.keys(), + ])) + records: list[dict[str, object]] = [] + database = get_database() + with database.SessionLocal() as session: + for phase in active_phases: + for module_id in ordered_ids: + manifest = manifests.get(module_id) + if manifest is None or manifest.migration_spec is None: + continue + for task in manifest.migration_spec.migration_tasks: + if task.phase != phase: + continue + record: dict[str, object] = { + "module_id": manifest.id, + "task_id": task.task_id, + "phase": task.phase, + "summary": task.summary, + "task_version": task.task_version, + "safety": task.safety, + "idempotent": task.idempotent, + "dry_run": dry_run, + } + if task.timeout_seconds is not None: + record["timeout_seconds"] = task.timeout_seconds + if not task.idempotent: + record.update({"status": "blocked", "message": "Task is not idempotent."}) + records.append(record) + raise ModuleMigrationTaskExecutionError( + f"Module migration task {manifest.id}/{task.task_id} is not idempotent.", + records=tuple(records), + ) + if dry_run: + record.update({"status": "skipped", "message": "Dry run; executor was not called."}) + records.append(record) + continue + if task.executor is None: + record.update({"status": "blocked", "message": "Task has no executor."}) + records.append(record) + raise ModuleMigrationTaskExecutionError( + f"Module migration task {manifest.id}/{task.task_id} has no executor.", + records=tuple(records), + ) + context = ModuleMigrationTaskContext( + module_id=manifest.id, + task_id=task.task_id, + phase=task.phase, + database_url=url, + target_version=manifest.version, + session=session, + dry_run=dry_run, + metadata=task.metadata, + ) + try: + result = task.executor(context) + normalized = _normalize_migration_task_result(result) + except Exception as exc: + session.rollback() + record.update({ + "status": "blocked", + "message": f"{type(exc).__name__}: {exc}", + }) + records.append(record) + raise ModuleMigrationTaskExecutionError( + f"Module migration task {manifest.id}/{task.task_id} failed.", + records=tuple(records), + ) from exc + record.update({ + "status": normalized.status, + "message": normalized.message, + "details": _jsonable_migration_task_details(normalized.details), + }) + records.append(record) + if normalized.status == "blocked": + session.rollback() + raise ModuleMigrationTaskExecutionError( + normalized.message or f"Module migration task {manifest.id}/{task.task_id} blocked migration.", + records=tuple(records), + ) + session.commit() + return tuple(records) + + +def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult: + if result is None: + return ModuleMigrationTaskResult() + if not isinstance(result, ModuleMigrationTaskResult): + raise TypeError("Module migration task executors must return ModuleMigrationTaskResult or None.") + if result.status not in {"ok", "warning", "blocked", "skipped"}: + raise ValueError(f"Unsupported module migration task status: {result.status!r}") + return result + + +def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, Any] | str: + try: + json.dumps(value) + except (TypeError, ValueError): + return str(dict(value)) + return value def _repo_root() -> Path: @@ -518,6 +668,7 @@ def migrate_database( database_url: str | None = None, reconcile_legacy_schema: bool = True, enabled_modules: tuple[str, ...] | list[str] | None = None, + migration_module_order: tuple[str, ...] | list[str] | None = None, manifest_factories: tuple[ManifestFactory, ...] = (), ) -> MigrationResult: url = database_url or settings.database_url @@ -526,14 +677,19 @@ def migrate_database( reconcile_change_sequence_retention_floor_drift(url) previous = database_revision(url) reconciled = reconcile_legacy_create_all_schema(url) if reconcile_legacy_schema else None - command.upgrade( - alembic_config( - database_url=url, + config = alembic_config( + database_url=url, + enabled_modules=enabled_modules, + manifest_factories=manifest_factories, + ) + if migration_module_order: + plan = registered_module_migration_plan( + url, enabled_modules=enabled_modules, manifest_factories=manifest_factories, - ), - "heads", - ) + ) + _upgrade_ordered_module_heads(config, plan, tuple(migration_module_order)) + command.upgrade(config, "heads") if reconcile_legacy_schema: reconcile_scope_table_retirement_drift(url) current = database_revision(url) @@ -542,3 +698,43 @@ def migrate_database( reconciled_revision=reconciled, current_revision=current, ) + + +def _upgrade_ordered_module_heads( + config: Config, + plan: MigrationMetadataPlan, + module_order: tuple[str, ...], +) -> None: + module_heads = _module_heads_by_id(config, plan) + upgraded: set[str] = set() + for module_id in dict.fromkeys(module_order): + for head in module_heads.get(module_id, ()): + if head in upgraded: + continue + command.upgrade(config, head) + upgraded.add(head) + + +def _module_heads_by_id(config: Config, plan: MigrationMetadataPlan) -> dict[str, tuple[str, ...]]: + script = ScriptDirectory.from_config(config) + heads = script.get_heads() + module_locations = { + item.module_id: Path(item.script_location).resolve() + for item in plan.modules + if item.script_location + } + grouped: dict[str, list[str]] = {module_id: [] for module_id in module_locations} + for head in heads: + revision = script.get_revision(head) + path_text = getattr(revision, "path", None) + if revision is None or not path_text: + continue + revision_path = Path(path_text).resolve() + for module_id, location in module_locations.items(): + try: + revision_path.relative_to(location) + except ValueError: + continue + grouped[module_id].append(head) + break + return {module_id: tuple(sorted(values)) for module_id, values in grouped.items() if values} diff --git a/tests/test_delete_veto_contract.py b/tests/test_delete_veto_contract.py new file mode 100644 index 0000000..c954720 --- /dev/null +++ b/tests/test_delete_veto_contract.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import unittest + +from govoplan_core.core.modules import DeleteVetoIssue, ModuleManifest +from govoplan_core.core.registry import PlatformRegistry + + +class DeleteVetoContractTests(unittest.TestCase): + def test_structured_provider_result_is_module_attributed(self) -> None: + registry = PlatformRegistry() + registry.register(ModuleManifest( + id="records", + name="Records", + version="test", + delete_veto_providers={ + "tenant": ( + lambda _session, _tenant_id, _resource_id: DeleteVetoIssue( + severity="warning", + code="records_retained", + message="Records will remain archived.", + ), + ), + }, + )) + + issues = registry.collect_delete_veto_issues("tenant", object(), "tenant-1", "tenant-1") + + self.assertEqual(1, len(issues)) + self.assertEqual("warning", issues[0].severity) + self.assertEqual("records_retained", issues[0].code) + self.assertEqual("records", issues[0].module_id) + self.assertEqual({"resource_type": "tenant", "resource_id": "tenant-1"}, issues[0].details) + + def test_exception_based_provider_remains_blocking_veto(self) -> None: + def veto(_session: object, _tenant_id: str, _resource_id: str) -> None: + raise RuntimeError("Tenant owns external records.") + + registry = PlatformRegistry() + registry.register(ModuleManifest( + id="external_records", + name="External Records", + version="test", + delete_veto_providers={"tenant": (veto,)}, + )) + + issues = registry.collect_delete_veto_issues("tenant", object(), "tenant-1", "tenant-1") + + self.assertEqual(1, len(issues)) + self.assertEqual("blocker", issues[0].severity) + self.assertEqual("module_veto", issues[0].code) + self.assertEqual("external_records", issues[0].module_id) + self.assertEqual("Tenant owns external records.", issues[0].message) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_module_system.py b/tests/test_module_system.py index ba0bb63..7bda510 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -57,6 +57,7 @@ from govoplan_core.core.module_installer import ( list_module_installer_requests, module_installer_daemon_status, module_install_preflight, + module_install_catalog_companion_module_ids, module_manifest_compatibility_issues, module_installer_lock_status, queue_module_installer_request, @@ -65,6 +66,7 @@ from govoplan_core.core.module_installer import ( retry_module_installer_request, rollback_module_install_run, run_module_install_plan, + structured_install_commands, supervise_module_install_plan, update_module_installer_daemon_status, update_module_installer_request, @@ -95,13 +97,14 @@ from govoplan_core.core.module_package_catalog import ( sign_module_package_catalog, validate_module_package_catalog, ) -from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleUninstallGuardResult +from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult from govoplan_core.core.module_guards import drop_table_retirement_provider from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest from govoplan_core.core.registry import PlatformRegistry, RegistryError from govoplan_core.admin.models import SystemSettings from govoplan_core.db.base import Base from govoplan_core.db.bootstrap import bootstrap_dev_data +from govoplan_core.db.migrations import run_registered_module_migration_tasks from govoplan_core.db.session import configure_database as configure_database_handle, get_database, reset_database from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_core.security.permissions import scope_grants @@ -1228,6 +1231,103 @@ finally: self.assertTrue(companion_issues) self.assertTrue(any("files" in issue.message for issue in companion_issues)) + def test_module_installer_suggests_catalog_companion_provider_updates(self) -> None: + available = { + "files": ModuleManifest( + id="files", + name="Files", + version="1.0.0", + provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),), + ), + "campaigns": ModuleManifest(id="campaigns", name="Campaigns", version="1.0.0"), + } + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="campaigns", + action="update", + source="catalog", + python_package="govoplan-campaign", + python_ref="govoplan-campaign==2.0.0", + ), + )) + validation = { + "configured": True, + "valid": True, + "warnings": [], + "modules": [ + { + "module_id": "files", + "provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}], + }, + { + "module_id": "campaigns", + "requires_interfaces": [{ + "name": "files.attachments", + "version_min": "2.0.0", + "version_max_exclusive": "3.0.0", + }], + }, + ], + } + + companions = module_install_catalog_companion_module_ids(plan, available, validation=validation) + + self.assertEqual(("files",), companions) + + def test_module_installer_suggests_catalog_companion_consumer_updates(self) -> None: + available = { + "files": ModuleManifest( + id="files", + name="Files", + version="1.0.0", + provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),), + ), + "campaigns": ModuleManifest( + id="campaigns", + name="Campaigns", + version="1.0.0", + requires_interfaces=( + ModuleInterfaceRequirement( + name="files.attachments", + version_min="1.0.0", + version_max_exclusive="2.0.0", + ), + ), + ), + } + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="files", + action="update", + source="catalog", + python_package="govoplan-files", + python_ref="govoplan-files==2.0.0", + ), + )) + validation = { + "configured": True, + "valid": True, + "warnings": [], + "modules": [ + { + "module_id": "files", + "provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}], + }, + { + "module_id": "campaigns", + "requires_interfaces": [{ + "name": "files.attachments", + "version_min": "2.0.0", + "version_max_exclusive": "3.0.0", + }], + }, + ], + } + + companions = module_install_catalog_companion_module_ids(plan, available, validation=validation) + + self.assertEqual(("campaigns",), companions) + def test_module_installer_preflight_blocks_unacknowledged_forward_only_catalog_update(self) -> None: available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")} plan = ModuleInstallPlan(items=( @@ -1251,6 +1351,8 @@ finally: "version": "2.0.0", "migration_safety": "forward_only", "migration_notes": "Database rollback requires restoring the pre-update snapshot.", + "recovery_tested": True, + "recovery_notes": "Snapshot restore was rehearsed on the staging dataset.", }], }, ): @@ -1266,7 +1368,7 @@ finally: blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"} self.assertIn("forward_only_migration_acknowledgement_required", blockers) - def test_module_installer_preflight_allows_acknowledged_forward_only_catalog_update(self) -> None: + def test_module_installer_preflight_requires_recovery_validation_for_forward_only_update(self) -> None: available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")} plan = ModuleInstallPlan(items=( ModuleInstallPlanItem( @@ -1301,16 +1403,234 @@ finally: maintenance_mode=True, ) + self.assertFalse(preflight.allowed) + blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"} + self.assertIn("recovery_validation_required", blockers) + self.assertIn("recovery_notes_required", blockers) + + def test_module_installer_preflight_allows_acknowledged_forward_only_catalog_update(self) -> None: + available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")} + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="files", + action="update", + source="catalog", + python_package="govoplan-files", + python_ref="govoplan-files==2.0.0", + data_safety_acknowledged=True, + ), + )) + + with patch( + "govoplan_core.core.module_package_catalog.validate_module_package_catalog", + return_value={ + "configured": True, + "valid": True, + "warnings": [], + "modules": [{ + "module_id": "files", + "version": "2.0.0", + "migration_safety": "forward_only", + "migration_notes": "Database rollback requires restoring the pre-update snapshot.", + "recovery_tested": True, + "recovery_notes": "Snapshot restore was rehearsed on the staging dataset.", + }], + }, + ): + preflight = module_install_preflight( + plan=plan, + available=available, + current_enabled=(), + desired_enabled=(), + maintenance_mode=True, + ) + self.assertTrue(preflight.allowed) self.assertEqual(("files",), tuple(item.module_id for item in preflight.target_plan)) self.assertEqual("1.0.0", preflight.target_plan[0].current_version) self.assertEqual("2.0.0", preflight.target_plan[0].target_version) self.assertEqual("forward_only", preflight.target_plan[0].migration_safety) + self.assertTrue(preflight.target_plan[0].recovery_tested) + self.assertEqual("Snapshot restore was rehearsed on the staging dataset.", preflight.target_plan[0].recovery_notes) self.assertTrue(preflight.target_plan[0].data_safety_acknowledged) self.assertEqual("2.0.0", preflight.as_dict()["target_plan"][0]["target_version"]) warnings = {issue.code for issue in preflight.issues if issue.severity == "warning"} self.assertIn("forward_only_migration_acknowledged", warnings) + def test_module_installer_preflight_blocks_catalog_downgrade_and_noop_updates_by_default(self) -> None: + available = { + "files": ModuleManifest(id="files", name="Files", version="1.0.0"), + "mail": ModuleManifest(id="mail", name="Mail", version="1.0.0"), + } + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="files", + action="update", + source="catalog", + python_package="govoplan-files", + python_ref="govoplan-files==0.9.0", + ), + ModuleInstallPlanItem( + module_id="mail", + action="update", + source="catalog", + python_package="govoplan-mail", + python_ref="govoplan-mail==1.0.0", + ), + )) + + with patch( + "govoplan_core.core.module_package_catalog.validate_module_package_catalog", + return_value={ + "configured": True, + "valid": True, + "warnings": [], + "modules": [ + {"module_id": "files", "version": "0.9.0"}, + {"module_id": "mail", "version": "1.0.0"}, + ], + }, + ): + preflight = module_install_preflight( + plan=plan, + available=available, + current_enabled=(), + desired_enabled=(), + maintenance_mode=True, + ) + + self.assertFalse(preflight.allowed) + blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"} + self.assertIn("catalog_downgrade_blocked", blockers) + self.assertIn("catalog_noop_update_blocked", blockers) + + def test_module_installer_preflight_allows_reviewed_downgrade_and_noop_updates(self) -> None: + available = { + "files": ModuleManifest(id="files", name="Files", version="1.0.0"), + "mail": ModuleManifest(id="mail", name="Mail", version="1.0.0"), + } + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="files", + action="update", + source="catalog", + python_package="govoplan-files", + python_ref="govoplan-files==0.9.0", + ), + ModuleInstallPlanItem( + module_id="mail", + action="update", + source="catalog", + python_package="govoplan-mail", + python_ref="govoplan-mail==1.0.0", + ), + )) + + with patch( + "govoplan_core.core.module_package_catalog.validate_module_package_catalog", + return_value={ + "configured": True, + "valid": True, + "warnings": [], + "modules": [ + {"module_id": "files", "version": "0.9.0", "allow_downgrade": True}, + {"module_id": "mail", "version": "1.0.0", "allow_same_version": True}, + ], + }, + ): + preflight = module_install_preflight( + plan=plan, + available=available, + current_enabled=(), + desired_enabled=(), + maintenance_mode=True, + ) + + self.assertTrue(preflight.allowed) + blocker_codes = {issue.code for issue in preflight.issues if issue.severity == "blocker"} + self.assertNotIn("catalog_downgrade_blocked", blocker_codes) + self.assertNotIn("catalog_noop_update_blocked", blocker_codes) + + def test_module_installer_preflight_requires_catalog_update_bridge_when_current_version_is_outside_window(self) -> None: + available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")} + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="files", + action="update", + source="catalog", + python_package="govoplan-files", + python_ref="govoplan-files==3.0.0", + ), + )) + + with patch( + "govoplan_core.core.module_package_catalog.validate_module_package_catalog", + return_value={ + "configured": True, + "valid": True, + "warnings": [], + "modules": [{ + "module_id": "files", + "version": "3.0.0", + "current_version_min": "2.0.0", + "current_version_max_exclusive": "3.0.0", + "bridge_notes": "Install 2.x bridge before moving to 3.x.", + }], + }, + ): + preflight = module_install_preflight( + plan=plan, + available=available, + current_enabled=(), + desired_enabled=(), + maintenance_mode=True, + ) + + self.assertFalse(preflight.allowed) + blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"} + self.assertIn("catalog_update_bridge_required", blockers) + self.assertEqual("2.0.0", preflight.target_plan[0].current_version_min) + self.assertEqual("3.0.0", preflight.target_plan[0].current_version_max_exclusive) + + def test_module_installer_preflight_reports_catalog_bridge_release_metadata(self) -> None: + available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")} + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="files", + action="update", + source="catalog", + python_package="govoplan-files", + python_ref="govoplan-files==2.0.0", + ), + )) + + with patch( + "govoplan_core.core.module_package_catalog.validate_module_package_catalog", + return_value={ + "configured": True, + "valid": True, + "warnings": [], + "modules": [{ + "module_id": "files", + "version": "2.0.0", + "bridge_release": True, + "bridge_notes": "Keeps both 1.x and 2.x attachment interfaces available.", + }], + }, + ): + preflight = module_install_preflight( + plan=plan, + available=available, + current_enabled=(), + desired_enabled=(), + maintenance_mode=True, + ) + + self.assertTrue(preflight.allowed) + self.assertIn("catalog_bridge_release", {issue.code for issue in preflight.issues if issue.severity == "info"}) + self.assertTrue(preflight.target_plan[0].bridge_release) + self.assertEqual("Keeps both 1.x and 2.x attachment interfaces available.", preflight.target_plan[0].bridge_notes) + def test_module_installer_preflight_requires_destructive_catalog_update_plan(self) -> None: available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")} plan = ModuleInstallPlan(items=( @@ -1371,6 +1691,8 @@ finally: "module_id": "files", "migration_safety": "destructive", "migration_notes": "Drops obsolete cache tables after exporting the retained documents.", + "recovery_tested": True, + "recovery_notes": "Restore and forward-recovery path verified on staging.", }], }, ): @@ -1446,6 +1768,275 @@ finally: self.assertTrue(preflight.allowed) self.assertNotIn("companion_update_required", {issue.code for issue in preflight.issues}) + def test_module_installer_preflight_orders_migration_plan_from_interface_requirements(self) -> None: + metadata = MetaData() + available = { + "files": ModuleManifest( + id="files", + name="Files", + version="1.0.0", + provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),), + migration_spec=MigrationSpec(module_id="files", metadata=metadata), + ), + "campaigns": ModuleManifest( + id="campaigns", + name="Campaigns", + version="1.0.0", + requires_interfaces=( + ModuleInterfaceRequirement( + name="files.attachments", + version_min="1.0.0", + version_max_exclusive="2.0.0", + ), + ), + migration_spec=MigrationSpec(module_id="campaigns", metadata=metadata), + ), + } + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="campaigns", + action="update", + python_package="govoplan-campaign", + python_ref="govoplan-campaign==2.0.0", + ), + ModuleInstallPlanItem( + module_id="files", + action="update", + python_package="govoplan-files", + python_ref="govoplan-files==2.0.0", + ), + )) + + preflight = module_install_preflight( + plan=plan, + available=available, + current_enabled=("files", "campaigns"), + desired_enabled=("files", "campaigns"), + maintenance_mode=True, + ) + + self.assertTrue(preflight.allowed) + self.assertEqual(("files", "campaigns"), tuple(step.module_id for step in preflight.migration_plan.steps)) + self.assertEqual(("files", "campaigns"), tuple(preflight.migration_plan.enabled_modules)) + self.assertEqual(["files", "campaigns"], preflight.as_dict()["migration_plan"]["enabled_modules"]) + + def test_module_installer_preflight_blocks_migration_graph_cycles(self) -> None: + metadata = MetaData() + available = { + "files": ModuleManifest( + id="files", + name="Files", + version="1.0.0", + migration_spec=MigrationSpec(module_id="files", metadata=metadata, migration_after=("campaigns",)), + ), + "campaigns": ModuleManifest( + id="campaigns", + name="Campaigns", + version="1.0.0", + migration_spec=MigrationSpec(module_id="campaigns", metadata=metadata, migration_after=("files",)), + ), + } + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="campaigns", + action="update", + python_package="govoplan-campaign", + python_ref="govoplan-campaign==2.0.0", + ), + ModuleInstallPlanItem( + module_id="files", + action="update", + python_package="govoplan-files", + python_ref="govoplan-files==2.0.0", + ), + )) + + preflight = module_install_preflight( + plan=plan, + available=available, + current_enabled=("files", "campaigns"), + desired_enabled=("files", "campaigns"), + maintenance_mode=True, + ) + + self.assertFalse(preflight.allowed) + blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"} + self.assertIn("migration_graph_cycle", blockers) + + def test_structured_install_commands_pass_target_modules_and_migration_order(self) -> None: + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="files", + action="install", + python_package="govoplan-files", + python_ref="govoplan-files==2.0.0", + ), + )) + + commands = structured_install_commands( + plan, + webui_root=None, + migrate_database_url="sqlite:///example.db", + migration_enabled_modules=("access", "files"), + migration_module_order=("files",), + migration_task_record_path=Path("/tmp/govoplan-migration-tasks.json"), + verify_modules=True, + ) + + init_db = commands[-1]["argv"] + self.assertIn("govoplan_core.commands.init_db", init_db) + self.assertIn("--enabled-module", init_db) + self.assertIn("access", init_db) + self.assertIn("files", init_db) + self.assertIn("--migration-module", init_db) + self.assertIn("--migration-task-record-output", init_db) + self.assertEqual("/tmp/govoplan-migration-tasks.json", commands[-1]["migration_task_record_path"]) + + def test_module_installer_preflight_reports_and_blocks_unsafe_migration_tasks(self) -> None: + metadata = MetaData() + available = { + "files": ModuleManifest( + id="files", + name="Files", + version="1.0.0", + migration_spec=MigrationSpec( + module_id="files", + metadata=metadata, + migration_tasks=( + ModuleMigrationTask( + task_id="legacy_path_check", + phase="pre_migration_check", + summary="Check legacy paths before the schema migration.", + executor=lambda _context: ModuleMigrationTaskResult(), + ), + ModuleMigrationTask( + task_id="unsafe_backfill", + phase="post_migration_backfill", + summary="Backfill legacy file spaces.", + safety="forward_only", + idempotent=False, + executor=lambda _context: ModuleMigrationTaskResult(), + ), + ), + ), + ), + } + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="files", + action="update", + python_package="govoplan-files", + python_ref="govoplan-files==2.0.0", + ), + )) + + preflight = module_install_preflight( + plan=plan, + available=available, + current_enabled=("files",), + desired_enabled=("files",), + maintenance_mode=True, + ) + + self.assertFalse(preflight.allowed) + task_ids = {task.task_id for task in preflight.migration_plan.tasks} + self.assertEqual({"legacy_path_check", "unsafe_backfill"}, task_ids) + blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"} + self.assertIn("migration_task_not_idempotent", blockers) + self.assertIn("migration_task_acknowledgement_required", blockers) + + def test_module_installer_preflight_allows_acknowledged_forward_only_migration_task(self) -> None: + metadata = MetaData() + available = { + "files": ModuleManifest( + id="files", + name="Files", + version="1.0.0", + migration_spec=MigrationSpec( + module_id="files", + metadata=metadata, + migration_tasks=( + ModuleMigrationTask( + task_id="backfill_spaces", + phase="post_migration_backfill", + summary="Backfill file spaces.", + safety="forward_only", + executor=lambda _context: ModuleMigrationTaskResult(status="ok"), + ), + ), + ), + ), + } + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="files", + action="update", + python_package="govoplan-files", + python_ref="govoplan-files==2.0.0", + data_safety_acknowledged=True, + ), + )) + + preflight = module_install_preflight( + plan=plan, + available=available, + current_enabled=("files",), + desired_enabled=("files",), + maintenance_mode=True, + ) + + self.assertTrue(preflight.allowed) + self.assertEqual(("backfill_spaces",), tuple(task.task_id for task in preflight.migration_plan.tasks)) + self.assertIn("tasks", preflight.as_dict()["migration_plan"]) + + def test_registered_module_migration_tasks_execute_in_phase_order(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-module-migration-tasks-", dir=_TEST_ROOT)) + database_url = f"sqlite:///{root / 'tasks.db'}" + calls: list[tuple[str, str, bool, str | None]] = [] + + def executor(context: ModuleMigrationTaskContext) -> ModuleMigrationTaskResult: + calls.append((context.module_id, context.task_id, context.session is not None, context.target_version)) + return ModuleMigrationTaskResult(status="warning", message=f"ran {context.task_id}", details={"phase": context.phase}) + + manifest = ModuleManifest( + id="taskmod", + name="Task Module", + version="2.0.0", + migration_spec=MigrationSpec( + module_id="taskmod", + metadata=MetaData(), + migration_tasks=( + ModuleMigrationTask( + task_id="verify", + phase="post_migration_verify", + summary="Verify migrated state.", + executor=executor, + ), + ModuleMigrationTask( + task_id="check", + phase="pre_migration_check", + summary="Check source state.", + executor=executor, + ), + ), + ), + ) + + records = run_registered_module_migration_tasks( + database_url=database_url, + enabled_modules=("taskmod",), + migration_module_order=("taskmod",), + phases=("pre_migration_check", "post_migration_verify"), + manifest_factories=(lambda: manifest,), + ) + + self.assertEqual( + [("taskmod", "check", True, "2.0.0"), ("taskmod", "verify", True, "2.0.0")], + calls, + ) + self.assertEqual(["check", "verify"], [record["task_id"] for record in records]) + self.assertEqual(["warning", "warning"], [record["status"] for record in records]) + def test_module_installer_preflight_blocks_provider_update_that_breaks_installed_consumer(self) -> None: available = { "files": ModuleManifest( @@ -2155,6 +2746,25 @@ finally: "optional_dependencies": ["mail"], "migration_safety": "forward_only", "migration_notes": "Requires a tested backup restore path.", + "migration_after": ["access"], + "migration_before": ["campaigns"], + "migration_tasks": [{ + "task_id": "backfill_spaces", + "phase": "post_migration_backfill", + "summary": "Backfill file spaces after schema migration.", + "task_version": "2", + "safety": "forward_only", + "idempotent": True, + "timeout_seconds": 30, + }], + "current_version_min": "0.1.0", + "current_version_max_exclusive": "0.2.0", + "bridge_release": True, + "bridge_notes": "Keeps 0.1.x and 0.2.x file-space contracts available.", + "allow_downgrade": True, + "allow_same_version": True, + "recovery_tested": True, + "recovery_notes": "Restore rehearsal completed for the release candidate.", "provides_interfaces": [{"name": "files.spaces", "version": "1.2.0"}], "requires_interfaces": [{"name": "access.directory", "version_min": "1.0.0", "optional": True}], "artifact_integrity": { @@ -2177,6 +2787,25 @@ finally: self.assertEqual(["mail"], catalog[0]["optional_dependencies"]) self.assertEqual("forward_only", catalog[0]["migration_safety"]) self.assertEqual("Requires a tested backup restore path.", catalog[0]["migration_notes"]) + self.assertEqual(["access"], catalog[0]["migration_after"]) + self.assertEqual(["campaigns"], catalog[0]["migration_before"]) + self.assertEqual([{ + "task_id": "backfill_spaces", + "phase": "post_migration_backfill", + "summary": "Backfill file spaces after schema migration.", + "task_version": "2", + "safety": "forward_only", + "idempotent": True, + "timeout_seconds": 30, + }], catalog[0]["migration_tasks"]) + self.assertEqual("0.1.0", catalog[0]["current_version_min"]) + self.assertEqual("0.2.0", catalog[0]["current_version_max_exclusive"]) + self.assertTrue(catalog[0]["bridge_release"]) + self.assertEqual("Keeps 0.1.x and 0.2.x file-space contracts available.", catalog[0]["bridge_notes"]) + self.assertTrue(catalog[0]["allow_downgrade"]) + self.assertTrue(catalog[0]["allow_same_version"]) + self.assertTrue(catalog[0]["recovery_tested"]) + self.assertEqual("Restore rehearsal completed for the release candidate.", catalog[0]["recovery_notes"]) self.assertEqual(["official"], catalog[0]["tags"]) self.assertEqual([{"name": "files.spaces", "version": "1.2.0"}], catalog[0]["provides_interfaces"]) self.assertEqual( @@ -2245,6 +2874,22 @@ finally: self.assertFalse(validation["valid"]) self.assertIn("migration_safety", str(validation["error"])) + def test_module_package_catalog_rejects_invalid_current_version_window(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-current-window-", dir=_TEST_ROOT)) + catalog_path = root / "catalog.json" + catalog_path.write_text(json.dumps({ + "modules": [{ + "module_id": "files", + "current_version_min": "2.0.0", + "current_version_max_exclusive": "2.0.0", + }], + }), encoding="utf-8") + + validation = validate_module_package_catalog(catalog_path) + + self.assertFalse(validation["valid"]) + self.assertIn("current-version range", str(validation["error"])) + def test_module_package_catalog_rejects_invalid_interface_ranges(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-interface-", dir=_TEST_ROOT)) catalog_path = root / "catalog.json" @@ -2344,7 +2989,7 @@ finally: sys.executable, "scripts/generate-release-catalog.py", "--version", - "0.1.6", + "0.1.7", "--catalog-output", str(catalog_path), ], @@ -2377,8 +3022,8 @@ finally: self.assertEqual(["files", "mail"], modules["campaigns"]["optional_dependencies"]) self.assertEqual("requires_review", modules["files"]["migration_safety"]) self.assertIn("migration", modules["files"]["migration_notes"].lower()) - self.assertEqual("0.1.6", modules["files"]["version"]) - self.assertIn("@v0.1.6", modules["files"]["python_ref"]) + self.assertEqual("0.1.7", modules["files"]["version"]) + self.assertIn("@v0.1.7", modules["files"]["python_ref"]) def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT)) @@ -3158,9 +3803,11 @@ finally: self.assertEqual(1, len(core_plan.script_locations)) self.assertTrue(core_plan.script_locations[0].endswith("govoplan_access/backend/migrations/versions")) self.assertEqual(1, len(core_plan.metadata)) + self.assertEqual(("access",), tuple(item.module_id for item in core_plan.modules)) files_plan = migration_metadata_plan(build_platform_registry(("files",))) self.assertEqual(2, len(files_plan.script_locations)) + self.assertIn("files", {item.module_id for item in files_plan.modules}) files_locations = "\n".join(files_plan.script_locations) self.assertIn("govoplan_access/backend/migrations/versions", files_locations) self.assertIn("govoplan_files/backend/migrations/versions", files_locations) diff --git a/webui/package-lock.json b/webui/package-lock.json index f45182e..e345e16 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,15 +1,16 @@ { "name": "@govoplan/core-webui", - "version": "0.1.6", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/admin-webui": "file:../../govoplan-admin/webui", + "@govoplan/audit-webui": "file:../../govoplan-audit/webui", "@govoplan/calendar-webui": "file:../../govoplan-calendar/webui", "@govoplan/campaign-webui": "file:../../govoplan-campaign/webui", "@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui", @@ -18,7 +19,8 @@ "@govoplan/idm-webui": "file:../../govoplan-idm/webui", "@govoplan/mail-webui": "file:../../govoplan-mail/webui", "@govoplan/ops-webui": "file:../../govoplan-ops/webui", - "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui" + "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", + "@govoplan/policy-webui": "file:../../govoplan-policy/webui" }, "devDependencies": { "@types/react": "^19.0.2", @@ -41,12 +43,12 @@ }, "../../govoplan-access/webui": { "name": "@govoplan/access-webui", - "version": "0.1.6", + "version": "0.1.7", "devDependencies": { "typescript": "^5.7.2" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", + "@govoplan/core-webui": "^0.1.7", "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -60,12 +62,28 @@ }, "../../govoplan-admin/webui": { "name": "@govoplan/admin-webui", - "version": "0.1.6", + "version": "0.1.7", "devDependencies": { "typescript": "^5.7.2" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", + "@govoplan/core-webui": "^0.1.7", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, + "../../govoplan-audit/webui": { + "name": "@govoplan/audit-webui", + "version": "0.1.7", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.7", "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -79,9 +97,9 @@ }, "../../govoplan-calendar/webui": { "name": "@govoplan/calendar-webui", - "version": "0.1.6", + "version": "0.1.7", "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", + "@govoplan/core-webui": "^0.1.7", "@vitejs/plugin-react": "^4.3.4", "lucide-react": "^1.23.0", "react": "^19.0.0", @@ -98,15 +116,15 @@ }, "../../govoplan-campaign/webui": { "name": "@govoplan/campaign-webui", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { - "read-excel-file": "^9.2.0" + "read-excel-file": "9.2.0" }, "devDependencies": { "typescript": "^5.7.2" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", + "@govoplan/core-webui": "^0.1.7", "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -120,9 +138,9 @@ }, "../../govoplan-dashboard/webui": { "name": "@govoplan/dashboard-webui", - "version": "0.1.6", + "version": "0.1.7", "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", + "@govoplan/core-webui": "^0.1.7", "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -136,9 +154,9 @@ }, "../../govoplan-docs/webui": { "name": "@govoplan/docs-webui", - "version": "0.1.6", + "version": "0.1.7", "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", + "@govoplan/core-webui": "^0.1.7", "@vitejs/plugin-react": "^4.3.4", "lucide-react": "^1.23.0", "react": "^19.0.0", @@ -155,9 +173,9 @@ }, "../../govoplan-files/webui": { "name": "@govoplan/files-webui", - "version": "0.1.6", + "version": "0.1.7", "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", + "@govoplan/core-webui": "^0.1.7", "@vitejs/plugin-react": "^4.3.4", "lucide-react": "^1.23.0", "react": "^19.0.0", @@ -174,9 +192,9 @@ }, "../../govoplan-idm/webui": { "name": "@govoplan/idm-webui", - "version": "0.1.6", + "version": "0.1.7", "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", + "@govoplan/core-webui": "^0.1.7", "@vitejs/plugin-react": "^4.3.4", "lucide-react": "^1.23.0", "react": "^19.0.0", @@ -193,12 +211,12 @@ }, "../../govoplan-mail/webui": { "name": "@govoplan/mail-webui", - "version": "0.1.6", + "version": "0.1.7", "devDependencies": { "typescript": "^5.7.2" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", + "@govoplan/core-webui": "^0.1.7", "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -212,9 +230,9 @@ }, "../../govoplan-ops/webui": { "name": "@govoplan/ops-webui", - "version": "0.1.6", + "version": "0.1.7", "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", + "@govoplan/core-webui": "^0.1.7", "@vitejs/plugin-react": "^4.3.4", "lucide-react": "^1.23.0", "react": "^19.0.0", @@ -231,9 +249,9 @@ }, "../../govoplan-organizations/webui": { "name": "@govoplan/organizations-webui", - "version": "0.1.6", + "version": "0.1.7", "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", + "@govoplan/core-webui": "^0.1.7", "@vitejs/plugin-react": "^4.3.4", "lucide-react": "^1.23.0", "react": "^19.0.0", @@ -248,6 +266,22 @@ } } }, + "../../govoplan-policy/webui": { + "name": "@govoplan/policy-webui", + "version": "0.1.7", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.7", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -980,6 +1014,10 @@ "resolved": "../../govoplan-admin/webui", "link": true }, + "node_modules/@govoplan/audit-webui": { + "resolved": "../../govoplan-audit/webui", + "link": true + }, "node_modules/@govoplan/calendar-webui": { "resolved": "../../govoplan-calendar/webui", "link": true @@ -1016,6 +1054,10 @@ "resolved": "../../govoplan-organizations/webui", "link": true }, + "node_modules/@govoplan/policy-webui": { + "resolved": "../../govoplan-policy/webui", + "link": true + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/webui/package-lock.release.json b/webui/package-lock.release.json index 25f21ca..afc2d53 100644 --- a/webui/package-lock.release.json +++ b/webui/package-lock.release.json @@ -1,25 +1,32 @@ { "name": "@govoplan/core-webui", - "version": "0.1.6", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { - "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.6", - "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.6", - "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.6", - "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.6", - "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.6", - "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.6" + "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.7", + "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.7", + "@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.7", + "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.7", + "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.7", + "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.7", + "@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.7", + "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.7", + "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.7", + "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.7", + "@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.7", + "@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.7", + "@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.7" }, "devDependencies": { "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1", @@ -27,7 +34,7 @@ "vite": "^6.0.6" }, "peerDependencies": { - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" @@ -713,11 +720,11 @@ } }, "node_modules/@govoplan/access-webui": { - "version": "0.1.6", - "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#37828fe34099c17267e63cfbc3a78ae1075b6b52", + "version": "0.1.7", + "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#d08a41fb56053331cc6f6ffe2757b6f2e241f33a", "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "@govoplan/core-webui": "^0.1.7", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" @@ -729,11 +736,27 @@ } }, "node_modules/@govoplan/admin-webui": { - "version": "0.1.6", - "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#0f2a9beca76c695a70a709bce2d843e6b1349277", + "version": "0.1.7", + "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#24edb7eb8a4f7b63297718319a3bbdf74b3a9aeb", "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "@govoplan/core-webui": "^0.1.7", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, + "node_modules/@govoplan/audit-webui": { + "version": "0.1.7", + "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#7ffb16d98198f7485275bb3204ed71fe13f9e76a", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.7", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" @@ -745,12 +768,12 @@ } }, "node_modules/@govoplan/calendar-webui": { - "version": "0.1.6", - "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#c3c867391c39813593930f0272a1a0142c70872b", + "version": "0.1.7", + "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#39d2c28ab12f59d549f5e6d11584e8dbba1e3f52", "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", + "@govoplan/core-webui": "^0.1.7", "@vitejs/plugin-react": "^4.3.4", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1", @@ -764,14 +787,14 @@ } }, "node_modules/@govoplan/campaign-webui": { - "version": "0.1.6", - "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#57f6066bf72ba817ce5a1973405d2422ae59678d", + "version": "0.1.7", + "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#83b2082c74ae9d09c1fec7eb709368ffc5116ecb", "dependencies": { - "read-excel-file": "^9.2.0" + "read-excel-file": "9.2.0" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "@govoplan/core-webui": "^0.1.7", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" @@ -782,13 +805,67 @@ } } }, - "node_modules/@govoplan/files-webui": { - "version": "0.1.6", - "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#18e6c3eb9b776bd58c18a750725d6cd8a403bc5c", + "node_modules/@govoplan/dashboard-webui": { + "version": "0.1.7", + "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#a8cf9949c64be366ce1d45ec4495a99b53aa0ee3", "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", + "@govoplan/core-webui": "^0.1.7", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, + "node_modules/@govoplan/docs-webui": { + "version": "0.1.7", + "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#370356882cf5becfdf6fc0c53f2a2b2b3db1e975", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.7", "@vitejs/plugin-react": "^4.3.4", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, + "node_modules/@govoplan/files-webui": { + "version": "0.1.7", + "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#620fdda2fc434005ccf9550f730687b9d84083ed", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.7", + "@vitejs/plugin-react": "^4.3.4", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, + "node_modules/@govoplan/idm-webui": { + "version": "0.1.7", + "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#3b0e8bc6000007d0db91d869d3045897b58b4b50", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.7", + "@vitejs/plugin-react": "^4.3.4", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1", @@ -802,11 +879,65 @@ } }, "node_modules/@govoplan/mail-webui": { - "version": "0.1.6", - "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#b4b0c76455e5fddadd03af3b023c82678dedf36f", + "version": "0.1.7", + "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#286b6cafd977a223418b5cc9446fd1a95e29428c", "peerDependencies": { - "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "@govoplan/core-webui": "^0.1.7", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, + "node_modules/@govoplan/ops-webui": { + "version": "0.1.7", + "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#812216ad13b513b9dd02b752bc3e8cf213e5e510", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.7", + "@vitejs/plugin-react": "^4.3.4", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, + "node_modules/@govoplan/organizations-webui": { + "version": "0.1.7", + "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#213157aca1079ecf9576bad77366be9601dfabb9", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.7", + "@vitejs/plugin-react": "^4.3.4", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, + "node_modules/@govoplan/policy-webui": { + "version": "0.1.7", + "resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#97f05a083fcc5c694e60f1939b47ee8cfbcac089", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.7", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" @@ -1584,9 +1715,9 @@ } }, "node_modules/lucide-react": { - "version": "0.555.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz", - "integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", + "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/webui/package.json b/webui/package.json index 3debf07..43cdd5f 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.6", + "version": "0.1.7", "private": true, "type": "module", "main": "src/index.ts", @@ -28,6 +28,7 @@ "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/admin-webui": "file:../../govoplan-admin/webui", + "@govoplan/audit-webui": "file:../../govoplan-audit/webui", "@govoplan/calendar-webui": "file:../../govoplan-calendar/webui", "@govoplan/campaign-webui": "file:../../govoplan-campaign/webui", "@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui", @@ -36,7 +37,8 @@ "@govoplan/idm-webui": "file:../../govoplan-idm/webui", "@govoplan/mail-webui": "file:../../govoplan-mail/webui", "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", - "@govoplan/ops-webui": "file:../../govoplan-ops/webui" + "@govoplan/ops-webui": "file:../../govoplan-ops/webui", + "@govoplan/policy-webui": "file:../../govoplan-policy/webui" }, "devDependencies": { "@types/react": "^19.0.2", diff --git a/webui/package.release.json b/webui/package.release.json index dcd0221..9643b5b 100644 --- a/webui/package.release.json +++ b/webui/package.release.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.6", + "version": "0.1.7", "private": true, "type": "module", "main": "src/index.ts", @@ -22,17 +22,19 @@ "preview": "vite preview --host 127.0.0.1 --port 4173" }, "dependencies": { - "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.6", - "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.6", - "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.6", - "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6", - "@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.6", - "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.6", - "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.6", - "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.6", - "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.6", - "@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.6", - "@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.6" + "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.7", + "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.7", + "@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.7", + "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.7", + "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.7", + "@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.7", + "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.7", + "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.7", + "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.7", + "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.7", + "@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.7", + "@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.7", + "@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.7" }, "devDependencies": { "lucide-react": "^1.23.0", diff --git a/webui/scripts/test-module-permutations.mjs b/webui/scripts/test-module-permutations.mjs index ff22401..04b9445 100644 --- a/webui/scripts/test-module-permutations.mjs +++ b/webui/scripts/test-module-permutations.mjs @@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process"; const packageByModule = { access: "@govoplan/access-webui", admin: "@govoplan/admin-webui", + audit: "@govoplan/audit-webui", campaigns: "@govoplan/campaign-webui", dashboard: "@govoplan/dashboard-webui", docs: "@govoplan/docs-webui", @@ -10,7 +11,8 @@ const packageByModule = { idm: "@govoplan/idm-webui", mail: "@govoplan/mail-webui", organizations: "@govoplan/organizations-webui", - ops: "@govoplan/ops-webui" + ops: "@govoplan/ops-webui", + policy: "@govoplan/policy-webui" }; const cases = [ @@ -18,6 +20,7 @@ const cases = [ { name: "access-only", modules: ["access"] }, { name: "admin-only", modules: ["admin"] }, { name: "access-with-admin", modules: ["access", "admin"] }, + { name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] }, { name: "dashboard-only", modules: ["dashboard"] }, { name: "files-only", modules: ["files"] }, { name: "mail-only", modules: ["mail"] }, @@ -27,7 +30,7 @@ const cases = [ { name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] }, { name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] }, { name: "docs-and-ops", modules: ["access", "docs", "ops"] }, - { name: "full-product", modules: ["access", "admin", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops"] } + { name: "full-product", modules: ["access", "admin", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops"] } ]; const npmExec = process.env.npm_execpath; diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 88baf06..475ed6c 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -13,6 +13,7 @@ const resolvedInstalledModulesVirtualId = `\0${installedModulesVirtualId}`; const defaultWebModulePackages = [ "@govoplan/access-webui", "@govoplan/admin-webui", + "@govoplan/audit-webui", "@govoplan/calendar-webui", "@govoplan/campaign-webui", "@govoplan/dashboard-webui", @@ -21,7 +22,8 @@ const defaultWebModulePackages = [ "@govoplan/idm-webui", "@govoplan/mail-webui", "@govoplan/organizations-webui", - "@govoplan/ops-webui" + "@govoplan/ops-webui", + "@govoplan/policy-webui" ]; function configuredWebModulePackages(): string[] { @@ -92,6 +94,7 @@ export default defineConfig({ fileURLToPath(new URL('.', import.meta.url)), fileURLToPath(new URL('../../govoplan-access/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-admin/webui', import.meta.url)), + fileURLToPath(new URL('../../govoplan-audit/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-calendar/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)), @@ -100,7 +103,8 @@ export default defineConfig({ fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)), - fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)) + fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)), + fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)) ] }, proxy: {