feat: expose multi-host runtime readiness

This commit is contained in:
2026-08-02 05:29:47 +02:00
parent c241085806
commit b464d016b2
6 changed files with 519 additions and 104 deletions
+2
View File
@@ -15,6 +15,8 @@ This repository owns:
- operator-facing status APIs - operator-facing status APIs
- runtime-node registration, heartbeat, composition, stale-node, and expected - runtime-node registration, heartbeat, composition, stale-node, and expected
replica projection replica projection
- queue-specific worker-pool coverage, release/composition skew, and the
deployment-rendered PostgreSQL connection budget
- audited API and worker drain/cancel controls - audited API and worker drain/cancel controls
- recovery-operation status and evidence-chain summaries - recovery-operation status and evidence-chain summaries
- governance inventory for module-declared permissions, roles, capabilities, - governance inventory for module-declared permissions, roles, capabilities,
+10
View File
@@ -39,6 +39,9 @@ The Ops API reports:
- runtime node identity, role, software/module composition, queues, heartbeat, - runtime node identity, role, software/module composition, queues, heartbeat,
stale state, and drain state stale state, and drain state
- configured versus active API and worker replica counts - configured versus active API and worker replica counts
- active worker-pool names, exact queue coverage, and missing queue owners
- release/module-composition skew and software-version skew across active nodes
- rendered PostgreSQL connection peak, declared server limit, and operator reserve
- recovery operation status, mode, checkpoint count, and last update - recovery operation status, mode, checkpoint count, and last update
These values are intentionally diagnostic. They do not replace deployment These values are intentionally diagnostic. They do not replace deployment
@@ -54,6 +57,13 @@ manual-intervention record means an operator must repair the current release or
restore a separately verified coordinated backup. Ops does not convert that restore a separately verified coordinated backup. Ops does not convert that
state into a safe rollback. state into a safe rollback.
For the `shared` profile, missing expected replicas, release/composition skew,
unserved queues, or an invalid PostgreSQL connection budget are readiness
errors. Stale historical records remain visible, but cannot downgrade one of
those errors to a warning. A database budget at or above 80 percent is reported
as a warning so operators retain room for measured bursts and administrative
connections.
`deployment_security` is inactive for local/test profiles. In staging or pilot `deployment_security` is inactive for local/test profiles. In staging or pilot
profiles it warns when secure cookies or CORS origins still look local. In profiles it warns when secure cookies or CORS origins still look local. In
production it becomes readiness-critical because TLS certificates, proxy production it becomes readiness-critical because TLS certificates, proxy
+398 -74
View File
@@ -35,6 +35,7 @@ from govoplan_core.core.runtime_coordination import (
cancel_runtime_node_drain, cancel_runtime_node_drain,
list_runtime_nodes, list_runtime_nodes,
request_runtime_node_drain, request_runtime_node_drain,
runtime_composition_hash,
) )
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
from govoplan_core.settings import settings as core_settings from govoplan_core.settings import settings as core_settings
@@ -70,7 +71,9 @@ def ops_readiness(
payload = _ops_status_payload(request) payload = _ops_status_payload(request)
readiness = payload["readiness"] readiness = payload["readiness"]
if not readiness["ready"]: if not readiness["ready"]:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=readiness) raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=readiness
)
return readiness return readiness
@@ -85,10 +88,16 @@ def run_ops_checks(
@router.get("/runtime/nodes") @router.get("/runtime/nodes")
def runtime_nodes( def runtime_nodes(
request: Request,
principal: ApiPrincipal = Depends(require_any_scope(*OPS_READ_SCOPES)), principal: ApiPrincipal = Depends(require_any_scope(*OPS_READ_SCOPES)),
) -> dict[str, Any]: ) -> dict[str, Any]:
del principal del principal
return _runtime_cluster_status() registry = _registry(request)
return _runtime_cluster_status(
expected_composition_hash=runtime_composition_hash(
tuple(manifest.id for manifest in registry.manifests())
)
)
@router.post("/runtime/nodes/{node_id}/drain") @router.post("/runtime/nodes/{node_id}/drain")
@@ -175,21 +184,42 @@ def _ops_status_payload(
) -> dict[str, Any]: ) -> dict[str, Any]:
registry = _registry(request) registry = _registry(request)
database = _database_status() database = _database_status()
maintenance_mode = database.get("maintenance_mode") if isinstance(database.get("maintenance_mode"), dict) else {"enabled": False, "message": None} maintenance_mode = (
database.get("maintenance_mode")
if isinstance(database.get("maintenance_mode"), dict)
else {"enabled": False, "message": None}
)
redis_check = _redis_check() redis_check = _redis_check()
current_profile = _current_profile() current_profile = _current_profile()
worker_check = _worker_check() worker_check = _worker_check()
runtime_cluster = _runtime_cluster_status() database_capacity = _database_capacity_check()
expected_composition_hash = runtime_composition_hash(
tuple(manifest.id for manifest in registry.manifests())
)
runtime_cluster = _runtime_cluster_status(
expected_composition_hash=expected_composition_hash
)
module_checks = _module_operational_checks( module_checks = _module_operational_checks(
registry, registry,
force=force_module_checks, force=force_module_checks,
) )
checks = [ checks = [
_check("module_registry", "Module registry", "ok", f"{len(registry.manifests())} modules enabled."), _check(
_check("database", "Database", "ok" if database["ok"] else "error", str(database["detail"])), "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), _maintenance_check(maintenance_mode),
redis_check, redis_check,
worker_check, worker_check,
database_capacity,
_runtime_cluster_check(runtime_cluster), _runtime_cluster_check(runtime_cluster),
_storage_check(), _storage_check(),
_backup_restore_check(), _backup_restore_check(),
@@ -211,11 +241,17 @@ def _ops_status_payload(
"redis_url": _redact_url(core_settings.redis_url), "redis_url": _redact_url(core_settings.redis_url),
"maintenance_mode": maintenance_mode, "maintenance_mode": maintenance_mode,
"database_url": _redact_url(core_settings.database_url), "database_url": _redact_url(core_settings.database_url),
"database_connection_peak": core_settings.database_connection_peak,
"database_connection_available": (
core_settings.database_connection_available
),
"file_storage_backend": core_settings.file_storage_backend, "file_storage_backend": core_settings.file_storage_backend,
"worker_metrics": worker_check.get("metrics", {}), "worker_metrics": worker_check.get("metrics", {}),
"operational_probe_count": len(module_checks), "operational_probe_count": len(module_checks),
"runtime_node_count": len(runtime_cluster.get("nodes", [])), "runtime_node_count": len(runtime_cluster.get("nodes", [])),
"recovery_required_count": runtime_cluster.get("recovery", {}).get("requires_attention", 0), "recovery_required_count": runtime_cluster.get("recovery", {}).get(
"requires_attention", 0
),
}, },
"readiness": readiness, "readiness": readiness,
"checks": checks, "checks": checks,
@@ -226,7 +262,10 @@ def _ops_status_payload(
} }
def _runtime_cluster_status() -> dict[str, Any]: def _runtime_cluster_status(
*,
expected_composition_hash: str | None = None,
) -> dict[str, Any]:
try: try:
with get_database().SessionLocal() as session: with get_database().SessionLocal() as session:
nodes = list_runtime_nodes( nodes = list_runtime_nodes(
@@ -237,8 +276,7 @@ def _runtime_cluster_status() -> dict[str, Any]:
operations = ( operations = (
session.query(RecoveryOperation) session.query(RecoveryOperation)
.filter( .filter(
RecoveryOperation.installation_id RecoveryOperation.installation_id == core_settings.installation_id
== core_settings.installation_id
) )
.order_by(RecoveryOperation.updated_at.desc()) .order_by(RecoveryOperation.updated_at.desc())
.limit(50) .limit(50)
@@ -253,12 +291,48 @@ def _runtime_cluster_status() -> dict[str, Any]:
"recovery": {"operations": [], "requires_attention": 0}, "recovery": {"operations": [], "requires_attention": 0},
} }
active_nodes = [ active_nodes = [
node node for node in nodes if node["state"] == "active" and not node["stale"]
for node in nodes
if node["state"] != "stopped" and not node["stale"]
] ]
active_api = sum(node["role"] == "api" for node in active_nodes) active_api = sum(node["role"] == "api" for node in active_nodes)
active_workers = sum(node["role"] == "worker" for node in active_nodes) active_workers = sum(node["role"] == "worker" for node in active_nodes)
runtime_nodes = [node for node in active_nodes if node["role"] in {"api", "worker"}]
composition_hashes = sorted(
{
str(node["composition_hash"])
for node in runtime_nodes
if node.get("composition_hash")
}
)
software_versions = sorted(
{
str(node["software_version"])
for node in runtime_nodes
if node.get("software_version")
}
)
unexpected_composition_nodes = [
str(node["node_id"])
for node in runtime_nodes
if expected_composition_hash
and node.get("composition_hash") != expected_composition_hash
]
active_worker_nodes = [node for node in active_nodes if node["role"] == "worker"]
active_queues = sorted(
{
str(queue)
for node in active_worker_nodes
for queue in (node.get("queues") or [])
if queue
}
)
expected_queues = _celery_queues() if core_settings.celery_enabled else []
missing_queues = sorted(set(expected_queues) - set(active_queues))
worker_pools = sorted(
{
str(node.get("metadata", {}).get("worker_pool") or "default")
for node in active_worker_nodes
}
)
operation_payloads = [ operation_payloads = [
{ {
"id": operation.id, "id": operation.id,
@@ -295,6 +369,22 @@ def _runtime_cluster_status() -> dict[str, Any]:
"worker": core_settings.runtime_expected_worker_replicas, "worker": core_settings.runtime_expected_worker_replicas,
}, },
"active": {"api": active_api, "worker": active_workers}, "active": {"api": active_api, "worker": active_workers},
"composition": {
"expected_hash": expected_composition_hash,
"active_hashes": composition_hashes,
"unexpected_nodes": unexpected_composition_nodes,
"skewed": len(composition_hashes) > 1 or bool(unexpected_composition_nodes),
},
"software_versions": {
"active": software_versions,
"skewed": len(software_versions) > 1,
},
"queues": {
"expected": expected_queues,
"active": active_queues,
"missing": missing_queues,
"worker_pools": worker_pools,
},
"nodes": nodes, "nodes": nodes,
"recovery": { "recovery": {
"operations": operation_payloads, "operations": operation_payloads,
@@ -317,15 +407,36 @@ def _runtime_cluster_check(cluster: dict[str, Any]) -> dict[str, Any]:
expected = cluster.get("expected") or {} expected = cluster.get("expected") or {}
active = cluster.get("active") or {} active = cluster.get("active") or {}
missing_api = max(0, int(expected.get("api") or 0) - int(active.get("api") or 0)) missing_api = max(0, int(expected.get("api") or 0) - int(active.get("api") or 0))
missing_workers = max(0, int(expected.get("worker") or 0) - int(active.get("worker") or 0)) missing_workers = max(
0, int(expected.get("worker") or 0) - int(active.get("worker") or 0)
)
composition = cluster.get("composition") or {}
versions = cluster.get("software_versions") or {}
queues = cluster.get("queues") or {}
composition_skewed = bool(composition.get("skewed"))
version_skewed = bool(versions.get("skewed"))
missing_queues = list(queues.get("missing") or [])
state = "ok" state = "ok"
detail = f"{len(nodes)} runtime node record(s); {len(stale)} stale." detail = f"{len(nodes)} runtime node record(s); {len(stale)} stale."
readiness_critical = False readiness_critical = False
if missing_api or missing_workers: if missing_api or missing_workers:
state = "error" if cluster.get("state_profile") == "shared" else "warning" state = "error" if cluster.get("state_profile") == "shared" else "warning"
readiness_critical = state == "error" readiness_critical = state == "error"
detail += f" Missing expected replicas: api={missing_api}, worker={missing_workers}." detail += (
elif stale: f" Missing expected replicas: api={missing_api}, worker={missing_workers}."
)
if composition_skewed or version_skewed or missing_queues:
state = "error" if cluster.get("state_profile") == "shared" else "warning"
readiness_critical = readiness_critical or state == "error"
if composition_skewed:
detail += " Active runtime composition differs from the loaded graph."
if version_skewed:
detail += " Active runtime software versions differ."
if missing_queues:
detail += (
" Queues without an active worker: " + ", ".join(missing_queues) + "."
)
if stale and state == "ok":
state = "warning" state = "warning"
return _check( return _check(
"runtime_cluster", "runtime_cluster",
@@ -338,6 +449,9 @@ def _runtime_cluster_check(cluster: dict[str, Any]) -> dict[str, Any]:
"stale": len(stale), "stale": len(stale),
"active_api": int(active.get("api") or 0), "active_api": int(active.get("api") or 0),
"active_workers": int(active.get("worker") or 0), "active_workers": int(active.get("worker") or 0),
"composition_skewed": composition_skewed,
"version_skewed": version_skewed,
"missing_queues": len(missing_queues),
"recovery_required": int( "recovery_required": int(
cluster.get("recovery", {}).get("requires_attention") or 0 cluster.get("recovery", {}).get("requires_attention") or 0
), ),
@@ -345,10 +459,70 @@ def _runtime_cluster_check(cluster: dict[str, Any]) -> dict[str, Any]:
) )
def _database_capacity_check() -> dict[str, Any]:
limit = core_settings.database_connection_limit
reserve = core_settings.database_connection_reserve
peak = core_settings.database_connection_peak
available = core_settings.database_connection_available
shared = core_settings.state_profile == "shared"
if limit is None or peak is None or available is None:
return _check(
"database_capacity",
"Database connection capacity",
"error" if shared else "inactive",
(
"Shared deployments must declare the PostgreSQL connection "
"limit and rendered peak demand."
if shared
else "No deployment-level PostgreSQL connection budget is configured."
),
readiness_critical=shared,
)
expected_available = limit - reserve
valid = reserve < limit and available == expected_available and peak <= available
utilization = peak / available if available else 1.0
if not valid:
return _check(
"database_capacity",
"Database connection capacity",
"error",
(
f"Rendered peak {peak} does not fit the declared available "
f"capacity {available} (limit {limit}, reserve {reserve})."
),
readiness_critical=shared,
metrics={
"limit": limit,
"reserve": reserve,
"available": available,
"peak": peak,
},
)
return _check(
"database_capacity",
"Database connection capacity",
"warning" if utilization >= 0.8 else "ok",
(
f"Rendered peak {peak} uses {utilization:.0%} of {available} "
f"connections available after the {reserve}-connection reserve."
),
metrics={
"limit": limit,
"reserve": reserve,
"available": available,
"peak": peak,
"utilization": round(utilization, 4),
},
)
def _registry(request: Request) -> PlatformRegistry: def _registry(request: Request) -> PlatformRegistry:
registry = getattr(request.app.state, "govoplan_registry", None) registry = getattr(request.app.state, "govoplan_registry", None)
if not isinstance(registry, PlatformRegistry): if not isinstance(registry, PlatformRegistry):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GovOPlaN module registry is not configured") raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="GovOPlaN module registry is not configured",
)
return registry return registry
@@ -376,26 +550,33 @@ def _governance_inventory(
access_control_count = ( access_control_count = (
len(manifest.resource_acl_providers) len(manifest.resource_acl_providers)
+ len(manifest.ownership_providers) + len(manifest.ownership_providers)
+ sum(len(providers) for providers in manifest.delete_veto_providers.values()) + sum(
len(providers) for providers in manifest.delete_veto_providers.values()
)
+ len(manifest.uninstall_guard_providers) + len(manifest.uninstall_guard_providers)
) )
modules.append({ modules.append(
{
"module_id": manifest.id, "module_id": manifest.id,
"name": manifest.name, "name": manifest.name,
"version": manifest.version, "version": manifest.version,
"permission_count": len(manifest.permissions), "permission_count": len(manifest.permissions),
"role_template_count": len(manifest.role_templates), "role_template_count": len(manifest.role_templates),
"capability_count": len(capability_names), "capability_count": len(capability_names),
"policy_count": sum(name.startswith("policy.") for name in capability_names), "policy_count": sum(
name.startswith("policy.") for name in capability_names
),
"documentation_count": len(manifest.documentation), "documentation_count": len(manifest.documentation),
"documentation_provider_count": len(manifest.documentation_providers), "documentation_provider_count": len(manifest.documentation_providers),
"access_control_count": access_control_count, "access_control_count": access_control_count,
"search_provider_count": len(manifest.search_providers) + len(manifest.search_sources), "search_provider_count": len(manifest.search_providers)
+ len(manifest.search_sources),
"migration_managed": manifest.migration_spec is not None, "migration_managed": manifest.migration_spec is not None,
"architecture": architecture, "architecture": architecture,
"external_provider_count": len(external_providers), "external_provider_count": len(external_providers),
"external_providers": external_providers, "external_providers": external_providers,
}) }
)
return { return {
"summary": { "summary": {
"module_count": len(modules), "module_count": len(modules),
@@ -404,12 +585,20 @@ def _governance_inventory(
"capability_count": sum(item["capability_count"] for item in modules), "capability_count": sum(item["capability_count"] for item in modules),
"policy_count": sum(item["policy_count"] for item in modules), "policy_count": sum(item["policy_count"] for item in modules),
"documented_module_count": sum( "documented_module_count": sum(
bool(item["documentation_count"] or item["documentation_provider_count"]) bool(
item["documentation_count"] or item["documentation_provider_count"]
)
for item in modules for item in modules
), ),
"access_control_count": sum(item["access_control_count"] for item in modules), "access_control_count": sum(
"search_provider_count": sum(item["search_provider_count"] for item in modules), item["access_control_count"] for item in modules
"migration_module_count": sum(bool(item["migration_managed"]) for item in modules), ),
"search_provider_count": sum(
item["search_provider_count"] for item in modules
),
"migration_module_count": sum(
bool(item["migration_managed"]) for item in modules
),
"architecture_declared_module_count": sum( "architecture_declared_module_count": sum(
bool(item["architecture"]) for item in modules bool(item["architecture"]) for item in modules
), ),
@@ -471,29 +660,63 @@ def _database_status() -> dict[str, Any]:
with get_database().session() as session: with get_database().session() as session:
session.execute(text("select 1")) session.execute(text("select 1"))
maintenance = saved_maintenance_mode(session).as_dict() maintenance = saved_maintenance_mode(session).as_dict()
return {"ok": True, "detail": "Database session succeeded.", "maintenance_mode": maintenance} return {
"ok": True,
"detail": "Database session succeeded.",
"maintenance_mode": maintenance,
}
except (RuntimeError, SQLAlchemyError) as exc: except (RuntimeError, SQLAlchemyError) as exc:
return {"ok": False, "detail": f"Database check failed: {exc}", "maintenance_mode": {"enabled": False, "message": None}} 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]: def _maintenance_check(maintenance_mode: dict[str, Any]) -> dict[str, Any]:
if maintenance_mode.get("enabled"): if maintenance_mode.get("enabled"):
message = str(maintenance_mode.get("message") or "Maintenance mode is 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(
return _check("maintenance_mode", "Maintenance mode", "ok", "Maintenance mode is off.") "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]: def _redis_check() -> dict[str, Any]:
if not core_settings.celery_enabled: if not core_settings.celery_enabled:
return _check("redis_broker", "Redis broker", "inactive", "Redis is not required while Celery is disabled.") return _check(
"redis_broker",
"Redis broker",
"inactive",
"Redis is not required while Celery is disabled.",
)
try: try:
from redis import Redis from redis import Redis
client = Redis.from_url(core_settings.redis_url, socket_connect_timeout=0.75, socket_timeout=0.75) client = Redis.from_url(
core_settings.redis_url, socket_connect_timeout=0.75, socket_timeout=0.75
)
client.ping() client.ping()
return _check("redis_broker", "Redis broker", "ok", f"Redis broker reachable at {_redact_url(core_settings.redis_url)}.") 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. 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) return _check(
"redis_broker",
"Redis broker",
"error",
f"Redis broker check failed: {exc}",
readiness_critical=True,
)
def _worker_check() -> dict[str, Any]: def _worker_check() -> dict[str, Any]:
@@ -531,25 +754,29 @@ def _worker_check() -> dict[str, Any]:
return result return result
if replies: if replies:
worker_names = ", ".join(sorted(replies)) worker_names = ", ".join(sorted(replies))
active_queues = sorted({ active_queues = sorted(
{
str(queue.get("name")) str(queue.get("name"))
for queues in active_queues_by_worker.values() for queues in active_queues_by_worker.values()
if isinstance(queues, list) if isinstance(queues, list)
for queue in queues for queue in queues
if isinstance(queue, Mapping) and queue.get("name") if isinstance(queue, Mapping) and queue.get("name")
}) }
)
expected_queues = _celery_queues() expected_queues = _celery_queues()
missing_queues = sorted(set(expected_queues) - set(active_queues)) missing_queues = sorted(set(expected_queues) - set(active_queues))
active_tasks = sum( active_tasks = sum(
len(tasks) len(tasks) for tasks in active_by_worker.values() if isinstance(tasks, list)
for tasks in active_by_worker.values()
if isinstance(tasks, list)
) )
queue_depths = _queue_depths(expected_queues) queue_depths = _queue_depths(expected_queues)
state = "warning" if missing_queues else "ok" state = "warning" if missing_queues else "ok"
detail = f"{len(replies)} worker(s) replied: {worker_names}." detail = f"{len(replies)} worker(s) replied: {worker_names}."
if missing_queues: if missing_queues:
detail += " No worker consumes configured queues: " + ", ".join(missing_queues) + "." detail += (
" No worker consumes configured queues: "
+ ", ".join(missing_queues)
+ "."
)
result = _check( result = _check(
"worker_split", "worker_split",
"Background workers", "Background workers",
@@ -596,24 +823,48 @@ def _queue_depths(queues: list[str]) -> dict[str, int]:
values = pipeline.execute() values = pipeline.execute()
except Exception: # noqa: BLE001 - worker heartbeat remains the readiness source. except Exception: # noqa: BLE001 - worker heartbeat remains the readiness source.
return {} return {}
return { return {queue: int(value) for queue, value in zip(queues, values, strict=True)}
queue: int(value)
for queue, value in zip(queues, values, strict=True)
}
def _storage_check() -> dict[str, Any]: def _storage_check() -> dict[str, Any]:
backend = str(core_settings.file_storage_backend or "local").lower() backend = str(core_settings.file_storage_backend or "local").lower()
if backend == "s3": if backend == "s3":
configured = bool(core_settings.file_storage_s3_endpoint_url and core_settings.file_storage_s3_bucket) configured = bool(
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) 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")) root = Path(str(core_settings.file_storage_local_root or "runtime/files"))
if root.exists() and root.is_dir(): if root.exists() and root.is_dir():
writable = os.access(root, os.W_OK) writable = os.access(root, os.W_OK)
state = "ok" if writable else "warning" 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}" detail = (
return _check("file_storage", "File storage", state, detail, readiness_critical=not writable) f"Local file storage root: {root}"
return _check("file_storage", "File storage", "warning", f"Local file storage root does not exist yet: {root}", readiness_critical=False) 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 _module_operational_checks( def _module_operational_checks(
@@ -630,13 +881,15 @@ def _module_operational_checks(
for registration in registrations: for registration in registrations:
cache_key = f"{registration.module_id}:{registration.check_id}" cache_key = f"{registration.module_id}:{registration.check_id}"
if registration.check_id in seen: if registration.check_id in seen:
results.append(_check( results.append(
_check(
f"ops.duplicate.{registration.check_id}", f"ops.duplicate.{registration.check_id}",
"Operational check registry", "Operational check registry",
"error", "error",
f"Operational check id {registration.check_id!r} is registered more than once.", f"Operational check id {registration.check_id!r} is registered more than once.",
readiness_critical=True, readiness_critical=True,
)) )
)
continue continue
seen.add(registration.check_id) seen.add(registration.check_id)
cached = _cached_module_check( cached = _cached_module_check(
@@ -694,7 +947,9 @@ def _backup_restore_check() -> dict[str, Any]:
except Exception: # noqa: BLE001 - malformed historical evidence is skipped. except Exception: # noqa: BLE001 - malformed historical evidence is skipped.
continue continue
snapshot = record.get("snapshot") snapshot = record.get("snapshot")
backup = snapshot.get("database_backup") if isinstance(snapshot, Mapping) else None backup = (
snapshot.get("database_backup") if isinstance(snapshot, Mapping) else None
)
if isinstance(backup, Mapping): if isinstance(backup, Mapping):
latest_evidence = (record, backup) latest_evidence = (record, backup)
break break
@@ -711,13 +966,10 @@ def _backup_restore_check() -> dict[str, Any]:
record, backup = latest_evidence record, backup = latest_evidence
restore_check = backup.get("restore_check") restore_check = backup.get("restore_check")
restore_check_ok = ( restore_check_ok = isinstance(restore_check, Mapping) and (
isinstance(restore_check, Mapping)
and (
restore_check.get("return_code") == 0 restore_check.get("return_code") == 0
or str(restore_check.get("result") or "").lower() == "ok" or str(restore_check.get("result") or "").lower() == "ok"
) )
)
drill_ok = bool(drill and drill.get("ok") is True) drill_ok = bool(drill and drill.get("ok") is True)
state = "ok" if restore_check_ok and drill_ok else "warning" state = "ok" if restore_check_ok and drill_ok else "warning"
missing: list[str] = [] missing: list[str] = []
@@ -725,9 +977,7 @@ def _backup_restore_check() -> dict[str, Any]:
missing.append("backup restore-readiness check") missing.append("backup restore-readiness check")
if not drill_ok: if not drill_ok:
missing.append("recorded rollback drill") missing.append("recorded rollback drill")
detail = ( detail = f"Installer run {record.get('run_id')} records a {backup.get('type', 'database')} backup."
f"Installer run {record.get('run_id')} records a {backup.get('type', 'database')} backup."
)
if missing: if missing:
detail += " Missing evidence: " + ", ".join(missing) + "." detail += " Missing evidence: " + ", ".join(missing) + "."
else: else:
@@ -749,7 +999,11 @@ def _backup_restore_check() -> dict[str, Any]:
def _restore_drill_evidence(runtime_dir: Path) -> dict[str, object] | None: def _restore_drill_evidence(runtime_dir: Path) -> dict[str, object] | None:
configured = os.environ.get("GOVOPLAN_RESTORE_DRILL_EVIDENCE_PATH") configured = os.environ.get("GOVOPLAN_RESTORE_DRILL_EVIDENCE_PATH")
path = Path(configured).expanduser() if configured else runtime_dir / "restore-drill-evidence.json" path = (
Path(configured).expanduser()
if configured
else runtime_dir / "restore-drill-evidence.json"
)
try: try:
import json import json
@@ -762,7 +1016,12 @@ def _restore_drill_evidence(runtime_dir: Path) -> dict[str, object] | None:
def _deployment_security_check(current_profile: str) -> dict[str, Any]: def _deployment_security_check(current_profile: str) -> dict[str, Any]:
app_env = str(core_settings.app_env or "").lower() app_env = str(core_settings.app_env or "").lower()
if app_env in {"dev", "test", "local"}: 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.") return _check(
"deployment_security",
"HTTP and certificates",
"inactive",
"Local/test profile; HTTPS and secure-cookie checks are deployment-owned.",
)
blockers: list[str] = [] blockers: list[str] = []
if not core_settings.auth_cookie_secure: if not core_settings.auth_cookie_secure:
@@ -770,13 +1029,27 @@ def _deployment_security_check(current_profile: str) -> dict[str, Any]:
cors_origins = _cors_origins() cors_origins = _cors_origins()
if not cors_origins: if not cors_origins:
blockers.append("CORS_ORIGINS is empty") 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): 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") blockers.append("CORS_ORIGINS contains local or wildcard origins")
if not blockers: if not blockers:
return _check("deployment_security", "HTTP and certificates", "ok", f"{current_profile} uses secure cookies and explicit non-local CORS origins.") 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 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)}." detail = f"{profile_label} profile needs deployment HTTP/certificate hardening: {', '.join(blockers)}."
return _check( return _check(
"deployment_security", "deployment_security",
@@ -806,14 +1079,32 @@ def _check(
} }
def _readiness(checks: list[dict[str, Any]], maintenance_mode: dict[str, Any]) -> dict[str, Any]: def _readiness(
checks: list[dict[str, Any]], maintenance_mode: dict[str, Any]
) -> dict[str, Any]:
blockers = [ blockers = [
{"id": check["id"], "label": check["label"], "state": check["state"], "detail": check["detail"]} {
"id": check["id"],
"label": check["label"],
"state": check["state"],
"detail": check["detail"],
}
for check in checks for check in checks
if check.get("readiness_critical") and check.get("state") != "ok" 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): if maintenance_mode.get("enabled") and not any(
blockers.append({"id": "maintenance_mode", "label": "Maintenance mode", "state": "warning", "detail": str(maintenance_mode.get("message") or "Maintenance mode is enabled.")}) 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 { return {
"ready": not blockers, "ready": not blockers,
"blockers": blockers, "blockers": blockers,
@@ -833,11 +1124,19 @@ def _current_profile() -> str:
def _celery_queues() -> list[str]: def _celery_queues() -> list[str]:
return [item.strip() for item in str(core_settings.celery_queues or "").split(",") if item.strip()] return [
item.strip()
for item in str(core_settings.celery_queues or "").split(",")
if item.strip()
]
def _cors_origins() -> list[str]: def _cors_origins() -> list[str]:
return [item.strip() for item in str(core_settings.cors_origins or "").split(",") if item.strip()] 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]]: def _deployment_profiles(current_profile: str) -> list[dict[str, Any]]:
@@ -846,28 +1145,53 @@ def _deployment_profiles(current_profile: str) -> list[dict[str, Any]]:
"id": "local-dev", "id": "local-dev",
"name": "Local development", "name": "Local development",
"current": current_profile == "local-dev", "current": current_profile == "local-dev",
"components": ["FastAPI", "SQLite or local database", "Vite dev server", "local file storage"], "components": [
"FastAPI",
"SQLite or local database",
"Vite dev server",
"local file storage",
],
"fit": "Developer workstation, demos, and module integration work.", "fit": "Developer workstation, demos, and module integration work.",
}, },
{ {
"id": "production-like-dev", "id": "production-like-dev",
"name": "Production-like development", "name": "Production-like development",
"current": current_profile == "production-like-dev", "current": current_profile == "production-like-dev",
"components": ["FastAPI", "Vite dev server", "PostgreSQL", "Redis broker", "Celery worker", "durable local file storage"], "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.", "fit": "Local validation of the production dependency shape without publishing packages.",
}, },
{ {
"id": "single-process", "id": "single-process",
"name": "Small institution", "name": "Small institution",
"current": current_profile == "single-process", "current": current_profile == "single-process",
"components": ["FastAPI", "PostgreSQL", "reverse proxy", "scheduled backups", "local or S3-compatible file storage"], "components": [
"FastAPI",
"PostgreSQL",
"reverse proxy",
"scheduled backups",
"local or S3-compatible file storage",
],
"fit": "Low-volume internal administration without heavy background jobs.", "fit": "Low-volume internal administration without heavy background jobs.",
}, },
{ {
"id": "split-worker", "id": "split-worker",
"name": "Institution platform", "name": "Institution platform",
"current": current_profile == "split-worker", "current": current_profile == "split-worker",
"components": ["FastAPI", "worker process", "Redis broker", "PostgreSQL", "object storage", "health probes"], "components": [
"FastAPI",
"worker process",
"Redis broker",
"PostgreSQL",
"object storage",
"health probes",
],
"fit": "Campaigns, imports, exports, scheduled work, and larger tenant counts.", "fit": "Campaigns, imports, exports, scheduled work, and larger tenant counts.",
}, },
] ]
+44 -2
View File
@@ -52,11 +52,13 @@ def test_module_operational_check_failure_is_isolated() -> None:
raise RuntimeError("secret detail") raise RuntimeError("secret detail")
result = routes._module_operational_checks( result = routes._module_operational_checks(
_Registry(OperationalCheckProviderRegistration( _Registry(
OperationalCheckProviderRegistration(
module_id="example", module_id="example",
check_id="example.failed", check_id="example.failed",
provider=provider, provider=provider,
)), )
),
force=True, force=True,
)[0] )[0]
@@ -82,3 +84,43 @@ def test_shared_runtime_cluster_missing_replicas_blocks_readiness() -> None:
assert check["state"] == "error" assert check["state"] == "error"
assert check["readiness_critical"] is True assert check["readiness_critical"] is True
assert check["metrics"]["recovery_required"] == 1 assert check["metrics"]["recovery_required"] == 1
def test_shared_runtime_cluster_skew_or_unserved_queue_blocks_readiness() -> None:
check = routes._runtime_cluster_check(
{
"available": True,
"state_profile": "shared",
"nodes": [],
"expected": {"api": 2, "worker": 2},
"active": {"api": 2, "worker": 2},
"composition": {"skewed": True},
"software_versions": {"skewed": False},
"queues": {"missing": ["calendar"]},
"recovery": {"requires_attention": 0},
}
)
assert check["state"] == "error"
assert check["readiness_critical"] is True
assert check["metrics"]["composition_skewed"] is True
assert check["metrics"]["missing_queues"] == 1
def test_database_capacity_check_enforces_shared_rendered_budget(
monkeypatch,
) -> None:
monkeypatch.setattr(routes.core_settings, "state_profile", "shared")
monkeypatch.setattr(routes.core_settings, "database_connection_limit", 100)
monkeypatch.setattr(routes.core_settings, "database_connection_reserve", 10)
monkeypatch.setattr(routes.core_settings, "database_connection_available", 90)
monkeypatch.setattr(routes.core_settings, "database_connection_peak", 70)
healthy = routes._database_capacity_check()
assert healthy["state"] == "ok"
assert healthy["metrics"]["peak"] == 70
monkeypatch.setattr(routes.core_settings, "database_connection_peak", 91)
overrun = routes._database_capacity_check()
assert overrun["state"] == "error"
assert overrun["readiness_critical"] is True
+18
View File
@@ -117,6 +117,22 @@ export type OpsRuntimeCluster = {
state_profile: string; state_profile: string;
expected?: { api: number; worker: number }; expected?: { api: number; worker: number };
active?: { api: number; worker: number }; active?: { api: number; worker: number };
composition?: {
expected_hash?: string | null;
active_hashes: string[];
unexpected_nodes: string[];
skewed: boolean;
};
software_versions?: {
active: string[];
skewed: boolean;
};
queues?: {
expected: string[];
active: string[];
missing: string[];
worker_pools: string[];
};
nodes: OpsRuntimeNode[]; nodes: OpsRuntimeNode[];
recovery: { recovery: {
operations: OpsRecoveryOperation[]; operations: OpsRecoveryOperation[];
@@ -137,6 +153,8 @@ export type OpsStatus = {
message?: string | null; message?: string | null;
}; };
database_url: string; database_url: string;
database_connection_peak?: number | null;
database_connection_available?: number | null;
file_storage_backend: string; file_storage_backend: string;
worker_metrics: { worker_metrics: {
workers?: number; workers?: number;
+19
View File
@@ -125,6 +125,7 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
<MetricCard label="i18n:govoplan-ops.redis.5eaa1f2f" value={status?.summary.redis_url ? "configured" : "-"} tone={status?.summary.celery_enabled ? "info" : "neutral"} detail={status?.summary.redis_url ?? "-"} /> <MetricCard label="i18n:govoplan-ops.redis.5eaa1f2f" value={status?.summary.redis_url ? "configured" : "-"} tone={status?.summary.celery_enabled ? "info" : "neutral"} detail={status?.summary.redis_url ?? "-"} />
<MetricCard label="i18n:govoplan-ops.warnings.1430f976" value={warningCount + errorCount} tone={errorCount ? "danger" : warningCount ? "warning" : "good"} detail="i18n:govoplan-ops.current_health_checks.7830bccf" /> <MetricCard label="i18n:govoplan-ops.warnings.1430f976" value={warningCount + errorCount} tone={errorCount ? "danger" : warningCount ? "warning" : "good"} detail="i18n:govoplan-ops.current_health_checks.7830bccf" />
<MetricCard label="Runtime nodes" value={status?.summary.runtime_node_count ?? 0} tone={status?.runtime_cluster.available ? "good" : "danger"} detail={runtimeNodeMetricDetail(status)} /> <MetricCard label="Runtime nodes" value={status?.summary.runtime_node_count ?? 0} tone={status?.runtime_cluster.available ? "good" : "danger"} detail={runtimeNodeMetricDetail(status)} />
<MetricCard label="Database capacity" value={databaseCapacityValue(status)} tone={databaseCapacityTone(status)} detail="Peak pooled connections / available connections" />
<MetricCard label="Recovery" value={status?.summary.recovery_required_count ?? 0} tone={status?.summary.recovery_required_count ? "danger" : "good"} detail="Operations requiring recovery attention" /> <MetricCard label="Recovery" value={status?.summary.recovery_required_count ?? 0} tone={status?.summary.recovery_required_count ? "danger" : "good"} detail="Operations requiring recovery attention" />
<MetricCard label="Provider bindings" value={status?.governance.summary.configured_external_provider_count ?? 0} tone={status?.governance.summary.provider_attention_count ? "warning" : "good"} detail={`${status?.governance.summary.provider_attention_count ?? 0} requiring attention`} /> <MetricCard label="Provider bindings" value={status?.governance.summary.configured_external_provider_count ?? 0} tone={status?.governance.summary.provider_attention_count ? "warning" : "good"} detail={`${status?.governance.summary.provider_attention_count ?? 0} requiring attention`} />
</div> </div>
@@ -480,6 +481,24 @@ function runtimeNodeMetricDetail(status: OpsStatus | null): string {
return `${active.api}/${expected.api} API · ${active.worker}/${expected.worker} workers`; return `${active.api}/${expected.api} API · ${active.worker}/${expected.worker} workers`;
} }
function databaseCapacityValue(status: OpsStatus | null): string {
const peak = status?.summary.database_connection_peak;
const available = status?.summary.database_connection_available;
return typeof peak === "number" && typeof available === "number"
? `${peak} / ${available}`
: "not declared";
}
function databaseCapacityTone(status: OpsStatus | null): "good" | "warning" | "danger" {
const peak = status?.summary.database_connection_peak;
const available = status?.summary.database_connection_available;
if (typeof peak !== "number" || typeof available !== "number") {
return status?.runtime_cluster.state_profile === "shared" ? "danger" : "warning";
}
if (peak > available) return "danger";
return peak / available >= 0.8 ? "warning" : "good";
}
function workerMetricDetail(status: OpsStatus | null): string { function workerMetricDetail(status: OpsStatus | null): string {
if (!status?.summary.celery_enabled) return "i18n:govoplan-ops.celery_worker_setting.323d7737"; if (!status?.summary.celery_enabled) return "i18n:govoplan-ops.celery_worker_setting.323d7737";
const metrics = status.summary.worker_metrics; const metrics = status.summary.worker_metrics;