Files
wild-pc/castle-api/src/castle_api/logs.py
Paul Payne 20bf78caf1 api+ui: kind-scoped save/delete endpoints and links
API: /config/{services,jobs,tools,static}/{name} now pin the twin they
target — _save_deployment/_delete_deployment take an explicit kind so a
patch to a 'backup' service can't bleed into a 'backup' job/tool. Add
/tools and /static endpoints; keep /deployments/{name} kind-agnostic.
New test_kind_twins proves per-kind save/delete isolation on disk.

UI: ConfigPanel and CreateDeploymentForm write to the kind-scoped
resource; GatewayPanel/NodeDetail/DeploymentsSection link via a shared
detailPath(name, kind) helper instead of the ambiguous /deployment/:name.

Includes incidental ruff-format reflow of untouched api files.
2026-07-06 02:51:08 -07:00

103 lines
2.8 KiB
Python

"""Log streaming — tail journalctl output for systemd-managed services."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from fastapi import APIRouter, HTTPException, Query, status
from starlette.responses import StreamingResponse
from castle_core.generators.systemd import unit_name
from castle_api.config import get_castle_root
router = APIRouter(prefix="/logs", tags=["logs"])
UNIT_PREFIX = "castle-"
@router.get("/{name}", response_model=None)
async def get_logs(
name: str,
n: int = Query(default=100, ge=1, le=5000, description="Number of lines"),
follow: bool = Query(default=False, description="Stream new lines via SSE"),
) -> StreamingResponse | dict:
"""Get logs for a systemd-managed service."""
root = get_castle_root()
if root:
from castle_core.config import load_config
config = load_config(root)
# A name may span kinds — the managed (systemd) one owns the journal.
dep_kind = next(
(
(k, s)
for k, s in config.deployments_named(name)
if getattr(s, "manage", None)
),
None,
)
if dep_kind is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{name}' is not a managed service",
)
kind, _spec = dep_kind
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Castle root not available",
)
unit = unit_name(name, kind)
if follow:
return StreamingResponse(
_follow_logs(unit, n),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Static tail
proc = await asyncio.create_subprocess_exec(
"journalctl",
"--user",
"-u",
unit,
"-n",
str(n),
"--no-pager",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
lines = (stdout or b"").decode().splitlines()
return {"name": name, "lines": lines}
async def _follow_logs(unit: str, n: int) -> AsyncGenerator[str, None]:
"""Stream journalctl -f output as SSE events."""
proc = await asyncio.create_subprocess_exec(
"journalctl",
"--user",
"-u",
unit,
"-n",
str(n),
"-f",
"--no-pager",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
assert proc.stdout is not None
async for line in proc.stdout:
text = line.decode().rstrip()
yield f"data: {text}\n\n"
except asyncio.CancelledError:
pass
finally:
proc.kill()
await proc.wait()