Release v0.1.6

This commit is contained in:
2026-07-07 16:00:38 +02:00
parent a2053518d1
commit 150b720f12
149 changed files with 14311 additions and 8005 deletions

View File

@@ -0,0 +1,272 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.modules import AccessDecision
ACCESS_MODULE_ID = "access"
TENANCY_MODULE_ID = "tenancy"
POLICY_MODULE_ID = "policy"
AUDIT_MODULE_ID = "audit"
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER = "access.principalResolver"
CAPABILITY_ACCESS_DIRECTORY = "access.directory"
CAPABILITY_ACCESS_PERMISSION_EVALUATOR = "access.permissionEvaluator"
CAPABILITY_ACCESS_RESOURCE_ACCESS = "access.resourceAccess"
CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider"
CAPABILITY_AUDIT_SINK = "audit.sink"
ACCESS_CAPABILITY_NAMES = frozenset(
{
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
CAPABILITY_ACCESS_DIRECTORY,
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
CAPABILITY_ACCESS_RESOURCE_ACCESS,
CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_ACCESS_ADMINISTRATION,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
CAPABILITY_TENANCY_TENANT_RESOLVER,
CAPABILITY_SECURITY_SECRET_PROVIDER,
CAPABILITY_AUDIT_SINK,
}
)
AuthMethod = Literal["session", "api_key", "service_account"]
AccessSubjectKind = Literal["account", "user", "group", "role", "tenant", "api_key", "service_account"]
AccessStatus = Literal["active", "inactive", "suspended"]
PermissionLevel = Literal["system", "tenant"]
AuditOutcome = Literal["success", "failure", "denied", "unknown"]
@dataclass(frozen=True, slots=True)
class PrincipalRef:
account_id: str
membership_id: str | None
tenant_id: str | None
scopes: frozenset[str] = field(default_factory=frozenset)
group_ids: frozenset[str] = field(default_factory=frozenset)
auth_method: AuthMethod = "session"
api_key_id: str | None = None
session_id: str | None = None
service_account_id: str | None = None
email: str | None = None
display_name: str | None = None
@dataclass(frozen=True, slots=True)
class AccountRef:
id: str
email: str
display_name: str | None = None
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class TenantRef:
id: str
name: str
slug: str | None = None
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class UserRef:
id: str
account_id: str
tenant_id: str
email: str | None = None
display_name: str | None = None
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class GroupRef:
id: str
tenant_id: str
name: str
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class RoleRef:
id: str
name: str
level: PermissionLevel
tenant_id: str | None = None
managed: bool = False
protected: bool = False
@dataclass(frozen=True, slots=True)
class AccessSubjectRef:
kind: AccessSubjectKind
id: str
tenant_id: str | None = None
label: str | None = None
@dataclass(frozen=True, slots=True)
class TenantOwnerCandidateRef:
account_id: str
email: str
display_name: str | None = None
@dataclass(frozen=True, slots=True)
class GovernanceTemplateMaterialization:
template_id: str
kind: Literal["group", "role"]
tenant_id: str
slug: str
name: str
description: str | None = None
permissions: tuple[str, ...] = ()
is_active: bool = True
required: bool = False
@dataclass(frozen=True, slots=True)
class AuditEvent:
event_type: str
action: str
outcome: AuditOutcome = "unknown"
actor: PrincipalRef | None = None
tenant_id: str | None = None
resource_type: str | None = None
resource_id: str | None = None
occurred_at: datetime | None = None
details: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class PrincipalResolver(Protocol):
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef:
...
@runtime_checkable
class TenantResolver(Protocol):
def get_tenant(self, tenant_id: str) -> TenantRef | None:
...
def current_tenant(self, principal: PrincipalRef) -> TenantRef | None:
...
@runtime_checkable
class AccessDirectory(Protocol):
def get_account(self, account_id: str) -> AccountRef | None:
...
def get_user(self, user_id: str) -> UserRef | None:
...
def get_users(self, user_ids: Iterable[str]) -> Mapping[str, UserRef]:
...
def users_for_tenant(self, tenant_id: str) -> Sequence[UserRef]:
...
def get_group(self, group_id: str) -> GroupRef | None:
...
def get_groups(self, group_ids: Iterable[str]) -> Mapping[str, GroupRef]:
...
def groups_for_tenant(self, tenant_id: str) -> Sequence[GroupRef]:
...
def groups_for_user(self, user_id: str, *, tenant_id: str) -> Sequence[GroupRef]:
...
def display_label(self, subject: AccessSubjectRef) -> str | None:
...
@runtime_checkable
class PermissionEvaluator(Protocol):
def scopes_grant(self, scopes: Iterable[str], required_scope: str) -> bool:
...
def has_scope(self, principal: PrincipalRef, required_scope: str) -> bool:
...
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
...
@runtime_checkable
class ResourceAccessService(Protocol):
def explain(self, principal: PrincipalRef, *, resource_type: str, resource_id: str, action: str) -> AccessDecision:
...
@runtime_checkable
class TenantAccessProvisioner(Protocol):
def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]:
...
def tenant_owner_candidates(self, session: object) -> Sequence[TenantOwnerCandidateRef]:
...
def ensure_tenant_owner_membership(
self,
session: object,
*,
tenant: object,
owner_account_id: str,
) -> str:
...
@runtime_checkable
class AccessAdministration(Protocol):
def system_account_count(self, session: object) -> int:
...
def role_count_for_tenant(self, session: object, tenant_id: str) -> int:
...
def active_api_key_count_for_tenant(self, session: object, tenant_id: str) -> int:
...
def actor_email_by_user_id(self, session: object, user_ids: Iterable[str]) -> Mapping[str, str | None]:
...
def user_ids_for_actor_filter(self, session: object, *, operator: str, value: str) -> Sequence[str]:
...
@runtime_checkable
class AccessGovernanceMaterializer(Protocol):
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
...
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
...
@runtime_checkable
class SecretProvider(Protocol):
def store_secret(self, *, scope: str, name: str, value: str) -> str:
...
def read_secret(self, secret_ref: str) -> str | None:
...
def delete_secret(self, secret_ref: str) -> None:
...
@runtime_checkable
class AuditSink(Protocol):
def record(self, event: AuditEvent) -> None:
...

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext"
CAPABILITY_CAMPAIGNS_ACCESS = "campaigns.access"
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT = "campaigns.policyContext"
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS = "campaigns.deliveryTasks"
CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention"
@dataclass(frozen=True, slots=True)
class CampaignMailPolicyContext:
id: str
tenant_id: str
owner_user_id: str | None = None
owner_group_id: str | None = None
mail_profile_policy: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CampaignPolicyContext:
id: str
tenant_id: str
owner_user_id: str | None = None
owner_group_id: str | None = None
settings: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class CampaignMailPolicyContextProvider(Protocol):
def get_campaign_mail_policy_context(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
) -> CampaignMailPolicyContext | None:
...
def set_campaign_mail_profile_policy(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
policy: Mapping[str, object],
) -> Mapping[str, object]:
...
@runtime_checkable
class CampaignAccessProvider(Protocol):
def campaign_exists(self, session: object, *, tenant_id: str, campaign_id: str) -> bool:
...
def can_read_campaign(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
user_id: str,
group_ids: Iterable[str] = (),
tenant_admin: bool = False,
) -> bool:
...
@runtime_checkable
class CampaignPolicyContextProvider(Protocol):
def get_campaign_policy_context(
self,
session: object,
*,
tenant_id: str | None = None,
campaign_id: str,
) -> CampaignPolicyContext | None:
...
def set_campaign_settings(
self,
session: object,
*,
tenant_id: str,
campaign_id: str,
settings: Mapping[str, object],
) -> Mapping[str, object]:
...
@runtime_checkable
class CampaignDeliveryTaskProvider(Protocol):
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
...
def append_sent_for_job(self, session: object, *, job_id: str) -> Mapping[str, object]:
...
@runtime_checkable
class CampaignRetentionProvider(Protocol):
def apply_retention(
self,
session: object,
*,
dry_run: bool,
now: datetime,
policy_for_campaign_id: Callable[[str | None], object],
) -> Mapping[str, Mapping[str, int]]:
...

