Release v0.1.7
All checks were successful
Dependency Audit / dependency-audit (push) Successful in 1m35s

This commit is contained in:
2026-07-11 02:46:04 +02:00
parent edb4687826
commit a00ef54821
25 changed files with 2740 additions and 201 deletions

View File

@@ -1,4 +1,5 @@
[![Module Matrix](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/module-matrix.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=module-matrix.yml&actor=0&status=0)
[![Release Integration](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/release-integration.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=release-integration.yml&actor=0&status=0)
[![Dependency Audit](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/dependency-audit.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=dependency-audit.yml&actor=0&status=0)
# govoplan-core

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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"

View File

@@ -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}

View File

@@ -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"

View File

@@ -92,19 +92,39 @@ PARENT="$(dirname "$ROOT")"
PACKAGE_MODULE_REPOS=(
"$PARENT/govoplan-access"
"$PARENT/govoplan-admin"
"$PARENT/govoplan-tenancy"
"$PARENT/govoplan-organizations"
"$PARENT/govoplan-approvals"
"$PARENT/govoplan-assets"
"$PARENT/govoplan-audit"
"$PARENT/govoplan-booking"
"$PARENT/govoplan-calendar"
"$PARENT/govoplan-campaign"
"$PARENT/govoplan-certificates"
"$PARENT/govoplan-committee"
"$PARENT/govoplan-consultation"
"$PARENT/govoplan-contracts"
"$PARENT/govoplan-dashboard"
"$PARENT/govoplan-docs"
"$PARENT/govoplan-facilities"
"$PARENT/govoplan-files"
"$PARENT/govoplan-forms-runtime"
"$PARENT/govoplan-grants"
"$PARENT/govoplan-helpdesk"
"$PARENT/govoplan-identity"
"$PARENT/govoplan-idm"
"$PARENT/govoplan-policy"
"$PARENT/govoplan-audit"
"$PARENT/govoplan-dashboard"
"$PARENT/govoplan-files"
"$PARENT/govoplan-inspections"
"$PARENT/govoplan-issue-reporting"
"$PARENT/govoplan-learning"
"$PARENT/govoplan-mail"
"$PARENT/govoplan-campaign"
"$PARENT/govoplan-calendar"
"$PARENT/govoplan-docs"
"$PARENT/govoplan-ops"
"$PARENT/govoplan-organizations"
"$PARENT/govoplan-permits"
"$PARENT/govoplan-policy"
"$PARENT/govoplan-procurement"
"$PARENT/govoplan-records"
"$PARENT/govoplan-resources"
"$PARENT/govoplan-risk-compliance"
"$PARENT/govoplan-tenancy"
"$PARENT/govoplan-transparency"
)
TAG_ONLY_MODULE_REPOS=(
"$PARENT/govoplan-addresses"
@@ -120,6 +140,7 @@ TAG_ONLY_MODULE_REPOS=(
"$PARENT/govoplan-notifications"
"$PARENT/govoplan-payments"
"$PARENT/govoplan-portal"
"$PARENT/govoplan-postbox"
"$PARENT/govoplan-reporting"
"$PARENT/govoplan-scheduling"
"$PARENT/govoplan-search"
@@ -173,6 +194,9 @@ if [[ -z "$NPM_BIN" ]]; then
fi
fi
export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes -o ConnectTimeout=15}"
GIT_REMOTE_TIMEOUT="${GIT_REMOTE_TIMEOUT:-30}"
normalize_bump() {
local value="${1,,}"
case "$value" in
@@ -282,56 +306,20 @@ update_manifest_version() {
local repo="$1"
local project_name="$2"
local version="$3"
local manifest_path=""
local manifest_paths=()
case "$project_name" in
govoplan-core)
return 0
;;
govoplan-access)
manifest_path="$repo/src/govoplan_access/backend/manifest.py"
;;
govoplan-admin)
manifest_path="$repo/src/govoplan_admin/backend/manifest.py"
;;
govoplan-tenancy)
manifest_path="$repo/src/govoplan_tenancy/backend/manifest.py"
;;
govoplan-organizations)
manifest_path="$repo/src/govoplan_organizations/backend/manifest.py"
;;
govoplan-identity)
manifest_path="$repo/src/govoplan_identity/backend/manifest.py"
;;
govoplan-idm)
manifest_path="$repo/src/govoplan_idm/backend/manifest.py"
;;
govoplan-policy)
manifest_path="$repo/src/govoplan_policy/backend/manifest.py"
;;
govoplan-audit)
manifest_path="$repo/src/govoplan_audit/backend/manifest.py"
;;
govoplan-files)
manifest_path="$repo/src/govoplan_files/backend/manifest.py"
;;
govoplan-mail)
manifest_path="$repo/src/govoplan_mail/backend/manifest.py"
;;
govoplan-campaign)
manifest_path="$repo/src/govoplan_campaign/backend/manifest.py"
;;
govoplan-calendar)
manifest_path="$repo/src/govoplan_calendar/backend/manifest.py"
;;
*)
fail "unknown project for manifest version update: $project_name"
;;
esac
if [[ "$project_name" == "govoplan-core" ]]; then
return 0
fi
[[ -f "$manifest_path" ]] || fail "missing module manifest: $manifest_path"
while IFS= read -r -d '' manifest_path; do
manifest_paths+=("$manifest_path")
done < <(find "$repo/src" -path "*/backend/manifest.py" -print0 2>/dev/null)
"$PYTHON" - "$manifest_path" "$version" <<'PYCODE'
[[ "${#manifest_paths[@]}" -gt 0 ]] || fail "missing module manifest under $repo/src"
for manifest_path in "${manifest_paths[@]}"; do
"$PYTHON" - "$manifest_path" "$version" <<'PYCODE'
from __future__ import annotations
import pathlib
@@ -347,10 +335,18 @@ text, count = re.subn(
text,
count=1,
)
if count == 0:
text, count = re.subn(
r'(?m)^(MODULE_VERSION\s*=\s*)["\'][^"\']+["\'](\s*)$',
rf'\1"{new_version}"\2',
text,
count=1,
)
if count != 1:
raise SystemExit(f"could not update ModuleManifest.version in {path}")
path.write_text(text)
PYCODE
done
}
update_webui_package() {
@@ -729,7 +725,7 @@ for repo in "${REPOS[@]}"; do
fi
set +e
git -C "$repo" ls-remote --exit-code --tags "$REMOTE" "refs/tags/$TAG" >/dev/null
timeout "$GIT_REMOTE_TIMEOUT" git -C "$repo" ls-remote --exit-code --tags "$REMOTE" "refs/tags/$TAG" >/dev/null
ls_remote_status=$?
set -e
@@ -790,9 +786,14 @@ for repo in "${MODULE_REPOS[@]}"; do
if [[ "$DRY_RUN" -eq 0 ]]; then
if git -C "$repo" diff --cached --quiet; then
if [[ "${REPO_KINDS[$repo]}" == "package" ]]; then
fail "no staged changes for ${PROJECT_NAMES[$repo]} after version bump"
if [[ "$TARGET_VERSION" == "${OLD_VERSIONS[$repo]}" ]]; then
echo "No staged changes for ${PROJECT_NAMES[$repo]}; version is already $TARGET_VERSION, tagging current HEAD."
else
fail "no staged changes for ${PROJECT_NAMES[$repo]} after version bump"
fi
else
echo "No staged changes for ${PROJECT_NAMES[$repo]}; tagging current HEAD."
fi
echo "No staged changes for ${PROJECT_NAMES[$repo]}; tagging current HEAD."
else
run git -C "$repo" commit -m "$COMMIT_MESSAGE"
fi

View File

@@ -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()

View File

@@ -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

View File

@@ -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:

View File

@@ -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]]

