Expose searchable module catalog compatibility
This commit is contained in:
@@ -84,6 +84,7 @@ from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, Maintenance
|
||||
from govoplan_core.core.lifecycle import ModuleLifecycleManager
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_core.core.versioning import compare_versions, format_version_range, version_satisfies_range
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.i18n import (
|
||||
REFERENCE_LANGUAGE_CODE,
|
||||
@@ -635,6 +636,12 @@ def _catalog_install_or_update_item(result: dict[str, object], module_id: str) -
|
||||
continue
|
||||
if raw_item.get("module_id") != module_id or raw_item.get("action") not in {"install", "update"}:
|
||||
continue
|
||||
if raw_item.get("availability") == "withdrawn":
|
||||
reason = str(raw_item.get("availability_reason") or "Catalog release has been withdrawn.")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Catalog entry {module_id} cannot be installed: {reason}",
|
||||
)
|
||||
return raw_item
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install/update entry not found: {module_id}")
|
||||
|
||||
@@ -709,18 +716,21 @@ def _catalog_license_features(raw_item: dict[str, object]) -> list[str]:
|
||||
def _module_package_catalog_item(
|
||||
raw_item: dict[str, object],
|
||||
available: Mapping[str, Any],
|
||||
catalog_items: list[dict[str, object]] | None = None,
|
||||
) -> ModulePackageCatalogItem:
|
||||
payload = dict(raw_item)
|
||||
manifest = available.get(str(raw_item.get("module_id") or ""))
|
||||
module_id = str(raw_item.get("module_id") or "")
|
||||
manifest = available.get(module_id)
|
||||
installed_version = str(getattr(manifest, "version", "") or "") or None
|
||||
target_version = raw_item.get("version")
|
||||
version_comparison = (
|
||||
compare_versions(target_version, installed_version)
|
||||
if installed_version and isinstance(target_version, str)
|
||||
else None
|
||||
)
|
||||
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
|
||||
)
|
||||
payload["update_available"] = version_comparison is not None and version_comparison > 0
|
||||
decision = module_license_decision(_catalog_license_features(raw_item))
|
||||
payload["license_allowed"] = bool(decision.get("allowed"))
|
||||
payload["license_enforced"] = bool(decision.get("enforced"))
|
||||
@@ -728,9 +738,157 @@ def _module_package_catalog_item(
|
||||
payload["license_missing_features"] = [str(item) for item in missing] if isinstance(missing, list) else []
|
||||
reason = decision.get("reason")
|
||||
payload["license_reason"] = reason if isinstance(reason, str) else None
|
||||
compatibility_reasons = _catalog_compatibility_reasons(
|
||||
raw_item,
|
||||
available=available,
|
||||
catalog_items=catalog_items or [raw_item],
|
||||
)
|
||||
if not bool(decision.get("allowed")):
|
||||
compatibility_reasons.append(
|
||||
str(reason or f"The active license policy does not allow {module_id}.")
|
||||
)
|
||||
if raw_item.get("action") not in {"install", "update"}:
|
||||
compatibility_reasons.append("This catalog action cannot be added to an install plan.")
|
||||
compatibility_reasons = list(dict.fromkeys(compatibility_reasons))
|
||||
payload["compatible"] = not compatibility_reasons
|
||||
payload["plan_allowed"] = bool(
|
||||
not compatibility_reasons
|
||||
and raw_item.get("action") in {"install", "update"}
|
||||
and (
|
||||
manifest is None
|
||||
or (version_comparison is not None and version_comparison > 0)
|
||||
or (version_comparison is not None and version_comparison < 0 and raw_item.get("allow_downgrade") is True)
|
||||
or (version_comparison == 0 and raw_item.get("allow_same_version") is True)
|
||||
)
|
||||
)
|
||||
payload["compatibility_reasons"] = compatibility_reasons
|
||||
if raw_item.get("availability") == "withdrawn":
|
||||
payload["catalog_state"] = "withdrawn"
|
||||
elif compatibility_reasons:
|
||||
payload["catalog_state"] = "blocked"
|
||||
elif payload["update_available"]:
|
||||
payload["catalog_state"] = "update_available"
|
||||
elif payload["installed"]:
|
||||
payload["catalog_state"] = "installed"
|
||||
else:
|
||||
payload["catalog_state"] = "available"
|
||||
return ModulePackageCatalogItem.model_validate(payload)
|
||||
|
||||
|
||||
def _catalog_compatibility_reasons(
|
||||
raw_item: dict[str, object],
|
||||
*,
|
||||
available: Mapping[str, Any],
|
||||
catalog_items: list[dict[str, object]],
|
||||
) -> list[str]:
|
||||
module_id = str(raw_item.get("module_id") or "")
|
||||
reasons: list[str] = []
|
||||
if raw_item.get("availability") == "withdrawn":
|
||||
reasons.append(
|
||||
str(raw_item.get("availability_reason") or "This catalog release has been withdrawn.")
|
||||
)
|
||||
|
||||
manifest = available.get(module_id)
|
||||
installed_version = str(getattr(manifest, "version", "") or "") or None
|
||||
version_min = _catalog_string(raw_item, "current_version_min")
|
||||
version_max = _catalog_string(raw_item, "current_version_max_exclusive")
|
||||
if installed_version and not version_satisfies_range(
|
||||
installed_version,
|
||||
version_min=version_min,
|
||||
version_max_exclusive=version_max,
|
||||
):
|
||||
reasons.append(
|
||||
f"Installed version {installed_version} is outside the supported update window "
|
||||
f"({format_version_range(version_min=version_min, version_max_exclusive=version_max)})."
|
||||
)
|
||||
target_version = _catalog_string(raw_item, "version")
|
||||
if (
|
||||
installed_version
|
||||
and target_version
|
||||
and compare_versions(target_version, installed_version) < 0
|
||||
and raw_item.get("allow_downgrade") is not True
|
||||
):
|
||||
reasons.append(
|
||||
f"Catalog version {target_version} is older than installed version {installed_version}; "
|
||||
"the release does not allow a downgrade."
|
||||
)
|
||||
|
||||
catalog_module_ids = {
|
||||
str(item.get("module_id") or "")
|
||||
for item in catalog_items
|
||||
if item.get("availability") != "withdrawn"
|
||||
}
|
||||
for dependency in _catalog_string_values(raw_item.get("dependencies")):
|
||||
if dependency not in available and dependency not in catalog_module_ids:
|
||||
reasons.append(f"Required module {dependency} is neither installed nor available in this catalog.")
|
||||
|
||||
providers = _catalog_interface_providers(catalog_items, available=available)
|
||||
requirements = raw_item.get("requires_interfaces")
|
||||
if isinstance(requirements, list):
|
||||
for requirement in requirements:
|
||||
if not isinstance(requirement, Mapping) or requirement.get("optional") is True:
|
||||
continue
|
||||
interface_name = str(requirement.get("name") or "").strip()
|
||||
if not interface_name:
|
||||
continue
|
||||
required_min = _catalog_mapping_string(requirement, "version_min")
|
||||
required_max = _catalog_mapping_string(requirement, "version_max_exclusive")
|
||||
if any(
|
||||
version_satisfies_range(
|
||||
provider_version,
|
||||
version_min=required_min,
|
||||
version_max_exclusive=required_max,
|
||||
)
|
||||
for provider_version in providers.get(interface_name, ())
|
||||
):
|
||||
continue
|
||||
reasons.append(
|
||||
f"Required interface {interface_name} "
|
||||
f"({format_version_range(version_min=required_min, version_max_exclusive=required_max)}) "
|
||||
"has no compatible installed or catalog provider."
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def _catalog_interface_providers(
|
||||
catalog_items: list[dict[str, object]],
|
||||
*,
|
||||
available: Mapping[str, Any],
|
||||
) -> dict[str, set[str]]:
|
||||
providers: dict[str, set[str]] = {}
|
||||
for item in catalog_items:
|
||||
if item.get("availability") == "withdrawn":
|
||||
continue
|
||||
declared = item.get("provides_interfaces")
|
||||
if not isinstance(declared, list):
|
||||
continue
|
||||
for provider in declared:
|
||||
if not isinstance(provider, Mapping):
|
||||
continue
|
||||
name = str(provider.get("name") or "").strip()
|
||||
version = str(provider.get("version") or "").strip()
|
||||
if name and version:
|
||||
providers.setdefault(name, set()).add(version)
|
||||
for manifest in available.values():
|
||||
for provider in getattr(manifest, "provides_interfaces", ()):
|
||||
name = str(getattr(provider, "name", "") or "").strip()
|
||||
version = str(getattr(provider, "version", "") or "").strip()
|
||||
if name and version:
|
||||
providers.setdefault(name, set()).add(version)
|
||||
return providers
|
||||
|
||||
|
||||
def _catalog_string_values(value: object) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
|
||||
def _catalog_mapping_string(value: Mapping[str, object], field: str) -> str | None:
|
||||
raw = value.get(field)
|
||||
return str(raw).strip() if isinstance(raw, str) and raw.strip() else None
|
||||
|
||||
|
||||
def _catalog_required_license_features(result: dict[str, object]) -> list[str]:
|
||||
required: list[str] = []
|
||||
for raw_item in result.get("modules", []):
|
||||
@@ -1260,9 +1418,13 @@ def read_module_package_catalog(
|
||||
if isinstance(lifecycle, ModuleLifecycleManager)
|
||||
else available_module_manifests(ignore_load_errors=True)
|
||||
)
|
||||
catalog_items = [item for item in result["modules"] if isinstance(item, dict)]
|
||||
license_diagnostics = module_license_diagnostics(required_features=_catalog_required_license_features(result))
|
||||
return ModulePackageCatalogResponse(
|
||||
modules=[_module_package_catalog_item(item, available) for item in result["modules"] if isinstance(item, dict)],
|
||||
modules=[
|
||||
_module_package_catalog_item(item, available, catalog_items)
|
||||
for item in catalog_items
|
||||
],
|
||||
configured=bool(result["configured"]),
|
||||
valid=bool(result["valid"]),
|
||||
path=result["path"] if isinstance(result["path"], str) else None,
|
||||
|
||||
@@ -387,6 +387,30 @@ class ModuleInterfaceRequirementItem(BaseModel):
|
||||
optional: bool = False
|
||||
|
||||
|
||||
class ModulePackageCatalogSource(BaseModel):
|
||||
repository: str
|
||||
tag: str
|
||||
commit: str
|
||||
repository_url: str | None = None
|
||||
revision_url: str | None = None
|
||||
|
||||
|
||||
class ModulePackageArtifactIdentity(BaseModel):
|
||||
ref: str | None = None
|
||||
path: str | None = None
|
||||
artifact_path: str | None = None
|
||||
url: str | None = None
|
||||
filename: str | None = None
|
||||
sha256: str | None = None
|
||||
size: int | None = None
|
||||
integrity: str | None = None
|
||||
sbom_url: str | None = None
|
||||
provenance_url: str | None = None
|
||||
registry_identity: str | None = None
|
||||
git_ref: str | None = None
|
||||
source_commit: str | None = None
|
||||
|
||||
|
||||
class ModulePackageCatalogItem(BaseModel):
|
||||
module_id: str
|
||||
name: str
|
||||
@@ -396,6 +420,16 @@ class ModulePackageCatalogItem(BaseModel):
|
||||
installed: bool = False
|
||||
installed_version: str | None = None
|
||||
update_available: bool = False
|
||||
availability: Literal["available", "withdrawn"] = "available"
|
||||
availability_reason: str | None = None
|
||||
configuration_requirements: list[str] = Field(default_factory=list)
|
||||
release_notes_url: str | None = None
|
||||
source: ModulePackageCatalogSource | None = None
|
||||
artifact_integrity: dict[str, ModulePackageArtifactIdentity] = Field(default_factory=dict)
|
||||
compatible: bool = True
|
||||
plan_allowed: bool = True
|
||||
compatibility_reasons: list[str] = Field(default_factory=list)
|
||||
catalog_state: Literal["available", "installed", "update_available", "blocked", "withdrawn"] = "available"
|
||||
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,7 +147,7 @@ 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. "
|
||||
"When no deployment-specific catalog is configured, the package directory discovers the signed public GovOPlaN stable catalog. Operators can search and filter available, installed, update, blocked, and withdrawn entries; each row exposes the signed source revision, artifact digest, release notes, and configuration requirements supplied by the catalog. Missing dependencies, incompatible named interfaces, unsupported update windows, and withdrawn releases block plan creation. Selecting an eligible 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. 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."
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user