feat(cloud-api): expose health readiness

This commit is contained in:
2026-07-12 18:33:45 +08:00
parent b8d02abf87
commit a5de7399f8
3 changed files with 112 additions and 2 deletions
+38 -1
View File
@@ -7,7 +7,8 @@ from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from fastapi import FastAPI from fastapi import FastAPI, status
from fastapi.responses import JSONResponse
from cloud.auth import create_auth_provider from cloud.auth import create_auth_provider
from cloud.config import CloudConfig from cloud.config import CloudConfig
@@ -90,12 +91,15 @@ def create_app(
app.state.cloud_config = control_config app.state.cloud_config = control_config
app.state.database = database app.state.database = database
app.state.cloud_services = services app.state.cloud_services = services
app.state.startup_complete = False
app.state.schema_ready = False
stop_workers = asyncio.Event() stop_workers = asyncio.Event()
worker_tasks: list[asyncio.Task[None]] = [] worker_tasks: list[asyncio.Task[None]] = []
try: try:
repository.health_check() repository.health_check()
if control_config.environment == "production": if control_config.environment == "production":
require_current_schema(control_config.database_url) require_current_schema(control_config.database_url)
app.state.schema_ready = True
worker_tasks = [ worker_tasks = [
asyncio.create_task( asyncio.create_task(
_run_scheduler_loop( _run_scheduler_loop(
@@ -116,8 +120,10 @@ def create_app(
), ),
] ]
app.state.worker_tasks = tuple(worker_tasks) app.state.worker_tasks = tuple(worker_tasks)
app.state.startup_complete = True
yield yield
finally: finally:
app.state.startup_complete = False
stop_workers.set() stop_workers.set()
try: try:
if worker_tasks: if worker_tasks:
@@ -129,6 +135,37 @@ def create_app(
repository.unbind() repository.unbind()
app = FastAPI(title="Device Cloud API", lifespan=lifespan) app = FastAPI(title="Device Cloud API", lifespan=lifespan)
@app.get("/health/live")
def health_live() -> dict[str, str]:
return {"status": "live"}
@app.get("/health/ready")
def health_ready():
checks = {
"configuration": bool(getattr(app.state, "startup_complete", False)),
"database": False,
"schema": bool(getattr(app.state, "schema_ready", False)),
"workers": False,
}
if checks["configuration"]:
try:
services.repository.health_check()
checks["database"] = True
except Exception:
checks["database"] = False
worker_state = getattr(app.state, "worker_tasks", ())
checks["workers"] = bool(worker_state) and all(
not task.done() for task in worker_state
)
ready = all(checks.values())
return JSONResponse(
status_code=(
status.HTTP_200_OK if ready else status.HTTP_503_SERVICE_UNAVAILABLE
),
content={"status": "ready" if ready else "not_ready", "checks": checks},
)
app.include_router( app.include_router(
create_cloud_router( create_cloud_router(
pool=pool, pool=pool,
+73
View File
@@ -182,6 +182,79 @@ def test_lifecycle_workers_log_failures_and_continue(monkeypatch) -> None:
} }
def test_liveness_stays_up_when_database_readiness_fails() -> None:
database_healthy = True
class FakeRepository:
def health_check(self) -> None:
if not database_healthy:
raise RuntimeError("database unavailable with secret credentials")
def list_queued_tasks(self) -> list[object]:
return []
def reap_expired_leases(self, *, now, max_attempts: int) -> list[str]:
return []
class FakeDatabase:
repository = FakeRepository()
def close(self) -> None:
return None
app = create_app(
config=CloudControlConfig(database_url="sqlite:///:memory:"),
database_factory=lambda _config: FakeDatabase(), # type: ignore[arg-type,return-value]
)
with TestClient(app) as client:
assert client.get("/health/live").json() == {"status": "live"}
ready = client.get("/health/ready")
assert ready.status_code == 200
assert all(ready.json()["checks"].values())
database_healthy = False
assert client.get("/health/live").status_code == 200
not_ready = client.get("/health/ready")
assert not_ready.status_code == 503
assert not_ready.json()["checks"]["database"] is False
assert "secret credentials" not in not_ready.text
def test_readiness_reports_stopped_worker() -> None:
class FakeRepository:
def health_check(self) -> None:
return None
def list_queued_tasks(self) -> list[object]:
return []
def reap_expired_leases(self, *, now, max_attempts: int) -> list[str]:
return []
class FakeDatabase:
repository = FakeRepository()
def close(self) -> None:
return None
class StoppedWorker:
def done(self) -> bool:
return True
app = create_app(
config=CloudControlConfig(database_url="sqlite:///:memory:"),
database_factory=lambda _config: FakeDatabase(), # type: ignore[arg-type,return-value]
)
with TestClient(app) as client:
app.state.worker_tasks = (StoppedWorker(),)
response = client.get("/health/ready")
assert response.status_code == 503
assert response.json()["checks"]["workers"] is False
def test_production_app_rejects_missing_credentials() -> None: def test_production_app_rejects_missing_credentials() -> None:
with pytest.raises(CloudConfigurationError, match="credential"): with pytest.raises(CloudConfigurationError, match="credential"):
create_app( create_app(
@@ -46,7 +46,7 @@
- [x] 6.1 Compose repository, pool, scheduler, plugin registry, public router, internal router, and auth providers in the cloud app factory. - [x] 6.1 Compose repository, pool, scheduler, plugin registry, public router, internal router, and auth providers in the cloud app factory.
- [x] 6.2 Implement FastAPI lifespan startup/shutdown for configuration validation, database checks, scheduler loop, and lease-reaper loop. - [x] 6.2 Implement FastAPI lifespan startup/shutdown for configuration validation, database checks, scheduler loop, and lease-reaper loop.
- [x] 6.3 Ensure lifecycle iteration failures are logged and retried without terminating later iterations. - [x] 6.3 Ensure lifecycle iteration failures are logged and retried without terminating later iterations.
- [ ] 6.4 Add `/health/live` and `/health/ready` with separate process, database/schema, and worker-state semantics. - [x] 6.4 Add `/health/live` and `/health/ready` with separate process, database/schema, and worker-state semantics.
- [ ] 6.5 Add structured correlation-aware logging for requests and task lifecycle events with sensitive payload redaction. - [ ] 6.5 Add structured correlation-aware logging for requests and task lifecycle events with sensitive payload redaction.
- [ ] 6.6 Add app-level tests for startup failures, readiness transitions, graceful shutdown, persisted queue recovery, and expired-lease recovery. - [ ] 6.6 Add app-level tests for startup failures, readiness transitions, graceful shutdown, persisted queue recovery, and expired-lease recovery.