Expose public module catalog discovery

This commit is contained in:
2026-08-06 21:13:13 +02:00
parent 218f94fa23
commit 1e55a80d3c
7 changed files with 79 additions and 12 deletions
+43 -5
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Mapping
import os
from pathlib import Path
from typing import Any
@@ -74,7 +75,11 @@ from govoplan_core.core.module_installer_notifications import (
installer_notification_subject,
)
from govoplan_core.core.module_license import module_license_decision, module_license_diagnostics
from govoplan_core.core.module_package_catalog import record_module_package_catalog_acceptance, validate_module_package_catalog
from govoplan_core.core.module_package_catalog import (
record_module_package_catalog_acceptance,
validate_module_package_catalog,
validate_official_module_package_catalog,
)
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, MaintenanceMode, saved_maintenance_mode, save_maintenance_mode
from govoplan_core.core.lifecycle import ModuleLifecycleManager
from govoplan_core.core.registry import PlatformRegistry
@@ -589,7 +594,7 @@ def _catalog_plan_item(
*,
validation: dict[str, object] | None = None,
) -> tuple[ModuleInstallPlanItem, dict[str, object]]:
result = validation or validate_module_package_catalog()
result = validation or _module_package_catalog_validation()
_require_valid_module_catalog(result)
raw_item = _catalog_install_or_update_item(result, module_id)
action = _catalog_plan_action(raw_item, module_id, available_module_ids)
@@ -604,6 +609,7 @@ def _catalog_plan_item(
python_ref=_catalog_string(raw_item, "python_ref"),
webui_package=_catalog_string(raw_item, "webui_package"),
webui_ref=_catalog_string(raw_item, "webui_ref"),
artifact_integrity=_catalog_mapping(raw_item, "artifact_integrity"),
notes=_catalog_plan_notes(raw_item, license_decision),
), result
@@ -616,6 +622,13 @@ def _require_valid_module_catalog(result: dict[str, object]) -> None:
)
def _module_package_catalog_validation() -> dict[str, object]:
configured = validate_module_package_catalog()
if configured.get("configured") or configured.get("error"):
return configured
return validate_official_module_package_catalog()
def _catalog_install_or_update_item(result: dict[str, object], module_id: str) -> dict[str, object]:
for raw_item in result.get("modules", []):
if not isinstance(raw_item, dict):
@@ -659,6 +672,11 @@ def _catalog_string(raw_item: dict[str, object], field: str) -> str | None:
return value if isinstance(value, str) else None
def _catalog_mapping(raw_item: dict[str, object], field: str) -> dict[str, object] | None:
value = raw_item.get(field)
return dict(value) if isinstance(value, Mapping) else None
def _catalog_plan_metadata(validation: dict[str, object]) -> dict[str, object]:
metadata: dict[str, object] = {
"source": validation.get("source") or validation.get("path"),
@@ -688,8 +706,21 @@ def _catalog_license_features(raw_item: dict[str, object]) -> list[str]:
return [str(item).strip() for item in value if str(item).strip()]
def _module_package_catalog_item(raw_item: dict[str, object]) -> ModulePackageCatalogItem:
def _module_package_catalog_item(
raw_item: dict[str, object],
available: Mapping[str, Any],
) -> ModulePackageCatalogItem:
payload = dict(raw_item)
manifest = available.get(str(raw_item.get("module_id") or ""))
installed_version = str(getattr(manifest, "version", "") or "") or None
target_version = raw_item.get("version")
payload["installed"] = manifest is not None
payload["installed_version"] = installed_version
payload["update_available"] = bool(
installed_version
and isinstance(target_version, str)
and target_version != installed_version
)
decision = module_license_decision(_catalog_license_features(raw_item))
payload["license_allowed"] = bool(decision.get("allowed"))
payload["license_enforced"] = bool(decision.get("enforced"))
@@ -1218,13 +1249,20 @@ def retry_module_install_request(
@router.get("/system/modules/package-catalog", response_model=ModulePackageCatalogResponse)
def read_module_package_catalog(
request: Request,
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
result = validate_module_package_catalog()
result = _module_package_catalog_validation()
lifecycle = getattr(request.app.state, "govoplan_lifecycle", None)
available = (
dict(lifecycle.available_modules)
if isinstance(lifecycle, ModuleLifecycleManager)
else available_module_manifests(ignore_load_errors=True)
)
license_diagnostics = module_license_diagnostics(required_features=_catalog_required_license_features(result))
return ModulePackageCatalogResponse(
modules=[_module_package_catalog_item(item) for item in result["modules"] if isinstance(item, dict)],
modules=[_module_package_catalog_item(item, available) for item in result["modules"] if isinstance(item, dict)],
configured=bool(result["configured"]),
valid=bool(result["valid"]),
path=result["path"] if isinstance(result["path"], str) else None,
@@ -393,6 +393,9 @@ class ModulePackageCatalogItem(BaseModel):
description: str | None = None
version: str | None = None
action: Literal["install", "update", "uninstall"] = "install"
installed: bool = False
installed_version: str | None = None
update_available: bool = False
dependencies: list[str] = Field(default_factory=list)
optional_dependencies: list[str] = Field(default_factory=list)
migration_safety: Literal["automatic", "requires_review", "forward_only", "destructive"] = "automatic"
+3 -1
View File
@@ -147,8 +147,9 @@ manifest = ModuleManifest(
summary="Move a reviewed module package plan through preflight, maintenance-gated queueing, daemon execution, and durable run evidence.",
body=(
"The Modules administration surface projects one operator workflow: save a package plan, resolve preflight findings, enter maintenance mode with the required authority, queue a supervised installer request, and inspect the matching run record. "
"When no deployment-specific catalog is configured, the package directory discovers the signed public GovOPlaN stable catalog and marks installed packages and available updates. Selecting an entry copies its exact signed registry identities into the plan; artifact download and digest verification happen only in the trusted installer. "
"The stage indicator is derived from the saved plan timestamp, the latest matching request, and its run; an older request is never presented as evidence for a newer plan. "
"Disabled queue actions name the earliest blocker, the person who can resolve it, and the plan surface where work continues. Package mutation remains outside the FastAPI process and recovery evidence remains durable in the installer ledger."
"Disabled queue actions name the earliest blocker, the person who can resolve it, and the plan surface where work continues. Package mutation remains outside the FastAPI process and recovery evidence remains durable in the installer ledger. Shared deployments require an immutable image rollout rather than node-local mutation. Tenant entitlement and user/View visibility remain policy settings, not package lifecycle operations."
),
layer="configured",
documentation_types=("admin",),
@@ -164,6 +165,7 @@ manifest = ModuleManifest(
"admin.module-lifecycle",
"admin.module-lifecycle.queue-blocker",
"admin.module-lifecycle.plan",
"admin.module-lifecycle.catalog",
"admin.module-lifecycle.evidence",
],
},