Release v0.1.6
This commit is contained in:
@@ -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}.")
|
||||
|
||||
41
src/govoplan_core/commands/module_install_plan.py
Normal file
41
src/govoplan_core/commands/module_install_plan.py
Normal 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()
|
||||
470
src/govoplan_core/commands/module_installer.py
Normal file
470
src/govoplan_core/commands/module_installer.py
Normal 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())
|
||||
37
src/govoplan_core/commands/module_verify.py
Normal file
37
src/govoplan_core/commands/module_verify.py
Normal 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())
|
||||
Reference in New Issue
Block a user