View File

@@ -31,13 +31,20 @@ def discover_module_manifests(
*,
enabled_modules: Iterable[str] | None = None,
group: str = ENTRY_POINT_GROUP,
ignore_load_errors: bool = False,
) -> tuple[ModuleManifest, ...]:
enabled = set(enabled_modules) if enabled_modules is not None else None
manifests: list[ModuleManifest] = []
for entry_point in iter_module_entry_points(group):
if enabled is not None and entry_point.name not in enabled:
continue
manifest = _load_manifest(entry_point)
try:
manifest = _load_manifest(entry_point)
except ModuleNotFoundError as exc:
module_root = entry_point.module.split(".", 1)[0]
if ignore_load_errors and exc.name == module_root:
continue
raise
if enabled is not None and manifest.id not in enabled:
continue
manifests.append(manifest)

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from threading import RLock
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status
from govoplan_core.core.module_management import ModuleManagementError, REQUIRED_PLATFORM_MODULES, plan_desired_enabled_modules
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
@dataclass(frozen=True, slots=True)
class ModuleLifecycleResult:
enabled_modules: tuple[str, ...]
activated_modules: tuple[str, ...]
deactivated_modules: tuple[str, ...]
mounted_modules: tuple[str, ...]
migrations_applied: bool = False
def require_module_active(module_id: str):
def dependency(request: Request) -> None:
registry = getattr(request.app.state, "govoplan_registry", None)
if isinstance(registry, PlatformRegistry) and registry.has_module(module_id):
return
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
return dependency
class ModuleLifecycleManager:
def __init__(
self,
*,
registry: PlatformRegistry,
available_modules: Mapping[str, ModuleManifest],
settings: object | None,
api_prefix: str = "/api/v1",
manifest_factories: Sequence[object] = (),
module_context_data: Mapping[str, object] | None = None,
) -> None:
self.registry = registry
self.available_modules = dict(available_modules)
self.settings = settings
self.api_prefix = api_prefix
self.manifest_factories = tuple(manifest_factories)
self.context = ModuleContext(registry=registry, settings=settings, data=dict(module_context_data or {}))
self._app: FastAPI | None = None
self._mounted_modules: set[str] = set()
self._lock = RLock()
def attach_app(self, app: FastAPI) -> None:
self._app = app
app.state.govoplan_lifecycle = self
def configure_runtime(self) -> None:
configure_runtime(self.context)
self.registry.configure_capability_context(self.context)
def active_module_ids(self) -> tuple[str, ...]:
return tuple(manifest.id for manifest in self.registry.manifests())
def mounted_module_ids(self) -> tuple[str, ...]:
return tuple(sorted(self._mounted_modules))
def apply_enabled_modules(
self,
requested_enabled: Sequence[str],
*,
migrate: bool = True,
protected_modules: Sequence[str] = REQUIRED_PLATFORM_MODULES,
) -> ModuleLifecycleResult:
with self._lock:
plan = plan_desired_enabled_modules(
requested_enabled,
self.available_modules,
protected_modules=protected_modules,
)
previous = self.active_module_ids()
previous_set = set(previous)
next_set = set(plan.enabled_modules)
activated = tuple(module_id for module_id in plan.enabled_modules if module_id not in previous_set)
deactivated = tuple(module_id for module_id in previous if module_id not in next_set)
if migrate:
self._migrate(plan.enabled_modules)
mounted = tuple(module_id for module_id in plan.enabled_modules if self._mount_module_router(module_id))
old_manifests = {manifest.id: manifest for manifest in self.registry.manifests()}
for module_id in deactivated:
hook = old_manifests[module_id].on_deactivate
if hook is not None:
hook(self.context)
self.registry.replace(self.available_modules[module_id] for module_id in plan.enabled_modules)
self.configure_runtime()
for module_id in activated:
hook = self.available_modules[module_id].on_activate
if hook is not None:
hook(self.context)
if self._app is not None:
self._app.openapi_schema = None
return ModuleLifecycleResult(
enabled_modules=plan.enabled_modules,
activated_modules=activated,
deactivated_modules=deactivated,
mounted_modules=mounted,
migrations_applied=migrate,
)
def _mount_module_router(self, module_id: str) -> bool:
if module_id in self._mounted_modules:
return False
app = self._app
if app is None:
raise ModuleManagementError("Module lifecycle manager is not attached to the FastAPI app.")
manifest = self.available_modules.get(module_id)
if manifest is None or manifest.route_factory is None:
self._mounted_modules.add(module_id)
return False
module_router = manifest.route_factory(self.context)
guarded = APIRouter(dependencies=[Depends(require_module_active(module_id))])
guarded.include_router(module_router)
app.include_router(guarded, prefix=self.api_prefix)
app.openapi_schema = None
self._mounted_modules.add(module_id)
return True
def _migrate(self, enabled_modules: Sequence[str]) -> None:
from govoplan_core.db.migrations import migrate_database
database_url = str(getattr(self.settings, "database_url", "") or "")
migrate_database(
database_url=database_url or None,
enabled_modules=enabled_modules,
manifest_factories=self.manifest_factories,
)

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
MAINTENANCE_SETTINGS_KEY = "maintenance_mode"
MAINTENANCE_ACCESS_SCOPE = "system:maintenance:access"
DEFAULT_MAINTENANCE_MESSAGE = "GovOPlaN is currently in maintenance mode."
@dataclass(frozen=True, slots=True)
class MaintenanceMode:
enabled: bool = False
message: str | None = None
def as_dict(self) -> dict[str, object]:
return {
"enabled": self.enabled,
"message": self.message,
}
def maintenance_mode_from_settings(settings: Mapping[str, object]) -> MaintenanceMode:
raw = settings.get(MAINTENANCE_SETTINGS_KEY)
if not isinstance(raw, Mapping):
return MaintenanceMode()
return MaintenanceMode(
enabled=bool(raw.get("enabled")),
message=_clean_message(raw.get("message")),
)
def saved_maintenance_mode(session: Session) -> MaintenanceMode:
item = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
if item is None:
return MaintenanceMode()
return maintenance_mode_from_settings(item.settings or {})
def save_maintenance_mode(session: Session, mode: MaintenanceMode) -> MaintenanceMode:
item = get_system_settings(session)
settings = dict(item.settings or {})
settings[MAINTENANCE_SETTINGS_KEY] = mode.as_dict()
item.settings = settings
session.add(item)
session.flush()
return mode
def maintenance_response_detail(mode: MaintenanceMode) -> dict[str, object]:
return {
"code": "maintenance_mode",
"message": mode.message or DEFAULT_MAINTENANCE_MESSAGE,
"required_scope": MAINTENANCE_ACCESS_SCOPE,
}
def _clean_message(value: object) -> str | None:
if value is None:
return None
cleaned = str(value).strip()
return cleaned or None

View File

