"""Container healthcheck for the single-container layout. Milestone 422 step 5. Exit 0 healthy, non-zero unhealthy. ## Why this is not just "does :8080 answer" In the multi-service stack every service has its OWN healthcheck, so a dead worker turns that service unhealthy while web stays green — docker knows which part failed. Collapsing them into one container collapses that too: a web-only check would report a perfectly healthy container while every lane inside it had crashed and been abandoned by supervisord after its retries. So this asserts both halves: hypercorn answers, AND every lane this container was configured to run is answering the broker. ## What it deliberately does NOT do It does not read the database, and it does not consult the `enabled` flag. A DISABLED lane still has a running process with its consumers cancelled (see the config generator), so it answers `inspect` and is healthy. Health is "is the process alive", and whether it should be consuming is a settings question the reconcile owns — conflating them would make turning a lane off in the UI mark the container unhealthy. It also cannot distinguish "the broker is down" from "every lane is down", and reports unhealthy either way. That is correct: a container that cannot reach its broker is not serving, whichever half is at fault. """ from __future__ import annotations import sys import urllib.error import urllib.request WEB_URL = "http://localhost:8080/api/health" WEB_TIMEOUT = 5.0 def _web_ok() -> tuple[bool, str]: try: with urllib.request.urlopen(WEB_URL, timeout=WEB_TIMEOUT) as resp: if resp.status == 200: return True, "" return False, f"web returned {resp.status}" except (urllib.error.URLError, OSError) as exc: return False, f"web unreachable: {exc}" def _lanes_ok() -> tuple[bool, str]: from ..services.worker_control import inspect_lanes_sync from ..services.worker_lanes import LANES # Every lane, ml included: one image carries them all since step 6, and a # disabled lane still runs a process (consumers cancelled), so it answers # inspect and is healthy. Health is "is the process alive"; whether it # should be consuming is the reconcile's business. expected = {lane.name for lane in LANES} live = inspect_lanes_sync() missing = sorted(n for n in expected if not live[n].present) if missing: return False, "lanes not answering: " + ", ".join(missing) return True, "" def main(argv: list[str] | None = None) -> int: ok, detail = _web_ok() if not ok: print(detail, file=sys.stderr) return 1 ok, detail = _lanes_ok() if not ok: print(detail, file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())