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.
103 lines
2.8 KiB
Python
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()
|