@@ -15,15 +15,17 @@ class MigrationMetadataPlan:
def migration_metadata_plan(registry: PlatformRegistry, *, extra_metadata: Iterable[MetaData] = ()) -> MigrationMetadataPlan:
metadata = list(extra_metadata)
metadata: list[MetaData] = []
script_locations: list[str] = []
for item in extra_metadata:
if item not in metadata:
metadata.append(item)
for manifest in registry.manifests():
spec = manifest.migration_spec
if spec is None:
continue
if isinstance(spec.metadata, MetaData):
if isinstance(spec.metadata, MetaData) and spec.metadata not in metadata:
metadata.append(spec.metadata)
if spec.script_location:
script_locations.append(spec.script_location)
return MigrationMetadataPlan(metadata=tuple(metadata), script_locations=tuple(script_locations))

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
from collections.abc import Callable
from sqlalchemy import func, inspect, select
from govoplan_core.core.modules import MigrationRetirementPlan, ModuleUninstallGuardResult
def persistent_table_uninstall_guard(
*models: object,
label: str | None = None,
) -> Callable[[object | None, str], tuple[ModuleUninstallGuardResult, ...]]:
def guard(session: object | None, module_id: str) -> tuple[ModuleUninstallGuardResult, ...]:
if session is None or not hasattr(session, "execute") or not hasattr(session, "get_bind"):
return (
ModuleUninstallGuardResult(
"warning",
"uninstall_guard_no_session",
"Persistent-data uninstall guard could not inspect the database without a session.",
),
)
bind = session.get_bind() # type: ignore[attr-defined]
inspector = inspect(bind)
populated_tables: list[str] = []
for model in models:
table = getattr(model, "__table__", None)
if table is None or not inspector.has_table(table.name):
continue
row_count = int(session.execute(select(func.count()).select_from(table)).scalar_one()) # type: ignore[attr-defined]
if row_count:
populated_tables.append(f"{table.name} ({row_count})")
if not populated_tables:
return ()
table_summary = ", ".join(populated_tables[:8])
if len(populated_tables) > 8:
table_summary += f", and {len(populated_tables) - 8} more"
return (
ModuleUninstallGuardResult(
"warning",
"persistent_data_present",
f"{label or module_id} owns persistent data: {table_summary}. Non-destructive uninstall leaves this data dormant until the module is reinstalled or an explicit retirement provider handles it.",
),
)
return guard
def drop_table_retirement_provider(
*models: object,
label: str | None = None,
) -> Callable[[object | None, str], MigrationRetirementPlan]:
def provider(session: object | None, module_id: str) -> MigrationRetirementPlan:
module_label = label or module_id
tables = tuple(_model_tables(models))
table_names = tuple(table.name for table in tables)
if session is None or not hasattr(session, "get_bind"):
return MigrationRetirementPlan(
supported=False,
summary=f"{module_label} table retirement could not inspect the database without a session.",
blocking_reason="No database session is available.",
)
if not tables:
return MigrationRetirementPlan(
supported=True,
summary=f"{module_label} declares no module-owned tables to retire.",
destroy_data_supported=True,
destroy_data_summary=f"{module_label} has no module-owned tables to drop.",
destroy_data_executor=lambda _session, _module_id: None,
)
bind = session.get_bind() # type: ignore[attr-defined]
inspector = inspect(bind)
existing = tuple(table for table in tables if inspector.has_table(table.name))
existing_names = tuple(table.name for table in existing)
missing_names = tuple(name for name in table_names if name not in existing_names)
summary = f"{module_label} can retire {len(existing_names)} table(s): {', '.join(existing_names) if existing_names else 'none currently present'}."
warnings: list[str] = []
if missing_names:
warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names))
def executor(execute_session: object, _module_id: str) -> None:
if not hasattr(execute_session, "get_bind"):
raise RuntimeError("No database session is available for destructive table retirement.")
execute_bind = execute_session.get_bind() # type: ignore[attr-defined]
live_inspector = inspect(execute_bind)
live_tables = [table for table in tables if live_inspector.has_table(table.name)]
if not live_tables:
return
metadata = live_tables[0].metadata
metadata.drop_all(bind=execute_bind, tables=live_tables, checkfirst=True)
return MigrationRetirementPlan(
supported=True,
summary=summary,
warnings=tuple(warnings),
destroy_data_supported=True,
destroy_data_summary=(
f"Destroying data will drop {module_label} table(s): "
+ (", ".join(existing_names) if existing_names else "none currently present")
+ "."
),
destroy_data_warnings=(
"This permanently deletes module-owned rows unless the installer rollback restores the database snapshot.",
),
destroy_data_executor=executor,
)
return provider
def _model_tables(models: tuple[object, ...]) -> tuple[object, ...]:
tables: list[object] = []
for model in models:
table = getattr(model, "__table__", None)
if table is not None:
tables.append(table)
return tuple(tables)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,225 @@
from __future__ import annotations
import base64
import binascii
from datetime import UTC, datetime
import json
import os
from pathlib import Path
from typing import Any
import urllib.error
import urllib.request
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
required = tuple(dict.fromkeys(str(item).strip() for item in required_features if str(item).strip()))
if not required:
return {"allowed": True, "required_features": [], "missing_features": [], "enforced": False}
validation = validate_module_license()
enforced = _license_enforcement_enabled()
if not validation["valid"]:
return {
"allowed": not enforced,
"required_features": list(required),
"missing_features": list(required),
"enforced": enforced,
"license": validation,
"reason": validation["error"] or "No valid license is configured.",
}
licensed_features = set(validation["features"])
missing = tuple(feature for feature in required if feature not in licensed_features)
return {
"allowed": not missing or not enforced,
"required_features": list(required),
"missing_features": list(missing),
"enforced": enforced,
"license": validation,
"reason": "Missing licensed feature(s): " + ", ".join(missing) if missing else None,
}
def validate_module_license(
path: Path | None = None,
*,
trusted_keys: dict[str, str] | None = None,
require_trusted: bool | None = None,
) -> dict[str, object]:
license_path = path or _configured_license_path()
effective_require_trusted = _license_enforcement_enabled() if require_trusted is None else require_trusted
if license_path is None:
return _license_result(valid=not effective_require_trusted, configured=False, path=None, error="No license file configured.")
if not license_path.exists():
return _license_result(valid=False, configured=True, path=license_path, error=f"License file does not exist: {license_path}")
try:
payload = json.loads(license_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("License file must contain a JSON object.")
features = _string_list(payload.get("features"))
valid_from = _parse_datetime(payload.get("valid_from"))
valid_until = _parse_datetime(payload.get("valid_until"))
now = datetime.now(tz=UTC)
if valid_from is not None and now < valid_from:
return _license_result(valid=False, configured=True, path=license_path, payload=payload, features=features, error=f"License is not valid before {valid_from.isoformat()}.")
if valid_until is not None and now > valid_until:
return _license_result(valid=False, configured=True, path=license_path, payload=payload, features=features, error=f"License expired at {valid_until.isoformat()}.")
signature_state = _license_signature_state(payload, trusted_keys=trusted_keys if trusted_keys is not None else _configured_trusted_keys())
if signature_state.get("fatal"):
return _license_result(valid=False, configured=True, path=license_path, payload=payload, features=features, signature_state=signature_state, error=str(signature_state["error"]))
if effective_require_trusted and not signature_state["trusted"]:
return _license_result(valid=False, configured=True, path=license_path, payload=payload, features=features, signature_state=signature_state, error=str(signature_state["error"] or "License must be signed by a trusted key."))
return _license_result(valid=True, configured=True, path=license_path, payload=payload, features=features, signature_state=signature_state)
except Exception as exc:
return _license_result(valid=False, configured=True, path=license_path, error=str(exc))
def _license_result(
*,
valid: bool,
configured: bool,
path: Path | None,
error: str | None = None,
payload: dict[str, object] | None = None,
features: list[str] | None = None,
signature_state: dict[str, object] | None = None,
) -> dict[str, object]:
state = signature_state or {"signed": False, "trusted": False, "key_id": None}
return {
"valid": valid,
"configured": configured,
"path": str(path) if path is not None else None,
"license_id": str(payload.get("license_id")) if payload and payload.get("license_id") else None,
"subject": str(payload.get("subject")) if payload and payload.get("subject") else None,
"features": features or [],
"valid_from": str(payload.get("valid_from")) if payload and payload.get("valid_from") else None,
"valid_until": str(payload.get("valid_until")) if payload and payload.get("valid_until") else None,
"signed": bool(state.get("signed")),
"trusted": bool(state.get("trusted")),
"key_id": state.get("key_id") if isinstance(state.get("key_id"), str) else None,
"error": error,
}
def _configured_license_path() -> Path | None:
value = os.getenv("GOVOPLAN_LICENSE_FILE", "").strip()
return Path(value).expanduser() if value else None
def _license_enforcement_enabled() -> bool:
return os.getenv("GOVOPLAN_LICENSE_ENFORCEMENT", "").strip().lower() in {"1", "true", "yes", "on"}
def _configured_trusted_keys() -> dict[str, str]:
file_value = os.getenv("GOVOPLAN_LICENSE_TRUSTED_KEYS_FILE", "").strip()
if file_value:
path = Path(file_value).expanduser()
if not path.exists():
raise ValueError(f"Trusted license key file does not exist: {path}")
return _parse_trusted_keys(path.read_text(encoding="utf-8"))
url_value = os.getenv("GOVOPLAN_LICENSE_TRUSTED_KEYS_URL", "").strip()
if url_value:
return _parse_trusted_keys(_read_trusted_keys_url(url_value))
value = os.getenv("GOVOPLAN_LICENSE_TRUSTED_KEYS", "").strip()
return _parse_trusted_keys(value) if value else {}
def _configured_trusted_keys_cache_path() -> Path | None:
value = os.getenv("GOVOPLAN_LICENSE_TRUSTED_KEYS_CACHE", "").strip()
return Path(value).expanduser() if value else None
def _read_trusted_keys_url(url: str) -> str:
if not url.startswith(("https://", "http://")):
raise ValueError("Trusted license key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path()
try:
with urllib.request.urlopen(url, timeout=15) as response:
body = response.read().decode("utf-8")
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return body
except (OSError, urllib.error.URLError):
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
raise
def _parse_trusted_keys(value: str) -> dict[str, str]:
payload = json.loads(value)
if isinstance(payload, dict) and isinstance(payload.get("keys"), list):
keys: dict[str, str] = {}
now = datetime.now(tz=UTC)
for item in payload["keys"]:
if not isinstance(item, dict):
continue
if str(item.get("status") or "active").strip().lower() in {"revoked", "disabled", "retired"}:
continue
not_before = _parse_datetime(item.get("not_before"))
not_after = _parse_datetime(item.get("not_after"))
if not_before is not None and now < not_before:
continue
if not_after is not None and now > not_after:
continue
key_id = str(item.get("key_id") or "").strip()
public_key = str(item.get("public_key") or item.get("public_key_base64") or "").strip()
if key_id and public_key:
keys[key_id] = public_key
return keys
if not isinstance(payload, dict):
raise ValueError("Trusted license keys must be a JSON object.")
return {str(key): str(item) for key, item in payload.items() if str(key).strip() and str(item).strip()}
def _license_signature_state(payload: dict[str, object], *, trusted_keys: dict[str, str]) -> dict[str, object]:
signature = payload.get("signature")
if signature is None:
return {"signed": False, "trusted": False, "key_id": None, "error": "License is unsigned.", "fatal": False}
if not isinstance(signature, dict):
return {"signed": True, "trusted": False, "key_id": None, "error": "License signature must be an object.", "fatal": True}
algorithm = str(signature.get("algorithm") or "").strip().lower()
key_id = str(signature.get("key_id") or "").strip()
value = str(signature.get("value") or "").strip()
if algorithm != "ed25519":
return {"signed": True, "trusted": False, "key_id": key_id or None, "error": f"Unsupported license signature algorithm: {algorithm!r}", "fatal": True}
if not key_id or not value:
return {"signed": True, "trusted": False, "key_id": key_id or None, "error": "License signature requires key_id and value.", "fatal": True}
trusted_key = trusted_keys.get(key_id)
if not trusted_key:
return {"signed": True, "trusted": False, "key_id": key_id, "error": f"License signature key is not trusted: {key_id}", "fatal": False}
try:
public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(trusted_key, validate=True))
signed_payload = dict(payload)
signed_payload.pop("signature", None)
public_key.verify(base64.b64decode(value, validate=True), _canonical_bytes(signed_payload))
except (ValueError, binascii.Error, InvalidSignature) as exc:
return {"signed": True, "trusted": False, "key_id": key_id, "error": f"License signature verification failed: {exc}", "fatal": True}
return {"signed": True, "trusted": True, "key_id": key_id, "error": None, "fatal": False}
def _canonical_bytes(payload: object) -> bytes:
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def _parse_datetime(value: object) -> datetime | None:
if value is None:
return None
text = str(value).strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
parsed = datetime.fromisoformat(text)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def _string_list(value: object) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("License features must be a list.")
return [str(item).strip() for item in value if str(item).strip()]

View File

@@ -0,0 +1,421 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
import shlex
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.db.session import get_database
from govoplan_core.server.registry import parse_enabled_modules
MODULE_SETTINGS_KEY = "module_management"
INSTALL_PLAN_KEY = "install_plan"
REQUIRED_PLATFORM_MODULES = ("tenancy", "access")
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
INSTALL_PLAN_ACTIONS = ("install", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:")
class ModuleManagementError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ModuleStatePlan:
enabled_modules: tuple[str, ...]
added_dependencies: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class ModuleInstallPlanItem:
module_id: str
action: str
python_package: str | None = None
python_ref: str | None = None
webui_package: str | None = None
webui_ref: str | None = None
destroy_data: bool = False
status: str = "planned"
notes: str | None = None
def as_dict(self) -> dict[str, object]:
payload: dict[str, object] = {
"module_id": self.module_id,
"action": self.action,
"status": self.status,
}
if self.destroy_data:
payload["destroy_data"] = True
for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"):
value = getattr(self, key)
if value:
payload[key] = value
return payload
@dataclass(frozen=True, slots=True)
class ModuleInstallPlan:
items: tuple[ModuleInstallPlanItem, ...] = ()
updated_at: str | None = None
def configured_enabled_modules(value: str | Iterable[str]) -> tuple[str, ...]:
return tuple(dict.fromkeys(parse_enabled_modules(value)))
def startup_candidate_module_ids(
configured: str | Iterable[str],
desired: Iterable[str] | None = None,
) -> tuple[str, ...]:
fallback = configured_enabled_modules(configured)
candidates = [*fallback]
if desired is not None:
candidates.extend(str(item).strip() for item in desired if str(item).strip())
candidates.extend(REQUIRED_PLATFORM_MODULES)
if "admin" in fallback:
candidates.append("admin")
return tuple(dict.fromkeys(candidates))
def saved_desired_enabled_modules(session: Session, fallback: Iterable[str]) -> tuple[str, ...]:
item = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
if item is None:
return tuple(fallback)
return desired_enabled_modules_from_settings(item.settings or {}, fallback)
def desired_enabled_modules_from_settings(settings: Mapping[str, object], fallback: Iterable[str]) -> tuple[str, ...]:
raw_management = settings.get(MODULE_SETTINGS_KEY)
if not isinstance(raw_management, Mapping):
return tuple(fallback)
raw_enabled = raw_management.get("desired_enabled")
if not isinstance(raw_enabled, list | tuple):
return tuple(fallback)
normalized = [str(item).strip() for item in raw_enabled if str(item).strip()]
return tuple(dict.fromkeys(normalized)) or tuple(fallback)
def load_startup_enabled_modules(
configured: str | Iterable[str],
*,
available: Mapping[str, ModuleManifest] | None = None,
) -> tuple[str, ...]:
fallback = configured_enabled_modules(configured)
try:
with get_database().session() as session:
desired = saved_desired_enabled_modules(session, fallback)
except (RuntimeError, SQLAlchemyError):
return fallback
if available is None:
return desired
protected = list(REQUIRED_PLATFORM_MODULES)
if "admin" in fallback:
protected.append("admin")
protected = [module_id for module_id in protected if module_id in available]
requested = [module_id for module_id in desired if module_id in available]
if not requested:
requested = [module_id for module_id in fallback if module_id in available]
return plan_desired_enabled_modules(requested, available, protected_modules=protected).enabled_modules
def save_desired_enabled_modules(session: Session, enabled_modules: Iterable[str]) -> tuple[str, ...]:
item = get_system_settings(session)
settings = dict(item.settings or {})
management = _management_settings(settings)
management["desired_enabled"] = list(dict.fromkeys(enabled_modules))
settings[MODULE_SETTINGS_KEY] = management
item.settings = settings
session.add(item)
session.flush()
return tuple(management["desired_enabled"]) # type: ignore[arg-type]
def module_install_plan_from_settings(settings: Mapping[str, object]) -> ModuleInstallPlan:
raw_management = settings.get(MODULE_SETTINGS_KEY)
if not isinstance(raw_management, Mapping):
return ModuleInstallPlan()
raw_plan = raw_management.get(INSTALL_PLAN_KEY)
if not isinstance(raw_plan, Mapping):
return ModuleInstallPlan()
raw_items = raw_plan.get("items")
if not isinstance(raw_items, list | tuple):
return ModuleInstallPlan(updated_at=_clean_optional_string(raw_plan.get("updated_at")))
return ModuleInstallPlan(
items=tuple(normalize_module_install_plan_item(item) for item in raw_items),
updated_at=_clean_optional_string(raw_plan.get("updated_at")),
)
def saved_module_install_plan(session: Session) -> ModuleInstallPlan:
item = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
if item is None:
return ModuleInstallPlan()
return module_install_plan_from_settings(item.settings or {})
def save_module_install_plan(
session: Session,
items: Iterable[Mapping[str, object] | ModuleInstallPlanItem],
) -> ModuleInstallPlan:
normalized = tuple(normalize_module_install_plan_item(item) for item in items)
item = get_system_settings(session)
settings = dict(item.settings or {})
management = _management_settings(settings)
updated_at = datetime.now(tz=UTC).isoformat()
management[INSTALL_PLAN_KEY] = {
"updated_at": updated_at,
"items": [entry.as_dict() for entry in normalized],
}
settings[MODULE_SETTINGS_KEY] = management
item.settings = settings
session.add(item)
session.flush()
return ModuleInstallPlan(items=normalized, updated_at=updated_at)
def clear_module_install_plan(session: Session) -> ModuleInstallPlan:
return save_module_install_plan(session, ())
def module_install_plan_commands(
plan: ModuleInstallPlan | Iterable[ModuleInstallPlanItem],
*,
statuses: Iterable[str] = ("planned",),
) -> tuple[str, ...]:
items = plan.items if isinstance(plan, ModuleInstallPlan) else tuple(plan)
included_statuses = {status for status in statuses}
commands: list[str] = []
for item in items:
if item.status not in included_statuses:
continue
if item.action == "install":
if item.python_ref:
commands.append(f"python -m pip install {shlex.quote(item.python_ref)}")
if item.webui_package and item.webui_ref:
commands.append(
"npm pkg set "
+ shlex.quote(f"dependencies.{item.webui_package}={item.webui_ref}")
+ " && npm install"
)
elif item.action == "uninstall":
if item.python_package:
commands.append(f"python -m pip uninstall -y {shlex.quote(item.python_package)}")
if item.webui_package:
commands.append(
"npm pkg delete "
+ shlex.quote(f"dependencies.{item.webui_package}")
+ " && npm install"
)
return tuple(commands)
def installed_module_distribution_name(module_id: str) -> str | None:
clean_module_id = str(module_id).strip()
if not clean_module_id:
return None
for entry_point in iter_module_entry_points():
if entry_point.name != clean_module_id:
continue
distribution = getattr(entry_point, "dist", None)
metadata = getattr(distribution, "metadata", None)
if metadata is None:
return None
name = metadata.get("Name")
return str(name).strip() if name else None
return None
def module_uninstall_plan_item(
module_id: str,
available: Mapping[str, ModuleManifest],
) -> ModuleInstallPlanItem:
clean_module_id = str(module_id).strip()
if not clean_module_id:
raise ModuleManagementError("Uninstall plan needs a module id.")
manifest = available.get(clean_module_id)
if manifest is None:
raise ModuleManagementError(f"Module {clean_module_id!r} is not installed.")
python_package = installed_module_distribution_name(clean_module_id)
if not python_package:
raise ModuleManagementError(
f"Could not determine the installed Python distribution for module {clean_module_id!r}."
)
frontend = manifest.frontend
return ModuleInstallPlanItem(
module_id=clean_module_id,
action="uninstall",
python_package=python_package,
webui_package=frontend.package_name if frontend and frontend.package_name else None,
notes="Non-destructive uninstall leaves module data and schema dormant.",
)
def desired_modules_after_package_plan(
desired_enabled: Iterable[str],
plan: ModuleInstallPlan,
*,
activate_installed_modules: bool = True,
remove_uninstalled_modules_from_desired: bool = True,
) -> tuple[str, ...]:
desired = [str(item).strip() for item in desired_enabled if str(item).strip()]
planned_items = tuple(item for item in plan.items if item.status == "planned")
if remove_uninstalled_modules_from_desired:
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")
return tuple(dict.fromkeys(desired))
def normalize_module_install_plan_item(
item: Mapping[str, object] | ModuleInstallPlanItem,
) -> ModuleInstallPlanItem:
if isinstance(item, ModuleInstallPlanItem):
raw = item.as_dict()
elif isinstance(item, Mapping):
raw = item
else:
raise ModuleManagementError("Install plan item must be an object.")
module_id = _required_string(raw.get("module_id"), "module_id")
action = _required_string(raw.get("action"), "action")
status = _clean_optional_string(raw.get("status")) or "planned"
python_package = _clean_optional_string(raw.get("python_package"))
python_ref = _clean_optional_string(raw.get("python_ref"))
webui_package = _clean_optional_string(raw.get("webui_package"))
webui_ref = _clean_optional_string(raw.get("webui_ref"))
destroy_data = _clean_bool(raw.get("destroy_data"))
notes = _clean_optional_string(raw.get("notes"))
if action not in INSTALL_PLAN_ACTIONS:
raise ModuleManagementError(f"Unsupported install plan action for {module_id!r}: {action!r}.")
if status not in INSTALL_PLAN_STATUSES:
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
if action == "install" 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):
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.")
if python_ref:
_validate_dependency_ref(python_ref, field="python_ref", module_id=module_id)
if webui_ref:
_validate_dependency_ref(webui_ref, field="webui_ref", module_id=module_id)
return ModuleInstallPlanItem(
module_id=module_id,
action=action,
python_package=python_package,
python_ref=python_ref,
webui_package=webui_package,
webui_ref=webui_ref,
destroy_data=destroy_data,
status=status,
notes=notes,
)
def plan_desired_enabled_modules(
requested_enabled: Iterable[str],
available: Mapping[str, ModuleManifest],
*,
protected_modules: Iterable[str] = PROTECTED_MODULES,
) -> ModuleStatePlan:
requested = {str(item).strip() for item in requested_enabled if str(item).strip()}
protected = {str(item).strip() for item in protected_modules if str(item).strip()}
requested.update(protected)
missing = sorted(module_id for module_id in requested if module_id not in available)
if missing:
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
added_dependencies: set[str] = set()
visiting: set[str] = set()
visited: set[str] = set()
ordered: list[str] = []
def visit(module_id: str) -> None:
if module_id in visited:
return
if module_id in visiting:
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
manifest = available.get(module_id)
if manifest is None:
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
visiting.add(module_id)
for dependency_id in manifest.dependencies:
if dependency_id not in available:
raise ModuleManagementError(f"Module {module_id!r} depends on uninstalled module {dependency_id!r}.")
if dependency_id not in requested:
added_dependencies.add(dependency_id)
requested.add(dependency_id)
visit(dependency_id)
visiting.remove(module_id)
visited.add(module_id)
ordered.append(module_id)
for module_id in sorted(requested):
visit(module_id)
return ModuleStatePlan(
enabled_modules=tuple(dict.fromkeys(ordered)),
added_dependencies=tuple(sorted(added_dependencies)),
)
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
for manifest in available.values():
for dependency_id in manifest.dependencies:
dependents.setdefault(dependency_id, []).append(manifest.id)
return {module_id: tuple(sorted(items)) for module_id, items in dependents.items()}
def _management_settings(settings: Mapping[str, object]) -> dict[str, object]:
raw_management = settings.get(MODULE_SETTINGS_KEY)
if isinstance(raw_management, Mapping):
return dict(raw_management)
return {}
def _required_string(value: object, field: str) -> str:
cleaned = _clean_optional_string(value)
if not cleaned:
raise ModuleManagementError(f"Install plan item needs {field}.")
return cleaned
def _clean_optional_string(value: object) -> str | None:
if value is None:
return None
cleaned = str(value).strip()
return cleaned or None
def _clean_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _validate_dependency_ref(value: str, *, field: str, module_id: str) -> None:
lowered = value.lower()
if lowered.startswith(LOCAL_DEPENDENCY_REF_PREFIXES) or value.startswith(("/", "./", "../", "~")):
raise ModuleManagementError(
f"Install plan item {module_id!r} uses a local {field}; use a tagged package or git ref instead."
)

View File

@@ -0,0 +1,651 @@
from __future__ import annotations
import base64
import binascii
from datetime import UTC, datetime
from pathlib import Path
import json
import os
from typing import Any
import urllib.error
import urllib.request
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
def module_package_catalog(
path: Path | str | None = None,
*,
require_trusted: bool | None = None,
approved_channels: tuple[str, ...] | None = None,
trusted_keys: dict[str, str] | None = None,
) -> tuple[dict[str, object], ...]:
catalog_source = _catalog_source(path)
if catalog_source is None or not _catalog_source_exists(catalog_source):
return ()
validation = validate_module_package_catalog(
catalog_source,
require_trusted=require_trusted,
approved_channels=approved_channels,
trusted_keys=trusted_keys,
)
if not validation["valid"]:
raise ValueError(str(validation["error"] or "Module package catalog is invalid."))
return tuple(item for item in validation["modules"] if isinstance(item, dict))
def validate_module_package_catalog(
path: Path | str | None = None,
*,
require_trusted: bool | None = None,
approved_channels: tuple[str, ...] | None = None,
trusted_keys: dict[str, str] | None = None,
) -> dict[str, object]:
catalog_source = _catalog_source(path)
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
if catalog_source is not None and not _catalog_source_exists(catalog_source):
return {
"valid": False,
"configured": True,
"path": str(catalog_source),
"source": str(catalog_source),
"source_type": _catalog_source_type(catalog_source),
"modules": [],
"channel": None,
"sequence": None,
"generated_at": None,
"expires_at": None,
"signed": False,
"trusted": False,
"key_id": None,
"warnings": [],
"error": f"Module package catalog does not exist: {catalog_source}",
}
warnings: list[str] = []
try:
payload = _read_catalog_payload(catalog_source)
modules = _normalize_catalog_modules(payload)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
generated_at = _catalog_optional_text(payload, "generated_at")
expires_at = _catalog_optional_text(payload, "expires_at")
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
freshness = _catalog_freshness_state(payload)
replay = _catalog_replay_state(channel=channel, sequence=sequence)
except Exception as exc:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"modules": [],
"channel": None,
"sequence": None,
"generated_at": None,
"expires_at": None,
"signed": False,
"trusted": False,
"key_id": None,
"warnings": [],
"error": str(exc),
}
if signature_state.get("fatal"):
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": str(signature_state["error"] or "Module package catalog signature is invalid."),
}
if effective_approved_channels and channel not in effective_approved_channels:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": f"Module package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.",
}
if effective_require_trusted and not signature_state["trusted"]:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": False,
"key_id": signature_state["key_id"],
"warnings": [],
"error": str(signature_state["error"] or "Module package catalog must be signed by a trusted key."),
}
if not freshness["valid"]:
return _invalid_catalog_result(
catalog_source,
modules=(),
channel=channel,
sequence=sequence,
generated_at=generated_at,
expires_at=expires_at,
signature_state=signature_state,
error=str(freshness["error"]),
)
if not replay["valid"]:
return _invalid_catalog_result(
catalog_source,
modules=(),
channel=channel,
sequence=sequence,
generated_at=generated_at,
expires_at=expires_at,
signature_state=signature_state,
error=str(replay["error"]),
)
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item)
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
if not signature_state["signed"]:
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not signature_state["trusted"]:
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return {
"valid": True,
"configured": catalog_source is not None and _catalog_source_exists(catalog_source),
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"modules": list(modules),
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": warnings,
"error": None,
}
def sign_module_package_catalog(
*,
path: Path,
key_id: str,
private_key_path: Path,
output_path: Path | None = None,
) -> Path:
payload = _read_catalog_payload(path)
if not isinstance(payload, dict):
raise ValueError("Only object-style module package catalogs can be signed.")
signature_payload = dict(payload)
signature_payload.pop("signature", None)
signature_payload.pop("signatures", None)
private_key = _load_private_key(private_key_path)
signature = private_key.sign(_canonical_catalog_bytes(signature_payload))
signature_payload["signature"] = {
"algorithm": "ed25519",
"key_id": key_id,
"value": base64.b64encode(signature).decode("ascii"),
}
target = output_path or path
target.write_text(json.dumps(signature_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return target
def record_module_package_catalog_acceptance(validation: dict[str, object]) -> None:
state_path = _configured_sequence_state_path()
if state_path is None or validation.get("valid") is not True:
return
channel = validation.get("channel")
sequence = validation.get("sequence")
if not isinstance(channel, str) or not isinstance(sequence, int):
return
try:
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
except json.JSONDecodeError:
state = {}
if not isinstance(state, dict):
state = {}
channels = state.get("channels")
if not isinstance(channels, dict):
channels = {}
channel_state = channels.get(channel)
if not isinstance(channel_state, dict):
channel_state = {}
channel_state["last_sequence"] = max(int(channel_state.get("last_sequence") or 0), sequence)
channel_state["accepted_at"] = datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
channel_state["key_id"] = validation.get("key_id")
channel_state["source"] = validation.get("source") or validation.get("path")
channels[channel] = channel_state
state["channels"] = channels
state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _catalog_source(path: Path | str | None) -> Path | str | None:
if path is not None:
return path if isinstance(path, str) and _is_http_url(path) else Path(path).expanduser()
url = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL", "").strip()
if url:
return url
return _configured_catalog_path()
def _configured_catalog_path() -> Path | None:
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG", "").strip()
return Path(value).expanduser() if value else None
def _configured_catalog_cache_path() -> Path | None:
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE", "").strip()
return Path(value).expanduser() if value else None
def _configured_sequence_state_path() -> Path | None:
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_SEQUENCE_STATE", "").strip()
return Path(value).expanduser() if value else None
def _configured_enforce_sequence() -> bool:
return os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_ENFORCE_SEQUENCE", "").strip().lower() in {"1", "true", "yes", "on"}
def _configured_require_signature() -> bool:
return os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE", "").strip().lower() in {"1", "true", "yes", "on"}
def _configured_approved_channels() -> tuple[str, ...]:
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS", "").strip()
if not value:
return ()
return tuple(item.strip() for item in value.split(",") if item.strip())
def _configured_trusted_keys() -> dict[str, str]:
file_value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "").strip()
if file_value:
path = Path(file_value).expanduser()
if not path.exists():
raise ValueError(f"Trusted catalog key file does not exist: {path}")
return _parse_trusted_keys(path.read_text(encoding="utf-8"))
url_value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_URL", "").strip()
if url_value:
return _parse_trusted_keys(_read_trusted_keys_url(url_value))
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS", "").strip()
return _parse_trusted_keys(value) if value else {}
def _configured_trusted_keys_cache_path() -> Path | None:
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_CACHE", "").strip()
return Path(value).expanduser() if value else None
def _read_trusted_keys_url(url: str) -> str:
if not _is_http_url(url):
raise ValueError("Trusted catalog key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path()
try:
with urllib.request.urlopen(url, timeout=15) as response:
body = response.read().decode("utf-8")
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return body
except (OSError, urllib.error.URLError):
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
raise
def _parse_trusted_keys(value: str) -> dict[str, str]:
payload = json.loads(value)
if isinstance(payload, dict) and isinstance(payload.get("keys"), list):
keys: dict[str, str] = {}
now = datetime.now(tz=UTC)
for item in payload["keys"]:
if not isinstance(item, dict):
continue
status = str(item.get("status") or "active").strip().lower()
if status in {"revoked", "disabled", "retired"}:
continue
not_before = _parse_datetime(item.get("not_before"))
not_after = _parse_datetime(item.get("not_after"))
if not_before is not None and now < not_before:
continue
if not_after is not None and now > not_after:
continue
key_id = str(item.get("key_id") or "").strip()
public_key = str(item.get("public_key") or item.get("public_key_base64") or "").strip()
if key_id and public_key:
keys[key_id] = public_key
return keys
if not isinstance(payload, dict):
raise ValueError("Trusted catalog keys must be a JSON object mapping key ids to base64 public keys.")
return {str(key): str(item) for key, item in payload.items() if str(key).strip() and str(item).strip()}
def _read_catalog_payload(source: Path | str | None) -> object:
if source is None:
return {"modules": []}
if isinstance(source, str) and _is_http_url(source):
return json.loads(_read_catalog_url(source))
return json.loads(Path(source).read_text(encoding="utf-8"))
def _read_catalog_url(url: str) -> str:
cache_path = _configured_catalog_cache_path()
try:
with urllib.request.urlopen(url, timeout=15) as response:
body = response.read().decode("utf-8")
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return body
except (OSError, urllib.error.URLError):
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
raise
def _normalize_catalog_modules(payload: object) -> tuple[dict[str, object], ...]:
raw_items = payload.get("modules") if isinstance(payload, dict) else payload
if not isinstance(raw_items, list):
raise ValueError("Module package catalog must be a list or an object with a 'modules' list.")
return tuple(_normalize_catalog_item(item) for item in raw_items)
def _catalog_channel(payload: object) -> str | None:
if not isinstance(payload, dict):
return None
channel = payload.get("channel")
if channel is None:
return None
text = str(channel).strip()
return text or None
def _catalog_optional_text(payload: object, key: str) -> str | None:
if not isinstance(payload, dict):
return None
value = payload.get(key)
if value is None:
return None
text = str(value).strip()
return text or None
def _catalog_sequence(payload: object) -> int | None:
if not isinstance(payload, dict):
return None
value = payload.get("sequence")
if value is None:
return None
try:
sequence = int(value)
except (TypeError, ValueError):
raise ValueError("Catalog sequence must be an integer.") from None
if sequence < 0:
raise ValueError("Catalog sequence must not be negative.")
return sequence
def _catalog_signature_state(payload: object, *, trusted_keys: dict[str, str]) -> dict[str, object]:
if not isinstance(payload, dict):
return {"signed": False, "trusted": False, "key_id": None, "error": "List-style catalogs cannot be signed.", "fatal": False}
signatures = _catalog_signatures(payload)
if not signatures:
return {"signed": False, "trusted": False, "key_id": None, "error": "Catalog is unsigned.", "fatal": False}
errors: list[str] = []
first_key_id: str | None = None
for signature in signatures:
result = _verify_catalog_signature(payload, signature, trusted_keys=trusted_keys)
key_id = result.get("key_id")
if first_key_id is None and isinstance(key_id, str):
first_key_id = key_id
if result.get("trusted"):
return {"signed": True, "trusted": True, "key_id": key_id, "error": None, "fatal": False}
if result.get("fatal"):
return {"signed": True, "trusted": False, "key_id": key_id, "error": result.get("error"), "fatal": True}
if result.get("error"):
errors.append(str(result["error"]))
return {
"signed": True,
"trusted": False,
"key_id": first_key_id,
"error": "; ".join(errors) if errors else "No catalog signature was trusted.",
"fatal": False,
}
def _catalog_signatures(payload: dict[str, object]) -> list[object]:
signatures: list[object] = []
signature = payload.get("signature")
if signature is not None:
signatures.append(signature)
raw_signatures = payload.get("signatures")
if isinstance(raw_signatures, list):
signatures.extend(raw_signatures)
elif raw_signatures is not None:
signatures.append(raw_signatures)
return signatures
def _verify_catalog_signature(payload: dict[str, object], signature: object, *, trusted_keys: dict[str, str]) -> dict[str, object]:
if not isinstance(signature, dict):
return {"signed": True, "trusted": False, "key_id": None, "error": "Catalog signature must be an object.", "fatal": True}
algorithm = str(signature.get("algorithm") or "").strip().lower()
key_id = str(signature.get("key_id") or "").strip()
value = str(signature.get("value") or "").strip()
if algorithm != "ed25519":
return {"signed": True, "trusted": False, "key_id": key_id or None, "error": f"Unsupported catalog signature algorithm: {algorithm!r}", "fatal": True}
if not key_id or not value:
return {"signed": True, "trusted": False, "key_id": key_id or None, "error": "Catalog signature requires key_id and value.", "fatal": True}
trusted_key = trusted_keys.get(key_id)
if not trusted_key:
return {"signed": True, "trusted": False, "key_id": key_id, "error": f"Catalog signature key is not trusted: {key_id}", "fatal": False}
try:
public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(trusted_key, validate=True))
signed_payload = dict(payload)
signed_payload.pop("signature", None)
signed_payload.pop("signatures", None)
public_key.verify(base64.b64decode(value, validate=True), _canonical_catalog_bytes(signed_payload))
except (ValueError, binascii.Error, InvalidSignature) as exc:
return {"signed": True, "trusted": False, "key_id": key_id, "error": f"Catalog signature verification failed: {exc}", "fatal": True}
return {"signed": True, "trusted": True, "key_id": key_id, "error": None, "fatal": False}
def _catalog_freshness_state(payload: object) -> dict[str, object]:
if not isinstance(payload, dict):
return {"valid": True, "warnings": ()}
now = datetime.now(tz=UTC)
not_before = _parse_datetime(payload.get("not_before"))
generated_at = _parse_datetime(payload.get("generated_at"))
expires_at = _parse_datetime(payload.get("expires_at"))
if not_before is not None and now < not_before:
return {"valid": False, "error": f"Catalog is not valid before {not_before.isoformat()}."}
if expires_at is not None and now > expires_at:
return {"valid": False, "error": f"Catalog expired at {expires_at.isoformat()}."}
warnings: list[str] = []
if generated_at is None:
warnings.append("Catalog has no generated_at timestamp.")
if expires_at is None:
warnings.append("Catalog has no expires_at timestamp; production catalogs should expire.")
return {"valid": True, "warnings": tuple(warnings)}
def _catalog_replay_state(*, channel: str | None, sequence: int | None) -> dict[str, object]:
state_path = _configured_sequence_state_path()
if state_path is None:
return {"valid": True, "warnings": ()}
if not channel:
return {"valid": False, "error": "Catalog sequence replay protection needs a channel."}
if sequence is None:
return {"valid": False, "error": "Catalog sequence replay protection needs a sequence."}
if not state_path.exists():
return {"valid": True, "warnings": ()}
try:
state = json.loads(state_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {"valid": False, "error": f"Catalog sequence state file is not valid JSON: {state_path}"}
channels = state.get("channels") if isinstance(state, dict) else None
channel_state = channels.get(channel) if isinstance(channels, dict) else None
last_sequence = channel_state.get("last_sequence") if isinstance(channel_state, dict) else None
if last_sequence is None:
return {"valid": True, "warnings": ()}
try:
last = int(last_sequence)
except (TypeError, ValueError):
return {"valid": False, "error": f"Catalog sequence state for channel {channel!r} is invalid."}
if sequence < last:
return {"valid": False, "error": f"Catalog sequence {sequence} is older than accepted sequence {last} for channel {channel!r}."}
if sequence == last and _configured_enforce_sequence():
return {"valid": False, "error": f"Catalog sequence {sequence} was already accepted for channel {channel!r}."}
return {"valid": True, "warnings": ()}
def _canonical_catalog_bytes(payload: object) -> bytes:
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def _load_private_key(path: Path) -> Ed25519PrivateKey:
private_key = serialization.load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(private_key, Ed25519PrivateKey):
raise ValueError("Catalog signing private key must be an Ed25519 PEM private key.")
return private_key
def _normalize_catalog_item(value: Any) -> dict[str, object]:
if not isinstance(value, dict):
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"}:
raise ValueError(f"Unsupported catalog action for {module_id!r}: {action!r}")
return {
"module_id": module_id,
"name": str(value.get("name") or module_id),
"description": _optional_str(value, "description"),
"version": _optional_str(value, "version"),
"action": action,
"python_package": _optional_str(value, "python_package"),
"python_ref": _optional_str(value, "python_ref"),
"webui_package": _optional_str(value, "webui_package"),
"webui_ref": _optional_str(value, "webui_ref"),
"license_features": _string_list(value.get("license_features")),
"notes": _optional_str(value, "notes"),
"tags": _string_list(value.get("tags")),
}
def _required_str(value: dict[str, Any], key: str) -> str:
item = _optional_str(value, key)
if not item:
raise ValueError(f"Module package catalog entry is missing {key!r}.")
return item
def _optional_str(value: dict[str, Any], key: str) -> str | None:
item = value.get(key)
if item is None:
return None
text = str(item).strip()
return text or None
def _string_list(value: Any) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("Module package catalog list fields must be lists.")
return [str(item).strip() for item in value if str(item).strip()]
def _parse_datetime(value: object) -> datetime | None:
if value is None:
return None
text = str(value).strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError as exc:
raise ValueError(f"Invalid datetime value: {value!r}") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def _catalog_source_exists(source: Path | str) -> bool:
if isinstance(source, str) and _is_http_url(source):
return True
return Path(source).exists()
def _catalog_source_type(source: Path | str | None) -> str | None:
if source is None:
return None
return "url" if isinstance(source, str) and _is_http_url(source) else "file"
def _is_http_url(value: str) -> bool:
return value.startswith(("https://", "http://"))
def _invalid_catalog_result(
source: Path | str | None,
*,
modules: tuple[dict[str, object], ...],
channel: str | None,
sequence: int | None,
generated_at: str | None,
expires_at: str | None,
signature_state: dict[str, object],
error: str,
) -> dict[str, object]:
return {
"valid": False,
"configured": source is not None,
"path": str(source) if source is not None else None,
"source": str(source) if source is not None else None,
"source_type": _catalog_source_type(source),
"modules": list(modules),
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": error,
}

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING
@@ -63,15 +63,56 @@ class FrontendModule:
module_id: str
package_name: str | None = None
asset_manifest: str | None = None
asset_manifest_signature: str | None = None
asset_manifest_public_key_id: str | None = None
asset_manifest_integrity: str | None = None
asset_manifest_contract_version: str = "1"
routes: tuple[FrontendRoute, ...] = ()
nav_items: tuple[NavItem, ...] = ()
settings_routes: tuple[FrontendRoute, ...] = ()
@dataclass(frozen=True, slots=True)
class MigrationRetirementPlan:
supported: bool
summary: str
warnings: tuple[str, ...] = ()
blocking_reason: str | None = None
destroy_data_supported: bool = False
destroy_data_summary: str | None = None
destroy_data_warnings: tuple[str, ...] = ()
destroy_data_executor: MigrationRetirementExecutor | None = None
MigrationRetirementExecutor = Callable[[object, str], None]
MigrationRetirementProvider = Callable[[object | None, str], MigrationRetirementPlan]
@dataclass(frozen=True, slots=True)
class MigrationSpec:
module_id: str
metadata: object | None = None
script_location: str | None = None
retirement_supported: bool = False
retirement_provider: MigrationRetirementProvider | None = None
retirement_notes: str | None = None
@dataclass(frozen=True, slots=True)
class ModuleCompatibility:
core_version_min: str | None = None
core_version_max: str | None = None
manifest_contract_version: str = "1"
@dataclass(frozen=True, slots=True)
class ModuleUninstallGuardResult:
severity: Literal["blocker", "warning", "info"]
code: str
message: str
UninstallGuardProvider = Callable[[object | None, str], Iterable[ModuleUninstallGuardResult]]
@dataclass(frozen=True, slots=True)
@@ -105,6 +146,7 @@ TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
DeleteVetoProvider = Callable[[object, str, str], None]
RouteFactory = Callable[[ModuleContext], "APIRouter"]
CapabilityFactory = Callable[[ModuleContext], object]
LifecycleHook = Callable[[ModuleContext], None]
@dataclass(frozen=True, slots=True)
@@ -123,5 +165,8 @@ class ModuleManifest:
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
on_activate: LifecycleHook | None = None
on_deactivate: LifecycleHook | None = None

View File

@@ -55,6 +55,24 @@ class PlatformRegistry:
self.register_capability_factory(manifest.id, name, factory)
return manifest
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
"""Replace the active manifest set without replacing this registry object."""
replacement = PlatformRegistry()
for manifest in manifests:
replacement.register(manifest)
snapshot = replacement.validate()
self._manifests = dict(replacement._manifests)
self._tenant_summary_providers = dict(replacement._tenant_summary_providers)
self._delete_veto_providers = defaultdict(list, {
resource_type: list(providers)
for resource_type, providers in replacement._delete_veto_providers.items()
})
self._capability_factories = dict(replacement._capability_factories)
self._capabilities.clear()
return snapshot
def get(self, module_id: str) -> ModuleManifest | None:
return self._manifests.get(module_id)
@@ -189,4 +207,3 @@ class PlatformRegistry:
unresolved = ", ".join(sorted(module_id for module_id, dependencies in incoming.items() if dependencies))
raise RegistryError(f"Module dependency cycle or unresolved dependency: {unresolved}")
return (self._manifests[module_id] for module_id in ordered)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from govoplan_core.core.modules import ModuleContext
_context: ModuleContext | None = None
def configure_runtime(context: ModuleContext) -> None:
global _context
_context = context
def get_runtime_context() -> ModuleContext | None:
return _context
def get_registry() -> object | None:
return _context.registry if _context is not None else None