blitz_api/app/system/service.py
fusion44 6e3238a5a7
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 <noreply@anthropic.com>
2026-07-12 18:41:18 +02:00

176 lines
5.7 KiB
Python

import asyncio
from typing import Dict, Optional
from fastapi import HTTPException, status
from loguru import logger
from app.api.config import config
from app.api.error_report.report import Frame
from app.api.startup_status import api_startup_status
from app.api.utils import Event, broadcast_msg
from app.external.result_type.src.result.result import Err, Ok
from app.system.health import build_health_info
from app.system.models import (
APIPlatform,
ConnectionInfo,
LoginInput,
RawDebugLogData,
SystemHealthInfo,
SystemInfo,
)
PLATFORM = config("BAPI_PLATFORM", default=APIPlatform.RASPIBLITZ)
if PLATFORM == APIPlatform.RASPIBLITZ:
logger.info("using RaspiBlitz system implementation")
from app.system.impl.raspiblitz import RaspiBlitzSystem as System
elif PLATFORM == APIPlatform.NATIVE_PYTHON:
logger.info("using native python system implementation")
from app.system.impl.native_python import NativePythonSystem as System
else:
raise RuntimeError(
f"Unsupported platform '{PLATFORM}'. Options: {APIPlatform.values_as_list()}"
)
system = System()
if system is None:
raise RuntimeError(f"Unknown platform {PLATFORM}")
HW_INFO_YIELD_TIME = system.get_hardware_info_yield_time()
async def change_password(type: Optional[str], old_password: str, new_password: str):
try:
return await system.change_password(type, old_password, new_password)
except HTTPException:
raise
except NotImplementedError as r:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=r.args[0])
async def get_system_info() -> SystemInfo:
try:
return await system.get_system_info()
except HTTPException:
raise
except NotImplementedError as r:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=r.args[0])
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)
async def get_hardware_info() -> map:
try:
return await system.get_hardware_info()
except HTTPException:
raise
except NotImplementedError as r:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=r.args[0])
async def get_connection_info() -> ConnectionInfo:
result = await system.get_connection_info()
match result:
case Ok(data):
return data
case Err(report):
e = report.last_error
report.attach_frame(Frame("unable to get connection info"))
if e is None:
logger.error(report.format_verbose())
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=report.format_verbose(),
)
if isinstance(e, HTTPException):
raise e
if isinstance(e, NotImplementedError):
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=e)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="unknown error getting connection info",
)
async def shutdown(reboot: bool) -> bool:
if reboot:
await broadcast_msg(Event.SYSTEM_REBOOT_NOTICE, {"reboot": True})
else:
await broadcast_msg(Event.SYSTEM_SHUTDOWN_NOTICE, {"shutdown": True})
try:
return await system.shutdown(reboot=reboot)
except HTTPException:
raise
except NotImplementedError as r:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=r.args[0])
async def get_debug_logs_raw() -> RawDebugLogData:
try:
return await system.get_debug_logs_raw()
except HTTPException:
raise
except NotImplementedError as r:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=r.args[0])
async def _handle_gather_hardware_info():
last_info = {}
while True:
try:
info = await get_hardware_info()
if last_info != info:
await broadcast_msg(Event.HARDWARE_INFO, info)
last_info = info
except Exception as e:
# never let a single failure kill the gatherer task, otherwise
# hardware SSE updates would stop until the API is restarted
logger.error(f"Error gathering hardware info: {e}")
await asyncio.sleep(HW_INFO_YIELD_TIME)
async def register_hardware_info_gatherer():
asyncio.create_task(_handle_gather_hardware_info())
async def login(i: LoginInput) -> Dict[str, str]:
result = await system.login(i)
match result:
case Ok(data):
return data
case Err(report):
e = report.last_error
report.attach_frame(Frame("error during login"))
if e is None:
logger.error("got report without error")
logger.error(report.format_verbose())
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=report.format_verbose(),
)
if (
isinstance(e, HTTPException)
and e.status_code == status.HTTP_401_UNAUTHORIZED
):
logger.info("unauthorized login attempt")
raise e
if (
isinstance(e, HTTPException)
and e.status_code != status.HTTP_401_UNAUTHORIZED
):
logger.error(report.format_verbose())
raise e
if isinstance(e, NotImplementedError):
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=e)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="unknown error",
)