feat: add governed configuration reference selectors

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent 36291a57c1
commit f3e69b97ee
8 changed files with 503 additions and 47 deletions
+201 -37
View File
@@ -123,6 +123,8 @@ SYSTEM_SETTINGS_COLLECTION = "admin.system_settings"
SYSTEM_SETTINGS_RESOURCE = "system_settings_section"
GOVERNANCE_TEMPLATES_COLLECTION = "admin.governance_templates"
GOVERNANCE_TEMPLATE_RESOURCE = "governance_template"
GOVERNANCE_TEMPLATES_FULL_CURSOR_SCOPE = "governance-templates"
ADMIN_FULL_CURSOR_PREFIX = "full:"
INSTALLER_RUNS_CURSOR_SCOPE = "admin.installer.runs.v1"
INSTALLER_REQUESTS_CURSOR_SCOPE = "admin.installer.requests.v1"
DEFAULT_INSTALLER_HISTORY_PAGE_SIZE = 25
@@ -322,10 +324,16 @@ def _record_system_settings_section_changes(
)
def _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem:
assignments = session.query(GovernanceTemplateAssignment).filter(
GovernanceTemplateAssignment.template_id == item.id
).order_by(GovernanceTemplateAssignment.tenant_id.asc()).all()
def _governance_template_item(
session: Session,
item: GovernanceTemplate,
*,
assignments: list[GovernanceTemplateAssignment] | None = None,
) -> GovernanceTemplateItem:
if assignments is None:
assignments = session.query(GovernanceTemplateAssignment).filter(
GovernanceTemplateAssignment.template_id == item.id
).order_by(GovernanceTemplateAssignment.tenant_id.asc()).all()
return GovernanceTemplateItem(
id=item.id,
kind=item.kind,
@@ -341,6 +349,77 @@ def _governance_template_item(session: Session, item: GovernanceTemplate) -> Gov
)
def _governance_template_items(
session: Session,
items: list[GovernanceTemplate],
) -> list[GovernanceTemplateItem]:
if not items:
return []
assignments_by_template: dict[str, list[GovernanceTemplateAssignment]] = {
item.id: [] for item in items
}
assignments = (
session.query(GovernanceTemplateAssignment)
.filter(GovernanceTemplateAssignment.template_id.in_(assignments_by_template))
.order_by(
GovernanceTemplateAssignment.template_id.asc(),
GovernanceTemplateAssignment.tenant_id.asc(),
)
.all()
)
for assignment in assignments:
assignments_by_template[assignment.template_id].append(assignment)
return [
_governance_template_item(
session,
item,
assignments=assignments_by_template[item.id],
)
for item in items
]
def _governance_template_page(query, *, page: int, page_size: int):
total = query.order_by(None).count()
pages = max(1, (total + page_size - 1) // page_size)
items = query.offset((page - 1) * page_size).limit(page_size).all()
return items, {
"total": total,
"page": page,
"page_size": page_size,
"pages": pages,
}
def _encode_admin_full_cursor(scope: str, *, page: int, snapshot_sequence: int) -> str:
return f"{ADMIN_FULL_CURSOR_PREFIX}{scope}:{page}:{snapshot_sequence}"
def _decode_admin_full_cursor(value: str | None, *, scope: str) -> tuple[int, int] | None:
if not value or not value.startswith(ADMIN_FULL_CURSOR_PREFIX):
return None
parts = value.split(":", 3)
if len(parts) != 4 or parts[1] != scope:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid full snapshot cursor",
)
try:
page = int(parts[2])
snapshot_sequence = int(parts[3])
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid full snapshot cursor",
) from exc
if page < 1 or snapshot_sequence < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid full snapshot cursor",
)
return page, snapshot_sequence
def _record_governance_template_change(session: Session, *, item: GovernanceTemplate, operation: str, principal: ApiPrincipal) -> None:
record_change(
session,
@@ -374,6 +453,11 @@ def _module_catalog_response(session: Session, request: Request, *, notes: list[
current_set = set(current_enabled)
desired_set = set(desired_enabled)
protected_set = set(PROTECTED_MODULES)
live_apply_enabled = (
lifecycle.live_apply_enabled()
if isinstance(lifecycle, ModuleLifecycleManager)
else False
)
items: list[ModuleCatalogItem] = []
for module_id, manifest in sorted(available.items(), key=lambda item: item[0]):
migration = manifest.migration_spec
@@ -397,7 +481,7 @@ def _module_catalog_response(session: Session, request: Request, *, notes: list[
frontend_package=frontend.package_name if frontend else None,
migration_module_id=migration.module_id if migration else None,
migration_script_location=migration.script_location if migration else None,
runtime_toggle_supported=True,
runtime_toggle_supported=live_apply_enabled,
install_uninstall_supported=manifest.id not in protected_set,
))
restart_required = current_set != desired_set
@@ -409,15 +493,22 @@ def _module_catalog_response(session: Session, request: Request, *, notes: list[
configured_enabled=configured_enabled,
protected_modules=list(PROTECTED_MODULES),
restart_required=restart_required,
runtime_toggle_supported=True,
runtime_toggle_supported=live_apply_enabled,
install_uninstall_supported=True,
install_plan_supported=True,
package_mutation_supported=False,
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
notes=notes or [
"Enable/disable changes are applied to the running server and saved as startup state.",
"Installing or uninstalling Python/WebUI packages is planned here and applied by the separate installer daemon during maintenance mode.",
],
notes=notes or (
[
"Enable/disable changes are applied to this development server and saved as startup state.",
"Installing or uninstalling Python/WebUI packages is planned here and applied by the separate installer daemon during maintenance mode.",
]
if live_apply_enabled
else [
"Enable/disable changes are saved as startup state. Restart all API and worker processes to apply them consistently.",
"Installing or uninstalling Python/WebUI packages is planned here and applied by the separate installer daemon during maintenance mode.",
]
),
)
@@ -616,7 +707,7 @@ def admin_overview(
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
counts = tenant_counts(session, tenant.id)
counts = tenant_counts(session, tenant.id, module_ids=())
access_admin = _access_administration()
capabilities = [item.scope for item in ALL_PERMISSIONS if has_scope(principal, item.scope)]
return AdminOverviewResponse(
@@ -630,7 +721,7 @@ def admin_overview(
active_user_count=counts["active_users"],
group_count=counts["groups"],
role_count=access_admin.role_count_for_tenant(session, tenant.id),
active_api_key_count=access_admin.active_api_key_count_for_tenant(session, tenant.id),
active_api_key_count=counts["active_api_keys"],
capabilities=capabilities,
)
@@ -1014,11 +1105,23 @@ def update_system_modules(
plan = plan_desired_enabled_modules(payload.enabled_modules, available)
except ModuleManagementError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
try:
result = lifecycle.apply_enabled_modules(plan.enabled_modules, protected_modules=PROTECTED_MODULES)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
desired = save_desired_enabled_modules(session, result.enabled_modules)
live_apply_enabled = lifecycle.live_apply_enabled()
result = None
if live_apply_enabled:
try:
result = lifecycle.apply_enabled_modules(
plan.enabled_modules,
protected_modules=PROTECTED_MODULES,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
desired = save_desired_enabled_modules(session, plan.enabled_modules)
activated = list(result.activated_modules) if result is not None else []
deactivated = list(result.deactivated_modules) if result is not None else []
mounted = list(result.mounted_modules) if result is not None else []
audit_from_principal(
session,
principal,
@@ -1027,12 +1130,12 @@ def update_system_modules(
object_type="module_state",
object_id="global",
details=audit_operation_context(
outcome="applied",
outcome="applied" if live_apply_enabled else "restart_required",
desired_enabled=list(desired),
added_dependencies=list(plan.added_dependencies),
activated=list(result.activated_modules),
deactivated=list(result.deactivated_modules),
mounted=list(result.mounted_modules),
activated=activated,
deactivated=deactivated,
mounted=mounted,
),
)
record_configuration_change_applied(
@@ -1046,13 +1149,19 @@ def update_system_modules(
audit_event="module_management.updated",
)
session.commit()
notes = ["Module state saved and applied to the running server."]
notes = [
(
"Module state saved and applied to the running development server."
if live_apply_enabled
else "Module state saved. Restart all API and worker processes to apply it consistently."
)
]
if plan.added_dependencies:
notes.append("Required dependencies added automatically: " + ", ".join(plan.added_dependencies))
if result.activated_modules:
notes.append("Activated: " + ", ".join(result.activated_modules))
if result.deactivated_modules:
notes.append("Deactivated: " + ", ".join(result.deactivated_modules))
if activated:
notes.append("Activated: " + ", ".join(activated))
if deactivated:
notes.append("Deactivated: " + ", ".join(deactivated))
return _module_catalog_response(session, request, notes=notes)
@@ -1301,6 +1410,8 @@ def write_system_settings(
@router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse)
def list_governance_templates(
kind: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:governance:read")),
):
@@ -1308,18 +1419,63 @@ def list_governance_templates(
query = session.query(GovernanceTemplate)
if kind:
query = query.filter(GovernanceTemplate.kind == kind)
items = query.order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all()
return GovernanceTemplateListResponse(templates=[_governance_template_item(session, item) for item in items])
items, pagination = _governance_template_page(
query.order_by(
GovernanceTemplate.kind.asc(),
GovernanceTemplate.name.asc(),
GovernanceTemplate.id.asc(),
),
page=page,
page_size=page_size,
)
return GovernanceTemplateListResponse(
templates=_governance_template_items(session, items),
**pagination,
)
def _full_governance_template_delta_response(session: Session) -> GovernanceTemplateListDeltaResponse:
items = session.query(GovernanceTemplate).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all()
def _full_governance_template_delta_response(
session: Session,
*,
cursor: tuple[int, int] | None = None,
limit: int = 500,
) -> GovernanceTemplateListDeltaResponse:
snapshot_sequence = (
cursor[1]
if cursor is not None
else max_sequence_id(
session,
module_id=ADMIN_MODULE_ID,
collections=(GOVERNANCE_TEMPLATES_COLLECTION,),
)
)
page = cursor[0] if cursor is not None else 1
items, pagination = _governance_template_page(
session.query(GovernanceTemplate).order_by(
GovernanceTemplate.kind.asc(),
GovernanceTemplate.name.asc(),
GovernanceTemplate.id.asc(),
),
page=page,
page_size=limit,
)
has_more = page < pagination["pages"]
watermark = (
_encode_admin_full_cursor(
GOVERNANCE_TEMPLATES_FULL_CURSOR_SCOPE,
page=page + 1,
snapshot_sequence=snapshot_sequence,
)
if has_more
else encode_sequence_watermark(snapshot_sequence)
)
return GovernanceTemplateListDeltaResponse(
templates=[_governance_template_item(session, item) for item in items],
templates=_governance_template_items(session, items),
deleted=[],
watermark=_admin_delta_watermark(session, (GOVERNANCE_TEMPLATES_COLLECTION,)),
has_more=False,
watermark=watermark,
has_more=has_more,
full=True,
**pagination,
)
@@ -1331,18 +1487,26 @@ def list_governance_templates_delta(
principal: ApiPrincipal = Depends(require_scope("system:governance:read")),
):
del principal
if since is None:
return _full_governance_template_delta_response(session)
full_cursor = _decode_admin_full_cursor(
since,
scope=GOVERNANCE_TEMPLATES_FULL_CURSOR_SCOPE,
)
if since is None or full_cursor is not None:
return _full_governance_template_delta_response(
session,
cursor=full_cursor,
limit=limit,
)
entries, has_more = _admin_delta_entries(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), since=since, limit=limit)
if entries is None:
return _full_governance_template_delta_response(session)
return _full_governance_template_delta_response(session, limit=limit)
changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"]
items = []
if changed_ids:
items = session.query(GovernanceTemplate).filter(GovernanceTemplate.id.in_(changed_ids)).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all()
visible_template_ids = {item.id for item in items}
return GovernanceTemplateListDeltaResponse(
templates=[_governance_template_item(session, item) for item in items],
templates=_governance_template_items(session, items),
deleted=_governance_template_deleted_entries(entries, visible_template_ids),
watermark=_admin_delta_response_watermark(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more,
@@ -437,6 +437,10 @@ class GovernanceTemplateItem(BaseModel):
class GovernanceTemplateListResponse(BaseModel):
templates: list[GovernanceTemplateItem]
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class GovernanceTemplateListDeltaResponse(BaseModel):
@@ -445,6 +449,10 @@ class GovernanceTemplateListDeltaResponse(BaseModel):
watermark: str | None = None
has_more: bool = False
full: bool = False
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class GovernanceTemplateCreateRequest(BaseModel):