View File

@@ -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}")

View File

@@ -1,19 +1,24 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
import json
import os
from pathlib import Path
from typing import Any
from alembic import command
from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine, inspect, text
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.db.session import configure_database
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.server.default_config import get_server_config
from govoplan_core.server.config import ManifestFactory
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
@@ -33,6 +38,9 @@ REVISION_CORE_CHANGE_SEQUENCE = "3f4a5b6c7d8e"
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
LEGACY_SCOPE_TABLE = "tenancy_tenants"
CORE_SCOPE_TABLE = "core_scopes"
PRE_MIGRATION_TASK_PHASES = ("pre_migration_check", "pre_migration_prepare")
POST_MIGRATION_TASK_PHASES = ("post_migration_backfill", "post_migration_verify")
MIGRATION_TASK_PHASES = (*PRE_MIGRATION_TASK_PHASES, *POST_MIGRATION_TASK_PHASES)
_NAMESPACE_TABLE_RENAMES = (
("tenants", "tenancy_tenants"),
@@ -160,12 +168,31 @@ class MigrationResult:
current_revision: str | None
class ModuleMigrationTaskExecutionError(RuntimeError):
def __init__(self, message: str, *, records: tuple[dict[str, object], ...]) -> None:
super().__init__(message)
self.records = records
def registered_module_migration_plan(
database_url: str | None = None,
*,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> MigrationMetadataPlan:
return migration_metadata_plan(_registered_module_registry(
database_url=database_url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
))
def _registered_module_registry(
*,
database_url: str | None = None,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
):
server_config = get_server_config()
active_database_url = database_url or settings.database_url
if active_database_url:
@@ -187,7 +214,130 @@ def registered_module_migration_plan(
active_enabled_modules,
manifest_factories=active_manifest_factories,
)
return migration_metadata_plan(registry)
return registry
def run_registered_module_migration_tasks(
*,
database_url: str | None = None,
enabled_modules: tuple[str, ...] | list[str] | None = None,
migration_module_order: tuple[str, ...] | list[str] | None = None,
phases: tuple[str, ...] | list[str] = MIGRATION_TASK_PHASES,
dry_run: bool = False,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> tuple[dict[str, object], ...]:
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip()))
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
if invalid_phases:
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
url = database_url or settings.database_url
registry = _registered_module_registry(
database_url=url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
)
manifests = {manifest.id: manifest for manifest in registry.manifests()}
ordered_ids = tuple(dict.fromkeys([
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
*manifests.keys(),
]))
records: list[dict[str, object]] = []
database = get_database()
with database.SessionLocal() as session:
for phase in active_phases:
for module_id in ordered_ids:
manifest = manifests.get(module_id)
if manifest is None or manifest.migration_spec is None:
continue
for task in manifest.migration_spec.migration_tasks:
if task.phase != phase:
continue
record: dict[str, object] = {
"module_id": manifest.id,
"task_id": task.task_id,
"phase": task.phase,
"summary": task.summary,
"task_version": task.task_version,
"safety": task.safety,
"idempotent": task.idempotent,
"dry_run": dry_run,
}
if task.timeout_seconds is not None:
record["timeout_seconds"] = task.timeout_seconds
if not task.idempotent:
record.update({"status": "blocked", "message": "Task is not idempotent."})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
records=tuple(records),
)
if dry_run:
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
records.append(record)
continue
if task.executor is None:
record.update({"status": "blocked", "message": "Task has no executor."})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} has no executor.",
records=tuple(records),
)
context = ModuleMigrationTaskContext(
module_id=manifest.id,
task_id=task.task_id,
phase=task.phase,
database_url=url,
target_version=manifest.version,
session=session,
dry_run=dry_run,
metadata=task.metadata,
)
try:
result = task.executor(context)
normalized = _normalize_migration_task_result(result)
except Exception as exc:
session.rollback()
record.update({
"status": "blocked",
"message": f"{type(exc).__name__}: {exc}",
})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} failed.",
records=tuple(records),
) from exc
record.update({
"status": normalized.status,
"message": normalized.message,
"details": _jsonable_migration_task_details(normalized.details),
})
records.append(record)
if normalized.status == "blocked":
session.rollback()
raise ModuleMigrationTaskExecutionError(
normalized.message or f"Module migration task {manifest.id}/{task.task_id} blocked migration.",
records=tuple(records),
)
session.commit()
return tuple(records)
def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult:
if result is None:
return ModuleMigrationTaskResult()
if not isinstance(result, ModuleMigrationTaskResult):
raise TypeError("Module migration task executors must return ModuleMigrationTaskResult or None.")
if result.status not in {"ok", "warning", "blocked", "skipped"}:
raise ValueError(f"Unsupported module migration task status: {result.status!r}")
return result
def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, Any] | str:
try:
json.dumps(value)
except (TypeError, ValueError):
return str(dict(value))
return value
def _repo_root() -> Path:
@@ -518,6 +668,7 @@ def migrate_database(
database_url: str | None = None,
reconcile_legacy_schema: bool = True,
enabled_modules: tuple[str, ...] | list[str] | None = None,
migration_module_order: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> MigrationResult:
url = database_url or settings.database_url
@@ -526,14 +677,19 @@ def migrate_database(
reconcile_change_sequence_retention_floor_drift(url)
previous = database_revision(url)
reconciled = reconcile_legacy_create_all_schema(url) if reconcile_legacy_schema else None
command.upgrade(
alembic_config(
database_url=url,
config = alembic_config(
database_url=url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
)
if migration_module_order:
plan = registered_module_migration_plan(
url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
),
"heads",
)
)
_upgrade_ordered_module_heads(config, plan, tuple(migration_module_order))
command.upgrade(config, "heads")
if reconcile_legacy_schema:
reconcile_scope_table_retirement_drift(url)
current = database_revision(url)
@@ -542,3 +698,43 @@ def migrate_database(
reconciled_revision=reconciled,
current_revision=current,
)
def _upgrade_ordered_module_heads(
config: Config,
plan: MigrationMetadataPlan,
module_order: tuple[str, ...],
) -> None:
module_heads = _module_heads_by_id(config, plan)
upgraded: set[str] = set()
for module_id in dict.fromkeys(module_order):
for head in module_heads.get(module_id, ()):
if head in upgraded:
continue
command.upgrade(config, head)
upgraded.add(head)
def _module_heads_by_id(config: Config, plan: MigrationMetadataPlan) -> dict[str, tuple[str, ...]]:
script = ScriptDirectory.from_config(config)
heads = script.get_heads()
module_locations = {
item.module_id: Path(item.script_location).resolve()
for item in plan.modules
if item.script_location
}
grouped: dict[str, list[str]] = {module_id: [] for module_id in module_locations}
for head in heads:
revision = script.get_revision(head)
path_text = getattr(revision, "path", None)
if revision is None or not path_text:
continue
revision_path = Path(path_text).resolve()
for module_id, location in module_locations.items():
try:
revision_path.relative_to(location)
except ValueError:
continue
grouped[module_id].append(head)
break
return {module_id: tuple(sorted(values)) for module_id, values in grouped.items() if values}

View 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()

View File

@@ -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)

View File

@@ -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",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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: {