atlus/backend/routers/services.py
roberts 342dc0f0cf Fix robustness issues across backend and frontend
- Add shutil.which guard to _run() in settings, asi_bridge routers
- Catch RuntimeError on WebSocket disconnect in services, asi_bridge
- Make file listing resilient to individual entry errors
- Fix keyboard double-fire on touch devices (touchstart + click)
- Update install.sh with correct Gitea repo URL
- Add six to requirements.txt (python-pam dependency)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:41:43 -05:00

131 lines
4.1 KiB
Python

"""Systemd service management wrapper."""
import asyncio
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from backend.auth import get_current_user, ws_authenticate
router = APIRouter(prefix="/api/services", tags=["services"])
log = logging.getLogger("atlus.services")
class ServiceAction(BaseModel):
unit: str
action: str # start, stop, restart, enable, disable
async def _run(cmd: list[str]) -> tuple[int, str, str]:
import shutil
if not shutil.which(cmd[0]):
return 1, "", f"{cmd[0]}: command not found"
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode, stdout.decode(), stderr.decode()
async def _get_services() -> list[dict]:
"""List all loaded systemd services."""
rc, out, _ = await _run([
"systemctl", "list-units", "--type=service", "--all",
"--no-pager", "--no-legend", "--plain",
])
services = []
for line in out.strip().splitlines():
parts = line.split(None, 4)
if len(parts) < 4:
continue
unit = parts[0]
if not unit.endswith(".service"):
continue
services.append({
"unit": unit,
"name": unit.removesuffix(".service"),
"load": parts[1],
"active": parts[2],
"sub": parts[3],
"description": parts[4] if len(parts) > 4 else "",
})
return services
async def _get_service_status(unit: str) -> dict:
"""Get detailed status for a single service."""
rc, out, _ = await _run(["systemctl", "show", unit, "--no-pager"])
props = {}
for line in out.strip().splitlines():
if "=" in line:
k, _, v = line.partition("=")
props[k] = v
return {
"unit": unit,
"name": unit.removesuffix(".service"),
"active": props.get("ActiveState", "unknown"),
"sub": props.get("SubState", "unknown"),
"enabled": props.get("UnitFileState", "unknown"),
"description": props.get("Description", ""),
"main_pid": int(props.get("MainPID", 0)),
"memory": props.get("MemoryCurrent", "0"),
"started": props.get("ActiveEnterTimestamp", ""),
}
@router.get("")
async def list_services(_user: str = Depends(get_current_user)):
return await _get_services()
@router.get("/{unit}")
async def service_status(unit: str, _user: str = Depends(get_current_user)):
return await _get_service_status(unit)
@router.post("/action")
async def service_action(req: ServiceAction, _user: str = Depends(get_current_user)):
if req.action not in ("start", "stop", "restart", "enable", "disable"):
raise HTTPException(400, f"Invalid action: {req.action}")
rc, out, err = await _run(["systemctl", req.action, req.unit])
if rc != 0:
raise HTTPException(500, f"systemctl {req.action} {req.unit} failed: {err}")
return {
"unit": req.unit,
"action": req.action,
"status": await _get_service_status(req.unit),
}
@router.websocket("/{unit}/logs")
async def service_logs(websocket: WebSocket, unit: str):
"""Stream journalctl -f output for a service."""
username = await ws_authenticate(websocket)
await websocket.accept()
proc = await asyncio.create_subprocess_exec(
"journalctl", "-u", unit, "-f", "-n", "100", "--no-pager",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
while True:
line = await proc.stdout.readline()
if not line:
break
await websocket.send_json({
"type": "log",
"unit": unit,
"line": line.decode(errors="replace").rstrip(),
})
except (WebSocketDisconnect, RuntimeError):
pass
except Exception:
log.exception("log stream error for %s", unit)
finally:
proc.kill()
await proc.wait()