From 6b29239cc08f1c034e2fe871c767c91046a6a5ac Mon Sep 17 00:00:00 2001 From: fusion44 Date: Sun, 12 Jul 2026 16:46:47 +0200 Subject: [PATCH] refactor(api): hoist api_startup_status into a shared leaf module Move the in-process startup-state singleton out of app.main into app.api.startup_status so app.system can read it without a circular import. No behavior change - main mutates the same shared object. Also lands the design spec and implementation plan for the system/health endpoint work (#145). Co-Authored-By: Claude Fable 5 --- app/api/startup_status.py | 7 + app/main.py | 6 +- .../2026-07-12-system-health-endpoint.md | 544 ++++++++++++++++++ ...026-07-12-system-health-endpoint-design.md | 144 +++++ tests/test_startup_status.py | 6 + 5 files changed, 703 insertions(+), 4 deletions(-) create mode 100644 app/api/startup_status.py create mode 100644 docs/superpowers/plans/2026-07-12-system-health-endpoint.md create mode 100644 docs/superpowers/specs/2026-07-12-system-health-endpoint-design.md create mode 100644 tests/test_startup_status.py diff --git a/app/api/startup_status.py b/app/api/startup_status.py new file mode 100644 index 0000000..b6ffe26 --- /dev/null +++ b/app/api/startup_status.py @@ -0,0 +1,7 @@ +from app.api.models import ApiStartupStatus + +# Single in-process source of truth for bitcoin/lightning startup state. +# Mutated by the startup tasks in app.main; read by the /system/health endpoint +# and the WebSocket warmup. Kept in this leaf module so app.system can import it +# without a circular dependency on app.main. +api_startup_status = ApiStartupStatus() diff --git a/app/main.py b/app/main.py index 5340a82..7626ed5 100644 --- a/app/main.py +++ b/app/main.py @@ -18,7 +18,8 @@ from starlette.websockets import WebSocketDisconnect from app.api.config import config as dconfig from app.api.error_report.report import Report from app.api.error_report.response import build_error_response -from app.api.models import ApiErrors, ApiStartupStatus, StartupState +from app.api.models import ApiErrors, StartupState +from app.api.startup_status import api_startup_status from app.api.utils import Event, broadcast_msg from app.api.warmup import ( get_bitcoin_client_warmup_data, @@ -194,9 +195,6 @@ async def unhandled_e_handler(_: Request, exc: Exception): ) -api_startup_status = ApiStartupStatus() - - async def _set_startup_status( bitcoin: None | StartupState = None, bitcoin_msg: None | str = None, diff --git a/docs/superpowers/plans/2026-07-12-system-health-endpoint.md b/docs/superpowers/plans/2026-07-12-system-health-endpoint.md new file mode 100644 index 0000000..7bf0ac3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-system-health-endpoint.md @@ -0,0 +1,544 @@ +# System Health Endpoint Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the stubbed `GET /system/health` into a real, unauthenticated availability/readiness endpoint driven by the existing startup state. + +**Architecture:** Hoist the in-process `api_startup_status` singleton into a shared module (breaks a circular import), compute health in one pure platform-independent function `build_health_info`, wire it through the service + router (unauthenticated, 503-when-unhealthy), drop the now-dead per-backend `get_system_health`, and regenerate the committed `openapi.json`. + +**Tech Stack:** Python 3.11+, FastAPI, Pydantic v2, pytest, jujutsu (jj) for commits. + +## Global Constraints + +- Endpoint is `GET /system/health`, **unauthenticated** — no `dependencies=[Depends(JWTBearer())]`. +- `healthy` (top level) = `api_startup_status.is_fully_initialized()` (bitcoin `DONE` and lightning `DONE` or `DISABLED`). +- HTTP status: **200** when `healthy`, **503** when not. The handler **sets** `response.status_code`; it never raises — so the 503 body stays a `SystemHealthInfo`, never an `ErrorMessage`. +- `verbose` only controls whether `subsystems` is populated; status code / `healthy` / `message` are identical either way. +- `subsystems`, when present, is exactly three entries in this order: `api`, `bitcoind`, `lightning`. +- `api` → always `healthy: true`. `bitcoind` → `healthy` iff `DONE`. `lightning` → `healthy` iff `DONE` or `DISABLED`; `DISABLED` reports `healthy: true, message: "disabled"`. +- Non-`DONE`, non-`DISABLED` subsystem `message` = the lowercased state value, and `": "` when the corresponding `*_msg` is non-empty. +- Top-level `message` = `""` when healthy; when unhealthy, `" not ready: "` for the first not-ready subsystem, bitcoind checked before lightning. +- The subsystem model field is named **`healthy`** (rename from `health`). +- No new config vars. No WebUI change. No live/deep RPC probe. +- Commit with **jj using explicit paths only** (never bare `jj commit`). Every commit message ends with: + `Co-Authored-By: Claude Fable 5 ` +- Run tests via: `cd /home/f44/dev/blitz/base/api && devenv shell -- bash -c 'uv run python -m pytest '`. + +--- + +### Task 1: Hoist `api_startup_status` into a shared module + +**Files:** +- Create: `app/api/startup_status.py` +- Modify: `app/main.py` (remove the local `api_startup_status` definition at line 197; add an import; drop the now-unused `ApiStartupStatus` name from the models import at line 19) +- Test: `tests/test_startup_status.py` + +**Interfaces:** +- Consumes: `ApiStartupStatus` from `app.api.models`. +- Produces: `app.api.startup_status.api_startup_status` — the single shared `ApiStartupStatus` instance the whole app reads/mutates. + +**Why:** `api_startup_status` currently lives in `app/main.py`, which imports `app.system.router`. The health service (Task 3) must read it from `app/system/`, which would create a cycle. Moving the singleton to a leaf module (`app.api.startup_status`, which depends only on `app.api.models`) breaks the cycle with no behavior change. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_startup_status.py +def test_main_uses_the_shared_singleton(): + import app.main as main + from app.api.startup_status import api_startup_status + + # main must read/mutate the exact same object, not its own copy + assert main.api_startup_status is api_startup_status +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `devenv shell -- bash -c 'uv run python -m pytest tests/test_startup_status.py -q'` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.api.startup_status'`. + +- [ ] **Step 3: Create the shared module** + +```python +# app/api/startup_status.py +from app.api.models import ApiStartupStatus + +# Single in-process source of truth for bitcoin/lightning startup state. +# Mutated by the startup tasks in app.main; read by the /system/health endpoint +# and the WebSocket warmup. Kept in this leaf module so app.system can import it +# without a circular dependency on app.main. +api_startup_status = ApiStartupStatus() +``` + +- [ ] **Step 4: Rewire `app/main.py`** + +Remove the standalone definition (currently `app/main.py:197`): +```python +api_startup_status = ApiStartupStatus() +``` + +Add an import next to the other `app.api.*` imports (near `app/main.py:17-20`): +```python +from app.api.startup_status import api_startup_status +``` + +Drop the now-unused `ApiStartupStatus` from the models import (currently `app/main.py:19`), changing: +```python +from app.api.models import ApiErrors, ApiStartupStatus, StartupState +``` +to: +```python +from app.api.models import ApiErrors, StartupState +``` +Leave every other `api_startup_status.<...>` reference in `main.py` untouched — they now operate on the imported shared object. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `devenv shell -- bash -c 'uv run python -m pytest tests/test_startup_status.py -q'` +Expected: PASS (1 passed). + +- [ ] **Step 6: Run the full suite (no behavior change)** + +Run: `devenv shell -- bash -c 'uv run python -m pytest -q'` +Expected: all pass (previous count was 125, now 126 with the new test). + +- [ ] **Step 7: Commit** + +```bash +jj commit app/api/startup_status.py app/main.py tests/test_startup_status.py -m "refactor(api): hoist api_startup_status into a shared leaf module + +Move the in-process startup-state singleton out of app.main into +app.api.startup_status so app.system can read it without a circular +import. No behavior change - main mutates the same shared object. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: Pure `build_health_info` + model rename + +**Files:** +- Create: `app/system/health.py` +- Modify: `app/system/models.py` (rename `SubSystemHealthInfo.health` → `healthy`; switch the two health models' fields from `Query(...)` to `Field(...)`) +- Test: `tests/test_system_health.py` + +**Interfaces:** +- Consumes: `ApiStartupStatus`, `StartupState` from `app.api.models`; `SystemHealthInfo`, `SubSystemHealthInfo` from `app.system.models`. +- Produces: `build_health_info(status: ApiStartupStatus, verbose: bool) -> SystemHealthInfo`. + +- [ ] **Step 1: Rename the model field and switch to `Field`** + +In `app/system/models.py`, add `Field` to the pydantic import (currently `from pydantic import BaseModel`): +```python +from pydantic import BaseModel, Field +``` +Replace the two model classes (currently `app/system/models.py:129-142`) with: +```python +class SubSystemHealthInfo(BaseModel): + name: str = Field(..., description="Name of the subsystem") + healthy: bool = Field(..., description="Whether this subsystem is healthy or not") + message: str = Field( + "", description="Optional message describing the subsystem's health" + ) + + +class SystemHealthInfo(BaseModel): + healthy: bool = Field( + ..., description="Whether the API and all its subsystems are ready" + ) + message: str = Field("", description="Set to a reason string when not healthy") + subsystems: list[SubSystemHealthInfo] = Field( + [], description="Per-subsystem health; populated only when verbose=true" + ) +``` +Leave the `from fastapi import Query` import in place — other models in this file still use `Query`. + +- [ ] **Step 2: Write the failing tests** + +```python +# tests/test_system_health.py +from app.api.models import ApiStartupStatus, StartupState +from app.system.health import build_health_info + + +def _names(info): + return [s.name for s in info.subsystems] + + +def _by_name(info, name): + return next(s for s in info.subsystems if s.name == name) + + +def test_all_ready_is_healthy_no_subsystems_without_verbose(): + status = ApiStartupStatus( + bitcoin=StartupState.DONE, lightning=StartupState.DONE + ) + info = build_health_info(status, verbose=False) + assert info.healthy is True + assert info.message == "" + assert info.subsystems == [] + + +def test_verbose_lists_three_subsystems_in_order(): + status = ApiStartupStatus( + bitcoin=StartupState.DONE, lightning=StartupState.DONE + ) + info = build_health_info(status, verbose=True) + assert _names(info) == ["api", "bitcoind", "lightning"] + assert _by_name(info, "api").healthy is True + assert _by_name(info, "bitcoind").healthy is True + assert _by_name(info, "lightning").healthy is True + + +def test_bitcoin_bootstrapping_is_unhealthy(): + status = ApiStartupStatus( + bitcoin=StartupState.BOOTSTRAPPING, lightning=StartupState.DONE + ) + info = build_health_info(status, verbose=True) + assert info.healthy is False + assert info.message == "bitcoind not ready: bootstrapping" + assert _by_name(info, "bitcoind").healthy is False + assert _by_name(info, "bitcoind").message == "bootstrapping" + + +def test_bitcoin_message_is_appended_when_present(): + status = ApiStartupStatus( + bitcoin=StartupState.BOOTSTRAPPING, + bitcoin_msg="syncing headers", + lightning=StartupState.DONE, + ) + info = build_health_info(status, verbose=True) + assert _by_name(info, "bitcoind").message == "bootstrapping: syncing headers" + + +def test_lightning_disabled_is_healthy_on_bitcoin_only_node(): + status = ApiStartupStatus( + bitcoin=StartupState.DONE, lightning=StartupState.DISABLED + ) + info = build_health_info(status, verbose=True) + assert info.healthy is True + ln = _by_name(info, "lightning") + assert ln.healthy is True + assert ln.message == "disabled" + + +def test_lightning_locked_is_unhealthy(): + status = ApiStartupStatus( + bitcoin=StartupState.DONE, lightning=StartupState.LOCKED + ) + info = build_health_info(status, verbose=True) + assert info.healthy is False + assert info.message == "lightning not ready: locked" + assert _by_name(info, "lightning").healthy is False +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `devenv shell -- bash -c 'uv run python -m pytest tests/test_system_health.py -q'` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.system.health'`. + +- [ ] **Step 4: Implement `build_health_info`** + +```python +# app/system/health.py +from app.api.models import ApiStartupStatus, StartupState +from app.system.models import SubSystemHealthInfo, SystemHealthInfo + + +def _state_message(state: StartupState, msg: str | None) -> str: + if msg: + return f"{state.value}: {msg}" + return state.value + + +def _bitcoind_subsystem(status: ApiStartupStatus) -> SubSystemHealthInfo: + if status.bitcoin == StartupState.DONE: + return SubSystemHealthInfo(name="bitcoind", healthy=True, message="") + return SubSystemHealthInfo( + name="bitcoind", + healthy=False, + message=_state_message(status.bitcoin, status.bitcoin_msg), + ) + + +def _lightning_subsystem(status: ApiStartupStatus) -> SubSystemHealthInfo: + if status.lightning == StartupState.DISABLED: + return SubSystemHealthInfo(name="lightning", healthy=True, message="disabled") + if status.lightning == StartupState.DONE: + return SubSystemHealthInfo(name="lightning", healthy=True, message="") + return SubSystemHealthInfo( + name="lightning", + healthy=False, + message=_state_message(status.lightning, status.lightning_msg), + ) + + +def build_health_info(status: ApiStartupStatus, verbose: bool) -> SystemHealthInfo: + """Build the SystemHealthInfo from the current startup state. + + Pure and synchronous: healthy == status.is_fully_initialized(). The `api` + subsystem is always healthy (a response proves the API is alive). + """ + healthy = status.is_fully_initialized() + + message = "" + if not healthy: + if status.bitcoin != StartupState.DONE: + message = f"bitcoind not ready: {status.bitcoin.value}" + else: + message = f"lightning not ready: {status.lightning.value}" + + subsystems: list[SubSystemHealthInfo] = [] + if verbose: + subsystems = [ + SubSystemHealthInfo(name="api", healthy=True, message=""), + _bitcoind_subsystem(status), + _lightning_subsystem(status), + ] + + return SystemHealthInfo(healthy=healthy, message=message, subsystems=subsystems) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `devenv shell -- bash -c 'uv run python -m pytest tests/test_system_health.py -q'` +Expected: PASS (6 passed). + +- [ ] **Step 6: Commit** + +```bash +jj commit app/system/health.py app/system/models.py tests/test_system_health.py -m "feat(api): add pure build_health_info + rename subsystem field to healthy + +build_health_info maps the startup state to SystemHealthInfo (platform +independent, pure, synchronous). Rename SubSystemHealthInfo.health -> +healthy to match the top level, and switch the two health models from +the misused Query() to Field(). + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: Wire into the service + router (unauthenticated, 503); drop dead backend method + +**Files:** +- Modify: `app/system/service.py` (rewrite `system_health`; add imports) +- Modify: `app/system/router.py` (drop auth, set 503, update docs, add `response_model`/`responses`) +- Modify: `app/system/impl/system_base.py` (remove abstract `get_system_health` + unused `SystemHealthInfo` import if now unused) +- Modify: `app/system/impl/native_python.py` (remove `get_system_health` + unused `SystemHealthInfo` import if now unused) +- Modify: `app/system/impl/raspiblitz.py` (remove `get_system_health` + unused `SystemHealthInfo` import if now unused) +- Test: `tests/routers/test_system.py` + +**Interfaces:** +- Consumes: `build_health_info` from `app.system.health`; `api_startup_status` from `app.api.startup_status`. +- Produces: `system_health(verbose: bool) -> SystemHealthInfo` (now reads shared state, no backend delegation); the unauthenticated `GET /system/health` route. + +- [ ] **Step 1: Write the failing route tests** + +Append to `tests/routers/test_system.py` (keep the existing imports/test; add these): +```python +import app.main as main +from app.api.models import StartupState + + +def _set_status(bitcoin, lightning): + main.api_startup_status.bitcoin = bitcoin + main.api_startup_status.lightning = lightning + + +def test_health_is_unauthenticated_and_200_when_ready(): + _set_status(StartupState.DONE, StartupState.DONE) + # no `with`: don't start lifespan/background tasks + c = TestClient(main.app) + resp = c.get("/system/health") # no Authorization header + assert resp.status_code == 200 + body = resp.json() + assert body["healthy"] is True + assert body["subsystems"] == [] + + +def test_health_503_when_not_ready_body_is_health_info(): + _set_status(StartupState.BOOTSTRAPPING, StartupState.DONE) + c = TestClient(main.app) + resp = c.get("/system/health") + assert resp.status_code == 503 + body = resp.json() + # body is a SystemHealthInfo, NOT an ErrorMessage + assert body["healthy"] is False + assert "error_code" not in body + assert body["message"] == "bitcoind not ready: bootstrapping" + + +def test_health_verbose_lists_subsystems(): + _set_status(StartupState.DONE, StartupState.DONE) + c = TestClient(main.app) + resp = c.get("/system/health?verbose=true") + assert resp.status_code == 200 + names = [s["name"] for s in resp.json()["subsystems"]] + assert names == ["api", "bitcoind", "lightning"] +``` + +- [ ] **Step 2: Run the new tests to verify they fail** + +Run: `devenv shell -- bash -c 'uv run python -m pytest tests/routers/test_system.py -q'` +Expected: FAIL — health currently requires JWT (`test_health_is_unauthenticated_and_200_when_ready` gets 403), and the stub always returns `healthy=True` with a 200 (so the 503 test fails). + +- [ ] **Step 3: Rewrite the service function** + +In `app/system/service.py`, add imports near the other `app.*` imports (top of file): +```python +from app.api.startup_status import api_startup_status +from app.system.health import build_health_info +``` +Replace the existing `system_health` (currently `app/system/service.py:59-65`) with: +```python +async def system_health(verbose: bool) -> SystemHealthInfo: + # Readiness is platform independent: read the shared startup state directly. + return build_health_info(api_startup_status, verbose) +``` +(Keep the existing `SystemHealthInfo` import in `service.py` — it is still used in the return annotation.) + +- [ ] **Step 4: Update the router** + +In `app/system/router.py`, replace the `/health` route (currently `app/system/router.py:137-149`) with: +```python +@router.get( + "/health", + name=f"{_PREFIX}.health", + summary="Returns info about the system's health", + response_model=SystemHealthInfo, + responses={503: {"description": "One or more subsystems are not ready"}}, +) +async def get_system_health( + response: Response, + verbose: bool = Query( + False, + description="If true, include a per-subsystem (api, bitcoind, lightning) health breakdown.", + ), +) -> SystemHealthInfo: + result = await system_health(verbose) + if not result.healthy: + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + return result +``` +Note: the JWT dependency is removed. `Response` and `status` are already imported (`app/system/router.py:1`); leave the `Depends`/`JWTBearer` imports in place (other routes still use them). + +- [ ] **Step 5: Remove the dead backend method** + +The service no longer delegates to the backend, so remove the abstract method and both stubs. + +In `app/system/impl/system_base.py`, delete (currently lines 20-22): +```python + @abstractmethod + async def get_system_health(self, verbose: bool) -> SystemHealthInfo: + raise NotImplementedError() +``` +In `app/system/impl/native_python.py`, delete (currently lines 58-60): +```python + @logger.catch(exclude=(HTTPException,)) + async def get_system_health(self, verbose: bool) -> SystemHealthInfo: + return SystemHealthInfo(healthy=True) +``` +In `app/system/impl/raspiblitz.py`, delete (currently lines 108-109): +```python + async def get_system_health(self, verbose: bool) -> SystemHealthInfo: + return SystemHealthInfo(healthy=True) +``` +Then, in each of those three files, check whether `SystemHealthInfo` is still referenced: +```bash +grep -n "SystemHealthInfo" app/system/impl/system_base.py app/system/impl/native_python.py app/system/impl/raspiblitz.py +``` +For any file where the only remaining hit is the import line, remove `SystemHealthInfo` from that file's `from app.system.models import (...)` import. + +- [ ] **Step 6: Run the system tests, then the full suite** + +Run: `devenv shell -- bash -c 'uv run python -m pytest tests/routers/test_system.py -q'` +Expected: PASS (existing auth test + the three new health tests). + +Run: `devenv shell -- bash -c 'uv run python -m pytest -q'` +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +jj commit app/system/service.py app/system/router.py app/system/impl/system_base.py app/system/impl/native_python.py app/system/impl/raspiblitz.py tests/routers/test_system.py -m "feat(api): implement GET /system/health readiness endpoint (#145) + +Make /system/health unauthenticated, compute real readiness from the +shared startup state via build_health_info, and return 503 (body still a +SystemHealthInfo) when a subsystem is not ready. Drop the now-dead +per-backend get_system_health delegation. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: Regenerate the committed `openapi.json` + +**Files:** +- Modify: `openapi.json` (generated artifact) + +**Why:** `openapi.json` is committed and used to generate client libraries. The route is now unauthenticated, its `verbose` description changed, it declares a 503 response, and the `SubSystemHealthInfo.health` field was renamed to `healthy` — all must be reflected. + +- [ ] **Step 1: Regenerate `openapi.json`** + +This mirrors the spec-writing half of `gen_client_libs.py` (the v1 sub-app selection + `get_openapi` + `json.dump`) without running the external client generators. + +Run: +```bash +cd /home/f44/dev/blitz/base/api && devenv shell -- bash -c 'uv run python - <" +``` + +--- + +## Self-Review + +- **Spec coverage:** unauthenticated GET (Task 3) ✓; readiness from `api_startup_status` via `is_fully_initialized` (Task 2 `build_health_info`) ✓; 200/503 with `SystemHealthInfo` body, never raised (Task 3 router) ✓; verbose subsystem breakdown, order api/bitcoind/lightning (Task 2) ✓; DISABLED lightning → healthy (Task 2) ✓; top-level message rule (Task 2) ✓; field rename `health`→`healthy` + `Query`→`Field` (Task 2) ✓; circular-import hoist (Task 1) ✓; drop dead backend delegation (Task 3) ✓; regenerate openapi.json (Task 4) ✓; pure-function + route tests (Tasks 2, 3) ✓. +- **Type consistency:** `build_health_info(status: ApiStartupStatus, verbose: bool) -> SystemHealthInfo` produced in Task 2 and consumed identically in Task 3; `api_startup_status` produced in Task 1 and consumed in Task 3; `SubSystemHealthInfo.healthy` used consistently across Tasks 2, 3, 4. +- **Placeholders:** none — every code step shows complete code; the openapi.json step shows the exact regeneration + verification commands (the file content itself is a generated artifact, not hand-authored). diff --git a/docs/superpowers/specs/2026-07-12-system-health-endpoint-design.md b/docs/superpowers/specs/2026-07-12-system-health-endpoint-design.md new file mode 100644 index 0000000..8cb5dff --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-system-health-endpoint-design.md @@ -0,0 +1,144 @@ +# Design: Availability / health-check endpoint + +- **Issue:** blitz_api#145 (add an availability / health check) +- **Date:** 2026-07-12 +- **Repo:** `api` (blitz_api, FastAPI) + +## Goal & current state + +Give developers, uptime monitors, container liveness probes, and the WebUI a +cheap, always-reachable way to ask "is the blitz API there, and are its +subsystems ready?" + +`GET /system/health` **already exists** but is a stub (`app/system/router.py`): +it is JWT-protected, its `verbose` flag is a documented no-op, and both backends +just `return SystemHealthInfo(healthy=True)`. The response models already exist +too (`app/system/models.py`): `SystemHealthInfo{healthy, message, subsystems}` +and `SubSystemHealthInfo{name, health, message}`. So this work **implements the +stub properly** and fixes two design flaws (it must be unauthenticated, and its +health signal must be real), rather than building from scratch. + +## The contract + +**Endpoint:** `GET /system/health` — pure GET, no body, no secrets, +**unauthenticated** (drop the JWT dependency; precedent: `GET /setup/status`). + +**Source of truth:** the in-process `api_startup_status` singleton (bitcoin + +lightning `StartupState` ∈ `offline / bootstrapping / locked / done / disabled`). +No RPC calls — reading it is instant and cannot hang, and it is the exact state +the WebSocket already pushes to the UI, so the endpoint and the live UI never +disagree. + +**Top-level `healthy`** = `api_startup_status.is_fully_initialized()`, i.e. +`bitcoin == DONE` **and** (`lightning == DONE` **or** `DISABLED`). The `api` +subsystem is implicitly always healthy — a response at all proves the API is +alive. + +| condition | HTTP status | `healthy` | `message` | +|---|---|---|---| +| all ready | **200** | `true` | `""` | +| any subsystem not ready | **503** | `false` | e.g. `"bitcoind not ready: bootstrapping"` | + +Status code, `healthy`, and `message` are **identical regardless of `verbose`**. +`verbose` only controls whether the `subsystems` array is populated. + +**Top-level `message`** is `""` when healthy. When unhealthy it names the first +not-ready subsystem in check order (bitcoind before lightning) as +`" not ready: "` — e.g. `"bitcoind not ready: bootstrapping"`. + +**Subsystem mapping (verbose):** three entries, in order — `api`, `bitcoind`, +`lightning`. +- `api` → always `healthy: true`, `message: ""`. +- `bitcoind` → `healthy` iff state `DONE`; else `healthy: false`, `message` = the + state (`offline` / `bootstrapping` / `locked`), appended with `bitcoin_msg` + when non-empty (`"bootstrapping: "`). +- `lightning` → `healthy` iff `DONE` **or** `DISABLED`. `DISABLED` reports + `healthy: true, message: "disabled"` (so a bitcoin-only node is fully + healthy). Other non-`DONE` states report `healthy: false`, `message` = the + state, appended with `lightning_msg` when non-empty. + +``` +GET /system/health → 200 {"healthy": true, "message": ""} +GET /system/health?verbose=true → 200 {"healthy": true, "message": "", + "subsystems": [api, bitcoind, lightning]} +# during startup: 503, healthy=false (body is still SystemHealthInfo) +``` + +## Architecture & refactor + +Readiness comes entirely from `api_startup_status` — identical on every platform +— so the current delegation into the `native_python` / `raspiblitz` backends +only duplicates logic. The logic is pulled out to one platform-independent unit. + +- **New `app/system/health.py`** — one pure function + `build_health_info(status: ApiStartupStatus, verbose: bool) -> SystemHealthInfo`. + All of the Section-above mapping lives here. Pure and synchronous → unit-tested + with zero mocking and no TestClient. +- **`app/system/service.py`** — `system_health(verbose)` reads the shared status + and returns `build_health_info(...)`; no more `await system.get_system_health`. +- **Remove** the now-dead `get_system_health` from `app/system/impl/system_base.py` + (abstract), `native_python.py`, and `raspiblitz.py`. (Reintroducible later if an + opt-in deep RPC probe is ever added — YAGNI now.) + +**Break the circular import.** `api_startup_status` currently lives in +`app/main.py`, which imports `system_router`, so `system/` cannot import back +without a cycle. Hoist the singleton into a tiny **new `app/api/startup_status.py`**: + +```python +from app.api.models import ApiStartupStatus + +api_startup_status = ApiStartupStatus() +``` + +`app/main.py` imports it from there (its startup setter keeps mutating the same +shared object — no behavior change); `app/system/service.py` imports it too. No +cycle (`startup_status.py` depends only on `app.api.models`, which imports +neither `main` nor `system`). + +**Router (`app/system/router.py`).** +- Drop `dependencies=[Depends(JWTBearer())]` → unauthenticated. +- Inject `response: Response`; set `response.status_code = 503` when + `not result.healthy` (this keeps `response_model` serialization + OpenAPI + intact, and — crucially — does **not** raise, so the error-normalization + handlers never touch the 503 body). +- Update the `verbose` description (no longer "not implemented") and add + `responses={503: {"description": "One or more subsystems are not ready"}}`. + +**Model (`app/system/models.py`).** Rename `SubSystemHealthInfo.health` → +`healthy` (matches the top level and fusion44's own example; verified no +consumers exist in `api` or `web`). While there, switch these response-model +fields from the misused `Query(...)` to `Field(...)`. + +**`openapi.json`** is committed and used to generate client libraries — +regenerate it after the route/model changes. + +## Testing + +**Pure-function unit tests (`tests/system/test_health.py`)** — the bulk of +coverage, no TestClient, no mocking: +- fully initialized (bitcoin `DONE`, lightning `DONE`) → `healthy=true`, + `message=""`, `subsystems=[]` when `verbose=false`. +- `verbose=true` → exactly three subsystems (`api`, `bitcoind`, `lightning`) + with correct `healthy`/`message` each. +- bitcoin `BOOTSTRAPPING` → `healthy=false`, message names bitcoind; verbose + shows `bitcoind.healthy=false`. +- lightning `DISABLED` on a `DONE` bitcoin (bitcoin-only node) → top-level + `healthy=true`, `lightning.healthy=true, message="disabled"`. +- lightning `LOCKED` → `healthy=false`. + +**Route tests (`tests/routers/test_system.py`)** — `TestClient(main.app)` +constructed **without** `with` (so `lifespan`/background init never starts), +monkeypatching the shared `api_startup_status`: +- unauthenticated `GET /system/health` → **200** with no token (proves the JWT + dependency is gone). +- unhealthy state → **503**, and the body is still a `SystemHealthInfo` + (not an `ErrorMessage` — the status code is set, not raised). +- `?verbose=true` populates `subsystems`. + +## Out of scope / follow-ups + +- **No WebUI change** — nothing consumes `SystemHealthInfo` yet. A pre-login + "is the API reachable" ping could use it later. +- **No deep / live RPC probe** — opt-in future addition; the abstract backend + seam can be reintroduced then. +- **No new config.** diff --git a/tests/test_startup_status.py b/tests/test_startup_status.py new file mode 100644 index 0000000..c0d0b90 --- /dev/null +++ b/tests/test_startup_status.py @@ -0,0 +1,6 @@ +def test_main_uses_the_shared_singleton(): + import app.main as main + from app.api.startup_status import api_startup_status + + # main must read/mutate the exact same object, not its own copy + assert main.api_startup_status is api_startup_status