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
+13
View File
@@ -40,6 +40,8 @@ The admin module owns the operator surfaces for module lifecycle management:
- installed/enabled/desired module state
- runtime activation and deactivation of installed modules
- signed catalog install planning
- automatic discovery from the signed public stable directory when no
deployment catalog override is configured
- non-destructive uninstall planning, with explicit `destroy_data` retirement
options where a module provides a retirement provider
- installer preflight status, maintenance-mode blockers, migration/restart
@@ -52,6 +54,13 @@ admin UI records operator intent and queues or renders commands for the trusted
installer process described in
`/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`.
Catalog entries show whether the package is already installed and whether the
directory advertises another version. Selecting an entry preserves its signed
registry URLs and integrity evidence in the reviewed plan. The installer, not
the browser or API request, downloads and verifies those artifacts. On a shared
or Kubernetes deployment, the same plan requires a new immutable image
composition instead of changing one running replica.
The WebUI presents the lifecycle as five derived stages: plan, preflight,
installer request, daemon execution, and run evidence. The projection resets
when the saved plan changes and associates evidence only with an installer
@@ -80,6 +89,10 @@ User and group module visibility is configured through Views, where each WebUI
module is represented by its root module surface. This keeps tenant operational
state distinct from presentation preferences.
Licensing remains a generic catalog/preset contract. Official open-source
GovOPlaN directory entries carry no feature requirement; a license affects an
entry only when that catalog explicitly declares `license_features`.
## Package Surfaces
The admin UI intentionally exposes two different package concepts:
+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",
],
},
+7
View File
@@ -30,6 +30,12 @@ class CatalogPlanItemTests(unittest.TestCase):
"python_package": "govoplan-calendar",
"python_ref": 42,
"webui_package": "@govoplan/calendar-webui",
"artifact_integrity": {
"python": {
"url": "https://packages.example.test/calendar.whl",
"sha256": "a" * 64,
}
},
"notes": "Catalog note",
"license_features": ["calendar.sync", "", 7],
},
@@ -59,6 +65,7 @@ class CatalogPlanItemTests(unittest.TestCase):
self.assertIsNone(item.python_ref)
self.assertEqual(item.webui_package, "@govoplan/calendar-webui")
self.assertIsNone(item.webui_ref)
self.assertEqual(item.artifact_integrity["python"]["sha256"], "a" * 64)
self.assertEqual(item.notes, "Catalog note\nLicense warning: Feature expires soon.")
self.assertEqual(item.catalog["source"], "https://catalog.example.test/modules.json")
self.assertEqual(item.catalog["channel"], "stable")
+3
View File
@@ -425,6 +425,9 @@ export type ModulePackageCatalogItem = {
description?: string | null;
version?: string | null;
action: "install" | "update" | "uninstall";
installed: boolean;
installed_version?: string | null;
update_available: boolean;
dependencies: string[];
optional_dependencies: string[];
migration_safety: "automatic" | "requires_review" | "forward_only" | "destructive";
@@ -489,7 +489,7 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
</div>
{(packageCatalog.source || packageCatalog.path) && <p className="module-package-catalog-description">i18n:govoplan-admin.catalog.4a88d27b {packageCatalog.source_type ?? "source"}: <code>{packageCatalog.source ?? packageCatalog.path}</code>{packageCatalog.cache_used && packageCatalog.cache_path ? <> i18n:govoplan-admin.cached_from.d73c5b2a <code>{packageCatalog.cache_path}</code></> : null}</p>}
{(packageCatalog.generated_at || packageCatalog.not_before || packageCatalog.expires_at) && <p className="module-package-catalog-description">i18n:govoplan-admin.generated.a2edf57c {packageCatalog.generated_at ?? "unknown"} i18n:govoplan-admin.valid_after.c615c873 {packageCatalog.not_before ?? "now"} i18n:govoplan-admin.expires.2d21b7de {packageCatalog.expires_at ?? "unknown"}</p>}
<LicenseStatus license={packageCatalog.license} />
{(packageCatalog.license.configured || packageCatalog.license.required_features.length > 0) && <LicenseStatus license={packageCatalog.license} />}
{packageCatalog.error && <p className="alert warning">{packageCatalog.error}</p>}
{packageCatalog.warnings.map((warning) => <p key={warning} className="alert warning">{warning}</p>)}
{!packageCatalog.configured && <div className="module-install-plan-empty">i18n:govoplan-admin.no_package_catalog_configured_set_govoplan_modul.2c5fceb3</div>}
@@ -502,6 +502,7 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
<strong>{item.name}</strong>
<code>{item.module_id}</code>
{item.version && <span>v{item.version}</span>}
{item.installed && <StatusBadge status={item.update_available ? "warning" : "success"} label={item.update_available ? "i18n:govoplan-admin.plan_update.86e6857a" : "i18n:govoplan-admin.installed.7bb4405c"} />}
</div>
<div className="module-management-details">
{item.python_package && <span>{item.python_package}</span>}
@@ -525,7 +526,7 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
{item.recovery_notes && <p className="module-package-catalog-description">{item.recovery_notes}</p>}
{item.license_reason && <p className={`module-package-catalog-description${item.license_allowed ? "" : " alert warning"}`}>{item.license_reason}</p>}
</div>
<Button onClick={() => void addCatalogItem(item)} disabled={!canWrite || planBusy || !packageCatalog.valid || !item.license_allowed || !["install", "update"].includes(item.action)} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !packageCatalog.valid || !item.license_allowed || !["install", "update"].includes(item.action) ? ADMIN_INTERFACE_I18N.catalogBlocked : undefined}>{catalogPlanButtonLabel(item, catalog)}</Button>
<Button onClick={() => void addCatalogItem(item)} disabled={!canWrite || planBusy || !packageCatalog.valid || !item.license_allowed || !["install", "update"].includes(item.action)} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !packageCatalog.valid || !item.license_allowed || !["install", "update"].includes(item.action) ? ADMIN_INTERFACE_I18N.catalogBlocked : undefined}>{catalogPlanButtonLabel(item)}</Button>
</div>
)}
</div>}
@@ -958,10 +959,10 @@ function ModuleStatus({ module, desiredEnabled }: {module: ModuleCatalogItem;des
return <StatusBadge status="inactive" label="i18n:govoplan-admin.inactive.09af574c" />;
}
function catalogPlanButtonLabel(item: ModulePackageCatalogItem, catalog: ModuleCatalogResponse | null): string {
if (item.action === "update") return "i18n:govoplan-admin.plan_update.86e6857a";
const installed = catalog?.modules.some((module) => module.id === item.module_id && module.installed) ?? false;
return installed ? "i18n:govoplan-admin.plan_update.86e6857a" : "i18n:govoplan-admin.plan_install.e82bffe6";
function catalogPlanButtonLabel(item: ModulePackageCatalogItem): string {
return item.action === "update" || item.installed
? "i18n:govoplan-admin.plan_update.86e6857a"
: "i18n:govoplan-admin.plan_install.e82bffe6";
}
function sameSet(left: ReadonlySet<string>, right: ReadonlySet<string>) {