Harden module update compatibility cleanup
This commit is contained in:
@@ -41,6 +41,7 @@ from govoplan_core.core.versioning import compare_versions, format_version_range
|
||||
IssueSeverity = Literal["blocker", "warning", "info"]
|
||||
ChecklistStatus = Literal["done", "pending", "blocked", "warning"]
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||
PACKAGE_TARGET_ACTIONS = {"install", "update"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -79,6 +80,44 @@ class ModuleInstallChecklistItem:
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModuleInstallTargetItem:
|
||||
module_id: str
|
||||
action: str
|
||||
source: str
|
||||
current_version: str | None = None
|
||||
target_version: str | None = None
|
||||
python_package: str | None = None
|
||||
python_ref: str | None = None
|
||||
webui_package: str | None = None
|
||||
webui_ref: str | None = None
|
||||
migration_safety: str = "automatic"
|
||||
migration_notes: str | None = None
|
||||
data_safety_acknowledged: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"module_id": self.module_id,
|
||||
"action": self.action,
|
||||
"source": self.source,
|
||||
"migration_safety": self.migration_safety,
|
||||
"data_safety_acknowledged": self.data_safety_acknowledged,
|
||||
}
|
||||
for key in (
|
||||
"current_version",
|
||||
"target_version",
|
||||
"python_package",
|
||||
"python_ref",
|
||||
"webui_package",
|
||||
"webui_ref",
|
||||
"migration_notes",
|
||||
):
|
||||
value = getattr(self, key)
|
||||
if value:
|
||||
payload[key] = value
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModuleInstallerPreflight:
|
||||
allowed: bool
|
||||
@@ -89,6 +128,7 @@ class ModuleInstallerPreflight:
|
||||
rollback_commands: tuple[str, ...]
|
||||
issues: tuple[ModuleInstallerIssue, ...]
|
||||
checklist: tuple[ModuleInstallChecklistItem, ...] = ()
|
||||
target_plan: tuple[ModuleInstallTargetItem, ...] = ()
|
||||
artifact_integrity: tuple[dict[str, object], ...] = ()
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
@@ -101,6 +141,7 @@ class ModuleInstallerPreflight:
|
||||
"rollback_commands": list(self.rollback_commands),
|
||||
"issues": [issue.as_dict() for issue in self.issues],
|
||||
"checklist": [item.as_dict() for item in self.checklist],
|
||||
"target_plan": [item.as_dict() for item in self.target_plan],
|
||||
"artifact_integrity": list(self.artifact_integrity),
|
||||
}
|
||||
|
||||
@@ -158,6 +199,7 @@ def module_install_preflight(
|
||||
desired = {item for item in desired_enabled}
|
||||
planned_items = tuple(item for item in plan.items if item.status == "planned")
|
||||
dependents = module_dependents(available)
|
||||
target_plan = _module_install_target_plan(plan, available)
|
||||
|
||||
if not planned_items:
|
||||
issues.append(ModuleInstallerIssue("warning", "empty_plan", "No planned package changes are present."))
|
||||
@@ -179,7 +221,14 @@ def module_install_preflight(
|
||||
available=available,
|
||||
session=session,
|
||||
))
|
||||
if item.action == "install":
|
||||
if item.action in PACKAGE_TARGET_ACTIONS:
|
||||
if item.action == "update" and item.module_id not in available:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"update_module_missing",
|
||||
"Package updates require the module to be installed; use install for new modules.",
|
||||
item.module_id,
|
||||
))
|
||||
if item.module_id not in available:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"info",
|
||||
@@ -243,6 +292,7 @@ def module_install_preflight(
|
||||
rollback_commands=rollback_commands,
|
||||
issues=tuple(issues),
|
||||
checklist=checklist,
|
||||
target_plan=target_plan,
|
||||
artifact_integrity=artifact_integrity,
|
||||
)
|
||||
|
||||
@@ -795,7 +845,7 @@ def structured_install_commands(
|
||||
for item in plan.items:
|
||||
if item.status != "planned":
|
||||
continue
|
||||
if item.action == "install":
|
||||
if item.action in PACKAGE_TARGET_ACTIONS:
|
||||
if item.python_ref:
|
||||
commands.append(_structured_command([sys.executable, "-m", "pip", "install", item.python_ref]))
|
||||
if item.webui_package and item.webui_ref and webui_root is not None:
|
||||
@@ -955,7 +1005,7 @@ def _module_verify_command(plan: ModuleInstallPlan) -> dict[str, Any] | None:
|
||||
for item in plan.items:
|
||||
if item.status != "planned":
|
||||
continue
|
||||
if item.action == "install" and item.python_ref:
|
||||
if item.action in PACKAGE_TARGET_ACTIONS and item.python_ref:
|
||||
installed.append(item.module_id)
|
||||
elif item.action == "uninstall" and item.python_package:
|
||||
absent.append(item.module_id)
|
||||
@@ -1089,10 +1139,10 @@ def _package_catalog_preflight_issues(
|
||||
plan: ModuleInstallPlan,
|
||||
available: Mapping[str, ModuleManifest],
|
||||
) -> tuple[ModuleInstallerIssue, ...]:
|
||||
install_items = tuple(item for item in plan.items if item.status == "planned" and item.action == "install")
|
||||
if not install_items:
|
||||
package_items = tuple(item for item in plan.items if item.status == "planned" and item.action in PACKAGE_TARGET_ACTIONS)
|
||||
if not package_items:
|
||||
return ()
|
||||
catalog_items = tuple(item for item in install_items if item.source == "catalog")
|
||||
catalog_items = tuple(item for item in package_items if item.source == "catalog")
|
||||
try:
|
||||
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
||||
|
||||
@@ -1132,27 +1182,79 @@ def _package_catalog_preflight_issues(
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _module_install_target_plan(
|
||||
plan: ModuleInstallPlan,
|
||||
available: Mapping[str, ModuleManifest],
|
||||
) -> tuple[ModuleInstallTargetItem, ...]:
|
||||
planned_items = tuple(item for item in plan.items if item.status == "planned")
|
||||
if not planned_items:
|
||||
return ()
|
||||
catalog_modules = _catalog_modules_for_target_plan(planned_items)
|
||||
targets: list[ModuleInstallTargetItem] = []
|
||||
for item in planned_items:
|
||||
manifest = available.get(item.module_id)
|
||||
catalog_module = catalog_modules.get(item.module_id) if item.source == "catalog" else None
|
||||
target_version = None if item.action == "uninstall" else _catalog_optional_string(catalog_module, "version")
|
||||
migration_safety = "automatic"
|
||||
migration_notes = None
|
||||
if catalog_module is not None and item.action in PACKAGE_TARGET_ACTIONS:
|
||||
migration_safety = _catalog_optional_string(catalog_module, "migration_safety") or "automatic"
|
||||
migration_notes = _catalog_optional_string(catalog_module, "migration_notes")
|
||||
targets.append(ModuleInstallTargetItem(
|
||||
module_id=item.module_id,
|
||||
action=item.action,
|
||||
source=item.source,
|
||||
current_version=manifest.version if manifest is not None else None,
|
||||
target_version=target_version,
|
||||
python_package=item.python_package or _catalog_optional_string(catalog_module, "python_package"),
|
||||
python_ref=item.python_ref or _catalog_optional_string(catalog_module, "python_ref"),
|
||||
webui_package=item.webui_package or _catalog_optional_string(catalog_module, "webui_package"),
|
||||
webui_ref=item.webui_ref or _catalog_optional_string(catalog_module, "webui_ref"),
|
||||
migration_safety=migration_safety,
|
||||
migration_notes=migration_notes,
|
||||
data_safety_acknowledged=item.data_safety_acknowledged,
|
||||
))
|
||||
return tuple(targets)
|
||||
|
||||
|
||||
def _catalog_modules_for_target_plan(
|
||||
planned_items: tuple[ModuleInstallPlanItem, ...],
|
||||
) -> dict[str, Mapping[str, object]]:
|
||||
if not any(item.source == "catalog" and item.action in PACKAGE_TARGET_ACTIONS for item in planned_items):
|
||||
return {}
|
||||
try:
|
||||
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
||||
|
||||
result = validate_module_package_catalog()
|
||||
except Exception:
|
||||
return {}
|
||||
if result.get("valid") is not True:
|
||||
return {}
|
||||
return _catalog_modules_by_id(result)
|
||||
|
||||
|
||||
def _catalog_optional_string(module: Mapping[str, object] | None, key: str) -> str | None:
|
||||
if module is None:
|
||||
return None
|
||||
value = module.get(key)
|
||||
if isinstance(value, str):
|
||||
cleaned = value.strip()
|
||||
return cleaned or None
|
||||
return None
|
||||
|
||||
|
||||
def _selected_catalog_interface_issues(
|
||||
catalog_items: tuple[ModuleInstallPlanItem, ...],
|
||||
validation: Mapping[str, object],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
) -> tuple[ModuleInstallerIssue, ...]:
|
||||
modules = {
|
||||
str(item.get("module_id")): item
|
||||
for item in validation.get("modules", [])
|
||||
if isinstance(item, Mapping) and item.get("module_id")
|
||||
}
|
||||
catalog_provider_versions: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
for module_id, module in modules.items():
|
||||
for provided in _catalog_interface_items(module.get("provides_interfaces")):
|
||||
name = provided.get("name") if isinstance(provided.get("name"), str) else None
|
||||
version = provided.get("version") if isinstance(provided.get("version"), str) else None
|
||||
if name and version:
|
||||
catalog_provider_versions[name].append((module_id, version))
|
||||
installed_provider_versions: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
for manifest in available.values():
|
||||
for provided in manifest.provides_interfaces:
|
||||
installed_provider_versions[provided.name].append((manifest.id, provided.version))
|
||||
modules = _catalog_modules_by_id(validation)
|
||||
planned_ids = {item.module_id for item in catalog_items}
|
||||
target_provider_versions = _catalog_target_provider_versions(
|
||||
catalog_items=catalog_items,
|
||||
modules=modules,
|
||||
available=available,
|
||||
)
|
||||
|
||||
issues: list[ModuleInstallerIssue] = []
|
||||
for item in catalog_items:
|
||||
@@ -1165,64 +1267,344 @@ def _selected_catalog_interface_issues(
|
||||
item.module_id,
|
||||
))
|
||||
continue
|
||||
for required in _catalog_interface_items(module.get("requires_interfaces")):
|
||||
name = required.get("name") if isinstance(required.get("name"), str) else None
|
||||
if not name:
|
||||
continue
|
||||
version_min = required.get("version_min") if isinstance(required.get("version_min"), str) else None
|
||||
version_max_exclusive = (
|
||||
required.get("version_max_exclusive")
|
||||
if isinstance(required.get("version_max_exclusive"), str)
|
||||
else None
|
||||
issues.extend(_catalog_dependency_issues(
|
||||
module_id=item.module_id,
|
||||
module=module,
|
||||
planned_ids=planned_ids,
|
||||
available=available,
|
||||
modules=modules,
|
||||
))
|
||||
issues.extend(_catalog_data_safety_issues(item=item, module=module))
|
||||
issues.extend(_catalog_requirement_issues(
|
||||
module_id=item.module_id,
|
||||
requirements=_catalog_interface_items(module.get("requires_interfaces")),
|
||||
target_provider_versions=target_provider_versions,
|
||||
catalog_modules=modules,
|
||||
planned_ids=planned_ids,
|
||||
code_prefix="catalog",
|
||||
))
|
||||
|
||||
for manifest in available.values():
|
||||
if manifest.id in planned_ids:
|
||||
continue
|
||||
requirements = tuple(
|
||||
{
|
||||
"name": requirement.name,
|
||||
"version_min": requirement.version_min,
|
||||
"version_max_exclusive": requirement.version_max_exclusive,
|
||||
"optional": requirement.optional,
|
||||
}
|
||||
for requirement in manifest.requires_interfaces
|
||||
)
|
||||
requirement_issues = _catalog_requirement_issues(
|
||||
module_id=manifest.id,
|
||||
requirements=requirements,
|
||||
target_provider_versions=target_provider_versions,
|
||||
catalog_modules=modules,
|
||||
planned_ids=planned_ids,
|
||||
code_prefix="target",
|
||||
)
|
||||
candidate_update = modules.get(manifest.id)
|
||||
if (
|
||||
any(issue.severity == "blocker" for issue in requirement_issues)
|
||||
and candidate_update is not None
|
||||
and _catalog_requirements_satisfied(
|
||||
_catalog_interface_items(candidate_update.get("requires_interfaces")),
|
||||
target_provider_versions,
|
||||
)
|
||||
optional = required.get("optional") is True
|
||||
providers = [
|
||||
*catalog_provider_versions.get(name, ()),
|
||||
*installed_provider_versions.get(name, ()),
|
||||
]
|
||||
matching = [
|
||||
(provider_module_id, version)
|
||||
for provider_module_id, version in providers
|
||||
if version_satisfies_range(
|
||||
version,
|
||||
version_min=version_min,
|
||||
version_max_exclusive=version_max_exclusive,
|
||||
)
|
||||
]
|
||||
if matching:
|
||||
continue
|
||||
version_range = format_version_range(
|
||||
):
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"companion_update_required",
|
||||
(
|
||||
f"Target package plan would leave installed module {manifest.id!r} "
|
||||
f"incompatible; add the catalog update for {manifest.id!r} to the same plan."
|
||||
),
|
||||
manifest.id,
|
||||
))
|
||||
continue
|
||||
issues.extend(requirement_issues)
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _catalog_data_safety_issues(
|
||||
*,
|
||||
item: ModuleInstallPlanItem,
|
||||
module: Mapping[str, object],
|
||||
) -> tuple[ModuleInstallerIssue, ...]:
|
||||
raw_safety = module.get("migration_safety")
|
||||
safety = raw_safety if isinstance(raw_safety, str) and raw_safety else "automatic"
|
||||
raw_notes = module.get("migration_notes")
|
||||
notes = raw_notes.strip() if isinstance(raw_notes, str) and raw_notes.strip() else None
|
||||
detail = f" Catalog note: {notes}" if notes else ""
|
||||
if safety == "automatic":
|
||||
return ()
|
||||
if safety == "requires_review":
|
||||
severity: IssueSeverity = "info" if item.data_safety_acknowledged else "warning"
|
||||
code = "migration_review_acknowledged" if item.data_safety_acknowledged else "migration_review_required"
|
||||
message = "Catalog marks this package change as requiring migration review before activation." + detail
|
||||
return (ModuleInstallerIssue(severity, code, message, item.module_id),)
|
||||
if safety == "forward_only":
|
||||
if not item.data_safety_acknowledged:
|
||||
return (ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"forward_only_migration_acknowledgement_required",
|
||||
"Catalog marks this package change as forward-only; acknowledge the data safety review before activation." + detail,
|
||||
item.module_id,
|
||||
),)
|
||||
return (ModuleInstallerIssue(
|
||||
"warning",
|
||||
"forward_only_migration_acknowledged",
|
||||
"Forward-only package change has been acknowledged by the operator." + detail,
|
||||
item.module_id,
|
||||
),)
|
||||
if safety == "destructive":
|
||||
issues: list[ModuleInstallerIssue] = []
|
||||
if not notes and not item.notes:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"destructive_migration_plan_required",
|
||||
"Catalog marks this package change as destructive; add catalog migration notes or operator notes describing the cleanup/retirement plan.",
|
||||
item.module_id,
|
||||
))
|
||||
if not item.data_safety_acknowledged:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"destructive_migration_acknowledgement_required",
|
||||
"Catalog marks this package change as destructive; acknowledge the data safety review before activation." + detail,
|
||||
item.module_id,
|
||||
))
|
||||
if not issues:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"warning",
|
||||
"destructive_migration_acknowledged",
|
||||
"Destructive package change has been acknowledged by the operator." + detail,
|
||||
item.module_id,
|
||||
))
|
||||
return tuple(issues)
|
||||
return (ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"catalog_migration_safety_invalid",
|
||||
f"Catalog entry has unsupported migration_safety value {safety!r}.",
|
||||
item.module_id,
|
||||
),)
|
||||
|
||||
|
||||
def _catalog_modules_by_id(validation: Mapping[str, object]) -> dict[str, Mapping[str, object]]:
|
||||
return {
|
||||
str(item.get("module_id")): item
|
||||
for item in validation.get("modules", [])
|
||||
if isinstance(item, Mapping) and item.get("module_id")
|
||||
}
|
||||
|
||||
|
||||
def _catalog_target_provider_versions(
|
||||
*,
|
||||
catalog_items: tuple[ModuleInstallPlanItem, ...],
|
||||
modules: Mapping[str, Mapping[str, object]],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
) -> dict[str, list[tuple[str, str]]]:
|
||||
planned_ids = {item.module_id for item in catalog_items}
|
||||
providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
for manifest in available.values():
|
||||
if manifest.id in planned_ids:
|
||||
continue
|
||||
for provided in manifest.provides_interfaces:
|
||||
providers[provided.name].append((manifest.id, provided.version))
|
||||
for item in catalog_items:
|
||||
module = modules.get(item.module_id)
|
||||
if module is None:
|
||||
continue
|
||||
for provided in _catalog_interface_items(module.get("provides_interfaces")):
|
||||
name = provided.get("name") if isinstance(provided.get("name"), str) else None
|
||||
version = provided.get("version") if isinstance(provided.get("version"), str) else None
|
||||
if name and version:
|
||||
providers[name].append((item.module_id, version))
|
||||
return providers
|
||||
|
||||
|
||||
def _catalog_dependency_issues(
|
||||
*,
|
||||
module_id: str,
|
||||
module: Mapping[str, object],
|
||||
planned_ids: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
modules: Mapping[str, Mapping[str, object]],
|
||||
) -> tuple[ModuleInstallerIssue, ...]:
|
||||
issues: list[ModuleInstallerIssue] = []
|
||||
for dependency_id in _catalog_string_list(module.get("dependencies")):
|
||||
if dependency_id in planned_ids or dependency_id in available:
|
||||
continue
|
||||
if dependency_id in modules:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"companion_update_required",
|
||||
(
|
||||
f"Catalog-sourced module {module_id!r} depends on {dependency_id!r}; "
|
||||
"add that catalog entry to the same package plan."
|
||||
),
|
||||
module_id,
|
||||
))
|
||||
continue
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"catalog_required_dependency_missing",
|
||||
(
|
||||
f"Catalog-sourced module {module_id!r} depends on {dependency_id!r}, "
|
||||
"but it is neither installed nor present in the package catalog."
|
||||
),
|
||||
module_id,
|
||||
))
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _catalog_requirement_issues(
|
||||
*,
|
||||
module_id: str,
|
||||
requirements: tuple[Mapping[str, object], ...],
|
||||
target_provider_versions: Mapping[str, list[tuple[str, str]]],
|
||||
catalog_modules: Mapping[str, Mapping[str, object]],
|
||||
planned_ids: set[str],
|
||||
code_prefix: str,
|
||||
) -> tuple[ModuleInstallerIssue, ...]:
|
||||
issues: list[ModuleInstallerIssue] = []
|
||||
for required in requirements:
|
||||
name = required.get("name") if isinstance(required.get("name"), str) else None
|
||||
if not name:
|
||||
continue
|
||||
version_min = required.get("version_min") if isinstance(required.get("version_min"), str) else None
|
||||
version_max_exclusive = (
|
||||
required.get("version_max_exclusive")
|
||||
if isinstance(required.get("version_max_exclusive"), str)
|
||||
else None
|
||||
)
|
||||
optional = required.get("optional") is True
|
||||
providers = target_provider_versions.get(name, ())
|
||||
matching = [
|
||||
(provider_module_id, version)
|
||||
for provider_module_id, version in providers
|
||||
if version_satisfies_range(
|
||||
version,
|
||||
version_min=version_min,
|
||||
version_max_exclusive=version_max_exclusive,
|
||||
)
|
||||
if not providers:
|
||||
if optional:
|
||||
continue
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"catalog_required_interface_missing",
|
||||
(
|
||||
f"Catalog-sourced module {item.module_id!r} requires interface "
|
||||
f"{name!r} ({version_range}), but neither the package catalog nor "
|
||||
"installed modules provide it."
|
||||
),
|
||||
item.module_id,
|
||||
))
|
||||
continue
|
||||
provider_summary = ", ".join(f"{provider_module_id}@{version}" for provider_module_id, version in providers)
|
||||
severity: IssueSeverity = "warning" if optional else "blocker"
|
||||
]
|
||||
if matching:
|
||||
continue
|
||||
version_range = format_version_range(
|
||||
version_min=version_min,
|
||||
version_max_exclusive=version_max_exclusive,
|
||||
)
|
||||
suggestions = _catalog_provider_suggestions(
|
||||
name=name,
|
||||
version_min=version_min,
|
||||
version_max_exclusive=version_max_exclusive,
|
||||
catalog_modules=catalog_modules,
|
||||
planned_ids=planned_ids,
|
||||
)
|
||||
if suggestions:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
severity,
|
||||
"catalog_interface_version_mismatch",
|
||||
"blocker",
|
||||
"companion_update_required",
|
||||
(
|
||||
f"Catalog-sourced module {item.module_id!r} requires interface "
|
||||
f"{name!r} ({version_range}), but available providers are {provider_summary}."
|
||||
f"Module {module_id!r} requires interface {name!r} ({version_range}); "
|
||||
f"add companion catalog update(s) to the same plan: {', '.join(suggestions)}."
|
||||
),
|
||||
item.module_id,
|
||||
module_id,
|
||||
))
|
||||
continue
|
||||
if not providers:
|
||||
if optional:
|
||||
continue
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
f"{code_prefix}_required_interface_missing",
|
||||
(
|
||||
f"Module {module_id!r} requires interface {name!r} ({version_range}), "
|
||||
"but the target package plan has no provider."
|
||||
),
|
||||
module_id,
|
||||
))
|
||||
continue
|
||||
provider_summary = ", ".join(f"{provider_module_id}@{version}" for provider_module_id, version in providers)
|
||||
severity: IssueSeverity = "warning" if optional else "blocker"
|
||||
issues.append(ModuleInstallerIssue(
|
||||
severity,
|
||||
f"{code_prefix}_interface_version_mismatch",
|
||||
(
|
||||
f"Module {module_id!r} requires interface {name!r} ({version_range}), "
|
||||
f"but target providers are {provider_summary}."
|
||||
),
|
||||
module_id,
|
||||
))
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _catalog_provider_suggestions(
|
||||
*,
|
||||
name: str,
|
||||
version_min: str | None,
|
||||
version_max_exclusive: str | None,
|
||||
catalog_modules: Mapping[str, Mapping[str, object]],
|
||||
planned_ids: set[str],
|
||||
) -> tuple[str, ...]:
|
||||
suggestions: list[str] = []
|
||||
for module_id, module in catalog_modules.items():
|
||||
if module_id in planned_ids:
|
||||
continue
|
||||
for provided in _catalog_interface_items(module.get("provides_interfaces")):
|
||||
provided_name = provided.get("name") if isinstance(provided.get("name"), str) else None
|
||||
version = provided.get("version") if isinstance(provided.get("version"), str) else None
|
||||
if provided_name != name or not version:
|
||||
continue
|
||||
if version_satisfies_range(
|
||||
version,
|
||||
version_min=version_min,
|
||||
version_max_exclusive=version_max_exclusive,
|
||||
):
|
||||
suggestions.append(module_id)
|
||||
break
|
||||
return tuple(sorted(dict.fromkeys(suggestions)))
|
||||
|
||||
|
||||
def _catalog_requirements_satisfied(
|
||||
requirements: tuple[Mapping[str, object], ...],
|
||||
target_provider_versions: Mapping[str, list[tuple[str, str]]],
|
||||
) -> bool:
|
||||
for required in requirements:
|
||||
name = required.get("name") if isinstance(required.get("name"), str) else None
|
||||
if not name:
|
||||
continue
|
||||
version_min = required.get("version_min") if isinstance(required.get("version_min"), str) else None
|
||||
version_max_exclusive = (
|
||||
required.get("version_max_exclusive")
|
||||
if isinstance(required.get("version_max_exclusive"), str)
|
||||
else None
|
||||
)
|
||||
providers = target_provider_versions.get(name, ())
|
||||
matching = [
|
||||
version
|
||||
for _provider_module_id, version in providers
|
||||
if version_satisfies_range(
|
||||
version,
|
||||
version_min=version_min,
|
||||
version_max_exclusive=version_max_exclusive,
|
||||
)
|
||||
]
|
||||
if matching:
|
||||
continue
|
||||
if required.get("optional") is True and not providers:
|
||||
continue
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _catalog_string_list(value: object) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
return tuple(str(item).strip() for item in value if str(item).strip())
|
||||
|
||||
|
||||
def _catalog_interface_items(value: object) -> tuple[Mapping[str, object], ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
@@ -1547,7 +1929,7 @@ def _verify_artifact_integrity(
|
||||
records: list[dict[str, object]] = []
|
||||
issues: list[ModuleInstallerIssue] = []
|
||||
for item in planned_items:
|
||||
if item.action != "install":
|
||||
if item.action not in PACKAGE_TARGET_ACTIONS:
|
||||
continue
|
||||
for kind, package_name, package_ref in (
|
||||
("python", item.python_package, item.python_ref),
|
||||
|
||||
@@ -19,7 +19,7 @@ MODULE_SETTINGS_KEY = "module_management"
|
||||
INSTALL_PLAN_KEY = "install_plan"
|
||||
REQUIRED_PLATFORM_MODULES = ("access",)
|
||||
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
||||
INSTALL_PLAN_ACTIONS = ("install", "uninstall")
|
||||
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
|
||||
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
||||
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
||||
LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:")
|
||||
@@ -46,6 +46,7 @@ class ModuleInstallPlanItem:
|
||||
webui_package: str | None = None
|
||||
webui_ref: str | None = None
|
||||
artifact_integrity: Mapping[str, object] | None = None
|
||||
data_safety_acknowledged: bool = False
|
||||
destroy_data: bool = False
|
||||
status: str = "planned"
|
||||
notes: str | None = None
|
||||
@@ -59,6 +60,8 @@ class ModuleInstallPlanItem:
|
||||
}
|
||||
if self.destroy_data:
|
||||
payload["destroy_data"] = True
|
||||
if self.data_safety_acknowledged:
|
||||
payload["data_safety_acknowledged"] = True
|
||||
if self.catalog:
|
||||
payload["catalog"] = dict(self.catalog)
|
||||
for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"):
|
||||
@@ -206,7 +209,7 @@ def module_install_plan_commands(
|
||||
for item in items:
|
||||
if item.status not in included_statuses:
|
||||
continue
|
||||
if item.action == "install":
|
||||
if item.action in {"install", "update"}:
|
||||
if item.python_ref:
|
||||
commands.append(f"python -m pip install {shlex.quote(item.python_ref)}")
|
||||
if item.webui_package and item.webui_ref:
|
||||
@@ -281,7 +284,7 @@ def desired_modules_after_package_plan(
|
||||
removed = {item.module_id for item in planned_items if item.action == "uninstall"}
|
||||
desired = [module_id for module_id in desired if module_id not in removed]
|
||||
if activate_installed_modules:
|
||||
desired.extend(item.module_id for item in planned_items if item.action == "install")
|
||||
desired.extend(item.module_id for item in planned_items if item.action in {"install", "update"})
|
||||
return tuple(dict.fromkeys(desired))
|
||||
|
||||
|
||||
@@ -305,6 +308,7 @@ def normalize_module_install_plan_item(
|
||||
webui_package = _clean_optional_string(raw.get("webui_package"))
|
||||
webui_ref = _clean_optional_string(raw.get("webui_ref"))
|
||||
artifact_integrity = _clean_optional_mapping(raw.get("artifact_integrity"), field="artifact_integrity", module_id=module_id)
|
||||
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
|
||||
destroy_data = _clean_bool(raw.get("destroy_data"))
|
||||
notes = _clean_optional_string(raw.get("notes"))
|
||||
|
||||
@@ -314,13 +318,13 @@ def normalize_module_install_plan_item(
|
||||
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
|
||||
if source not in INSTALL_PLAN_SOURCES:
|
||||
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
|
||||
if action == "install" and not python_ref:
|
||||
if action in {"install", "update"} and not python_ref:
|
||||
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
|
||||
if action == "uninstall" and not python_package:
|
||||
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
|
||||
if action != "uninstall" and destroy_data:
|
||||
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
|
||||
if action == "install" and bool(webui_package) != bool(webui_ref):
|
||||
if action in {"install", "update"} and bool(webui_package) != bool(webui_ref):
|
||||
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
|
||||
if action == "uninstall" and webui_ref and not webui_package:
|
||||
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
|
||||
@@ -339,6 +343,7 @@ def normalize_module_install_plan_item(
|
||||
webui_package=webui_package,
|
||||
webui_ref=webui_ref,
|
||||
artifact_integrity=artifact_integrity,
|
||||
data_safety_acknowledged=data_safety_acknowledged,
|
||||
destroy_data=destroy_data,
|
||||
status=status,
|
||||
notes=notes,
|
||||
|
||||
@@ -19,6 +19,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
|
||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
|
||||
|
||||
|
||||
def module_package_catalog(
|
||||
@@ -598,7 +599,7 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
||||
raise ValueError("Module package catalog entries must be objects.")
|
||||
module_id = _required_str(value, "module_id")
|
||||
action = str(value.get("action") or "install")
|
||||
if action not in {"install", "uninstall"}:
|
||||
if action not in {"install", "update", "uninstall"}:
|
||||
raise ValueError(f"Unsupported catalog action for {module_id!r}: {action!r}")
|
||||
item = {
|
||||
"module_id": module_id,
|
||||
@@ -606,6 +607,10 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
||||
"description": _optional_str(value, "description"),
|
||||
"version": _optional_str(value, "version"),
|
||||
"action": action,
|
||||
"dependencies": _string_list(value.get("dependencies")),
|
||||
"optional_dependencies": _string_list(value.get("optional_dependencies")),
|
||||
"migration_safety": _catalog_migration_safety(value.get("migration_safety"), module_id=module_id),
|
||||
"migration_notes": _optional_str(value, "migration_notes"),
|
||||
"python_package": _optional_str(value, "python_package"),
|
||||
"python_ref": _optional_str(value, "python_ref"),
|
||||
"webui_package": _optional_str(value, "webui_package"),
|
||||
@@ -622,6 +627,16 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
||||
return item
|
||||
|
||||
|
||||
def _catalog_migration_safety(value: Any, *, module_id: str) -> str:
|
||||
if value is None:
|
||||
return "automatic"
|
||||
safety = str(value).strip()
|
||||
if safety not in CATALOG_MIGRATION_SAFETY:
|
||||
allowed = ", ".join(CATALOG_MIGRATION_SAFETY)
|
||||
raise ValueError(f"Unsupported migration_safety for {module_id!r}: {safety!r}; expected one of {allowed}.")
|
||||
return safety
|
||||
|
||||
|
||||
def _required_str(value: dict[str, Any], key: str) -> str:
|
||||
item = _optional_str(value, key)
|
||||
if not item:
|
||||
|
||||
@@ -6,6 +6,42 @@ from govoplan_core.security.permissions import scopes_grant
|
||||
|
||||
|
||||
LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
|
||||
"admin:users:read": "access:membership:read",
|
||||
"admin:users:create": "access:membership:create",
|
||||
"admin:users:update": "access:membership:update",
|
||||
"admin:users:suspend": "access:membership:update",
|
||||
"admin:groups:read": "access:group:read",
|
||||
"admin:groups:write": "access:group:write",
|
||||
"admin:groups:manage_members": "access:group:manage_members",
|
||||
"admin:roles:read": "access:role:read",
|
||||
"admin:roles:write": "access:role:write",
|
||||
"admin:roles:assign": "access:role:assign",
|
||||
"admin:api_keys:read": "access:api_key:read",
|
||||
"admin:api_keys:create": "access:api_key:create",
|
||||
"admin:api_keys:revoke": "access:api_key:revoke",
|
||||
"admin:settings:read": "access:setting:read",
|
||||
"admin:settings:write": "access:setting:write",
|
||||
"admin:policies:read": "access:policy:read",
|
||||
"admin:policies:write": "access:policy:write",
|
||||
"system:tenants:read": "access:tenant:read",
|
||||
"system:tenants:create": "access:tenant:create",
|
||||
"system:tenants:update": "access:tenant:update",
|
||||
"system:tenants:suspend": "access:tenant:suspend",
|
||||
"system:accounts:read": "access:account:read",
|
||||
"system:accounts:create": "access:account:create",
|
||||
"system:accounts:update": "access:account:update",
|
||||
"system:accounts:suspend": "access:account:suspend",
|
||||
"system:roles:read": "access:system_role:read",
|
||||
"system:roles:write": "access:system_role:write",
|
||||
"system:roles:assign": "access:system_role:assign",
|
||||
"system:access:read": "access:system_role:read",
|
||||
"system:access:assign": "access:system_role:assign",
|
||||
"system:audit:read": "access:audit:read",
|
||||
"system:settings:read": "access:system_setting:read",
|
||||
"system:settings:write": "access:system_setting:write",
|
||||
"system:maintenance:access": "access:maintenance:access",
|
||||
"system:governance:read": "access:governance:read",
|
||||
"system:governance:write": "access:governance:write",
|
||||
"campaign:read": "campaigns:campaign:read",
|
||||
"campaign:create": "campaigns:campaign:create",
|
||||
"campaign:update": "campaigns:campaign:update",
|
||||
@@ -44,14 +80,28 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
|
||||
"mail_servers:manage_credentials": "mail:secret:manage",
|
||||
}
|
||||
|
||||
MODULE_TO_LEGACY_SCOPES = {module: legacy for legacy, module in LEGACY_TO_MODULE_SCOPES.items()}
|
||||
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values())
|
||||
MODULE_TO_LEGACY_SCOPE_ALIASES: dict[str, tuple[str, ...]] = {}
|
||||
for legacy_scope, module_scope in LEGACY_TO_MODULE_SCOPES.items():
|
||||
MODULE_TO_LEGACY_SCOPE_ALIASES[module_scope] = (
|
||||
*MODULE_TO_LEGACY_SCOPE_ALIASES.get(module_scope, ()),
|
||||
legacy_scope,
|
||||
)
|
||||
MODULE_TO_LEGACY_SCOPES = {
|
||||
module_scope: aliases[0]
|
||||
for module_scope, aliases in MODULE_TO_LEGACY_SCOPE_ALIASES.items()
|
||||
}
|
||||
MODULE_SYSTEM_SCOPES = frozenset(
|
||||
module
|
||||
for legacy, module in LEGACY_TO_MODULE_SCOPES.items()
|
||||
if legacy.startswith("system:")
|
||||
)
|
||||
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES
|
||||
|
||||
|
||||
def compatible_required_scopes(required: str) -> tuple[str, ...]:
|
||||
legacy = MODULE_TO_LEGACY_SCOPES.get(required)
|
||||
if legacy:
|
||||
return (required, legacy)
|
||||
legacy_aliases = MODULE_TO_LEGACY_SCOPE_ALIASES.get(required)
|
||||
if legacy_aliases:
|
||||
return (required, *legacy_aliases)
|
||||
module = LEGACY_TO_MODULE_SCOPES.get(required)
|
||||
if module:
|
||||
return (required, module)
|
||||
@@ -60,10 +110,18 @@ def compatible_required_scopes(required: str) -> tuple[str, ...]:
|
||||
|
||||
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
|
||||
granted = list(scopes)
|
||||
if required in MODULE_SYSTEM_SCOPES:
|
||||
return "*" in granted or "system:*" in granted or any(
|
||||
scope != "tenant:*" and scopes_grant([scope], alias)
|
||||
for scope in granted
|
||||
for alias in compatible_required_scopes(required)
|
||||
)
|
||||
if scopes_grant(granted, required):
|
||||
return True
|
||||
if "tenant:*" in granted or "*" in granted:
|
||||
if required in MODULE_TENANT_SCOPES:
|
||||
return True
|
||||
if "system:*" in granted or "*" in granted:
|
||||
if required in MODULE_SYSTEM_SCOPES:
|
||||
return True
|
||||
return any(scopes_grant(granted, alias) for alias in compatible_required_scopes(required) if alias != required)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user