Release v0.1.6

This commit is contained in:
2026-07-07 16:00:38 +02:00
parent a2053518d1
commit 150b720f12
149 changed files with 14311 additions and 8005 deletions

View File

@@ -10,12 +10,13 @@ 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("--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(settings.database_url)
migration = migrate_database()
configure_database(args.database_url)
migration = migrate_database(database_url=args.database_url)
if migration.reconciled_revision:
print(f"Reconciled legacy database marker to {migration.reconciled_revision}.")
print(f"Database schema upgraded to {migration.current_revision}.")

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import argparse
import json
from govoplan_core.core.module_management import module_install_plan_commands, saved_module_install_plan
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.settings import settings
def main() -> None:
parser = argparse.ArgumentParser(description="Print the GovOPlaN module package install plan.")
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
parser.add_argument("--format", choices=("shell", "json"), default="shell", help="Output format.")
args = parser.parse_args()
configure_database(str(args.database_url))
with get_database().session() as session:
plan = saved_module_install_plan(session)
if args.format == "json":
print(json.dumps({
"updated_at": plan.updated_at,
"items": [item.as_dict() for item in plan.items],
"commands": list(module_install_plan_commands(plan)),
}, indent=2, sort_keys=True))
return
if not plan.items:
print("# No planned module package changes.")
return
print("# GovOPlaN module package install plan")
if plan.updated_at:
print(f"# Updated: {plan.updated_at}")
for command in module_install_plan_commands(plan):
print(command)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,470 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
import time
from typing import Any
from govoplan_core.core.module_installer import (
ModuleInstallerError,
cancel_module_installer_request,
claim_next_module_installer_request,
default_installer_runtime_dir,
list_module_installer_runs,
list_module_installer_requests,
module_installer_daemon_status,
module_install_preflight,
module_installer_lock_status,
queue_module_installer_request,
read_module_installer_run,
read_module_installer_request,
retry_module_installer_request,
rollback_module_install_run,
run_module_install_plan,
supervise_module_install_plan,
update_module_installer_request,
update_module_installer_daemon_status,
)
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
from govoplan_core.core.module_management import (
configured_enabled_modules,
saved_desired_enabled_modules,
saved_module_install_plan,
)
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.server.registry import available_module_manifests
from govoplan_core.settings import settings
def main() -> int:
parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.")
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.")
parser.add_argument("--webui-root", type=Path, default=_default_webui_root(), help="Core WebUI root for npm package changes.")
parser.add_argument("--npm-bin", default="npm", help="npm executable to use for WebUI package changes.")
parser.add_argument("--build-webui", action="store_true", help="Run npm run build after npm install.")
parser.add_argument("--migrate", action="store_true", help="Run core/module database migrations after package changes and before restart.")
parser.add_argument("--database-backup-command", help="Shell command that creates a database backup before --migrate for non-SQLite databases.")
parser.add_argument("--database-restore-command", help="Shell command that restores the database backup during rollback.")
parser.add_argument("--database-restore-check-command", help="Shell command that validates the created backup can be used before migrations proceed.")
parser.add_argument("--no-activate-installed-modules", action="store_true", help="Do not add successfully installed modules to saved startup state.")
parser.add_argument("--keep-uninstalled-modules-in-desired", action="store_true", help="Do not remove successfully uninstalled modules from saved startup state.")
parser.add_argument("--format", choices=("shell", "json"), default="shell", help="Output format for preflight/dry-run.")
parser.add_argument("--apply", action="store_true", help="Execute the saved plan after preflight passes.")
parser.add_argument("--dry-run", action="store_true", help="Create a run record and snapshots but do not execute commands.")
parser.add_argument("--supervise", action="store_true", help="Apply the plan, run optional restart/health checks, and roll back if the server does not recover.")
parser.add_argument("--restart-command", help="Shell command run after apply and after automatic rollback, for example a systemctl restart command.")
parser.add_argument("--health-url", help="HTTP health URL to poll after apply, for example http://127.0.0.1:8000/health.")
parser.add_argument("--health-timeout-seconds", type=float, default=60.0, help="Maximum time to wait for health after restart.")
parser.add_argument("--health-interval-seconds", type=float, default=2.0, help="Delay between health probes.")
parser.add_argument("--rollback", metavar="RUN_ID", help="Restore package snapshots from a previous installer run.")
parser.add_argument("--list-runs", action="store_true", help="List recent installer run records and lock status.")
parser.add_argument("--show-run", metavar="RUN_ID", help="Print one installer run record.")
parser.add_argument("--lock-status", action="store_true", help="Print the current installer lock status.")
parser.add_argument("--list-requests", action="store_true", help="List queued/running/completed installer requests.")
parser.add_argument("--show-request", metavar="REQUEST_ID", help="Print one installer request record.")
parser.add_argument("--cancel-request", metavar="REQUEST_ID", help="Cancel a queued installer request.")
parser.add_argument("--retry-request", metavar="REQUEST_ID", help="Queue a retry for a failed or cancelled installer request.")
parser.add_argument("--enqueue-supervised", action="store_true", help="Queue a supervised installer request for a running daemon instead of applying immediately.")
parser.add_argument("--daemon-status", action="store_true", help="Print installer daemon heartbeat/status.")
parser.add_argument("--daemon", action="store_true", help="Run the installer request daemon.")
parser.add_argument("--daemon-once", action="store_true", help="Process at most one queued request and exit.")
parser.add_argument("--poll-interval-seconds", type=float, default=2.0, help="Delay between daemon queue polls.")
parser.add_argument("--validate-package-catalog", nargs="?", const="", metavar="PATH", help="Validate a module package catalog JSON file, or the configured catalog when PATH is omitted.")
parser.add_argument("--require-signed-catalog", action="store_true", help="Require package catalog validation to trust an Ed25519 signature.")
parser.add_argument("--approved-catalog-channel", action="append", default=[], help="Approved package catalog channel; may be repeated.")
parser.add_argument("--catalog-trusted-key", action="append", default=[], metavar="KEY_ID=BASE64_PUBLIC_KEY", help="Trusted Ed25519 catalog public key; may be repeated.")
parser.add_argument("--sign-package-catalog", type=Path, metavar="PATH", help="Sign a module package catalog JSON file.")
parser.add_argument("--catalog-signing-key-id", help="Key id to record when signing a package catalog.")
parser.add_argument("--catalog-signing-private-key", type=Path, help="PEM Ed25519 private key used by --sign-package-catalog.")
parser.add_argument("--catalog-output", type=Path, help="Output path for --sign-package-catalog. Defaults to overwriting the input file.")
args = parser.parse_args()
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
try:
if args.sign_package_catalog:
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
path = sign_module_package_catalog(
path=args.sign_package_catalog,
key_id=args.catalog_signing_key_id,
private_key_path=args.catalog_signing_private_key,
output_path=args.catalog_output,
)
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
return 0
if args.validate_package_catalog is not None:
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
result = validate_module_package_catalog(
path,
require_trusted=args.require_signed_catalog,
approved_channels=tuple(args.approved_catalog_channel),
trusted_keys=_trusted_catalog_keys_from_args(args.catalog_trusted_key),
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") else 1
if args.daemon_status:
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.daemon or args.daemon_once:
return _run_daemon(args=args, runtime_dir=runtime_dir)
if args.list_requests:
_print_result({
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
"daemon": module_installer_daemon_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_request:
_print_result(read_module_installer_request(runtime_dir=runtime_dir, request_id=args.show_request), output_format=args.format)
return 0
if args.cancel_request:
_print_result(cancel_module_installer_request(runtime_dir=runtime_dir, request_id=args.cancel_request, cancelled_by="cli"), output_format=args.format)
return 0
if args.retry_request:
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
return 0
if args.enqueue_supervised:
request = queue_module_installer_request(
runtime_dir=runtime_dir,
requested_by="cli",
options=_request_options_from_args(args),
)
_print_result(request, output_format=args.format)
return 0
if args.list_runs:
_print_result({
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
"lock": module_installer_lock_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_run:
_print_result(read_module_installer_run(runtime_dir=runtime_dir, run_id=args.show_run), output_format=args.format)
return 0
if args.lock_status:
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.rollback:
result = rollback_module_install_run(
run_id=args.rollback,
runtime_dir=runtime_dir,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
database_restore_command=args.database_restore_command,
database_url=str(args.database_url),
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
configure_database(str(args.database_url))
available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session:
configured = configured_enabled_modules(settings.enabled_modules)
desired = saved_desired_enabled_modules(session, configured)
plan = saved_module_install_plan(session)
if args.supervise:
result = supervise_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
restart_command=args.restart_command,
health_url=args.health_url,
health_timeout_seconds=args.health_timeout_seconds,
health_interval_seconds=args.health_interval_seconds,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
if args.apply or args.dry_run:
result = run_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
dry_run=args.dry_run,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
from govoplan_core.core.maintenance import saved_maintenance_mode
maintenance = saved_maintenance_mode(session)
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
maintenance_mode=maintenance.enabled,
session=session,
webui_root=args.webui_root,
runtime_dir=runtime_dir,
)
_print_preflight(preflight.as_dict(), output_format=args.format)
return 0 if preflight.allowed else 1
except ModuleInstallerError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
def _default_webui_root() -> Path:
return Path(__file__).resolve().parents[3] / "webui"
def _run_daemon(*, args: argparse.Namespace, runtime_dir: Path) -> int:
update_module_installer_daemon_status(
runtime_dir=runtime_dir,
patch={
"status": "running",
"mode": "once" if args.daemon_once else "daemon",
"started_at": _now(),
"current_request_id": None,
},
)
try:
while True:
update_module_installer_daemon_status(
runtime_dir=runtime_dir,
patch={
"status": "polling",
"last_poll_at": _now(),
"current_request_id": None,
},
)
request = claim_next_module_installer_request(runtime_dir=runtime_dir)
if request is not None:
request_id = str(request["request_id"])
update_module_installer_daemon_status(
runtime_dir=runtime_dir,
patch={
"status": "processing",
"current_request_id": request_id,
"last_claimed_at": _now(),
},
)
_process_request(request=request, args=args, runtime_dir=runtime_dir)
update_module_installer_daemon_status(
runtime_dir=runtime_dir,
patch={
"status": "polling",
"current_request_id": None,
"last_completed_request_id": request_id,
},
)
if args.daemon_once:
return 0
elif args.daemon_once:
return 0
time.sleep(max(args.poll_interval_seconds, 0.2))
finally:
update_module_installer_daemon_status(
runtime_dir=runtime_dir,
patch={
"status": "stopped",
"stopped_at": _now(),
"current_request_id": None,
},
)
def _process_request(*, request: dict[str, object], args: argparse.Namespace, runtime_dir: Path) -> None:
request_id = str(request["request_id"])
options = request.get("options") if isinstance(request.get("options"), dict) else {}
configure_database(str(args.database_url))
try:
available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session:
configured = configured_enabled_modules(settings.enabled_modules)
desired = saved_desired_enabled_modules(session, configured)
plan = saved_module_install_plan(session)
result = supervise_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=_path_option(options, "webui_root") or args.webui_root,
npm_bin=_str_option(options, "npm_bin") or args.npm_bin,
build_webui=_bool_option(options, "build_webui", args.build_webui),
migrate_database=_bool_option(options, "migrate_database", args.migrate),
database_backup_command=_str_option(options, "database_backup_command") or args.database_backup_command,
database_restore_command=_str_option(options, "database_restore_command") or args.database_restore_command,
database_restore_check_command=_str_option(options, "database_restore_check_command") or args.database_restore_check_command,
activate_installed_modules=_bool_option(options, "activate_installed_modules", not args.no_activate_installed_modules),
remove_uninstalled_modules_from_desired=_bool_option(options, "remove_uninstalled_modules_from_desired", not args.keep_uninstalled_modules_in_desired),
restart_commands=_list_option(options, "restart_commands") or _list_option(options, "restart_command") or ([args.restart_command] if args.restart_command else []),
health_urls=_list_option(options, "health_urls") or _list_option(options, "health_url") or ([args.health_url] if args.health_url else []),
health_timeout_seconds=_float_option(options, "health_timeout_seconds", args.health_timeout_seconds),
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
)
update_module_installer_request(
runtime_dir=runtime_dir,
request_id=request_id,
patch={
"status": "completed" if result.return_code == 0 else "failed",
"finished_at": _now(),
"result": result.as_dict(),
},
)
except Exception as exc:
update_module_installer_request(
runtime_dir=runtime_dir,
request_id=request_id,
patch={
"status": "failed",
"finished_at": _now(),
"error": str(exc),
},
)
def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
return {
"webui_root": str(args.webui_root) if args.webui_root else None,
"npm_bin": args.npm_bin,
"build_webui": args.build_webui,
"migrate_database": args.migrate,
"database_backup_command": args.database_backup_command,
"database_restore_command": args.database_restore_command,
"database_restore_check_command": args.database_restore_check_command,
"activate_installed_modules": not args.no_activate_installed_modules,
"remove_uninstalled_modules_from_desired": not args.keep_uninstalled_modules_in_desired,
"restart_commands": [args.restart_command] if args.restart_command else [],
"health_urls": [args.health_url] if args.health_url else [],
"health_timeout_seconds": args.health_timeout_seconds,
"health_interval_seconds": args.health_interval_seconds,
}
def _str_option(options: object, key: str) -> str | None:
if not isinstance(options, dict):
return None
value = options.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _path_option(options: object, key: str) -> Path | None:
value = _str_option(options, key)
return Path(value).expanduser() if value else None
def _bool_option(options: object, key: str, default: bool) -> bool:
if not isinstance(options, dict):
return default
value = options.get(key)
return value if isinstance(value, bool) else default
def _float_option(options: object, key: str, default: float) -> float:
if not isinstance(options, dict):
return default
value = options.get(key)
if isinstance(value, int | float):
return float(value)
return default
def _list_option(options: object, key: str) -> list[str]:
if not isinstance(options, dict):
return []
value = options.get(key)
if isinstance(value, str):
return [item.strip() for item in value.splitlines() if item.strip()]
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
return []
def _trusted_catalog_keys_from_args(values: list[str]) -> dict[str, str] | None:
if not values:
return None
keys: dict[str, str] = {}
for value in values:
key_id, separator, public_key = value.partition("=")
if not separator or not key_id.strip() or not public_key.strip():
raise ModuleInstallerError("--catalog-trusted-key must use KEY_ID=BASE64_PUBLIC_KEY.")
keys[key_id.strip()] = public_key.strip()
return keys
def _now() -> str:
from datetime import UTC, datetime
return datetime.now(tz=UTC).isoformat()
def _print_preflight(payload: dict[str, object], *, output_format: str) -> None:
if output_format == "json":
print(json.dumps(payload, indent=2, sort_keys=True))
return
print(f"# Allowed: {payload['allowed']}")
print(f"# Maintenance mode: {payload['maintenance_mode']}")
print(f"# Frontend rebuild required: {payload['frontend_rebuild_required']}")
for issue in payload.get("issues", []):
if not isinstance(issue, dict):
continue
print(f"# {issue.get('severity')}: {issue.get('code')}: {issue.get('message')}")
commands = payload.get("commands")
if isinstance(commands, list) and commands:
print("")
for command in commands:
print(command)
checklist = payload.get("checklist")
if isinstance(checklist, list) and checklist:
print("")
for item in checklist:
if not isinstance(item, dict):
continue
detail = f": {item.get('detail')}" if item.get("detail") else ""
print(f"# checklist {item.get('status')}: {item.get('label')}{detail}")
def _print_result(payload: dict[str, object], *, output_format: str) -> None:
if output_format == "json":
print(json.dumps(payload, indent=2, sort_keys=True))
return
if "runs" in payload or "requests" in payload or "locked" in payload or "request_id" in payload or "run_id" not in payload:
print(json.dumps(payload, indent=2, sort_keys=True))
return
print(f"# Run: {payload['run_id']}")
print(f"# Status: {payload['status']}")
print(f"# Record: {payload['record_path']}")
if payload.get("error"):
print(f"# Error: {payload['error']}")
commands = payload.get("commands")
if isinstance(commands, list) and commands:
print("")
for command in commands:
print(command)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import argparse
import sys
from govoplan_core.core.module_installer import module_manifest_compatibility_issues
from govoplan_core.server.registry import available_module_manifests
def main() -> int:
parser = argparse.ArgumentParser(description="Verify installed GovOPlaN module manifests in a fresh interpreter.")
parser.add_argument("--installed", action="append", default=[], help="Module id expected to be discoverable.")
parser.add_argument("--absent", action="append", default=[], help="Module id expected not to be discoverable.")
args = parser.parse_args()
manifests = available_module_manifests(ignore_load_errors=True)
failed = False
for module_id in args.installed:
if module_id not in manifests:
print(f"missing expected module manifest: {module_id}", file=sys.stderr)
failed = True
for module_id in args.absent:
if module_id in manifests:
print(f"unexpected module manifest still discoverable: {module_id}", file=sys.stderr)
failed = True
for issue in module_manifest_compatibility_issues(manifests):
print(f"{issue.severity}: {issue.module_id or 'global'}: {issue.message}", file=sys.stderr)
if issue.severity == "blocker":
failed = True
if failed:
return 1
print("Module manifests verified.")
return 0
if __name__ == "__main__":
raise SystemExit(main())