Expose public module catalog discovery
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
Reference in New Issue
Block a user