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