feat: add ComponentGrid and ComponentTable components for displaying components in grid and table formats
feat: implement HealthBadge and RoleBadge components for displaying health status and roles feat: create LogViewer component for streaming logs of services feat: develop SecretsEditor for managing secrets with CRUD operations feat: introduce ToolCard component for displaying tool information style: add global CSS variables and styles for consistent theming feat: set up API client for handling requests to the backend feat: implement hooks for fetching components, statuses, and tools feat: create routes for dashboard, component details, and tools feat: add service management endpoints for starting, stopping, and restarting services feat: implement event bus for handling real-time updates via SSE feat: create health check and logs endpoints for monitoring services feat: add tests for health and tools endpoints to ensure functionality chore: update project configuration files and dependencies for better development experience
This commit is contained in:
143
castle-api/src/castle_api/services.py
Normal file
143
castle-api/src/castle_api/services.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Service management — start/stop/restart systemd-managed components."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from castle_cli.config import load_config
|
||||
|
||||
from castle_api.config import settings
|
||||
from castle_api.health import check_all_health
|
||||
from castle_api.models import HealthStatus
|
||||
from castle_api.stream import broadcast
|
||||
|
||||
router = APIRouter(prefix="/services", tags=["services"])
|
||||
|
||||
UNIT_PREFIX = "castle-"
|
||||
SELF_NAME = "castle-api"
|
||||
|
||||
|
||||
async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
|
||||
"""Run a systemctl --user command. Returns (success, output)."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"systemctl", "--user", action, unit,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
output = (stdout or stderr or b"").decode().strip()
|
||||
return proc.returncode == 0, output
|
||||
|
||||
|
||||
async def _get_unit_status(unit: str) -> str:
|
||||
"""Get the active status of a systemd unit."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"systemctl", "--user", "is-active", unit,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
return (stdout or b"").decode().strip()
|
||||
|
||||
|
||||
def _validate_managed(name: str) -> None:
|
||||
"""Raise 404 if the component isn't systemd-managed."""
|
||||
config = load_config(settings.castle_root)
|
||||
if name not in config.managed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"'{name}' is not a managed service",
|
||||
)
|
||||
|
||||
|
||||
async def _broadcast_health_with_override(
|
||||
override_name: str, override_status: str
|
||||
) -> None:
|
||||
"""Run health checks but override one component's status from systemd."""
|
||||
config = load_config(settings.castle_root)
|
||||
statuses = await check_all_health(config)
|
||||
|
||||
# Replace the overridden component's status with the systemd truth
|
||||
result = []
|
||||
for s in statuses:
|
||||
if s.id == override_name:
|
||||
result.append(HealthStatus(
|
||||
id=override_name,
|
||||
status="down" if override_status != "active" else "up",
|
||||
latency_ms=None,
|
||||
))
|
||||
else:
|
||||
result.append(s)
|
||||
|
||||
await broadcast("health", {
|
||||
"statuses": [s.model_dump() for s in result],
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
|
||||
|
||||
async def _deferred_systemctl(action: str, unit: str, delay: float = 0.5) -> None:
|
||||
"""Run a systemctl action after a delay, allowing the HTTP response to flush."""
|
||||
await asyncio.sleep(delay)
|
||||
await _systemctl(action, unit)
|
||||
|
||||
|
||||
async def _do_action(name: str, action: str) -> JSONResponse:
|
||||
"""Execute a systemctl action and broadcast updated health."""
|
||||
_validate_managed(name)
|
||||
unit = f"{UNIT_PREFIX}{name}.service"
|
||||
|
||||
# Self-restart: defer the systemctl call so the response can be sent first
|
||||
if name == SELF_NAME and action in ("restart", "stop"):
|
||||
asyncio.create_task(_deferred_systemctl(action, unit))
|
||||
return JSONResponse(
|
||||
status_code=202,
|
||||
content={"component": name, "action": action, "status": "accepted"},
|
||||
)
|
||||
|
||||
ok, output = await _systemctl(action, unit)
|
||||
unit_status = await _get_unit_status(unit)
|
||||
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail=output or f"Failed to {action}")
|
||||
|
||||
# Broadcast immediately with systemd status as the source of truth
|
||||
await _broadcast_health_with_override(name, unit_status)
|
||||
|
||||
return JSONResponse(
|
||||
content={"component": name, "action": action, "status": unit_status},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{name}/unit")
|
||||
def get_unit(name: str) -> dict[str, str | None]:
|
||||
"""Return the generated systemd unit file(s) for a managed component."""
|
||||
from castle_cli.commands.service import _generate_timer, _generate_unit
|
||||
|
||||
_validate_managed(name)
|
||||
config = load_config(settings.castle_root)
|
||||
manifest = config.managed[name]
|
||||
unit = _generate_unit(config, name, manifest)
|
||||
timer = _generate_timer(name, manifest)
|
||||
return {"service": unit, "timer": timer}
|
||||
|
||||
|
||||
@router.post("/{name}/start")
|
||||
async def start_service(name: str) -> JSONResponse:
|
||||
"""Start a systemd-managed service."""
|
||||
return await _do_action(name, "start")
|
||||
|
||||
|
||||
@router.post("/{name}/stop")
|
||||
async def stop_service(name: str) -> JSONResponse:
|
||||
"""Stop a systemd-managed service."""
|
||||
return await _do_action(name, "stop")
|
||||
|
||||
|
||||
@router.post("/{name}/restart")
|
||||
async def restart_service(name: str) -> JSONResponse:
|
||||
"""Restart a systemd-managed service."""
|
||||
return await _do_action(name, "restart")
|
||||
Reference in New Issue
Block a user