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"
|
||||
|
||||
Reference in New Issue
Block a user