Adds `GET /api/v1/system/health`, a structured read-only health surface for
automated readiness checks, and keeps `/health` as the cheap liveness probe.
webui/system_health.py composes a DTO from fail-soft dependency probes: the
control-plane database, the local checkout, and — opt-in via `?deep=1` — live
Gitea reachability, each carrying status, reason, and probe latency. Required
probes drive readiness; the optional Gitea probe can only degrade overall
status, because local inventory stays serveable when the remote is
unreachable. A probe that did not run leaves readiness incomplete rather than
silently passing.
Read-only throughout: the control-plane database is opened through a `mode=ro`
URI because `ControlPlaneDB.__init__` creates directories and runs migrations,
which a health check must never do. No restart or reload control is exposed;
those are Phase 2 and #630 forbids process-kill recovery.
No unproven claims: `stale_runtime.mutation_safe` is true only when the
runtime, checkout, and remote commits are all known and equal, and MCP
namespaces always report `unproven` because a web process cannot exercise the
IDE-managed client path (#543). Probe details are redacted at the browser
boundary — URLs lose userinfo and query strings, credential-shaped text is
masked.
`/health` is expanded additively: every MVP key is retained, plus `started_at`,
`uptime_seconds`, and a pointer to the versioned API. The versioned route
returns 503 when not ready so automation can branch on the status code alone.
Verified at master 9eb0f29: focused file 40 passed / 11 subtests; `-k "webui or
health"` 230 passed / 159 subtests; full suite 4358 passed with the 11
pre-existing master-drift failures unchanged from the clean-master baseline.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
683 lines
23 KiB
Python
683 lines
23 KiB
Python
"""Read-only system-health model for the operator console API (#634).
|
|
|
|
`/health` answers liveness only. Operators automating readiness checks need a
|
|
structured view of *why* the control plane is or is not usable: which
|
|
dependencies answered, how long they took, what version of the code is running,
|
|
and whether the runtime is stale relative to its remote.
|
|
|
|
Three rules shape this module.
|
|
|
|
* **Read-only.** Every probe opens its subject read-only. The control-plane
|
|
database is opened through a ``mode=ro`` URI so a health check can never
|
|
create or migrate a schema, and no probe writes, restarts, or reloads
|
|
anything — restart controls are Phase 2, and #630 forbids process-kill
|
|
recovery outright.
|
|
* **Fail-soft.** A dependency that is unreachable is a *status*, not an
|
|
exception. Probes catch their own failures and report them as a degraded or
|
|
down entry carrying a reason.
|
|
* **Never claim more than was proven.** Readiness is derived only from probes
|
|
that actually ran, ``mutation_safe`` stays false unless the parity commits are
|
|
known and equal, and an MCP namespace is reported unproven because a web
|
|
process cannot exercise the IDE-managed client path (#543).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
import control_plane_db
|
|
import mcp_namespace_health
|
|
from gitea_auth import api_request, get_auth_header, gitea_url
|
|
|
|
from webui.project_registry import load_registry
|
|
|
|
SERVICE_NAME = "mcp-control-plane-webui"
|
|
API_PATH = "/api/v1/system/health"
|
|
|
|
STATUS_OK = "ok"
|
|
STATUS_DEGRADED = "degraded"
|
|
STATUS_DOWN = "down"
|
|
STATUS_SKIPPED = "skipped"
|
|
STATUS_UNPROVEN = "unproven"
|
|
|
|
# Statuses that count as a healthy answer from a probe.
|
|
_HEALTHY_STATUSES = frozenset({STATUS_OK})
|
|
# Statuses meaning "this probe did not run", as opposed to "it ran and failed".
|
|
_NOT_RUN_STATUSES = frozenset({STATUS_SKIPPED})
|
|
|
|
_DEEP_PROBE_TTL_ENV = "WEBUI_HEALTH_PROBE_TTL_SECONDS"
|
|
_DEFAULT_DEEP_PROBE_TTL = 15.0
|
|
_GITEA_PROBE_TIMEOUT_SECONDS = 5.0
|
|
|
|
_OFFLINE_ENV = "WEBUI_TEST_OFFLINE"
|
|
|
|
# Credential-shaped material that must never reach the browser, mirroring the
|
|
# forbidden client patterns in webui/deployment_boundary.py.
|
|
_SECRET_RE = re.compile(
|
|
r"(?i)\b(token|password|passwd|secret|authorization|bearer)\b\s*[:=]?\s*\S+"
|
|
)
|
|
_LONG_OPAQUE_RE = re.compile(r"\b[A-Za-z0-9_\-]{32,}\b")
|
|
|
|
# Captured once at import so uptime measures this process, not the request.
|
|
_STARTED_AT = datetime.now(timezone.utc)
|
|
_STARTED_MONOTONIC = time.monotonic()
|
|
|
|
# TTL cache for the expensive (network) probe only.
|
|
_deep_cache: dict[str, tuple[float, "DependencyProbe"]] = {}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DependencyProbe:
|
|
"""One dependency check, fail-soft, with its own latency."""
|
|
|
|
name: str
|
|
kind: str
|
|
status: str
|
|
detail: str
|
|
required: bool
|
|
latency_ms: float | None = None
|
|
metadata: dict[str, Any] | None = None
|
|
|
|
@property
|
|
def healthy(self) -> bool:
|
|
return self.status in _HEALTHY_STATUSES
|
|
|
|
@property
|
|
def ran(self) -> bool:
|
|
return self.status not in _NOT_RUN_STATUSES
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VersionInfo:
|
|
git_sha: str | None
|
|
git_describe: str | None
|
|
control_plane_schema_version: int | None
|
|
python_version: str
|
|
known: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StaleRuntime:
|
|
"""Parity between the running code, the checkout, and the remote.
|
|
|
|
``mutation_safe`` is deliberately conservative: unknown is not safe.
|
|
"""
|
|
|
|
daemon_head: str | None
|
|
checkout_head: str | None
|
|
remote_head: str | None
|
|
stale: bool
|
|
determinable: bool
|
|
mutation_safe: bool
|
|
reasons: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SystemHealthSnapshot:
|
|
status: str
|
|
ready: bool
|
|
readiness_complete: bool
|
|
readiness_reasons: tuple[str, ...]
|
|
service: str
|
|
mode: str
|
|
version: VersionInfo
|
|
started_at: str
|
|
uptime_seconds: float
|
|
timestamp: str
|
|
deep_probes_requested: bool
|
|
dependencies: tuple[DependencyProbe, ...]
|
|
mcp_namespaces: tuple[dict[str, Any], ...]
|
|
stale_runtime: StaleRuntime
|
|
probe_errors: tuple[str, ...] = ()
|
|
|
|
|
|
def process_uptime() -> tuple[str, float]:
|
|
"""Process start timestamp and uptime — in-memory, safe for `/health`."""
|
|
return _STARTED_AT.isoformat(), round(time.monotonic() - _STARTED_MONOTONIC, 3)
|
|
|
|
|
|
def _offline() -> bool:
|
|
return (os.environ.get(_OFFLINE_ENV) or "").strip().lower() in {"1", "true", "yes"}
|
|
|
|
|
|
def _repo_root() -> Path:
|
|
override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
|
|
if override:
|
|
return Path(override).resolve()
|
|
return Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def _deep_probe_ttl() -> float:
|
|
raw = (os.environ.get(_DEEP_PROBE_TTL_ENV) or "").strip()
|
|
if not raw:
|
|
return _DEFAULT_DEEP_PROBE_TTL
|
|
try:
|
|
value = float(raw)
|
|
except ValueError:
|
|
return _DEFAULT_DEEP_PROBE_TTL
|
|
return value if value >= 0 else _DEFAULT_DEEP_PROBE_TTL
|
|
|
|
|
|
def redact(text: str) -> str:
|
|
"""Strip credential-shaped material from operator-visible probe text.
|
|
|
|
Probe details carry exception strings, and an exception raised by an HTTP
|
|
client can quote the request that failed. Redaction happens here, at the
|
|
boundary where those strings become part of a browser-bound payload.
|
|
"""
|
|
if not text:
|
|
return ""
|
|
cleaned = _redact_urls(text)
|
|
cleaned = _SECRET_RE.sub(lambda m: f"{m.group(1)}=[redacted]", cleaned)
|
|
return _LONG_OPAQUE_RE.sub("[redacted]", cleaned)
|
|
|
|
|
|
def _redact_urls(text: str) -> str:
|
|
return re.sub(r"https?://\S+", lambda m: redact_url(m.group(0)), text)
|
|
|
|
|
|
def redact_url(url: str) -> str:
|
|
"""Reduce a URL to scheme://host/path — no userinfo, no query, no fragment."""
|
|
try:
|
|
parts = urlsplit(url)
|
|
except ValueError:
|
|
return "[redacted-url]"
|
|
if not parts.scheme or not parts.hostname:
|
|
return "[redacted-url]"
|
|
netloc = parts.hostname
|
|
if parts.port:
|
|
netloc = f"{netloc}:{parts.port}"
|
|
return urlunsplit((parts.scheme, netloc, parts.path, "", ""))
|
|
|
|
|
|
def _git(repo: Path, *args: str) -> str | None:
|
|
try:
|
|
completed = subprocess.run(
|
|
["git", "-C", str(repo), *args],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
timeout=10,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
if completed.returncode != 0:
|
|
return None
|
|
return (completed.stdout or "").strip() or None
|
|
|
|
|
|
def _load_version(repo: Path, *, schema_version: int | None) -> VersionInfo:
|
|
import platform
|
|
|
|
git_sha = None if _offline() else _git(repo, "rev-parse", "HEAD")
|
|
describe = None if _offline() else _git(repo, "describe", "--tags", "--always")
|
|
return VersionInfo(
|
|
git_sha=git_sha,
|
|
git_describe=describe,
|
|
control_plane_schema_version=schema_version,
|
|
python_version=platform.python_version(),
|
|
known=bool(git_sha),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dependency probes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _elapsed_ms(started: float) -> float:
|
|
return round((time.monotonic() - started) * 1000, 3)
|
|
|
|
|
|
def probe_control_plane_db(db_path: str | None = None) -> DependencyProbe:
|
|
"""Read-only reachability check for the control-plane SQLite substrate.
|
|
|
|
Opened through a ``mode=ro`` URI on purpose: ``ControlPlaneDB.__init__``
|
|
creates directories and runs schema migrations, which a health check must
|
|
never do.
|
|
"""
|
|
path = (db_path or control_plane_db.default_db_path()).strip()
|
|
started = time.monotonic()
|
|
metadata: dict[str, Any] = {"path": path}
|
|
|
|
def _result(status: str, detail: str) -> DependencyProbe:
|
|
return DependencyProbe(
|
|
name="control_plane_db",
|
|
kind="sqlite",
|
|
status=status,
|
|
detail=detail,
|
|
required=True,
|
|
latency_ms=_elapsed_ms(started),
|
|
metadata=metadata,
|
|
)
|
|
|
|
if not path or not os.path.exists(path):
|
|
return _result(STATUS_DOWN, "control-plane database file does not exist yet")
|
|
try:
|
|
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5)
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT value FROM schema_meta WHERE key = 'schema_version'"
|
|
).fetchone()
|
|
leases = conn.execute(
|
|
"SELECT COUNT(*) FROM leases WHERE status = 'active'"
|
|
).fetchone()
|
|
finally:
|
|
conn.close()
|
|
except sqlite3.Error as exc:
|
|
return _result(STATUS_DOWN, redact(f"control-plane database unreadable: {exc}"))
|
|
|
|
schema_version = int(row[0]) if row and str(row[0]).isdigit() else None
|
|
metadata["schema_version"] = schema_version
|
|
metadata["active_leases"] = int(leases[0]) if leases else None
|
|
if schema_version is None:
|
|
return _result(
|
|
STATUS_DEGRADED, "control-plane database has no recorded schema version"
|
|
)
|
|
if schema_version != control_plane_db.SCHEMA_VERSION:
|
|
return _result(
|
|
STATUS_DEGRADED,
|
|
f"control-plane schema version {schema_version} does not match the "
|
|
f"version this code expects ({control_plane_db.SCHEMA_VERSION})",
|
|
)
|
|
return _result(STATUS_OK, f"schema v{schema_version} readable")
|
|
|
|
|
|
def probe_repository(repo: Path) -> DependencyProbe:
|
|
"""Local checkout reachability — required, cheap, no network."""
|
|
started = time.monotonic()
|
|
metadata: dict[str, Any] = {"repo_root": str(repo)}
|
|
if _offline():
|
|
return DependencyProbe(
|
|
name="repository",
|
|
kind="git",
|
|
status=STATUS_SKIPPED,
|
|
detail=f"{_OFFLINE_ENV} is set; git probe skipped",
|
|
required=True,
|
|
latency_ms=_elapsed_ms(started),
|
|
metadata=metadata,
|
|
)
|
|
head = _git(repo, "rev-parse", "HEAD")
|
|
if not head:
|
|
return DependencyProbe(
|
|
name="repository",
|
|
kind="git",
|
|
status=STATUS_DOWN,
|
|
detail=f"HEAD could not be read at {repo}",
|
|
required=True,
|
|
latency_ms=_elapsed_ms(started),
|
|
metadata=metadata,
|
|
)
|
|
branch = _git(repo, "rev-parse", "--abbrev-ref", "HEAD")
|
|
metadata["head"] = head
|
|
metadata["branch"] = branch
|
|
return DependencyProbe(
|
|
name="repository",
|
|
kind="git",
|
|
status=STATUS_OK,
|
|
detail=f"checkout readable at {branch or 'detached HEAD'}",
|
|
required=True,
|
|
latency_ms=_elapsed_ms(started),
|
|
metadata=metadata,
|
|
)
|
|
|
|
|
|
def probe_gitea(host: str) -> DependencyProbe:
|
|
"""Live Gitea reachability. Expensive (network), so opt-in via ``deep``.
|
|
|
|
Optional by design: the console stays useful for local inventory when the
|
|
remote is unreachable, so a failure here degrades status without claiming
|
|
the process itself is unready.
|
|
"""
|
|
started = time.monotonic()
|
|
metadata: dict[str, Any] = {"host": host}
|
|
|
|
def _failure(status: str, detail: str) -> DependencyProbe:
|
|
return DependencyProbe(
|
|
name="gitea",
|
|
kind="http",
|
|
status=status,
|
|
detail=detail,
|
|
required=False,
|
|
latency_ms=_elapsed_ms(started),
|
|
metadata=metadata,
|
|
)
|
|
|
|
if not host:
|
|
return _failure(STATUS_DEGRADED, "no Gitea host is configured in the registry")
|
|
try:
|
|
auth = get_auth_header(host)
|
|
except Exception as exc: # noqa: BLE001 — credential guards are a status here
|
|
return _failure(STATUS_DEGRADED, redact(f"credential lookup refused: {exc}"))
|
|
if not auth:
|
|
return _failure(STATUS_DEGRADED, f"no credentials available for {host}")
|
|
|
|
url = gitea_url(host, "/api/v1/version")
|
|
metadata["endpoint"] = redact_url(url)
|
|
try:
|
|
data = api_request("GET", url, auth, timeout=_GITEA_PROBE_TIMEOUT_SECONDS)
|
|
except Exception as exc: # noqa: BLE001 — a down dependency is a status
|
|
return _failure(STATUS_DOWN, redact(f"Gitea probe failed: {exc}"))
|
|
|
|
if isinstance(data, dict) and data.get("version"):
|
|
metadata["gitea_version"] = str(data["version"])
|
|
return DependencyProbe(
|
|
name="gitea",
|
|
kind="http",
|
|
status=STATUS_OK,
|
|
detail=f"{host} reachable",
|
|
required=False,
|
|
latency_ms=_elapsed_ms(started),
|
|
metadata=metadata,
|
|
)
|
|
|
|
|
|
def _skipped_gitea(host: str) -> DependencyProbe:
|
|
return DependencyProbe(
|
|
name="gitea",
|
|
kind="http",
|
|
status=STATUS_SKIPPED,
|
|
detail="network probe not requested; call with ?deep=1 to run it",
|
|
required=False,
|
|
latency_ms=None,
|
|
metadata={"host": host},
|
|
)
|
|
|
|
|
|
def namespace_summaries() -> tuple[dict[str, Any], ...]:
|
|
"""Declared MCP namespaces, each honestly reported as unproven.
|
|
|
|
The web process runs outside the IDE-managed MCP client, so it cannot
|
|
invoke a namespace tool. Per #543 only a ``client_namespace`` probe proves
|
|
that path, and inventing a healthy verdict here is exactly the false claim
|
|
the mutation gates exist to prevent.
|
|
"""
|
|
rows: list[dict[str, Any]] = []
|
|
for namespace, required_tool in sorted(
|
|
mcp_namespace_health.REQUIRED_NAMESPACE_TOOLS.items()
|
|
):
|
|
classification = mcp_namespace_health.classify_namespace_probe(
|
|
namespace,
|
|
required_tool=required_tool,
|
|
probe_result=None,
|
|
probe_source=mcp_namespace_health.PROBE_SOURCE_UNKNOWN,
|
|
)
|
|
rows.append(
|
|
{
|
|
"namespace": namespace,
|
|
"required_tool": required_tool,
|
|
"status": STATUS_UNPROVEN,
|
|
"ide_namespace_proven": bool(classification.get("ide_namespace_proven")),
|
|
"reason": (
|
|
"the web console cannot invoke the IDE-managed MCP client; "
|
|
"namespace health must be proven with a client_namespace "
|
|
"probe (#543)"
|
|
),
|
|
"error_type": classification.get("error_type"),
|
|
}
|
|
)
|
|
return tuple(rows)
|
|
|
|
|
|
def assess_stale_runtime(
|
|
repo: Path,
|
|
*,
|
|
daemon_head: str | None = None,
|
|
git_reader: Callable[..., str | None] | None = None,
|
|
) -> StaleRuntime:
|
|
"""Three-way parity view: running code, local checkout, remote-tracking ref.
|
|
|
|
``mutation_safe`` requires all three to be known and equal. Anything less —
|
|
including "the remote ref was never fetched" — is reported as not safe with
|
|
a reason, so an operator never reads an unproven green.
|
|
"""
|
|
reader = git_reader or (lambda *args: _git(repo, *args))
|
|
reasons: list[str] = []
|
|
# The offline switch suppresses real subprocess calls; an explicitly
|
|
# injected reader is already a substitute for them and is always used.
|
|
offline = _offline() and git_reader is None
|
|
|
|
checkout_head = None if offline else reader("rev-parse", "HEAD")
|
|
remote_head = None if offline else reader("rev-parse", "@{upstream}")
|
|
if offline:
|
|
reasons.append(f"{_OFFLINE_ENV} is set; parity commits were not read")
|
|
else:
|
|
if checkout_head is None:
|
|
reasons.append("local checkout HEAD could not be read")
|
|
if remote_head is None:
|
|
reasons.append(
|
|
"no remote-tracking commit is known for the current branch; "
|
|
"remote staleness is indeterminate (no fetch is performed here)"
|
|
)
|
|
|
|
effective_daemon = daemon_head if daemon_head is not None else checkout_head
|
|
if daemon_head is None:
|
|
reasons.append(
|
|
"the running MCP daemon's startup commit is not observable from the "
|
|
"web process; the checkout commit is reported in its place"
|
|
)
|
|
|
|
determinable = bool(checkout_head and remote_head and effective_daemon)
|
|
stale = bool(
|
|
determinable and len({checkout_head, remote_head, effective_daemon}) > 1
|
|
)
|
|
if stale:
|
|
reasons.append(
|
|
"runtime, checkout, and remote commits disagree; restart the MCP "
|
|
"server after updating the checkout before trusting capability gates"
|
|
)
|
|
|
|
return StaleRuntime(
|
|
daemon_head=effective_daemon,
|
|
checkout_head=checkout_head,
|
|
remote_head=remote_head,
|
|
stale=stale,
|
|
determinable=determinable,
|
|
mutation_safe=bool(determinable and not stale),
|
|
reasons=tuple(reasons),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Snapshot assembly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _default_host() -> str:
|
|
registry = load_registry()
|
|
if not registry.projects:
|
|
return ""
|
|
raw = registry.projects[0].remote_host
|
|
parts = urlsplit(raw.strip())
|
|
return parts.netloc or raw.strip().rstrip("/")
|
|
|
|
|
|
def _aggregate(
|
|
probes: tuple[DependencyProbe, ...],
|
|
) -> tuple[str, bool, bool, tuple[str, ...]]:
|
|
"""Fold probe results into overall status and readiness.
|
|
|
|
Required probes drive readiness; optional probes can only degrade status.
|
|
A probe that did not run leaves readiness incomplete rather than passing.
|
|
"""
|
|
reasons: list[str] = []
|
|
required = [probe for probe in probes if probe.required]
|
|
unrun_required = [probe for probe in required if not probe.ran]
|
|
failed_required = [probe for probe in required if probe.ran and not probe.healthy]
|
|
failed_optional = [
|
|
probe
|
|
for probe in probes
|
|
if not probe.required and probe.ran and not probe.healthy
|
|
]
|
|
|
|
for probe in unrun_required:
|
|
reasons.append(
|
|
f"required dependency '{probe.name}' was not probed: {probe.detail}"
|
|
)
|
|
for probe in failed_required:
|
|
reasons.append(
|
|
f"required dependency '{probe.name}' is {probe.status}: {probe.detail}"
|
|
)
|
|
for probe in failed_optional:
|
|
reasons.append(
|
|
f"optional dependency '{probe.name}' is {probe.status}: {probe.detail}"
|
|
)
|
|
|
|
readiness_complete = not unrun_required
|
|
ready = readiness_complete and not failed_required
|
|
|
|
if any(probe.status == STATUS_DOWN for probe in failed_required):
|
|
status = STATUS_DOWN
|
|
elif failed_required or failed_optional or unrun_required:
|
|
status = STATUS_DEGRADED
|
|
else:
|
|
status = STATUS_OK
|
|
return status, ready, readiness_complete, tuple(reasons)
|
|
|
|
|
|
def load_system_health(
|
|
*,
|
|
deep: bool = False,
|
|
host: str | None = None,
|
|
probes: tuple[DependencyProbe, ...] | None = None,
|
|
daemon_head: str | None = None,
|
|
use_cache: bool = True,
|
|
) -> SystemHealthSnapshot:
|
|
"""Assemble the read-only system-health snapshot.
|
|
|
|
``deep=True`` adds the network probe against Gitea; its result is cached for
|
|
a short TTL so repeated dashboard polls do not amplify into remote load.
|
|
"""
|
|
repo = _repo_root()
|
|
probe_errors: list[str] = []
|
|
|
|
if probes is None:
|
|
collected: list[DependencyProbe] = []
|
|
for probe_fn in (
|
|
lambda: probe_control_plane_db(),
|
|
lambda: probe_repository(repo),
|
|
):
|
|
try:
|
|
collected.append(probe_fn())
|
|
except Exception as exc: # noqa: BLE001 — a probe must not 500 the API
|
|
probe_errors.append(redact(f"probe raised: {exc}"))
|
|
resolved_host = host if host is not None else _default_host()
|
|
if deep and not _offline():
|
|
collected.append(_cached_gitea_probe(resolved_host, use_cache=use_cache))
|
|
else:
|
|
collected.append(_skipped_gitea(resolved_host))
|
|
probes = tuple(collected)
|
|
|
|
status, ready, readiness_complete, reasons = _aggregate(probes)
|
|
stale = assess_stale_runtime(repo, daemon_head=daemon_head)
|
|
if stale.stale:
|
|
if status == STATUS_OK:
|
|
status = STATUS_DEGRADED
|
|
reasons = reasons + (
|
|
"runtime is stale relative to its remote-tracking commit",
|
|
)
|
|
|
|
db_probe = next((p for p in probes if p.name == "control_plane_db"), None)
|
|
schema_version = None
|
|
if db_probe and db_probe.metadata:
|
|
schema_version = db_probe.metadata.get("schema_version")
|
|
|
|
return SystemHealthSnapshot(
|
|
status=status,
|
|
ready=ready,
|
|
readiness_complete=readiness_complete,
|
|
readiness_reasons=reasons,
|
|
service=SERVICE_NAME,
|
|
mode="read-only",
|
|
version=_load_version(repo, schema_version=schema_version),
|
|
started_at=_STARTED_AT.isoformat(),
|
|
uptime_seconds=round(time.monotonic() - _STARTED_MONOTONIC, 3),
|
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
deep_probes_requested=deep,
|
|
dependencies=probes,
|
|
mcp_namespaces=namespace_summaries(),
|
|
stale_runtime=stale,
|
|
probe_errors=tuple(probe_errors),
|
|
)
|
|
|
|
|
|
def _cached_gitea_probe(host: str, *, use_cache: bool = True) -> DependencyProbe:
|
|
ttl = _deep_probe_ttl()
|
|
now = time.monotonic()
|
|
if use_cache and ttl > 0:
|
|
cached = _deep_cache.get(host)
|
|
if cached and (now - cached[0]) < ttl:
|
|
return cached[1]
|
|
probe = probe_gitea(host)
|
|
if use_cache and ttl > 0:
|
|
_deep_cache[host] = (now, probe)
|
|
return probe
|
|
|
|
|
|
def clear_probe_cache() -> None:
|
|
"""Drop cached deep-probe results (tests and operator-forced refresh)."""
|
|
_deep_cache.clear()
|
|
|
|
|
|
def probe_to_dict(probe: DependencyProbe) -> dict[str, Any]:
|
|
return {
|
|
"name": probe.name,
|
|
"kind": probe.kind,
|
|
"status": probe.status,
|
|
"detail": probe.detail,
|
|
"required": probe.required,
|
|
"healthy": probe.healthy,
|
|
"latency_ms": probe.latency_ms,
|
|
"metadata": dict(probe.metadata or {}),
|
|
}
|
|
|
|
|
|
def snapshot_to_dict(snapshot: SystemHealthSnapshot) -> dict[str, Any]:
|
|
return {
|
|
"status": snapshot.status,
|
|
"service": snapshot.service,
|
|
"mode": snapshot.mode,
|
|
"api": API_PATH,
|
|
"timestamp": snapshot.timestamp,
|
|
"readiness": {
|
|
"ready": snapshot.ready,
|
|
"complete": snapshot.readiness_complete,
|
|
"reasons": list(snapshot.readiness_reasons),
|
|
},
|
|
"version": {
|
|
"git_sha": snapshot.version.git_sha,
|
|
"git_describe": snapshot.version.git_describe,
|
|
"control_plane_schema_version": (
|
|
snapshot.version.control_plane_schema_version
|
|
),
|
|
"python_version": snapshot.version.python_version,
|
|
"known": snapshot.version.known,
|
|
},
|
|
"process": {
|
|
"started_at": snapshot.started_at,
|
|
"uptime_seconds": snapshot.uptime_seconds,
|
|
},
|
|
"deep_probes_requested": snapshot.deep_probes_requested,
|
|
"dependencies": [probe_to_dict(probe) for probe in snapshot.dependencies],
|
|
"mcp_namespaces": [dict(row) for row in snapshot.mcp_namespaces],
|
|
"stale_runtime": {
|
|
"daemon_head": snapshot.stale_runtime.daemon_head,
|
|
"checkout_head": snapshot.stale_runtime.checkout_head,
|
|
"remote_head": snapshot.stale_runtime.remote_head,
|
|
"stale": snapshot.stale_runtime.stale,
|
|
"determinable": snapshot.stale_runtime.determinable,
|
|
"mutation_safe": snapshot.stale_runtime.mutation_safe,
|
|
"reasons": list(snapshot.stale_runtime.reasons),
|
|
},
|
|
"probe_errors": list(snapshot.probe_errors),
|
|
}
|