Release v0.1.7
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
[](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=module-matrix.yml&actor=0&status=0)
|
||||
[](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=release-integration.yml&actor=0&status=0)
|
||||
[](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=dependency-audit.yml&actor=0&status=0)
|
||||
|
||||
# govoplan-core
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<manifest.version>`, 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:
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,55 +306,19 @@ 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)
|
||||
if [[ "$project_name" == "govoplan-core" ]]; then
|
||||
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
|
||||
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)
|
||||
|
||||
[[ "${#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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
else
|
||||
run git -C "$repo" commit -m "$COMMIT_MESSAGE"
|
||||
fi
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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(
|
||||
config = alembic_config(
|
||||
database_url=url,
|
||||
enabled_modules=enabled_modules,
|
||||
manifest_factories=manifest_factories,
|
||||
),
|
||||
"heads",
|
||||
)
|
||||
if migration_module_order:
|
||||
plan = registered_module_migration_plan(
|
||||
url,
|
||||
enabled_modules=enabled_modules,
|
||||
manifest_factories=manifest_factories,
|
||||
)
|
||||
_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}
|
||||
|
||||
57
tests/test_delete_veto_contract.py
Normal file
57
tests/test_delete_veto_contract.py
Normal file
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
94
webui/package-lock.json
generated
94
webui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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#b9b371a70470e30f470f8a9a3b03ee53e8657f84",
|
||||
"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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user