Harden module update compatibility cleanup
Some checks failed
Dependency Audit / dependency-audit (push) Successful in 1m46s
Module Matrix / module-matrix (push) Failing after 2m38s
Release Integration / release-integration (push) Failing after 1m41s

This commit is contained in:
2026-07-10 23:27:48 +02:00
parent 2b0cdf13f3
commit b788afcae1
16 changed files with 1442 additions and 886 deletions

View File

@@ -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),

View File

@@ -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,

View File

@@ -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:

View File

@@ -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)