"""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: pass except Exception: log.exception("log stream error for %s", unit) finally: proc.kill() await proc.wait()