chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:22 +02:00
parent 41528f1947
commit 63ff9a3366
20 changed files with 1151 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""GovOPlaN operations module."""
__all__ = ["__version__"]
__version__ = "0.1.6"

View File

@@ -0,0 +1 @@
"""Backend integration for the GovOPlaN ops module."""

View File

@@ -0,0 +1 @@
"""Ops API package."""

View File

@@ -0,0 +1 @@
"""Ops API v1 package."""

View File

@@ -0,0 +1,285 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from govoplan_access.backend.auth.dependencies import ApiPrincipal, require_any_scope
from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import get_database
from govoplan_core.settings import settings as core_settings
from govoplan_ops.backend.manifest import OPS_READ_SCOPES
router = APIRouter(prefix="/ops", tags=["ops"])
@router.get("/status")
def ops_status(
request: Request,
principal: ApiPrincipal = Depends(require_any_scope(*OPS_READ_SCOPES)),
) -> dict[str, Any]:
del principal
return _ops_status_payload(request)
@router.get("/readiness")
def ops_readiness(
request: Request,
principal: ApiPrincipal = Depends(require_any_scope(*OPS_READ_SCOPES)),
) -> dict[str, Any]:
del principal
payload = _ops_status_payload(request)
readiness = payload["readiness"]
if not readiness["ready"]:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=readiness)
return readiness
def _ops_status_payload(request: Request) -> dict[str, Any]:
registry = _registry(request)
database = _database_status()
maintenance_mode = database.get("maintenance_mode") if isinstance(database.get("maintenance_mode"), dict) else {"enabled": False, "message": None}
redis_check = _redis_check()
current_profile = _current_profile()
checks = [
_check("module_registry", "Module registry", "ok", f"{len(registry.manifests())} modules enabled."),
_check("database", "Database", "ok" if database["ok"] else "error", str(database["detail"])),
_maintenance_check(maintenance_mode),
redis_check,
_worker_check(),
_storage_check(),
_deployment_security_check(current_profile),
]
readiness = _readiness(checks, maintenance_mode)
return {
"summary": {
"app_env": core_settings.app_env,
"active_profile": current_profile,
"module_count": len(registry.manifests()),
"celery_enabled": bool(core_settings.celery_enabled),
"celery_queues": _celery_queues(),
"redis_url": _redact_url(core_settings.redis_url),
"maintenance_mode": maintenance_mode,
"database_url": _redact_url(core_settings.database_url),
"file_storage_backend": core_settings.file_storage_backend,
},
"readiness": readiness,
"checks": checks,
"deployment_profiles": _deployment_profiles(current_profile),
"sizing": _sizing_assumptions(),
}
def _registry(request: Request) -> PlatformRegistry:
registry = getattr(request.app.state, "govoplan_registry", None)
if not isinstance(registry, PlatformRegistry):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GovOPlaN module registry is not configured")
return registry
def _database_status() -> dict[str, Any]:
try:
with get_database().session() as session:
session.execute(text("select 1"))
maintenance = saved_maintenance_mode(session).as_dict()
return {"ok": True, "detail": "Database session succeeded.", "maintenance_mode": maintenance}
except (RuntimeError, SQLAlchemyError) as exc:
return {"ok": False, "detail": f"Database check failed: {exc}", "maintenance_mode": {"enabled": False, "message": None}}
def _maintenance_check(maintenance_mode: dict[str, Any]) -> dict[str, Any]:
if maintenance_mode.get("enabled"):
message = str(maintenance_mode.get("message") or "Maintenance mode is enabled.")
return _check("maintenance_mode", "Maintenance mode", "warning", message, readiness_critical=True)
return _check("maintenance_mode", "Maintenance mode", "ok", "Maintenance mode is off.")
def _redis_check() -> dict[str, Any]:
if not core_settings.celery_enabled:
return _check("redis_broker", "Redis broker", "inactive", "Redis is not required while Celery is disabled.")
try:
from redis import Redis
client = Redis.from_url(core_settings.redis_url, socket_connect_timeout=0.75, socket_timeout=0.75)
client.ping()
return _check("redis_broker", "Redis broker", "ok", f"Redis broker reachable at {_redact_url(core_settings.redis_url)}.")
except Exception as exc: # noqa: BLE001 - diagnostic endpoint should report the concrete failure.
return _check("redis_broker", "Redis broker", "error", f"Redis broker check failed: {exc}", readiness_critical=True)
def _worker_check() -> dict[str, Any]:
if not core_settings.celery_enabled:
return _check("worker_split", "Background workers", "warning", "Celery is disabled; long-running work executes only through synchronous or dev paths.")
try:
from govoplan_core.celery_app import celery
inspector = celery.control.inspect(timeout=0.75)
replies = inspector.ping() or {}
except Exception as exc: # noqa: BLE001 - diagnostic endpoint should report the concrete failure.
return _check("worker_split", "Background workers", "error", f"Worker heartbeat check failed: {exc}", readiness_critical=True)
if replies:
worker_names = ", ".join(sorted(replies))
return _check("worker_split", "Background workers", "ok", f"{len(replies)} worker(s) replied: {worker_names}.")
return _check("worker_split", "Background workers", "error", "Celery is enabled, but no workers replied to heartbeat.", readiness_critical=True)
def _storage_check() -> dict[str, Any]:
backend = str(core_settings.file_storage_backend or "local").lower()
if backend == "s3":
configured = bool(core_settings.file_storage_s3_endpoint_url and core_settings.file_storage_s3_bucket)
return _check("file_storage", "File storage", "ok" if configured else "warning", "S3 file storage is configured." if configured else "S3 file storage needs endpoint and bucket settings.", readiness_critical=not configured)
root = Path(str(core_settings.file_storage_local_root or "runtime/files"))
if root.exists() and root.is_dir():
writable = os.access(root, os.W_OK)
state = "ok" if writable else "warning"
detail = f"Local file storage root: {root}" if writable else f"Local file storage root is not writable: {root}"
return _check("file_storage", "File storage", state, detail, readiness_critical=not writable)
return _check("file_storage", "File storage", "warning", f"Local file storage root does not exist yet: {root}", readiness_critical=False)
def _deployment_security_check(current_profile: str) -> dict[str, Any]:
app_env = str(core_settings.app_env or "").lower()
if app_env in {"dev", "test", "local"}:
return _check("deployment_security", "HTTP and certificates", "inactive", "Local/test profile; HTTPS and secure-cookie checks are deployment-owned.")
blockers: list[str] = []
if not core_settings.auth_cookie_secure:
blockers.append("AUTH_COOKIE_SECURE=false")
cors_origins = _cors_origins()
if not cors_origins:
blockers.append("CORS_ORIGINS is empty")
elif any(origin == "*" or origin.startswith("http://localhost") or origin.startswith("http://127.0.0.1") for origin in cors_origins):
blockers.append("CORS_ORIGINS contains local or wildcard origins")
if not blockers:
return _check("deployment_security", "HTTP and certificates", "ok", f"{current_profile} uses secure cookies and explicit non-local CORS origins.")
profile_label = "production" if app_env in {"prod", "production"} else app_env or current_profile
detail = f"{profile_label} profile needs deployment HTTP/certificate hardening: {', '.join(blockers)}."
return _check(
"deployment_security",
"HTTP and certificates",
"warning",
detail,
readiness_critical=app_env in {"prod", "production"},
)
def _check(check_id: str, label: str, state: str, detail: str, *, readiness_critical: bool = False) -> dict[str, Any]:
return {"id": check_id, "label": label, "state": state, "detail": detail, "readiness_critical": readiness_critical}
def _readiness(checks: list[dict[str, Any]], maintenance_mode: dict[str, Any]) -> dict[str, Any]:
blockers = [
{"id": check["id"], "label": check["label"], "state": check["state"], "detail": check["detail"]}
for check in checks
if check.get("readiness_critical") and check.get("state") != "ok"
]
if maintenance_mode.get("enabled") and not any(item["id"] == "maintenance_mode" for item in blockers):
blockers.append({"id": "maintenance_mode", "label": "Maintenance mode", "state": "warning", "detail": str(maintenance_mode.get("message") or "Maintenance mode is enabled.")})
return {
"ready": not blockers,
"blockers": blockers,
"profile": _current_profile(),
}
def _current_profile() -> str:
app_env = str(core_settings.app_env or "").lower()
if app_env == "dev" and core_settings.celery_enabled:
return "production-like-dev"
if app_env == "dev":
return "local-dev"
if core_settings.celery_enabled:
return "split-worker"
return "single-process"
def _celery_queues() -> list[str]:
return [item.strip() for item in str(core_settings.celery_queues or "").split(",") if item.strip()]
def _cors_origins() -> list[str]:
return [item.strip() for item in str(core_settings.cors_origins or "").split(",") if item.strip()]
def _deployment_profiles(current_profile: str) -> list[dict[str, Any]]:
profiles = [
{
"id": "local-dev",
"name": "Local development",
"current": current_profile == "local-dev",
"components": ["FastAPI", "SQLite or local database", "Vite dev server", "local file storage"],
"fit": "Developer workstation, demos, and module integration work.",
},
{
"id": "production-like-dev",
"name": "Production-like development",
"current": current_profile == "production-like-dev",
"components": ["FastAPI", "Vite dev server", "PostgreSQL", "Redis broker", "Celery worker", "durable local file storage"],
"fit": "Local validation of the production dependency shape without publishing packages.",
},
{
"id": "single-process",
"name": "Small institution",
"current": current_profile == "single-process",
"components": ["FastAPI", "PostgreSQL", "reverse proxy", "scheduled backups", "local or S3-compatible file storage"],
"fit": "Low-volume internal administration without heavy background jobs.",
},
{
"id": "split-worker",
"name": "Institution platform",
"current": current_profile == "split-worker",
"components": ["FastAPI", "worker process", "Redis broker", "PostgreSQL", "object storage", "health probes"],
"fit": "Campaigns, imports, exports, scheduled work, and larger tenant counts.",
},
]
return profiles
def _sizing_assumptions() -> list[dict[str, str]]:
return [
{
"area": "API",
"baseline": "One process behind a reverse proxy.",
"scale_trigger": "Sustained request latency or concurrent admin/portal usage.",
"operator_note": "Scale horizontally only after session, CSRF, and proxy headers are stable.",
},
{
"area": "Workers",
"baseline": "Disabled in local development; one worker for production imports, mail, and package jobs.",
"scale_trigger": "Backlogs in send_email, append_sent, imports, or export queues.",
"operator_note": "Keep package installer execution separate from ordinary worker queues.",
},
{
"area": "Database",
"baseline": "PostgreSQL for shared-tenancy production.",
"scale_trigger": "Tenant growth, audit volume, DMS/file metadata, or reporting workloads.",
"operator_note": "Backups and restore checks are part of the deployment profile, not optional documentation.",
},
{
"area": "Files",
"baseline": "Local storage for development; S3-compatible object storage for production.",
"scale_trigger": "Cross-node deployments, DMS volume, or file retention requirements.",
"operator_note": "Retention policy and legal-hold behavior should be verified before large imports.",
},
]
def _redact_url(value: str) -> str:
try:
parsed = urlsplit(value)
except ValueError:
return "<invalid>"
if parsed.username or parsed.password:
host = parsed.hostname or ""
port = f":{parsed.port}" if parsed.port else ""
return f"{parsed.scheme}://<credentials>@{host}{port}{parsed.path}"
return value

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
OPS_READ_SCOPE = "ops:operations:read"
OPS_READ_SCOPES = (OPS_READ_SCOPE, "system:settings:read", "admin:settings:read")
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Operations",
level="system",
module_id=module_id,
resource=resource,
action=action,
)
def _route_factory(context: ModuleContext):
del context
from govoplan_ops.backend.api.v1.routes import router
return router
manifest = ModuleManifest(
id="ops",
name="Ops",
version="0.1.6",
dependencies=("access",),
optional_dependencies=("audit", "docs", "notifications"),
permissions=(
_permission(
OPS_READ_SCOPE,
"View operations status",
"Read runtime health, deployment profile, and sizing information.",
),
),
role_templates=(
RoleTemplate(
slug="ops_reader",
name="Operations reader",
description="Read platform health and deployment profile information.",
permissions=(OPS_READ_SCOPE,),
level="system",
),
),
route_factory=_route_factory,
nav_items=(NavItem(path="/ops", label="Ops", icon="activity", required_any=OPS_READ_SCOPES, order=890),),
frontend=FrontendModule(
module_id="ops",
package_name="@govoplan/ops-webui",
routes=(FrontendRoute(path="/ops", component="OpsPage", required_any=OPS_READ_SCOPES, order=890),),
nav_items=(NavItem(path="/ops", label="Ops", icon="activity", required_any=OPS_READ_SCOPES, order=890),),
),
)
def get_manifest() -> ModuleManifest:
return manifest

View File

@@ -0,0 +1 @@