intermittent commit

This commit is contained in:
2026-07-14 13:22:10 +02:00
parent 8aa1943581
commit e6f7c45f0a
76 changed files with 6039 additions and 2188 deletions

View File

@@ -5,7 +5,6 @@ import json
from pathlib import Path
import sys
import time
from typing import Any
from govoplan_core.core.module_installer import (
ModuleInstallerError,
@@ -27,6 +26,13 @@ from govoplan_core.core.module_installer import (
update_module_installer_request,
update_module_installer_daemon_status,
)
from govoplan_core.core.module_installer_notifications import (
build_runtime_notification_registry,
emit_module_installer_notification,
installer_notification_body,
installer_notification_priority,
installer_notification_subject,
)
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
from govoplan_core.core.module_management import (
@@ -39,7 +45,7 @@ from govoplan_core.server.registry import available_module_manifests
from govoplan_core.settings import settings
def main() -> int:
def _build_parser() -> argparse.ArgumentParser:
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.")
@@ -95,202 +101,295 @@ def main() -> int:
parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.")
parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.")
parser.add_argument("--license-notes", help="Optional operator note for --issue-license.")
args = parser.parse_args()
return parser
def main() -> int:
args = _build_parser().parse_args()
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
try:
if args.issue_license:
if not args.license_id or not args.license_subject or not args.license_valid_until:
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
if not args.license_signing_key_id or not args.license_signing_private_key:
raise ModuleInstallerError("--issue-license requires --license-signing-key-id and --license-signing-private-key.")
if not [item for item in args.license_feature if item.strip()]:
raise ModuleInstallerError("--issue-license requires at least one --license-feature.")
try:
path = issue_module_license(
path=args.issue_license,
license_id=args.license_id,
subject=args.license_subject,
features=args.license_feature,
valid_from=args.license_valid_from,
valid_until=args.license_valid_until,
signing_key_id=args.license_signing_key_id,
signing_private_key_path=args.license_signing_private_key,
issuer=args.license_issuer,
notes=args.license_notes,
)
except (OSError, ValueError) as exc:
raise ModuleInstallerError(str(exc)) from exc
_print_result(
{
"issued": True,
"path": str(path),
"license_id": args.license_id,
"subject": args.license_subject,
"features": list(dict.fromkeys(item.strip() for item in args.license_feature if item.strip())),
"valid_until": args.license_valid_until,
"key_id": args.license_signing_key_id,
},
output_format=args.format,
)
return 0
if args.validate_license is not None:
path = Path(args.validate_license).expanduser() if args.validate_license else None
result = module_license_diagnostics(
path,
require_trusted=args.require_trusted_license,
trusted_keys=_trusted_keys_from_args(args.license_trusted_key, flag_name="--license-trusted-key"),
required_features=args.license_required_feature,
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") and not result.get("missing_features") else 1
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_keys_from_args(args.catalog_trusted_key, flag_name="--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
return _dispatch_command(args=args, runtime_dir=runtime_dir)
except ModuleInstallerError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
for handler in (
_handle_license_command,
_handle_catalog_command,
_handle_runtime_command,
_handle_queue_command,
_handle_run_history_command,
):
result = handler(args=args, runtime_dir=runtime_dir)
if result is not None:
return result
return _handle_install_command(args=args, runtime_dir=runtime_dir)
def _handle_license_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
del runtime_dir
if args.issue_license:
return _issue_license_command(args)
if args.validate_license is not None:
return _validate_license_command(args)
return None
def _issue_license_command(args: argparse.Namespace) -> int:
if not args.license_id or not args.license_subject or not args.license_valid_until:
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
if not args.license_signing_key_id or not args.license_signing_private_key:
raise ModuleInstallerError("--issue-license requires --license-signing-key-id and --license-signing-private-key.")
if not [item for item in args.license_feature if item.strip()]:
raise ModuleInstallerError("--issue-license requires at least one --license-feature.")
try:
path = issue_module_license(
path=args.issue_license,
license_id=args.license_id,
subject=args.license_subject,
features=args.license_feature,
valid_from=args.license_valid_from,
valid_until=args.license_valid_until,
signing_key_id=args.license_signing_key_id,
signing_private_key_path=args.license_signing_private_key,
issuer=args.license_issuer,
notes=args.license_notes,
)
except (OSError, ValueError) as exc:
raise ModuleInstallerError(str(exc)) from exc
_print_result(
{
"issued": True,
"path": str(path),
"license_id": args.license_id,
"subject": args.license_subject,
"features": list(dict.fromkeys(item.strip() for item in args.license_feature if item.strip())),
"valid_until": args.license_valid_until,
"key_id": args.license_signing_key_id,
},
output_format=args.format,
)
return 0
def _validate_license_command(args: argparse.Namespace) -> int:
path = Path(args.validate_license).expanduser() if args.validate_license else None
result = module_license_diagnostics(
path,
require_trusted=args.require_trusted_license,
trusted_keys=_trusted_keys_from_args(args.license_trusted_key, flag_name="--license-trusted-key"),
required_features=args.license_required_feature,
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") and not result.get("missing_features") else 1
def _handle_catalog_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
del runtime_dir
if args.sign_package_catalog:
return _sign_package_catalog_command(args)
if args.validate_package_catalog is not None:
return _validate_package_catalog_command(args)
return None
def _sign_package_catalog_command(args: argparse.Namespace) -> int:
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
def _validate_package_catalog_command(args: argparse.Namespace) -> int:
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_keys_from_args(args.catalog_trusted_key, flag_name="--catalog-trusted-key"),
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") else 1
def _handle_runtime_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
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)
return None
def _handle_queue_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
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
return None
def _handle_run_history_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
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:
return _rollback_command(args=args, runtime_dir=runtime_dir)
return None
def _rollback_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
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
def _handle_install_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
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:
return _supervise_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
if args.apply or args.dry_run:
return _apply_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
return _preflight_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
def _supervise_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
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
def _apply_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
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
def _preflight_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
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
def _default_webui_root() -> Path:
return Path(__file__).resolve().parents[3] / "webui"
@@ -355,6 +454,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
request_id = str(request["request_id"])
options = request.get("options") if isinstance(request.get("options"), dict) else {}
configure_database(str(args.database_url))
_emit_installer_daemon_notification(request, event_kind="module_installer.request.running")
try:
available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session:
@@ -384,7 +484,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
request_context=_request_context(request),
)
update_module_installer_request(
updated_request = update_module_installer_request(
runtime_dir=runtime_dir,
request_id=request_id,
patch={
@@ -393,8 +493,12 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
"result": result.as_dict(),
},
)
_emit_installer_daemon_notification(
updated_request,
event_kind="module_installer.request.completed" if result.return_code == 0 else "module_installer.request.failed",
)
except Exception as exc:
update_module_installer_request(
updated_request = update_module_installer_request(
runtime_dir=runtime_dir,
request_id=request_id,
patch={
@@ -403,6 +507,34 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
"error": str(exc),
},
)
_emit_installer_daemon_notification(updated_request, event_kind="module_installer.request.failed")
def _emit_installer_daemon_notification(request: dict[str, object], *, event_kind: str) -> None:
tenant_id = str(request.get("tenant_id") or "")
if not tenant_id:
return
registry = build_runtime_notification_registry(settings)
if registry is None:
return
status_value = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
try:
with get_database().SessionLocal() as session:
emitted = emit_module_installer_notification(
session=session,
registry=registry,
tenant_id=tenant_id,
request=request,
event_kind=event_kind,
subject=installer_notification_subject(event_kind, request),
body_text=installer_notification_body(event_kind, request),
recipient_id=str(request.get("requested_by") or "") or None,
priority=installer_notification_priority(status_value),
)
if emitted:
session.commit()
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
return
def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
@@ -427,6 +559,7 @@ def _request_context(request: dict[str, object]) -> dict[str, object]:
context: dict[str, object] = {
"request_id": request.get("request_id"),
"requested_by": request.get("requested_by"),
"tenant_id": request.get("tenant_id"),
}
if request.get("retry_of"):
context["retry_of"] = request["retry_of"]