Release v0.1.6
This commit is contained in:
272
src/govoplan_core/core/access.py
Normal file
272
src/govoplan_core/core/access.py
Normal 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:
|
||||
...
|
||||
115
src/govoplan_core/core/campaigns.py
Normal file
115
src/govoplan_core/core/campaigns.py
Normal 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]]:
|
||||
...
|
||||
@@ -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)
|
||||
|
||||
145
src/govoplan_core/core/lifecycle.py
Normal file
145
src/govoplan_core/core/lifecycle.py
Normal 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,
|
||||
)
|
||||
67
src/govoplan_core/core/maintenance.py
Normal file
67
src/govoplan_core/core/maintenance.py
Normal 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
|
||||
@@ -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))
|
||||
|
||||
|
||||
117
src/govoplan_core/core/module_guards.py
Normal file
117
src/govoplan_core/core/module_guards.py
Normal 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)
|
||||
1667
src/govoplan_core/core/module_installer.py
Normal file
1667
src/govoplan_core/core/module_installer.py
Normal file
File diff suppressed because it is too large
Load Diff
225
src/govoplan_core/core/module_license.py
Normal file
225
src/govoplan_core/core/module_license.py
Normal 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()]
|
||||
421
src/govoplan_core/core/module_management.py
Normal file
421
src/govoplan_core/core/module_management.py
Normal 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."
|
||||
)
|
||||
651
src/govoplan_core/core/module_package_catalog.py
Normal file
651
src/govoplan_core/core/module_package_catalog.py
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
19
src/govoplan_core/core/runtime.py
Normal file
19
src/govoplan_core/core/runtime.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user