chore: sync GovOPlaN module split state
This commit is contained in:
5
src/govoplan_ops/__init__.py
Normal file
5
src/govoplan_ops/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""GovOPlaN operations module."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.6"
|
||||
1
src/govoplan_ops/backend/__init__.py
Normal file
1
src/govoplan_ops/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend integration for the GovOPlaN ops module."""
|
||||
1
src/govoplan_ops/backend/api/__init__.py
Normal file
1
src/govoplan_ops/backend/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Ops API package."""
|
||||
1
src/govoplan_ops/backend/api/v1/__init__.py
Normal file
1
src/govoplan_ops/backend/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Ops API v1 package."""
|
||||
285
src/govoplan_ops/backend/api/v1/routes.py
Normal file
285
src/govoplan_ops/backend/api/v1/routes.py
Normal 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
|
||||
64
src/govoplan_ops/backend/manifest.py
Normal file
64
src/govoplan_ops/backend/manifest.py
Normal 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
|
||||
1
src/govoplan_ops/py.typed
Normal file
1
src/govoplan_ops/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user