blitz_api/app/system/impl/native_python.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

196 lines
7.2 KiB
Python

import secrets
from typing import Dict
import psutil
from fastapi import HTTPException, status
from loguru import logger
from app.api.config import config
from app.api.constants import API_VERSION
from app.api.error_report.report import Report
from app.auth.auth_handler import sign_jwt
from app.external.result_type.src.result.result import Err, Ok, Result
from app.lightning.service import get_ln_info
from app.system.impl.system_base import SystemBase
from app.system.models import (
APIPlatform,
ConnectionInfo,
LoginInput,
RawDebugLogData,
SystemInfo,
)
_SLEEP_TIME = config("BAPI_GATHER_HW_INFO_INTERVAL", default=2, cast=float)
_CPU_AVG_PERIOD = config("BAPI_CPU_USAGE_AVERAGING_PERIOD", default=0.5, cast=float)
_HW_INFO_YIELD_TIME = _SLEEP_TIME + _CPU_AVG_PERIOD
class NativePythonSystem(SystemBase):
@logger.catch(exclude=(HTTPException,))
async def get_system_info(self) -> SystemInfo:
lninfo = await get_ln_info()
version = config("BAPI_NP_VERSION", default="")
tor_api = config("BAPI_NP_TOR_ADDRESS_API_ENDPOINT", default="")
tor_api_docs = config("BAPI_NP_TOR_ADDRESS_API_DOCS", default="")
lan_api = config("BAPI_NP_LOCAL_ADDRESS_API_ENDPOINT", default="")
lan_api_docs = config("BAPI_NP_LOCAL_ADDRESS_API_DOCS", default="")
ssh_address = config("BAPI_NP_SSH_ADDRESS", default="")
return SystemInfo(
alias=lninfo.alias,
color=lninfo.color,
platform=APIPlatform.NATIVE_PYTHON,
platform_version=version,
api_version=API_VERSION,
tor_web_ui=tor_api_docs,
tor_api=tor_api,
lan_web_ui=lan_api_docs,
lan_api=lan_api,
ssh_address=ssh_address,
chain=lninfo.chains[0].network,
)
@logger.catch(exclude=(HTTPException,))
async def shutdown(self, reboot: bool) -> bool:
logger.info("Shutdown / reboot not supported in native_python mode.")
return False
@logger.catch(exclude=(HTTPException,))
async def get_connection_info(self) -> Result[ConnectionInfo, Report]:
# return an empty connection info object for now
return Ok(ConnectionInfo())
@logger.catch(exclude=(HTTPException,), reraise=True)
async def login(self, i: LoginInput) -> Result[Dict[str, str], Report]:
# https://github.com/fusion44/blitz_api/issues/255
pw = config("BAPI_NATIVE_LOGIN_PASSWORD", cast=str)
if not isinstance(pw, str):
return Err(
Report(
"unable to convert the .env password to a string",
error=HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Password is incorrect",
),
)
)
if len(pw) < 8:
return Err(
Report(
"given password is less than 8 characters",
error=HTTPException(
status.HTTP_401_UNAUTHORIZED,
detail="given password is less than 8 characters",
),
)
)
matches = secrets.compare_digest(i.password, pw)
if matches:
return Ok(sign_jwt())
return Err(
Report(
"password is incorrect",
error=HTTPException(
status.HTTP_401_UNAUTHORIZED, detail="password is incorrect"
),
)
)
async def change_password(self, type: str, old_password: str, new_password: str):
# no @logger.catch: NotImplementedError must propagate to the service
# layer (which turns it into a 501), not be swallowed into a None return
raise NotImplementedError()
async def get_debug_logs_raw(self) -> RawDebugLogData:
raise NotImplementedError()
@logger.catch(exclude=(HTTPException,))
async def get_hardware_info(self) -> map:
info = {}
try:
info["cpu_overall_percent"] = psutil.cpu_percent(interval=_CPU_AVG_PERIOD)
info["cpu_per_cpu_percent"] = psutil.cpu_percent(
interval=_CPU_AVG_PERIOD, percpu=True
)
v = psutil.virtual_memory()
info["vram_total_bytes"] = v.total
info["vram_available_bytes"] = v.available
info["vram_used_bytes"] = v.used
info["vram_usage_percent"] = v.percent
s = psutil.swap_memory()
info["swap_ram_total_bytes"] = s.total
info["swap_used_bytes"] = s.used
info["swap_usage_bytes"] = s.percent
info["temperatures_celsius"] = psutil.sensors_temperatures()
info["boot_time_timestamp"] = psutil.boot_time()
disk_io = psutil.disk_io_counters()
info["disk_io_read_count"] = disk_io.read_count
info["disk_io_write_count"] = disk_io.write_count
info["disk_io_read_bytes"] = disk_io.read_bytes
info["disk_io_write_bytes"] = disk_io.write_bytes
disks = []
partitions = psutil.disk_partitions()
for partition in partitions:
p = {}
p["device"] = partition.device
p["mountpoint"] = partition.mountpoint
p["filesystem_type"] = partition.fstype
try:
usage = psutil.disk_usage(partition.mountpoint)
p["partition_total_bytes"] = usage.total
p["partition_used_bytes"] = usage.used
p["partition_free_bytes"] = usage.free
p["partition_percent"] = usage.percent
except PermissionError:
continue
disks.append(p)
info["disks"] = disks
nets = []
addresses = psutil.net_if_addrs()
for name, address in addresses.items():
net = {}
nets.append(net)
net["interface_name"] = name
for a in address:
if str(a.family) == "AddressFamily.AF_INET":
net["address"] = a.address
elif str(a.family) == "AddressFamily.AF_PACKET":
net["mac_address"] = a.address
net_io = psutil.net_io_counters()
info["networks"] = nets
info["networks_bytes_sent"] = net_io.bytes_sent
info["networks_bytes_received"] = net_io.bytes_recv
except FileNotFoundError:
logger.warning("Unable to access /proc/stat to get CPU stats")
except OSError as e:
logger.warning(
f"""Unable to query system: {e}
Check if the system is hardened against such calls.
For example in Nix you must not harden the following:
ProtectProc = "invisible"; // to get HW info
ProcSubset = "pid"; // to get HW info
RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6"; // to get network info
"""
)
return info
def get_hardware_info_yield_time(self) -> float:
return _HW_INFO_YIELD_TIME