Rename Castle -> Wild PC across the repo
Repo-side rename only (Phases 1-3 of the migration plan); the live box (~/.castle, systemd units, /data/castle, domains) is a separate cutover. - Slug `castle` -> `wildpc`: CLI command, module names (wildpc_core/cli/api), dist names, entry point `wildpc = wildpc_cli.main:main`. - Identifiers: CastleConfig/NATSClient/DirError/MDNS -> Wildpc*. - Env/constants: CASTLE_* -> WILDPC_*; ~/.castle -> ~/.wildpc, castle.yaml -> wildpc.yaml, /data/castle -> /data/wildpc. - Systemd UNIT_PREFIX castle- -> wildpc-; own programs castle-api/gateway/etc. - Display prose "Castle" -> "Wild PC" in docs, agent-guide files, README, frontend. - Package dirs and bootstrap yaml renamed via git mv; lockfiles regenerated; redundant nested uv.lock files dropped (workspace root lock is authoritative). Tests: core 273, cli 47, wildpc-api 120 all pass. Frontend type-checks + builds. Fixed a stale test fixture (secret_env_path kind arg) broken pre-rename.
This commit is contained in:
3
wildpc-api/src/wildpc_api/__init__.py
Normal file
3
wildpc-api/src/wildpc_api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Wild PC API."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
234
wildpc-api/src/wildpc_api/agent_registry.py
Normal file
234
wildpc-api/src/wildpc_api/agent_registry.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""Resolve launchable agents for the dashboard terminal UX.
|
||||
|
||||
Config (a `wildpc.yaml` `agents:` block) declares agents; this layer adds the
|
||||
runtime view: is the binary present, and what's the absolute cwd. Wild PC stays
|
||||
agnostic — it only ever runs `command args` in a pty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from wildpc_core.config import USER_TOOL_PATH_DIRS
|
||||
|
||||
from wildpc_api.config import get_wildpc_root, get_config
|
||||
|
||||
# Zero-config fallback so the feature works out of the box. A wildpc.yaml
|
||||
# `agents:` block overrides this entirely.
|
||||
_DEFAULT_AGENTS: dict[str, dict] = {
|
||||
"claude": {
|
||||
"command": "claude",
|
||||
"description": "Anthropic Claude Code",
|
||||
# Bare --resume pops claude's interactive session-history picker (whereas
|
||||
# --continue would silently reopen only the most recent conversation).
|
||||
"resume_args": ["--resume"],
|
||||
},
|
||||
"opencode": {
|
||||
"command": "opencode",
|
||||
"description": "opencode",
|
||||
# opencode has no CLI flag to pop a picker, but it lists sessions as JSON
|
||||
# and resumes by id — so the dashboard renders the picker itself.
|
||||
"sessions": {
|
||||
"list_command": ["opencode", "session", "list", "--format", "json"],
|
||||
"resume": ["--session", "{id}"],
|
||||
"id_field": "id",
|
||||
"title_field": "title",
|
||||
"time_field": "updated",
|
||||
},
|
||||
},
|
||||
"amplifier": {
|
||||
"command": "amplifier",
|
||||
"description": "Amplifier",
|
||||
# `amplifier resume` is a subcommand that interactively selects a past
|
||||
# session (like claude --resume). (`amplifier continue` = most recent.)
|
||||
"resume_args": ["resume"],
|
||||
},
|
||||
}
|
||||
|
||||
# Extra dirs to look in beyond the process PATH — where user CLIs actually live.
|
||||
# opencode installs to ~/.opencode/bin, which the systemd service PATH omits.
|
||||
_EXTRA_PATH_DIRS = [
|
||||
*USER_TOOL_PATH_DIRS,
|
||||
Path.home() / ".opencode" / "bin",
|
||||
]
|
||||
|
||||
|
||||
def _augmented_path() -> str:
|
||||
extra = [str(d) for d in _EXTRA_PATH_DIRS if d.exists()]
|
||||
current = os.environ.get("PATH", "")
|
||||
return os.pathsep.join([*extra, current]) if extra else current
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedAgent:
|
||||
name: str
|
||||
command: str
|
||||
args: list[str] = field(default_factory=list)
|
||||
cwd: str = ""
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
description: str | None = None
|
||||
resume_args: list[str] = field(default_factory=list)
|
||||
sessions: dict | None = None # SessionsSpec-shaped, or None
|
||||
resolved_command: str | None = None # absolute path if found on PATH
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.resolved_command is not None
|
||||
|
||||
@property
|
||||
def can_continue(self) -> bool:
|
||||
return bool(self.resume_args)
|
||||
|
||||
@property
|
||||
def can_list_sessions(self) -> bool:
|
||||
return bool(self.sessions and self.sessions.get("list_command"))
|
||||
|
||||
def launch_env(self) -> dict[str, str]:
|
||||
"""Env overrides for the pty: augmented PATH so the agent's own tool
|
||||
lookups (node, etc.) resolve, plus any configured env."""
|
||||
return {"PATH": _augmented_path(), **self.env}
|
||||
|
||||
def info(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"command": self.command,
|
||||
"available": self.available,
|
||||
"cwd": self.cwd,
|
||||
"description": self.description,
|
||||
"can_continue": self.can_continue,
|
||||
"can_list_sessions": self.can_list_sessions,
|
||||
}
|
||||
|
||||
|
||||
def _agent_specs() -> dict[str, dict]:
|
||||
"""Configured agents (as plain dicts), or the built-in defaults."""
|
||||
try:
|
||||
config = get_config()
|
||||
except FileNotFoundError:
|
||||
return _DEFAULT_AGENTS
|
||||
if config.agents:
|
||||
return {n: s.model_dump(exclude_none=True) for n, s in config.agents.items()}
|
||||
return _DEFAULT_AGENTS
|
||||
|
||||
|
||||
def default_cwd() -> str:
|
||||
"""Where agents launch by default: the wildpc git repo (its CLAUDE.md /
|
||||
AGENTS.md and `wildpc` sources), falling back to the config root, then home."""
|
||||
try:
|
||||
repo = get_config().repo
|
||||
if repo:
|
||||
return str(repo)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
root = get_wildpc_root()
|
||||
return str(root) if root else str(Path.home())
|
||||
|
||||
|
||||
def _resolve(name: str, spec: dict) -> ResolvedAgent:
|
||||
command = spec["command"]
|
||||
cwd = spec.get("cwd") or default_cwd()
|
||||
resolved = shutil.which(command, path=_augmented_path())
|
||||
return ResolvedAgent(
|
||||
name=name,
|
||||
command=command,
|
||||
args=list(spec.get("args") or []),
|
||||
cwd=cwd,
|
||||
env=dict(spec.get("env") or {}),
|
||||
description=spec.get("description"),
|
||||
resume_args=list(spec.get("resume_args") or []),
|
||||
sessions=spec.get("sessions"),
|
||||
resolved_command=resolved,
|
||||
)
|
||||
|
||||
|
||||
def list_agents() -> list[ResolvedAgent]:
|
||||
return [_resolve(name, spec) for name, spec in _agent_specs().items()]
|
||||
|
||||
|
||||
def resolve_agent(name: str) -> ResolvedAgent | None:
|
||||
specs = _agent_specs()
|
||||
spec = specs.get(name)
|
||||
if spec is None:
|
||||
return None
|
||||
return _resolve(name, spec)
|
||||
|
||||
|
||||
def _dig(obj: object, path: str) -> object:
|
||||
"""Read a (possibly dotted) field path off a nested dict, else None."""
|
||||
cur = obj
|
||||
for part in path.split("."):
|
||||
if isinstance(cur, dict):
|
||||
cur = cur.get(part)
|
||||
else:
|
||||
return None
|
||||
return cur
|
||||
|
||||
|
||||
def resume_argv(agent: ResolvedAgent, session_id: str) -> list[str] | None:
|
||||
"""Build the argv that resumes a specific past session by id, or None."""
|
||||
if not agent.sessions:
|
||||
return None
|
||||
template = agent.sessions.get("resume") or []
|
||||
if not template:
|
||||
return None
|
||||
base = agent.resolved_command or agent.command
|
||||
return [base, *(part.replace("{id}", session_id) for part in template)]
|
||||
|
||||
|
||||
async def list_agent_history(agent: ResolvedAgent, limit: int = 40) -> list[dict]:
|
||||
"""Run the agent's declared list_command and normalize it to
|
||||
[{id, title, time}] — newest first. Best-effort: any failure yields []."""
|
||||
if not agent.can_list_sessions:
|
||||
return []
|
||||
assert agent.sessions is not None
|
||||
argv = agent.sessions["list_command"]
|
||||
env = {**os.environ, "PATH": _augmented_path()}
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
cwd=agent.cwd or None,
|
||||
env=env,
|
||||
)
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=20)
|
||||
except (OSError, asyncio.TimeoutError):
|
||||
return []
|
||||
try:
|
||||
data = json.loads(out.decode() or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
items = (
|
||||
data
|
||||
if isinstance(data, list)
|
||||
else (data.get("sessions") or data.get("data") or [])
|
||||
)
|
||||
id_f = agent.sessions.get("id_field", "id")
|
||||
title_f = agent.sessions.get("title_field", "title")
|
||||
time_f = agent.sessions.get("time_field", "updated")
|
||||
out_rows: list[dict] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
sid = _dig(item, id_f)
|
||||
if not sid:
|
||||
continue
|
||||
out_rows.append(
|
||||
{
|
||||
"agent": agent.name,
|
||||
"id": str(sid),
|
||||
"title": str(_dig(item, title_f) or ""),
|
||||
"time": _dig(item, time_f),
|
||||
}
|
||||
)
|
||||
# Newest first when the time field is sortable; keep input order otherwise.
|
||||
try:
|
||||
out_rows.sort(key=lambda r: r["time"] or 0, reverse=True)
|
||||
except TypeError:
|
||||
pass
|
||||
return out_rows[:limit]
|
||||
448
wildpc-api/src/wildpc_api/agent_sessions.py
Normal file
448
wildpc-api/src/wildpc_api/agent_sessions.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""Live agent/terminal session registry.
|
||||
|
||||
A session is a running terminal (an agent CLI or a shell) whose lifetime is
|
||||
decoupled from any WebSocket connection — closing a tab detaches, it does not
|
||||
kill the process. Sessions can be listed, re-attached (resumed) by id, and
|
||||
explicitly terminated.
|
||||
|
||||
Two backends behind one interface, chosen at startup:
|
||||
|
||||
- **tmux** (default when `tmux` is on PATH): each session is a tmux session, so
|
||||
it **survives a wildpc-api restart** and is rediscoverable. The tmux server is
|
||||
started inside its own systemd scope so it isn't in wildpc-api's cgroup (which
|
||||
systemd would kill on restart); tmux's own systemd integration puts each pane
|
||||
in its own scope too. Each WebSocket is a `tmux attach` client, so multi-client
|
||||
and repaint-on-attach come for free.
|
||||
- **memory** (fallback when tmux is absent, or `WILDPC_API_AGENT_BACKEND=memory`):
|
||||
wildpc-api owns the pty directly and fans output out to subscribers with a
|
||||
scrollback buffer. Simpler, but sessions die on restart.
|
||||
|
||||
The WebSocket handler is backend-agnostic: it calls `manager.create/get_info/
|
||||
list/close`, then `manager.attach(id)` and drives the returned `Attachment`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Protocol
|
||||
|
||||
from wildpc_core.config import WILDPC_HOME
|
||||
|
||||
from wildpc_api.pty_session import PtySession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCROLLBACK_BYTES = 256 * 1024 # memory backend replay cap
|
||||
_EXITED_TTL = 3600 # memory backend: reap exited sessions after this
|
||||
|
||||
|
||||
class Attachment(Protocol):
|
||||
"""One WebSocket's live view of a session."""
|
||||
|
||||
def reader(self) -> AsyncIterator[bytes]: ...
|
||||
def write(self, data: bytes) -> None: ...
|
||||
def resize(self, cols: int, rows: int) -> None: ...
|
||||
async def aclose(self) -> None: ...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MemSession:
|
||||
def __init__(self, sid: str, agent: str, command: str, cwd: str) -> None:
|
||||
self.id = sid
|
||||
self.agent = agent
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
self.created_at = time.time()
|
||||
self.pty: PtySession | None = None
|
||||
self.scrollback = bytearray()
|
||||
self.subscribers: set[asyncio.Queue[bytes | None]] = set()
|
||||
self.exited = False
|
||||
self.exit_code: int | None = None
|
||||
self.exited_at: float | None = None
|
||||
|
||||
def _on_output(self, data: bytes) -> None:
|
||||
self.scrollback.extend(data)
|
||||
if len(self.scrollback) > _SCROLLBACK_BYTES:
|
||||
del self.scrollback[: len(self.scrollback) - _SCROLLBACK_BYTES]
|
||||
for q in list(self.subscribers):
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
except asyncio.QueueFull:
|
||||
self.subscribers.discard(q)
|
||||
|
||||
def _on_exit(self) -> None:
|
||||
self.exited = True
|
||||
self.exit_code = self.pty.proc.returncode if self.pty else None
|
||||
self.exited_at = time.time()
|
||||
for q in list(self.subscribers):
|
||||
q.put_nowait(None)
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return not self.exited and self.pty is not None and self.pty.running
|
||||
|
||||
def info(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"agent": self.agent,
|
||||
"command": self.command,
|
||||
"cwd": self.cwd,
|
||||
"created_at": self.created_at,
|
||||
"running": self.running,
|
||||
"exited": self.exited,
|
||||
"exit_code": self.exit_code,
|
||||
"cols": self.pty.cols if self.pty else None,
|
||||
"rows": self.pty.rows if self.pty else None,
|
||||
"clients": len(self.subscribers),
|
||||
}
|
||||
|
||||
|
||||
class _MemAttachment:
|
||||
def __init__(
|
||||
self, session: _MemSession, q: asyncio.Queue[bytes | None], snap: bytes
|
||||
):
|
||||
self._s = session
|
||||
self._q = q
|
||||
self._snap = snap
|
||||
|
||||
async def reader(self) -> AsyncIterator[bytes]:
|
||||
if self._snap:
|
||||
yield self._snap
|
||||
while (chunk := await self._q.get()) is not None:
|
||||
yield chunk
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
if self._s.pty:
|
||||
self._s.pty.write(data)
|
||||
|
||||
def resize(self, cols: int, rows: int) -> None:
|
||||
if self._s.pty:
|
||||
self._s.pty.resize(cols, rows)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self._s.subscribers.discard(self._q)
|
||||
|
||||
|
||||
class MemoryManager:
|
||||
backend = "memory"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sessions: dict[str, _MemSession] = {}
|
||||
|
||||
def _reap(self) -> None:
|
||||
now = time.time()
|
||||
for sid in [
|
||||
s.id
|
||||
for s in self._sessions.values()
|
||||
if s.exited and s.exited_at and now - s.exited_at > _EXITED_TTL
|
||||
]:
|
||||
self._sessions.pop(sid, None)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
agent: str,
|
||||
argv: list[str],
|
||||
cwd: str,
|
||||
env: dict[str, str],
|
||||
cols: int,
|
||||
rows: int,
|
||||
) -> str:
|
||||
self._reap()
|
||||
sid = uuid.uuid4().hex[:12]
|
||||
s = _MemSession(sid, agent, shlex.join(argv), cwd)
|
||||
s.pty = await PtySession.start(
|
||||
argv[0],
|
||||
args=argv[1:],
|
||||
cwd=cwd or None,
|
||||
env=env,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
on_output=s._on_output,
|
||||
on_exit=s._on_exit,
|
||||
)
|
||||
self._sessions[sid] = s
|
||||
return sid
|
||||
|
||||
async def get_info(self, sid: str) -> dict | None:
|
||||
s = self._sessions.get(sid)
|
||||
return s.info() if s else None
|
||||
|
||||
async def list(self) -> list[dict]:
|
||||
self._reap()
|
||||
return [
|
||||
s.info()
|
||||
for s in sorted(
|
||||
self._sessions.values(), key=lambda s: s.created_at, reverse=True
|
||||
)
|
||||
]
|
||||
|
||||
async def attach(self, sid: str, cols: int, rows: int) -> Attachment | None:
|
||||
s = self._sessions.get(sid)
|
||||
if s is None or not s.running or s.pty is None:
|
||||
return None
|
||||
# Subscribe + snapshot atomically (no await between → the output callback,
|
||||
# in this same loop, can't interleave), so replay has no gap/dup.
|
||||
q: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=256)
|
||||
s.subscribers.add(q)
|
||||
snap = bytes(s.scrollback)
|
||||
s.pty.resize(cols, rows)
|
||||
return _MemAttachment(s, q, snap)
|
||||
|
||||
async def close(self, sid: str) -> bool:
|
||||
s = self._sessions.pop(sid, None)
|
||||
if s is None:
|
||||
return False
|
||||
if s.pty:
|
||||
await s.pty.close()
|
||||
for q in list(s.subscribers):
|
||||
q.put_nowait(None)
|
||||
return True
|
||||
|
||||
async def close_all(self) -> None:
|
||||
for sid in list(self._sessions):
|
||||
await self.close(sid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tmux backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TMUX_SOCK = WILDPC_HOME / "run" / "agents.sock"
|
||||
_PREFIX = "wildpc-"
|
||||
_KEEPALIVE = "__keepalive__" # hidden session that keeps the isolated server alive
|
||||
|
||||
|
||||
class _TmuxAttachment:
|
||||
def __init__(self, pty: PtySession, q: asyncio.Queue[bytes | None]):
|
||||
self._pty = pty
|
||||
self._q = q
|
||||
|
||||
@classmethod
|
||||
async def start(
|
||||
cls, tmux_bin: str, sock: str, name: str, cols: int, rows: int
|
||||
) -> _TmuxAttachment:
|
||||
q: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=256)
|
||||
pty = await PtySession.start(
|
||||
tmux_bin,
|
||||
args=["-S", sock, "attach-session", "-t", name],
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
on_output=lambda d: q.put_nowait(d),
|
||||
on_exit=lambda: q.put_nowait(None),
|
||||
)
|
||||
return cls(pty, q)
|
||||
|
||||
async def reader(self) -> AsyncIterator[bytes]:
|
||||
while (chunk := await self._q.get()) is not None:
|
||||
yield chunk
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self._pty.write(data)
|
||||
|
||||
def resize(self, cols: int, rows: int) -> None:
|
||||
self._pty.resize(cols, rows)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._pty.close() # kills the attach client → detaches; session lives
|
||||
|
||||
|
||||
class TmuxManager:
|
||||
backend = "tmux"
|
||||
|
||||
def __init__(self, tmux_bin: str) -> None:
|
||||
self._bin = tmux_bin
|
||||
self._sock = str(_TMUX_SOCK)
|
||||
self._ready = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _raw(self, *args: str) -> tuple[int, str, str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
self._bin,
|
||||
"-S",
|
||||
self._sock,
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
out, err = await proc.communicate()
|
||||
return (
|
||||
proc.returncode or 0,
|
||||
out.decode(errors="replace"),
|
||||
err.decode(errors="replace"),
|
||||
)
|
||||
|
||||
async def _server_running(self) -> bool:
|
||||
_, out, err = await self._raw("list-sessions")
|
||||
text = (out + err).lower()
|
||||
return not any(
|
||||
m in text for m in ("no server", "error connecting", "no such file")
|
||||
)
|
||||
|
||||
async def _ensure(self) -> None:
|
||||
if self._ready:
|
||||
return
|
||||
async with self._lock:
|
||||
if self._ready:
|
||||
return
|
||||
_TMUX_SOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not await self._server_running():
|
||||
# Start the server inside its OWN systemd scope so it is NOT in
|
||||
# wildpc-api's cgroup (which a restart kills). It must be born with
|
||||
# a keepalive session: an empty server races its own empty-exit
|
||||
# check against `exit-empty off` and dies, after which a plain
|
||||
# `new-session` would restart it inside wildpc-api's cgroup. The
|
||||
# keepalive (a detached `sleep infinity`, name-filtered from
|
||||
# listings) guarantees the isolated server persists.
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"systemd-run",
|
||||
"--user",
|
||||
"--scope",
|
||||
"--quiet",
|
||||
"--collect",
|
||||
self._bin,
|
||||
"-S",
|
||||
self._sock,
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
_KEEPALIVE,
|
||||
"sleep infinity",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, err = await proc.communicate()
|
||||
if proc.returncode != 0 or not await self._server_running():
|
||||
logger.warning(
|
||||
"agent tmux server not isolated (systemd-run: %s); sessions "
|
||||
"will still work but won't survive a wildpc-api restart",
|
||||
err.decode(errors="replace").strip() or "unknown error",
|
||||
)
|
||||
# Clean, terminal-like defaults (idempotent).
|
||||
for opt in (
|
||||
("set", "-g", "status", "off"), # no tmux status bar
|
||||
("set", "-g", "window-size", "latest"), # newest client's size wins
|
||||
("set", "-g", "escape-time", "0"),
|
||||
("set", "-g", "destroy-unattached", "off"),
|
||||
("set", "-s", "exit-empty", "off"),
|
||||
):
|
||||
await self._raw(*opt)
|
||||
self._ready = True
|
||||
|
||||
async def create(
|
||||
self,
|
||||
agent: str,
|
||||
argv: list[str],
|
||||
cwd: str,
|
||||
env: dict[str, str],
|
||||
cols: int,
|
||||
rows: int,
|
||||
) -> str:
|
||||
await self._ensure()
|
||||
sid = uuid.uuid4().hex[:12]
|
||||
name = f"{_PREFIX}{sid}"
|
||||
cmd = shlex.join(argv) # tmux runs this as the pane's shell-command
|
||||
args = [
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
name,
|
||||
"-x",
|
||||
str(cols),
|
||||
"-y",
|
||||
str(rows),
|
||||
]
|
||||
if cwd:
|
||||
args += ["-c", cwd]
|
||||
for k, v in env.items():
|
||||
args += ["-e", f"{k}={v}"]
|
||||
args += [cmd]
|
||||
rc, _, err = await self._raw(*args)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"tmux new-session failed: {err.strip()}")
|
||||
await self._raw("set-option", "-t", name, "@agent", agent)
|
||||
return sid
|
||||
|
||||
async def _list_raw(self) -> list[dict]:
|
||||
fmt = "#{session_name}\t#{session_created}\t#{@agent}\t#{session_attached}\t#{window_width}\t#{window_height}"
|
||||
rc, out, _ = await self._raw("list-sessions", "-F", fmt)
|
||||
if rc != 0:
|
||||
return []
|
||||
rows: list[dict] = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 6 or not parts[0].startswith(_PREFIX):
|
||||
continue
|
||||
name, created, agent, attached, w, h = parts[:6]
|
||||
rows.append(
|
||||
{
|
||||
"id": name[len(_PREFIX) :],
|
||||
"agent": agent or name[len(_PREFIX) :],
|
||||
"command": "",
|
||||
"cwd": "",
|
||||
"created_at": float(created) if created.isdigit() else None,
|
||||
"running": True,
|
||||
"exited": False,
|
||||
"exit_code": None,
|
||||
"cols": int(w) if w.isdigit() else None,
|
||||
"rows": int(h) if h.isdigit() else None,
|
||||
"clients": int(attached) if attached.isdigit() else 0,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
async def get_info(self, sid: str) -> dict | None:
|
||||
for row in await self._list_raw():
|
||||
if row["id"] == sid:
|
||||
return row
|
||||
return None
|
||||
|
||||
async def list(self) -> list[dict]:
|
||||
rows = await self._list_raw()
|
||||
rows.sort(key=lambda r: r["created_at"] or 0, reverse=True)
|
||||
return rows
|
||||
|
||||
async def attach(self, sid: str, cols: int, rows: int) -> Attachment | None:
|
||||
if await self.get_info(sid) is None:
|
||||
return None
|
||||
return await _TmuxAttachment.start(
|
||||
self._bin, self._sock, f"{_PREFIX}{sid}", cols, rows
|
||||
)
|
||||
|
||||
async def close(self, sid: str) -> bool:
|
||||
rc, _, _ = await self._raw("kill-session", "-t", f"{_PREFIX}{sid}")
|
||||
return rc == 0
|
||||
|
||||
async def close_all(self) -> None:
|
||||
# No-op: tmux sessions are meant to SURVIVE a wildpc-api shutdown.
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_manager() -> MemoryManager | TmuxManager:
|
||||
choice = os.environ.get("WILDPC_API_AGENT_BACKEND", "auto").lower()
|
||||
tmux_bin = shutil.which("tmux")
|
||||
if choice == "memory":
|
||||
return MemoryManager()
|
||||
if choice == "tmux" and not tmux_bin:
|
||||
raise RuntimeError("WILDPC_API_AGENT_BACKEND=tmux but tmux is not on PATH")
|
||||
if tmux_bin and choice in ("auto", "tmux"):
|
||||
return TmuxManager(tmux_bin)
|
||||
return MemoryManager()
|
||||
|
||||
|
||||
manager: MemoryManager | TmuxManager = _make_manager()
|
||||
257
wildpc-api/src/wildpc_api/agents.py
Normal file
257
wildpc-api/src/wildpc_api/agents.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""Agent terminal UX — list agents, run/resume them in a terminal over WebSocket.
|
||||
|
||||
Wild PC is assistant-agnostic: it launches a configured command (or a shell) in a
|
||||
real tty and streams raw bytes to an xterm.js terminal in the browser. Sessions
|
||||
outlive their WebSocket connection (and, with the tmux backend, a wildpc-api
|
||||
restart), so they can be listed, resumed, and killed. The session backend lives
|
||||
in `agent_sessions.py`; this module is the HTTP/WebSocket surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from wildpc_api.agent_registry import (
|
||||
default_cwd,
|
||||
list_agent_history,
|
||||
list_agents,
|
||||
resolve_agent,
|
||||
resume_argv,
|
||||
)
|
||||
from wildpc_api.agent_sessions import manager
|
||||
from wildpc_api.config import get_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/agents", tags=["agents"])
|
||||
|
||||
# The reserved agent name that launches the user's login shell (Option A: plain
|
||||
# terminal). Not a configured agent — Wild PC knows nothing about what you run in it.
|
||||
TERMINAL_AGENT = "terminal"
|
||||
|
||||
|
||||
# --- Origin allowlist (this endpoint is an interactive shell) ---------------
|
||||
|
||||
|
||||
def _origin_allowed(origin: str | None) -> bool:
|
||||
"""Allow same-node/local and configured-domain origins for the WS handshake.
|
||||
|
||||
CORS (`*`) governs XHR, not WebSocket upgrades, so we gate the handshake
|
||||
here. Non-browser clients (no Origin) and localhost are allowed; browser
|
||||
origins must match the gateway domain (or `WILDPC_API_TERMINAL_ORIGINS`).
|
||||
Set `WILDPC_API_ALLOW_ALL_ORIGINS=1` to disable the check entirely.
|
||||
"""
|
||||
if os.environ.get("WILDPC_API_ALLOW_ALL_ORIGINS"):
|
||||
return True
|
||||
if not origin:
|
||||
return True # curl/websocat and other non-browser clients
|
||||
host = (urlparse(origin).hostname or "").lower()
|
||||
if host in ("localhost", "127.0.0.1", "::1"):
|
||||
return True
|
||||
try:
|
||||
domain = get_config().gateway.domain
|
||||
except Exception:
|
||||
domain = None
|
||||
if domain and (host == domain or host.endswith("." + domain)):
|
||||
return True
|
||||
extra = os.environ.get("WILDPC_API_TERMINAL_ORIGINS", "")
|
||||
allowed = {h.strip().lower() for h in extra.split(",") if h.strip()}
|
||||
return host in allowed
|
||||
|
||||
|
||||
# --- HTTP: agents + sessions ------------------------------------------------
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_agents() -> list[dict]:
|
||||
"""List launchable agents with availability (binary present) + default cwd."""
|
||||
return [a.info() for a in list_agents()]
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def get_sessions() -> list[dict]:
|
||||
"""List live terminal sessions (running, backend-managed)."""
|
||||
return await manager.list()
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_history() -> list[dict]:
|
||||
"""Unified list of agents' own past sessions (from each agent's declared
|
||||
`sessions.list_command`), newest first. Agents without the capability
|
||||
(or with none stored) simply contribute nothing."""
|
||||
agents = [a for a in list_agents() if a.available and a.can_list_sessions]
|
||||
results = await asyncio.gather(*(list_agent_history(a) for a in agents))
|
||||
rows = [row for agent_rows in results for row in agent_rows]
|
||||
try:
|
||||
rows.sort(key=lambda r: r["time"] or 0, reverse=True)
|
||||
except TypeError:
|
||||
pass
|
||||
return rows
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_session(session_id: str) -> dict:
|
||||
"""Kill a session and drop it from the backend."""
|
||||
ok = await manager.close(session_id)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="session not found"
|
||||
)
|
||||
return {"status": "closed", "id": session_id}
|
||||
|
||||
|
||||
# --- WebSocket: interactive session ----------------------------------------
|
||||
|
||||
|
||||
def _login_shell() -> str:
|
||||
return os.environ.get("SHELL") or "/bin/bash"
|
||||
|
||||
|
||||
async def _build_launch(
|
||||
name: str, cont: bool = False, resume_session: str | None = None
|
||||
) -> tuple[str, list[str], str, dict[str, str]]:
|
||||
"""Return (agent_label, argv, cwd, env) for a new session.
|
||||
|
||||
Everything runs under the user's *interactive login* shell (`-lic`) so it
|
||||
inherits exactly the environment a real terminal would — the systemd service
|
||||
env is stripped down and would otherwise miss vars from shell rc files (API
|
||||
keys, etc.). Agents are `exec`'d so the terminal's foreground process is the
|
||||
agent itself. `cont` appends the agent's `resume_args`; `resume_session`
|
||||
builds its resume-by-id argv — wildpc only passes declared flags through.
|
||||
"""
|
||||
shell = _login_shell()
|
||||
if name == TERMINAL_AGENT:
|
||||
return TERMINAL_AGENT, [shell, "-l"], default_cwd(), {}
|
||||
agent = resolve_agent(name)
|
||||
if agent is None:
|
||||
raise LookupError(f"unknown agent: {name}")
|
||||
if not agent.available:
|
||||
raise LookupError(f"agent not installed: {name}")
|
||||
if resume_session:
|
||||
inner = resume_argv(agent, resume_session)
|
||||
if inner is None:
|
||||
raise LookupError(f"agent cannot resume by id: {name}")
|
||||
else:
|
||||
inner = [agent.resolved_command or agent.command, *agent.args]
|
||||
if cont and agent.resume_args:
|
||||
inner += agent.resume_args
|
||||
argv = [shell, "-lic", f"exec {shlex.join(inner)}"]
|
||||
return name, argv, agent.cwd, agent.env
|
||||
|
||||
|
||||
@router.websocket("/{name}/session")
|
||||
async def agent_session(ws: WebSocket, name: str) -> None:
|
||||
"""Run (or resume) an agent in a terminal; stream it to the browser.
|
||||
|
||||
Query params (mutually exclusive, all optional):
|
||||
- `session=<id>` resume an existing live session.
|
||||
- `resume_session=<id>` start the agent resumed to one of *its own* past
|
||||
sessions (agent-native, by id).
|
||||
- `mode=continue` start the agent with its `resume_args`.
|
||||
Disconnecting detaches — it does NOT kill the session.
|
||||
"""
|
||||
if not _origin_allowed(ws.headers.get("origin")):
|
||||
await ws.close(code=1008) # policy violation
|
||||
return
|
||||
|
||||
resume_id = ws.query_params.get("session")
|
||||
session_id: str
|
||||
is_resume = False
|
||||
agent_label = name
|
||||
|
||||
try:
|
||||
if resume_id:
|
||||
info = await manager.get_info(resume_id)
|
||||
if not info or not info.get("running"):
|
||||
await ws.accept()
|
||||
await ws.send_json({"type": "error", "error": "session not resumable"})
|
||||
await ws.close()
|
||||
return
|
||||
session_id = resume_id
|
||||
is_resume = True
|
||||
agent_label = info.get("agent") or name
|
||||
else:
|
||||
cont = ws.query_params.get("mode") == "continue"
|
||||
resume_session = ws.query_params.get("resume_session")
|
||||
agent_label, argv, cwd, env = await _build_launch(
|
||||
name, cont, resume_session
|
||||
)
|
||||
session_id = await manager.create(
|
||||
agent_label, argv, cwd, env, cols=80, rows=24
|
||||
)
|
||||
except LookupError as e:
|
||||
await ws.accept()
|
||||
await ws.send_json({"type": "error", "error": str(e)})
|
||||
await ws.close()
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("failed to launch agent %s", name)
|
||||
await ws.accept()
|
||||
await ws.send_json({"type": "error", "error": "failed to launch"})
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
att = await manager.attach(session_id, cols=80, rows=24)
|
||||
if att is None:
|
||||
await ws.send_json({"type": "error", "error": "session not attachable"})
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"type": "session",
|
||||
"id": session_id,
|
||||
"agent": agent_label,
|
||||
"resumed": is_resume,
|
||||
}
|
||||
)
|
||||
|
||||
async def pump() -> None:
|
||||
try:
|
||||
async for chunk in att.reader():
|
||||
if ws.application_state != WebSocketState.CONNECTED:
|
||||
break
|
||||
await ws.send_bytes(chunk)
|
||||
# reader ended without cancellation → the session ended
|
||||
if ws.application_state == WebSocketState.CONNECTED:
|
||||
info = await manager.get_info(session_id)
|
||||
code = info.get("exit_code") if info else None
|
||||
await ws.send_json({"type": "exit", "code": code})
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pump_task = asyncio.create_task(pump())
|
||||
try:
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
if msg["type"] == "websocket.disconnect":
|
||||
break
|
||||
data = msg.get("bytes")
|
||||
text = msg.get("text")
|
||||
if data is not None:
|
||||
att.write(data)
|
||||
elif text is not None:
|
||||
try:
|
||||
ctrl = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if ctrl.get("type") == "resize":
|
||||
att.resize(int(ctrl["cols"]), int(ctrl["rows"]))
|
||||
elif ctrl.get("type") == "input":
|
||||
att.write(str(ctrl.get("data", "")).encode())
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
pump_task.cancel()
|
||||
await att.aclose()
|
||||
# Intentionally do NOT close the session — it lives on for resume.
|
||||
67
wildpc-api/src/wildpc_api/config.py
Normal file
67
wildpc-api/src/wildpc_api/config.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Configuration for wildpc-api."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from wildpc_core.config import WildpcConfig, load_config
|
||||
from wildpc_core.registry import NodeRegistry, load_registry
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Service settings loaded from environment variables."""
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 9020
|
||||
|
||||
# Mesh coordination (all off by default — single-node works without them)
|
||||
nats_enabled: bool = False
|
||||
nats_url: str = "nats://localhost:4222"
|
||||
nats_token: str | None = None
|
||||
mdns_enabled: bool = False
|
||||
|
||||
# LLM assist (off by default). Calls the litellm proxy's OpenAI-compatible
|
||||
# /chat/completions to generate a tool-call schema for CLIs the deterministic
|
||||
# parser can't structure (subcommand trees). The key is NOT stored here — it's
|
||||
# read from the secret backend by name at call time.
|
||||
llm_enabled: bool = False
|
||||
llm_base_url: str = "https://litellm.civil.payne.io/v1"
|
||||
llm_model: str = "qwen"
|
||||
llm_api_key_secret: str = "LITELLM_MASTER_KEY"
|
||||
|
||||
model_config = {
|
||||
"env_prefix": "WILDPC_API_",
|
||||
"env_file": ".env",
|
||||
}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def get_registry() -> NodeRegistry:
|
||||
"""Load the node registry. Raises if not found."""
|
||||
return load_registry()
|
||||
|
||||
|
||||
def get_wildpc_root() -> Path | None:
|
||||
"""Get the wildpc repo root from the registry, if available."""
|
||||
try:
|
||||
registry = load_registry()
|
||||
if registry.node.wildpc_root:
|
||||
return Path(registry.node.wildpc_root)
|
||||
except (FileNotFoundError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_config() -> WildpcConfig:
|
||||
"""Load wildpc.yaml via the registry's wildpc_root.
|
||||
|
||||
Raises FileNotFoundError if repo not available.
|
||||
"""
|
||||
root = get_wildpc_root()
|
||||
if root is None:
|
||||
raise FileNotFoundError(
|
||||
"Wild PC repo not available. Set wildpc_root in registry."
|
||||
)
|
||||
return load_config(root)
|
||||
595
wildpc-api/src/wildpc_api/config_editor.py
Normal file
595
wildpc-api/src/wildpc_api/config_editor.py
Normal file
@@ -0,0 +1,595 @@
|
||||
"""Config editor — read, validate, save, and apply wildpc.yaml changes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Body, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from wildpc_core.config import (
|
||||
KINDS,
|
||||
WildpcConfig,
|
||||
_DEPLOYMENT_ADAPTER,
|
||||
_program_to_yaml_dict,
|
||||
_spec_to_yaml_dict,
|
||||
load_config,
|
||||
parse_gateway,
|
||||
save_config,
|
||||
write_deployment_file,
|
||||
write_program_file,
|
||||
)
|
||||
from wildpc_core.manifest import ProgramSpec, kind_for
|
||||
|
||||
from wildpc_api.config import get_wildpc_root, get_config
|
||||
from wildpc_api.stream import broadcast
|
||||
|
||||
router = APIRouter(prefix="/config", tags=["config"])
|
||||
|
||||
|
||||
class ConfigResponse(BaseModel):
|
||||
yaml_content: str
|
||||
|
||||
|
||||
class ConfigSaveRequest(BaseModel):
|
||||
yaml_content: str
|
||||
|
||||
|
||||
class ConfigSaveResponse(BaseModel):
|
||||
ok: bool
|
||||
program_count: int
|
||||
service_count: int
|
||||
job_count: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
class ApplyResponse(BaseModel):
|
||||
ok: bool
|
||||
actions: list[str]
|
||||
errors: list[str]
|
||||
|
||||
|
||||
class ProgramConfigRequest(BaseModel):
|
||||
config: dict
|
||||
|
||||
|
||||
class ServiceConfigRequest(BaseModel):
|
||||
config: dict
|
||||
|
||||
|
||||
class JobConfigRequest(BaseModel):
|
||||
config: dict
|
||||
|
||||
|
||||
def _require_repo() -> Path:
|
||||
"""Return the wildpc repo root, or raise 503 if it's not available."""
|
||||
root = get_wildpc_root()
|
||||
if root is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Wild PC repo not available on this node.",
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def _aggregate_yaml(config: WildpcConfig) -> str:
|
||||
"""Build a unified virtual wildpc.yaml from the directory-per-resource config."""
|
||||
data: dict = {"gateway": {"port": config.gateway.port}}
|
||||
if config.repo:
|
||||
data["repo"] = str(config.repo)
|
||||
if config.role and config.role != "follower":
|
||||
data["role"] = config.role
|
||||
# `secrets:` isn't modeled on WildpcConfig — surface it from the raw file so the
|
||||
# aggregate view/round-trip includes it.
|
||||
try:
|
||||
raw = yaml.safe_load((config.root / "wildpc.yaml").read_text()) or {}
|
||||
if raw.get("secrets"):
|
||||
data["secrets"] = raw["secrets"]
|
||||
except Exception:
|
||||
pass
|
||||
if config.programs:
|
||||
data["programs"] = {
|
||||
n: _program_to_yaml_dict(s, config) for n, s in config.programs.items()
|
||||
}
|
||||
deps = {n: _spec_to_yaml_dict(s) for _k, n, s in config.all_deployments()}
|
||||
if deps:
|
||||
data["deployments"] = deps
|
||||
return yaml.dump(data, default_flow_style=False, sort_keys=False)
|
||||
|
||||
|
||||
@router.get("", response_model=ConfigResponse)
|
||||
def get_config_yaml() -> ConfigResponse:
|
||||
"""Get a unified virtual wildpc.yaml aggregated from all resource files."""
|
||||
root = _require_repo()
|
||||
config = load_config(root)
|
||||
return ConfigResponse(yaml_content=_aggregate_yaml(config))
|
||||
|
||||
|
||||
@router.put("", response_model=ConfigSaveResponse)
|
||||
def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
|
||||
"""Validate and save wildpc.yaml. Does NOT apply changes."""
|
||||
root = _require_repo()
|
||||
errors: list[str] = []
|
||||
|
||||
# Parse YAML
|
||||
try:
|
||||
data = yaml.safe_load(request.yaml_content)
|
||||
except yaml.YAMLError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid YAML: {e}",
|
||||
)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="YAML must be a mapping",
|
||||
)
|
||||
|
||||
# repo: drives repo-relative source resolution (fall back to existing config)
|
||||
repo_path = None
|
||||
if data.get("repo"):
|
||||
repo_path = Path(data["repo"]).expanduser()
|
||||
else:
|
||||
try:
|
||||
repo_path = load_config(root).repo
|
||||
except Exception:
|
||||
repo_path = None
|
||||
|
||||
def _resolve_source(spec: ProgramSpec) -> None:
|
||||
if not spec.source:
|
||||
return
|
||||
if spec.source.startswith("repo:") and repo_path:
|
||||
spec.source = str(repo_path / spec.source[5:])
|
||||
elif not Path(spec.source).is_absolute():
|
||||
spec.source = str(root / spec.source)
|
||||
|
||||
# Validate programs
|
||||
programs: dict[str, ProgramSpec] = {}
|
||||
programs_data = data.get("programs") or {}
|
||||
for name, comp_data in programs_data.items():
|
||||
try:
|
||||
comp_data_copy = dict(comp_data) if comp_data else {}
|
||||
comp_data_copy["id"] = name
|
||||
spec = ProgramSpec.model_validate(comp_data_copy)
|
||||
_resolve_source(spec)
|
||||
programs[name] = spec
|
||||
except Exception as e:
|
||||
errors.append(f"programs.{name}: {e}")
|
||||
|
||||
# Validate deployments (a flat name→spec map in the manager-discriminated shape).
|
||||
deployments = {}
|
||||
for name, dep_data in (data.get("deployments") or {}).items():
|
||||
try:
|
||||
dep_copy = dict(dep_data) if dep_data else {}
|
||||
dep_copy["id"] = name
|
||||
deployments[name] = _DEPLOYMENT_ADAPTER.validate_python(dep_copy)
|
||||
except Exception as e:
|
||||
errors.append(f"deployments.{name}: {e}")
|
||||
|
||||
if errors:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"errors": errors},
|
||||
)
|
||||
|
||||
prog_count = len(programs)
|
||||
svc_count = sum(1 for d in deployments.values() if kind_for(d) == "service")
|
||||
job_count = sum(1 for d in deployments.values() if kind_for(d) == "job")
|
||||
|
||||
config = WildpcConfig(
|
||||
root=root,
|
||||
repo=repo_path,
|
||||
# Parse the FULL gateway block (tls/domain/tunnel/cert_hook/…), not just
|
||||
# port — otherwise a whole-file save silently wipes the gateway config.
|
||||
gateway=parse_gateway(data.get("gateway", {})),
|
||||
programs=programs,
|
||||
deployments=deployments,
|
||||
)
|
||||
save_config(config)
|
||||
|
||||
return ConfigSaveResponse(
|
||||
ok=True,
|
||||
program_count=prog_count,
|
||||
service_count=svc_count,
|
||||
job_count=job_count,
|
||||
errors=[],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/programs/{name}")
|
||||
def save_program(name: str, request: ProgramConfigRequest) -> dict:
|
||||
"""Update a single program's config in wildpc.yaml (PATCH semantics).
|
||||
|
||||
Like deployments: the incoming config is shallow-merged over the existing
|
||||
program spec, so a partial save can't drop source/stack/commands/build/… that
|
||||
the client didn't send. Omitted keys are preserved; an explicit ``null`` clears.
|
||||
"""
|
||||
_require_repo()
|
||||
config = get_config()
|
||||
incoming = dict(request.config)
|
||||
|
||||
if name in config.programs:
|
||||
base = config.programs[name].model_dump(mode="json", exclude_none=True)
|
||||
merged = {**base, **incoming, "id": name}
|
||||
merged = {k: v for k, v in merged.items() if v is not None}
|
||||
else:
|
||||
merged = {**incoming, "id": name}
|
||||
|
||||
try:
|
||||
spec = ProgramSpec.model_validate(merged)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid program config: {e}",
|
||||
)
|
||||
|
||||
config.programs[name] = spec
|
||||
write_program_file(config, name) # PATCH: only this program file
|
||||
return {"ok": True, "program": name}
|
||||
|
||||
|
||||
@router.delete("/programs/{name}")
|
||||
async def delete_program(name: str, cascade: bool = False) -> dict:
|
||||
"""Remove a program from wildpc.yaml.
|
||||
|
||||
Without ``cascade``, refuses (409) if any deployment still references the
|
||||
program. With ``cascade=true`` it first tears down and removes those
|
||||
deployments — dispatched by manager: uninstall a tool from PATH, stop+disable
|
||||
a service or job, drop a static route — so nothing is left running, installed,
|
||||
or served, then removes the program. A program and its 1:1 tool/static
|
||||
deployment are one thing to the user, so this makes "Delete" just work.
|
||||
"""
|
||||
config = get_config()
|
||||
if name not in config.programs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Program '{name}' not found",
|
||||
)
|
||||
refs = [(k, d) for k, d, spec in config.all_deployments() if spec.program == name]
|
||||
if refs and not cascade:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
f"'{name}' still has deployments ({', '.join(d for _k, d in refs)}). "
|
||||
"Delete them first, or pass cascade=true to remove them too."
|
||||
),
|
||||
)
|
||||
|
||||
removed: list[str] = []
|
||||
if refs:
|
||||
from wildpc_core.lifecycle import deactivate
|
||||
|
||||
for kind, ref in refs:
|
||||
# Best-effort teardown (uninstall/stop/disable); still remove the config
|
||||
# even if the runtime is already gone.
|
||||
try:
|
||||
await deactivate(ref, kind, config, config.root)
|
||||
except Exception:
|
||||
pass
|
||||
del config.store_for(kind)[ref]
|
||||
write_deployment_file(config, kind, ref) # unlinks the removed deployment
|
||||
removed.append(ref)
|
||||
|
||||
del config.programs[name]
|
||||
write_program_file(config, name) # unlinks the program file only
|
||||
|
||||
if removed:
|
||||
# Converge the runtime: prune any orphan units and regenerate the Caddyfile
|
||||
# (dropping static routes), then reload the gateway.
|
||||
from wildpc_core.deploy import deploy
|
||||
|
||||
try:
|
||||
deploy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"program": name,
|
||||
"action": "deleted",
|
||||
"removed_deployments": removed,
|
||||
}
|
||||
|
||||
|
||||
def _save_deployment(name: str, config_dict: dict, kind: str | None = None) -> dict:
|
||||
"""Create/update a deployment (any manager) with PATCH semantics.
|
||||
|
||||
The incoming config is shallow-merged over the existing spec, so a save can
|
||||
never silently drop a field the client didn't send (the astro/postgres bug):
|
||||
a present key replaces wholesale, an **omitted** key is preserved, and an
|
||||
explicit ``null`` clears the key (back to its default). On CREATE there's no
|
||||
base, so the incoming config stands alone.
|
||||
|
||||
``kind`` pins the twin this save targets — a kind-scoped endpoint
|
||||
(``/services|/jobs|/tools|/static``) passes it so a partial patch to a
|
||||
``backup`` service can never bleed into a ``backup`` job/tool sharing the
|
||||
name. The kind-agnostic ``/deployments/{name}`` leaves it None and infers.
|
||||
"""
|
||||
_require_repo()
|
||||
config = get_config()
|
||||
incoming = dict(config_dict)
|
||||
|
||||
# Resolve the (name, kind) this save targets. An explicit kind is
|
||||
# authoritative (kind-scoped endpoint). Otherwise: a partial patch (e.g. just
|
||||
# {reach: off}) has no manager, so we can't derive kind from it — prefer the
|
||||
# existing same-named deployment when there's exactly one; else derive the
|
||||
# kind from the incoming spec (a create, or disambiguating a shared name).
|
||||
named = config.deployments_named(name)
|
||||
existing = None
|
||||
if kind is not None:
|
||||
existing = config.deployment(kind, name)
|
||||
elif len(named) == 1:
|
||||
existing = named[0][1]
|
||||
else:
|
||||
try:
|
||||
probe = _DEPLOYMENT_ADAPTER.validate_python({**incoming, "id": name})
|
||||
existing = config.deployment(kind_for(probe), name)
|
||||
except Exception:
|
||||
existing = None
|
||||
|
||||
if existing is not None:
|
||||
base = existing.model_dump(mode="json", exclude_none=True)
|
||||
merged = {**base, **incoming, "id": name}
|
||||
# An explicit null means "clear" — drop the key so its default applies.
|
||||
merged = {k: v for k, v in merged.items() if v is not None}
|
||||
else:
|
||||
merged = {**incoming, "id": name}
|
||||
# On CREATE with no description, inherit the referenced program's.
|
||||
if not merged.get("description"):
|
||||
prog = merged.get("program")
|
||||
if prog and prog in config.programs and config.programs[prog].description:
|
||||
merged["description"] = config.programs[prog].description
|
||||
|
||||
try:
|
||||
dep = _DEPLOYMENT_ADAPTER.validate_python(merged)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid deployment config: {e}",
|
||||
)
|
||||
target_kind = kind_for(dep)
|
||||
# A field edit that changes the derived kind (e.g. adds a schedule) moves the
|
||||
# spec to the new store; drop the stale entry under the requested kind.
|
||||
if kind is not None and target_kind != kind:
|
||||
config.store_for(kind).pop(name, None)
|
||||
write_deployment_file(config, kind, name) # spec now absent → unlinks old file
|
||||
config.store_for(target_kind)[name] = dep
|
||||
write_deployment_file(config, target_kind, name) # PATCH: only this file
|
||||
return {"ok": True, "deployment": name}
|
||||
|
||||
|
||||
def _delete_deployment(name: str, kind: str | None = None) -> dict:
|
||||
"""Remove a deployment. A kind-scoped delete drops only that twin; the
|
||||
kind-agnostic path removes every kind sharing the name."""
|
||||
config = get_config()
|
||||
removed_kinds = []
|
||||
kinds = (kind,) if kind is not None else KINDS
|
||||
for k in kinds:
|
||||
if name in config.store_for(k):
|
||||
del config.store_for(k)[name]
|
||||
removed_kinds.append(k)
|
||||
if not removed_kinds:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Deployment '{name}' not found",
|
||||
)
|
||||
for k in removed_kinds:
|
||||
write_deployment_file(config, k, name) # spec absent → unlinks
|
||||
return {"ok": True, "deployment": name, "action": "deleted"}
|
||||
|
||||
|
||||
# The deployment endpoints — `deployments` is canonical; `services`/`jobs` remain
|
||||
# as aliases (the kind is derived, so all three target the one collection).
|
||||
@router.put("/deployments/{name}")
|
||||
def save_deployment(name: str, request: ServiceConfigRequest) -> dict:
|
||||
"""Create/update a deployment of any kind (service/job/tool/static)."""
|
||||
return _save_deployment(name, request.config)
|
||||
|
||||
|
||||
@router.delete("/deployments/{name}")
|
||||
def delete_deployment(name: str) -> dict:
|
||||
return _delete_deployment(name)
|
||||
|
||||
|
||||
class EnabledRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.put("/deployments/{name}/enabled")
|
||||
def set_deployment_enabled(name: str, request: EnabledRequest) -> dict:
|
||||
"""Set a deployment's declared `enabled` state (desired on/off).
|
||||
|
||||
Edits config only — the caller runs `POST /apply` to converge. Keeps the
|
||||
declarative flow: change what you want, then apply.
|
||||
"""
|
||||
config = get_config()
|
||||
deps = config.deployments_named(name)
|
||||
if not deps:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Deployment '{name}' not found",
|
||||
)
|
||||
# A name may span kinds — toggle all of them together.
|
||||
for kind, dep in deps:
|
||||
dep.enabled = request.enabled
|
||||
write_deployment_file(config, kind, name)
|
||||
return {"ok": True, "deployment": name, "enabled": request.enabled}
|
||||
|
||||
|
||||
# Kind-scoped endpoints — pin the twin so a save/delete can't hit a same-named
|
||||
# deployment of another kind (a `backup` service vs job vs tool).
|
||||
@router.put("/services/{name}")
|
||||
def save_service(name: str, request: ServiceConfigRequest) -> dict:
|
||||
"""Create/update the *service* named `name`."""
|
||||
return _save_deployment(name, request.config, kind="service")
|
||||
|
||||
|
||||
@router.delete("/services/{name}")
|
||||
def delete_service(name: str) -> dict:
|
||||
return _delete_deployment(name, kind="service")
|
||||
|
||||
|
||||
@router.put("/jobs/{name}")
|
||||
def save_job(name: str, request: JobConfigRequest) -> dict:
|
||||
"""Create/update the *job* named `name`."""
|
||||
return _save_deployment(name, request.config, kind="job")
|
||||
|
||||
|
||||
@router.delete("/jobs/{name}")
|
||||
def delete_job(name: str) -> dict:
|
||||
return _delete_deployment(name, kind="job")
|
||||
|
||||
|
||||
@router.put("/tools/{name}")
|
||||
def save_tool(name: str, request: ServiceConfigRequest) -> dict:
|
||||
"""Create/update the *tool* named `name`."""
|
||||
return _save_deployment(name, request.config, kind="tool")
|
||||
|
||||
|
||||
@router.delete("/tools/{name}")
|
||||
def delete_tool(name: str) -> dict:
|
||||
return _delete_deployment(name, kind="tool")
|
||||
|
||||
|
||||
@router.post("/tools/{name}/schema")
|
||||
async def generate_tool_schema(
|
||||
name: str, deep: bool = False, assist: str | None = None
|
||||
) -> dict:
|
||||
"""Generate a *draft* tool-call schema (neutral core) from the tool's ``--help``.
|
||||
|
||||
Not saved — the client reviews/edits it and persists via ``PUT
|
||||
/config/tools/{name}`` (the schema rides in the deployment config as
|
||||
``tool_schema``). Two modes:
|
||||
|
||||
* default (``assist`` unset) — deterministic: parse ``--help``. ``deep`` walks
|
||||
subcommands. 422 if the tool isn't installed / emits no help.
|
||||
* ``assist=llm`` — send the recursive ``--help`` to the litellm proxy for a
|
||||
structured schema (the escape hatch for subcommand trees the parser can only
|
||||
render as a ``command`` string). 503 if LLM assist is disabled; 502 on an
|
||||
upstream/validation failure.
|
||||
"""
|
||||
from wildpc_core.tool_schema import (
|
||||
ToolSchemaError,
|
||||
collect_tool_help,
|
||||
derive_tool_schema,
|
||||
)
|
||||
|
||||
config = get_config()
|
||||
if config.deployment("tool", name) is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tool '{name}' not found",
|
||||
)
|
||||
|
||||
if assist == "llm":
|
||||
from wildpc_api.config import settings
|
||||
from wildpc_api.llm import LLMAssistError, generate_tool_schema_llm
|
||||
|
||||
if not settings.llm_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="LLM assist is disabled (set WILDPC_API_LLM_ENABLED=true).",
|
||||
)
|
||||
try:
|
||||
help_text = collect_tool_help(config, name)
|
||||
except ToolSchemaError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)
|
||||
)
|
||||
try:
|
||||
schema = await generate_tool_schema_llm(help_text, name)
|
||||
except LLMAssistError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)
|
||||
)
|
||||
return {"ok": True, "schema": schema, "assist": "llm"}
|
||||
|
||||
try:
|
||||
schema = derive_tool_schema(config, name, deep=deep)
|
||||
except ToolSchemaError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(e),
|
||||
)
|
||||
return {"ok": True, "schema": schema}
|
||||
|
||||
|
||||
@router.post("/tools/schema/validate")
|
||||
def validate_tool_schema_endpoint(core: dict = Body(...)) -> dict:
|
||||
"""Deterministically validate a tool-call schema core (no LLM) — the shape and
|
||||
that ``parameters`` is a valid JSON Schema. Lets the UI check a hand-edited
|
||||
schema. Returns ``{valid, errors}``."""
|
||||
from wildpc_core.tool_schema import validate_tool_schema_core
|
||||
|
||||
errors = validate_tool_schema_core(core)
|
||||
return {"valid": not errors, "errors": errors}
|
||||
|
||||
|
||||
@router.put("/static/{name}")
|
||||
def save_static(name: str, request: ServiceConfigRequest) -> dict:
|
||||
"""Create/update the *static* frontend named `name`."""
|
||||
return _save_deployment(name, request.config, kind="static")
|
||||
|
||||
|
||||
@router.delete("/static/{name}")
|
||||
def delete_static(name: str) -> dict:
|
||||
return _delete_deployment(name, kind="static")
|
||||
|
||||
|
||||
@router.put("/references/{name}")
|
||||
def save_reference(name: str, request: ServiceConfigRequest) -> dict:
|
||||
"""Create/update an external *reference* (manager: none, base_url) — an
|
||||
endpoint wildpc doesn't run (a SaaS API, a remote/external service)."""
|
||||
return _save_deployment(name, request.config, kind="reference")
|
||||
|
||||
|
||||
@router.delete("/references/{name}")
|
||||
def delete_reference(name: str) -> dict:
|
||||
return _delete_deployment(name, kind="reference")
|
||||
|
||||
|
||||
@router.post("/apply", response_model=ApplyResponse)
|
||||
async def apply_config() -> ApplyResponse:
|
||||
"""Converge the running system to match wildpc.yaml (a thin wrapper on core
|
||||
``apply``). Renders units/Caddyfile/tunnel, then reconciles the runtime —
|
||||
activating what's enabled and down, restarting only what changed, deactivating
|
||||
the disabled. Kept as ``/config/apply`` for compatibility; ``/apply`` exposes
|
||||
the same converge with per-deployment targeting and ``--plan``.
|
||||
"""
|
||||
from wildpc_core.deploy import apply
|
||||
|
||||
# apply is blocking (systemctl + gateway reload) — run off the event loop.
|
||||
try:
|
||||
result = await asyncio.to_thread(apply)
|
||||
except Exception as e:
|
||||
return ApplyResponse(ok=False, actions=[], errors=[f"Apply failed: {e}"])
|
||||
|
||||
actions: list[str] = []
|
||||
for verb, names in (
|
||||
("Activated", result.activated),
|
||||
("Restarted", result.restarted),
|
||||
("Deactivated", result.deactivated),
|
||||
):
|
||||
if names:
|
||||
actions.append(f"{verb} {', '.join(sorted(names))}")
|
||||
if not result.changed:
|
||||
actions.append("Already converged — nothing to do")
|
||||
|
||||
await broadcast("config-changed", {"actions": actions})
|
||||
return ApplyResponse(ok=True, actions=actions, errors=[])
|
||||
|
||||
|
||||
async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"systemctl",
|
||||
"--user",
|
||||
action,
|
||||
unit,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
return proc.returncode == 0, (stdout or stderr or b"").decode().strip()
|
||||
72
wildpc-api/src/wildpc_api/deploy_routes.py
Normal file
72
wildpc-api/src/wildpc_api/deploy_routes.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Apply (converge) API endpoint.
|
||||
|
||||
`POST /apply` reconciles the running system to match config — render units/
|
||||
Caddyfile/tunnel, then activate/restart/deactivate to match. It replaces the old
|
||||
`/deploy` (+ separate start/enable calls). `?plan=true` returns the diff without
|
||||
touching anything.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from wildpc_core.config import WildpcDirError
|
||||
from wildpc_core.deploy import apply
|
||||
|
||||
router = APIRouter(tags=["apply"])
|
||||
|
||||
|
||||
class ApplyRequest(BaseModel):
|
||||
"""Optional request body for apply."""
|
||||
|
||||
name: str | None = None
|
||||
plan: bool = False
|
||||
|
||||
|
||||
class ApplyResponse(BaseModel):
|
||||
"""The diff a converge enacted (or would enact, for a plan)."""
|
||||
|
||||
status: str
|
||||
planned: bool
|
||||
changed: bool
|
||||
activated: list[str]
|
||||
restarted: list[str]
|
||||
deactivated: list[str]
|
||||
unchanged: list[str]
|
||||
messages: list[str]
|
||||
|
||||
|
||||
@router.post("/apply", response_model=ApplyResponse)
|
||||
def run_apply(request: ApplyRequest | None = None) -> ApplyResponse:
|
||||
"""Converge the running system to match wildpc.yaml.
|
||||
|
||||
Renders systemd units + the Caddyfile + tunnel config, then reconciles the
|
||||
runtime: activate enabled deployments that are down, restart any whose unit
|
||||
changed, deactivate disabled ones. Pass a name to converge one deployment,
|
||||
or `plan: true` to compute the diff without changing anything.
|
||||
"""
|
||||
name = request.name if request else None
|
||||
plan = request.plan if request else False
|
||||
|
||||
try:
|
||||
result = apply(target_name=name, plan=plan)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except WildpcDirError as e:
|
||||
# A fixable misconfiguration (e.g. data_dir points somewhere unwritable), not a
|
||||
# server fault — return the actionable message so the dashboard can show it.
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
return ApplyResponse(
|
||||
status="ok",
|
||||
planned=result.planned,
|
||||
changed=result.changed,
|
||||
activated=result.activated,
|
||||
restarted=result.restarted,
|
||||
deactivated=result.deactivated,
|
||||
unchanged=result.unchanged,
|
||||
messages=result.messages,
|
||||
)
|
||||
39
wildpc-api/src/wildpc_api/graph.py
Normal file
39
wildpc-api/src/wildpc_api/graph.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""The relationship model as JSON — repos, `requires` edges, derived status.
|
||||
|
||||
Read-only diagnostic (see docs/relationships.md). Everything is computed on the
|
||||
fly: repos from git, predicates (functional/fresh) from config + git. Nothing
|
||||
stored.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from wildpc_core.audit import suggest_consumption
|
||||
from wildpc_core.relations import build_model
|
||||
|
||||
from wildpc_api.config import get_config
|
||||
|
||||
graph_router = APIRouter(tags=["graph"])
|
||||
|
||||
|
||||
@graph_router.get("/graph")
|
||||
def get_graph() -> dict:
|
||||
"""The whole relationship model: repos (with freshness), deployment nodes (with
|
||||
`functional?`), and `requires` edges."""
|
||||
model = build_model(get_config(), check=True, freshness=True)
|
||||
return {
|
||||
"repos": [dataclasses.asdict(r) for r in model.repos],
|
||||
"nodes": [dataclasses.asdict(n) for n in model.nodes],
|
||||
"edges": [dataclasses.asdict(e) for e in model.edges],
|
||||
}
|
||||
|
||||
|
||||
@graph_router.get("/graph/suggestions")
|
||||
def get_suggestions() -> dict:
|
||||
"""Undeclared-consumption *suggestions* — an opt-in advisory that matches each
|
||||
deployment's env endpoint values against provider sockets. Never writes; the
|
||||
graph itself stays declaration-derived. Accept one by declaring the `requires`."""
|
||||
return {"suggestions": [dataclasses.asdict(s) for s in suggest_consumption(get_config())]}
|
||||
72
wildpc-api/src/wildpc_api/health.py
Normal file
72
wildpc-api/src/wildpc_api/health.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Async health checker — fans out HTTP requests to service health endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from wildpc_core.registry import NodeRegistry
|
||||
|
||||
from wildpc_api.models import HealthStatus
|
||||
|
||||
|
||||
async def check_all_health(registry: NodeRegistry) -> list[HealthStatus]:
|
||||
"""Check health of all deployed components.
|
||||
|
||||
Services with a port + health_path are checked via HTTP.
|
||||
Managed services without an HTTP health endpoint fall back to systemd unit status.
|
||||
"""
|
||||
http_targets: list[tuple[str, str]] = []
|
||||
systemd_targets: list[str] = []
|
||||
|
||||
for _kind, name, deployed in registry.all():
|
||||
if deployed.port and deployed.health_path:
|
||||
url = f"http://127.0.0.1:{deployed.port}{deployed.health_path}"
|
||||
http_targets.append((name, url))
|
||||
elif deployed.managed and not deployed.schedule:
|
||||
# Managed service with no HTTP health endpoint — use systemd
|
||||
systemd_targets.append(name)
|
||||
|
||||
tasks: list[asyncio.Task[HealthStatus]] = []
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
for name, url in http_targets:
|
||||
tasks.append(asyncio.ensure_future(_check_http(client, name, url)))
|
||||
for name in systemd_targets:
|
||||
tasks.append(asyncio.ensure_future(_check_systemd(name)))
|
||||
if not tasks:
|
||||
return []
|
||||
return list(await asyncio.gather(*tasks))
|
||||
|
||||
|
||||
async def _check_http(client: httpx.AsyncClient, name: str, url: str) -> HealthStatus:
|
||||
"""Check a single service's health endpoint."""
|
||||
start = time.monotonic()
|
||||
try:
|
||||
resp = await client.get(url)
|
||||
latency = int((time.monotonic() - start) * 1000)
|
||||
# 2xx/3xx = the server answered (a 3xx redirect still means it's alive —
|
||||
# e.g. the gateway's :<port> now redirects to the dashboard subdomain).
|
||||
if resp.status_code < 400:
|
||||
return HealthStatus(id=name, status="up", latency_ms=latency)
|
||||
return HealthStatus(id=name, status="down", latency_ms=latency)
|
||||
except httpx.HTTPError:
|
||||
latency = int((time.monotonic() - start) * 1000)
|
||||
return HealthStatus(id=name, status="down", latency_ms=latency)
|
||||
|
||||
|
||||
async def _check_systemd(name: str) -> HealthStatus:
|
||||
"""Check a managed service's health via its systemd unit state."""
|
||||
unit = f"wildpc-{name}.service"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"systemctl",
|
||||
"--user",
|
||||
"is-active",
|
||||
unit,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
state = (stdout or b"").decode().strip()
|
||||
return HealthStatus(id=name, status="up" if state == "active" else "down")
|
||||
159
wildpc-api/src/wildpc_api/llm.py
Normal file
159
wildpc-api/src/wildpc_api/llm.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""LLM assist — generate a tool-call schema from a CLI's --help via the litellm proxy.
|
||||
|
||||
wildpc's first LLM-backed feature. The deterministic parser
|
||||
(``wildpc_core.tool_schema``) falls back to a single ``command`` string for
|
||||
subcommand trees (git, wildpc); this asks an LLM to read the recursive ``--help``
|
||||
and produce a *structured* neutral tool-call core instead.
|
||||
|
||||
Goes through wildpc's litellm proxy over its OpenAI-compatible
|
||||
``/chat/completions`` (model-agnostic; the fleet standardizes on litellm), using
|
||||
forced tool-calling to constrain the output. Every response is validated
|
||||
deterministically (``validate_tool_schema_core``); on failure the errors are fed
|
||||
back to the model to repair, up to a cap — so a weak model's malformed draft is
|
||||
fixed rather than surfaced. The result is a reviewed draft — never auto-saved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from wildpc_core.config import read_secret
|
||||
from wildpc_core.tool_schema import validate_tool_schema_core
|
||||
|
||||
from wildpc_api.config import settings
|
||||
|
||||
_TIMEOUT = 60.0
|
||||
# Total attempts = 1 initial + repairs. Sonnet is valid first try; the extra
|
||||
# rounds salvage weaker models (repairing a bad enum, etc.).
|
||||
_MAX_ATTEMPTS = 3
|
||||
_SYSTEM = (
|
||||
"You convert a command-line tool's --help output into a tool-call schema for "
|
||||
"an AI agent. Call the `emit_tool_schema` function exactly once. Produce a "
|
||||
"JSON Schema in `parameters` (type: object) whose properties let an agent "
|
||||
"invoke the tool: one property per meaningful option or subcommand path, each "
|
||||
"typed (boolean for flags, string for valued options, enum as a LIST of the "
|
||||
"allowed values for fixed choices) with a concise description drawn from the "
|
||||
"help. For a tool with subcommands, include a `subcommand` property (an enum "
|
||||
"listing the available subcommands) plus the important shared options. `name` "
|
||||
"must match ^[a-zA-Z0-9_-]{1,64}$. Do not invent options not in the help."
|
||||
)
|
||||
|
||||
_EMIT_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "emit_tool_schema",
|
||||
"description": "Emit the structured tool-call definition for this CLI.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Sanitized tool name."},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "One-line description of what the tool does.",
|
||||
},
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"A JSON Schema object (type: object with a properties map) "
|
||||
"describing the tool's invocation arguments."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["name", "description", "parameters"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class LLMAssistError(Exception):
|
||||
"""The LLM assist couldn't produce a valid schema (config, upstream, or output)."""
|
||||
|
||||
|
||||
def _extract_args(data: dict) -> dict | None:
|
||||
"""Pull the emit_tool_schema arguments out of a chat-completions response.
|
||||
|
||||
Returns the parsed arguments dict (even if not yet valid — the caller
|
||||
validates), or None if the model didn't call the function / returned non-JSON.
|
||||
"""
|
||||
try:
|
||||
call = data["choices"][0]["message"]["tool_calls"][0]
|
||||
args = call["function"]["arguments"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return args if isinstance(args, dict) else None
|
||||
|
||||
|
||||
async def _complete(messages: list[dict], key: str) -> dict:
|
||||
"""One forced-tool-call chat completion against the litellm proxy."""
|
||||
payload = {
|
||||
"model": settings.llm_model,
|
||||
"messages": messages,
|
||||
"tools": [_EMIT_TOOL],
|
||||
"tool_choice": {"type": "function", "function": {"name": "emit_tool_schema"}},
|
||||
}
|
||||
url = f"{settings.llm_base_url.rstrip('/')}/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {key}"}
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
try:
|
||||
resp = await client.post(url, json=payload, headers=headers)
|
||||
except httpx.HTTPError as e:
|
||||
raise LLMAssistError(f"litellm request failed: {e}") from e
|
||||
if resp.status_code >= 400:
|
||||
raise LLMAssistError(f"litellm returned {resp.status_code}: {resp.text[:300]}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _repair_message(prior: dict, errors: list[str]) -> dict:
|
||||
"""A user turn that shows the invalid schema + its errors and asks for a fix."""
|
||||
return {
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Your previous emit_tool_schema output failed validation:\n"
|
||||
+ "\n".join(f"- {e}" for e in errors)
|
||||
+ "\n\nHere is what you returned:\n"
|
||||
+ json.dumps(prior, indent=2)
|
||||
+ "\n\nCall emit_tool_schema again with every problem fixed. Remember: "
|
||||
"`enum` must be a list of the allowed values, not a count."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def generate_tool_schema_llm(help_text: str, name: str) -> dict:
|
||||
"""Ask the LLM for a structured neutral tool-call core, repairing invalid
|
||||
output against ``validate_tool_schema_core`` until it passes (up to
|
||||
``_MAX_ATTEMPTS``). Raises ``LLMAssistError`` on config/upstream failure or if
|
||||
it can't be made valid."""
|
||||
key = read_secret(settings.llm_api_key_secret)
|
||||
if not key:
|
||||
raise LLMAssistError(
|
||||
f"LLM assist enabled but secret '{settings.llm_api_key_secret}' is unset."
|
||||
)
|
||||
base = [
|
||||
{"role": "system", "content": _SYSTEM},
|
||||
{"role": "user", "content": f"Tool name: {name}\n\n--- help ---\n{help_text}"},
|
||||
]
|
||||
messages = list(base)
|
||||
errors = ["model did not call emit_tool_schema with JSON arguments"]
|
||||
for _attempt in range(_MAX_ATTEMPTS):
|
||||
data = await _complete(messages, key)
|
||||
args = _extract_args(data)
|
||||
if args is not None:
|
||||
# Pin the name we were given so a model name-drift never costs a repair.
|
||||
args["name"] = name
|
||||
errors = validate_tool_schema_core(args)
|
||||
if not errors:
|
||||
return args
|
||||
# Rebuild from base + a single repair turn (avoids provider-specific
|
||||
# tool_call/tool_result threading; each repair is a fresh forced call).
|
||||
messages = [*base, _repair_message(args or {}, errors)]
|
||||
raise LLMAssistError(
|
||||
f"could not produce a valid schema after {_MAX_ATTEMPTS} attempts: "
|
||||
+ "; ".join(errors)
|
||||
)
|
||||
102
wildpc-api/src/wildpc_api/logs.py
Normal file
102
wildpc-api/src/wildpc_api/logs.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""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 wildpc_core.generators.systemd import unit_name
|
||||
|
||||
from wildpc_api.config import get_wildpc_root
|
||||
|
||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||
|
||||
UNIT_PREFIX = "wildpc-"
|
||||
|
||||
|
||||
@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_wildpc_root()
|
||||
if root:
|
||||
from wildpc_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="Wild PC 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()
|
||||
186
wildpc-api/src/wildpc_api/main.py
Normal file
186
wildpc-api/src/wildpc_api/main.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Wild PC API — dashboard data, health aggregation, and service management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from wildpc_api.agent_sessions import manager as agent_session_manager
|
||||
from wildpc_api.agents import router as agents_router
|
||||
from wildpc_api.config import get_registry, settings
|
||||
from wildpc_api.config_editor import router as config_router
|
||||
from wildpc_api.deploy_routes import router as deploy_router
|
||||
from wildpc_api.graph import graph_router
|
||||
from wildpc_api.repos import repos_router
|
||||
from wildpc_api.logs import router as logs_router
|
||||
from wildpc_api.routes import router as dashboard_router
|
||||
from wildpc_api.secrets import router as secrets_router
|
||||
from wildpc_api.services import router as services_router
|
||||
from wildpc_api.stream import (
|
||||
close_all_subscribers,
|
||||
health_poll_loop,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
)
|
||||
from wildpc_api.nodes import router as nodes_router
|
||||
from wildpc_api.programs import programs_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Set by _watch_shutdown when uvicorn begins its shutdown sequence.
|
||||
_shutting_down = False
|
||||
|
||||
|
||||
async def _watch_shutdown(server: uvicorn.Server) -> None:
|
||||
"""Poll uvicorn's should_exit flag and close SSE subscribers promptly."""
|
||||
while not server.should_exit:
|
||||
await asyncio.sleep(0.5)
|
||||
global _shutting_down
|
||||
_shutting_down = True
|
||||
close_all_subscribers()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
global _shutting_down
|
||||
_shutting_down = False
|
||||
|
||||
poll_task = asyncio.create_task(health_poll_loop())
|
||||
|
||||
# --- Mesh coordination (opt-in) ---
|
||||
nats_client = None
|
||||
mdns_service = None
|
||||
|
||||
if settings.nats_enabled:
|
||||
try:
|
||||
from wildpc_api.nats_client import WildpcNATSClient
|
||||
|
||||
registry = get_registry()
|
||||
nats_client = WildpcNATSClient(
|
||||
local_hostname=registry.node.hostname,
|
||||
local_registry=registry,
|
||||
servers=settings.nats_url,
|
||||
token=settings.nats_token,
|
||||
)
|
||||
await nats_client.start()
|
||||
app.state.nats_client = nats_client
|
||||
except Exception:
|
||||
logger.exception("Failed to start NATS mesh client")
|
||||
nats_client = None
|
||||
|
||||
if settings.mdns_enabled:
|
||||
try:
|
||||
from wildpc_api.mdns import WildpcMDNS
|
||||
|
||||
registry = get_registry()
|
||||
mdns_service = WildpcMDNS(
|
||||
hostname=registry.node.hostname,
|
||||
gateway_port=registry.node.gateway_port,
|
||||
api_port=settings.port,
|
||||
)
|
||||
mdns_service.start()
|
||||
app.state.mdns = mdns_service
|
||||
except Exception:
|
||||
logger.exception("Failed to start mDNS")
|
||||
mdns_service = None
|
||||
|
||||
yield
|
||||
|
||||
_shutting_down = True
|
||||
poll_task.cancel()
|
||||
|
||||
if nats_client:
|
||||
await nats_client.stop()
|
||||
if mdns_service:
|
||||
mdns_service.stop()
|
||||
|
||||
await agent_session_manager.close_all()
|
||||
close_all_subscribers()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="wildpc-api",
|
||||
description="Wild PC API and service management",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(config_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(logs_router)
|
||||
app.include_router(nodes_router)
|
||||
app.include_router(secrets_router)
|
||||
app.include_router(services_router)
|
||||
app.include_router(programs_router)
|
||||
app.include_router(deploy_router)
|
||||
app.include_router(agents_router)
|
||||
app.include_router(graph_router)
|
||||
app.include_router(repos_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
"""Health check endpoint."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/stream")
|
||||
async def sse_stream() -> StreamingResponse:
|
||||
"""SSE stream — pushes health updates and service action events."""
|
||||
q = subscribe()
|
||||
|
||||
async def event_generator() -> AsyncGenerator[str, None]:
|
||||
try:
|
||||
yield "event: connected\ndata: {}\n\n"
|
||||
while True:
|
||||
msg = await q.get()
|
||||
if not msg:
|
||||
break
|
||||
yield msg
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
unsubscribe(q)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
"""Run the application with uvicorn."""
|
||||
config = uvicorn.Config(
|
||||
"wildpc_api.main:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
reload=False,
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
async def serve_with_watcher() -> None:
|
||||
watcher = asyncio.create_task(_watch_shutdown(server))
|
||||
await server.serve()
|
||||
watcher.cancel()
|
||||
|
||||
asyncio.run(serve_with_watcher())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
124
wildpc-api/src/wildpc_api/mdns.py
Normal file
124
wildpc-api/src/wildpc_api/mdns.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""mDNS discovery for wildpc mesh — zero-config LAN peer discovery.
|
||||
|
||||
Advertises this node as _wildpc._tcp and browses for _wildpc._tcp peers. This is
|
||||
the on-LAN convenience path; cross-network meshes use explicit NATS seed URLs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
|
||||
from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WILDPC_SERVICE_TYPE = "_wildpc._tcp.local."
|
||||
|
||||
|
||||
class WildpcMDNS:
|
||||
"""Advertise this wildpc node and discover peers via mDNS."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hostname: str,
|
||||
gateway_port: int,
|
||||
api_port: int,
|
||||
) -> None:
|
||||
self._hostname = hostname
|
||||
self._gateway_port = gateway_port
|
||||
self._api_port = api_port
|
||||
self._zeroconf: Zeroconf | None = None
|
||||
self._browsers: list[ServiceBrowser] = []
|
||||
self._service_info: ServiceInfo | None = None
|
||||
|
||||
# Discovered state
|
||||
self.peers: dict[
|
||||
str, dict
|
||||
] = {} # hostname -> {gateway_port, api_port, addresses}
|
||||
|
||||
def _on_service_state_change(
|
||||
self,
|
||||
zeroconf: Zeroconf,
|
||||
service_type: str,
|
||||
name: str,
|
||||
state_change: ServiceStateChange,
|
||||
) -> None:
|
||||
"""Handle discovered/removed services."""
|
||||
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
|
||||
info = zeroconf.get_service_info(service_type, name)
|
||||
if info is None:
|
||||
return
|
||||
|
||||
if service_type == WILDPC_SERVICE_TYPE:
|
||||
self._handle_wildpc_peer(info)
|
||||
|
||||
elif state_change == ServiceStateChange.Removed:
|
||||
if service_type == WILDPC_SERVICE_TYPE:
|
||||
# Extract hostname from service name (format: "hostname._wildpc._tcp.local.")
|
||||
peer_hostname = name.replace(f".{WILDPC_SERVICE_TYPE}", "")
|
||||
if peer_hostname != self._hostname and peer_hostname in self.peers:
|
||||
del self.peers[peer_hostname]
|
||||
logger.info("mDNS: peer %s removed", peer_hostname)
|
||||
|
||||
def _handle_wildpc_peer(self, info: ServiceInfo) -> None:
|
||||
"""Process a discovered wildpc peer."""
|
||||
props = {
|
||||
k.decode() if isinstance(k, bytes) else k: v.decode()
|
||||
if isinstance(v, bytes)
|
||||
else v
|
||||
for k, v in info.properties.items()
|
||||
}
|
||||
peer_hostname = props.get("hostname", "")
|
||||
if not peer_hostname or peer_hostname == self._hostname:
|
||||
return
|
||||
|
||||
addresses = [
|
||||
socket.inet_ntoa(addr) for addr in info.addresses if len(addr) == 4
|
||||
]
|
||||
|
||||
self.peers[peer_hostname] = {
|
||||
"gateway_port": int(props.get("gateway_port", 9000)),
|
||||
"api_port": int(props.get("api_port", 9020)),
|
||||
"addresses": addresses,
|
||||
}
|
||||
logger.info("mDNS: discovered peer %s at %s", peer_hostname, addresses)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start advertising and browsing."""
|
||||
self._zeroconf = Zeroconf()
|
||||
|
||||
# Advertise ourselves
|
||||
self._service_info = ServiceInfo(
|
||||
WILDPC_SERVICE_TYPE,
|
||||
f"{self._hostname}.{WILDPC_SERVICE_TYPE}",
|
||||
port=self._gateway_port,
|
||||
properties={
|
||||
"hostname": self._hostname,
|
||||
"gateway_port": str(self._gateway_port),
|
||||
"api_port": str(self._api_port),
|
||||
},
|
||||
)
|
||||
self._zeroconf.register_service(self._service_info)
|
||||
logger.info(
|
||||
"mDNS: advertising %s on port %d", self._hostname, self._gateway_port
|
||||
)
|
||||
|
||||
# Browse for peer wildpc nodes
|
||||
self._browsers.append(
|
||||
ServiceBrowser(
|
||||
self._zeroconf,
|
||||
WILDPC_SERVICE_TYPE,
|
||||
handlers=[self._on_service_state_change],
|
||||
)
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop advertising and close."""
|
||||
if self._zeroconf:
|
||||
if self._service_info:
|
||||
self._zeroconf.unregister_service(self._service_info)
|
||||
self._zeroconf.close()
|
||||
self._zeroconf = None
|
||||
self._browsers.clear()
|
||||
logger.info("mDNS: stopped")
|
||||
78
wildpc-api/src/wildpc_api/mesh.py
Normal file
78
wildpc-api/src/wildpc_api/mesh.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Mesh state manager — aggregates remote node registries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from wildpc_core.registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Remote registries older than this are considered stale.
|
||||
STALE_TTL_SECONDS = 300 # 5 minutes
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoteNode:
|
||||
"""A remote node's registry and metadata."""
|
||||
|
||||
registry: NodeRegistry
|
||||
last_seen: float = field(default_factory=time.time)
|
||||
online: bool = True
|
||||
|
||||
@property
|
||||
def is_stale(self) -> bool:
|
||||
return (time.time() - self.last_seen) > STALE_TTL_SECONDS
|
||||
|
||||
|
||||
class MeshStateManager:
|
||||
"""Singleton holding remote node state discovered via MQTT.
|
||||
|
||||
Thread-safe for reads from the FastAPI request handlers.
|
||||
Mutations happen only from the MQTT callback task.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._nodes: dict[str, RemoteNode] = {}
|
||||
|
||||
def update_node(self, hostname: str, registry: NodeRegistry) -> None:
|
||||
"""Add or update a remote node's registry."""
|
||||
self._nodes[hostname] = RemoteNode(registry=registry)
|
||||
logger.info(
|
||||
"Mesh: updated node %s (%d deployed)", hostname, len(registry.deployed)
|
||||
)
|
||||
|
||||
def set_offline(self, hostname: str) -> None:
|
||||
"""Mark a node as offline (LWT received)."""
|
||||
if hostname in self._nodes:
|
||||
self._nodes[hostname].online = False
|
||||
logger.info("Mesh: node %s went offline", hostname)
|
||||
|
||||
def remove_node(self, hostname: str) -> None:
|
||||
"""Remove a node entirely."""
|
||||
if self._nodes.pop(hostname, None):
|
||||
logger.info("Mesh: removed node %s", hostname)
|
||||
|
||||
def get_node(self, hostname: str) -> RemoteNode | None:
|
||||
"""Get a specific remote node."""
|
||||
return self._nodes.get(hostname)
|
||||
|
||||
def all_nodes(self, *, include_stale: bool = False) -> dict[str, RemoteNode]:
|
||||
"""Return all remote nodes, optionally filtering out stale ones."""
|
||||
if include_stale:
|
||||
return dict(self._nodes)
|
||||
return {h: n for h, n in self._nodes.items() if not n.is_stale}
|
||||
|
||||
def prune_stale(self) -> list[str]:
|
||||
"""Remove nodes that have gone stale. Returns list of pruned hostnames."""
|
||||
pruned = [h for h, n in self._nodes.items() if n.is_stale]
|
||||
for h in pruned:
|
||||
del self._nodes[h]
|
||||
logger.info("Mesh: pruned stale node %s", h)
|
||||
return pruned
|
||||
|
||||
|
||||
# Module-level singleton — imported by MQTT client and API routes.
|
||||
mesh_state = MeshStateManager()
|
||||
55
wildpc-api/src/wildpc_api/mesh_gateway.py
Normal file
55
wildpc-api/src/wildpc_api/mesh_gateway.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Regenerate the gateway with cross-node (remote) routes from the live mesh.
|
||||
|
||||
Local routes come from `wildpc apply` (static). Remote routes are dynamic — they
|
||||
appear/vanish as peers join/leave — so the API owns them: on a mesh change it
|
||||
re-renders the Caddyfile (same generator `apply` uses, plus remote routes for
|
||||
online peers) and reloads the gateway iff the content changed.
|
||||
|
||||
Safety: with no cross-node `requires`, the output equals the local-only Caddyfile,
|
||||
so this is a verified no-op until this node actually consumes a peer service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from wildpc_core.config import SPECS_DIR
|
||||
from wildpc_core.generators.caddyfile import generate_caddyfile_from_registry
|
||||
|
||||
from wildpc_api.config import get_registry
|
||||
from wildpc_api.mesh import mesh_state
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_GATEWAY_UNIT = "wildpc-wildpc-gateway.service"
|
||||
|
||||
|
||||
def _regenerate(reload: bool) -> bool:
|
||||
try:
|
||||
reg = get_registry()
|
||||
except Exception:
|
||||
return False
|
||||
remotes = {h: n.registry for h, n in mesh_state.all_nodes().items()}
|
||||
try:
|
||||
content = generate_caddyfile_from_registry(reg, remotes)
|
||||
except Exception:
|
||||
logger.exception("mesh gateway: Caddyfile generation failed")
|
||||
return False
|
||||
path = SPECS_DIR / "Caddyfile"
|
||||
old = path.read_text() if path.exists() else ""
|
||||
if content == old:
|
||||
return False
|
||||
path.write_text(content)
|
||||
logger.info("mesh gateway: Caddyfile updated with cross-node routes")
|
||||
if reload:
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "reload", _GATEWAY_UNIT], check=False
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def refresh_remote_routes(reload: bool = True) -> bool:
|
||||
"""Async wrapper — runs the blocking regen off the event loop."""
|
||||
return await asyncio.to_thread(_regenerate, reload)
|
||||
105
wildpc-api/src/wildpc_api/mesh_wire.py
Normal file
105
wildpc-api/src/wildpc_api/mesh_wire.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Mesh wire format — (de)serialize a NodeRegistry for cross-node transport.
|
||||
|
||||
Transport-agnostic (no MQTT/NATS imports). Only the fields needed for mesh
|
||||
routing are included; env vars, run_cmd, and wildpc_root are **excluded** to
|
||||
avoid leaking secrets — this invariant is load-bearing and must be preserved by
|
||||
any transport that carries this payload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from wildpc_core.registry import (
|
||||
Deployment,
|
||||
NodeConfig,
|
||||
NodeRegistry,
|
||||
)
|
||||
|
||||
|
||||
def registry_to_json(registry: NodeRegistry) -> str:
|
||||
"""Serialize a NodeRegistry to JSON (secret-stripped)."""
|
||||
data: dict = {
|
||||
"node": {
|
||||
"hostname": registry.node.hostname,
|
||||
"gateway_port": registry.node.gateway_port,
|
||||
# acme domain — lets peers build launch URLs (<subdomain>.<gateway_domain>)
|
||||
# for this node's exposed apps. Omitted when the node has no domain.
|
||||
"gateway_domain": registry.node.gateway_domain,
|
||||
# fleet role — so peers know which node is the config/secret authority.
|
||||
"role": registry.node.role,
|
||||
# routable host peers proxy to for this node's services.
|
||||
"address": registry.node.address,
|
||||
},
|
||||
"deployed": {},
|
||||
}
|
||||
|
||||
for _kind, name, comp in registry.all():
|
||||
entry: dict = {
|
||||
"manager": comp.manager,
|
||||
"launcher": comp.launcher,
|
||||
"kind": comp.kind,
|
||||
}
|
||||
if comp.stack:
|
||||
entry["stack"] = comp.stack
|
||||
if comp.description:
|
||||
entry["description"] = comp.description
|
||||
if comp.port is not None:
|
||||
entry["port"] = comp.port
|
||||
if comp.health_path:
|
||||
entry["health_path"] = comp.health_path
|
||||
if comp.subdomain:
|
||||
entry["subdomain"] = comp.subdomain
|
||||
if comp.schedule:
|
||||
entry["schedule"] = comp.schedule
|
||||
if comp.managed:
|
||||
entry["managed"] = comp.managed
|
||||
# Socket surface + external target — so a peer can resolve cross-node
|
||||
# consumption endpoints (still no secrets: only ports/URLs).
|
||||
if getattr(comp, "tcp_port", None) is not None:
|
||||
entry["tcp_port"] = comp.tcp_port
|
||||
if getattr(comp, "base_url", None):
|
||||
entry["base_url"] = comp.base_url
|
||||
# requires — deployment refs (no secrets), so peers can draw cross-node deps.
|
||||
if getattr(comp, "requires", None):
|
||||
entry["requires"] = comp.requires
|
||||
data["deployed"][NodeRegistry.key(comp.kind, name)] = entry
|
||||
|
||||
return json.dumps(data)
|
||||
|
||||
|
||||
def json_to_registry(payload: str) -> NodeRegistry:
|
||||
"""Deserialize a NodeRegistry from a JSON payload."""
|
||||
data = json.loads(payload)
|
||||
node_data = data.get("node", {})
|
||||
node = NodeConfig(
|
||||
hostname=node_data.get("hostname", ""),
|
||||
wildpc_root=node_data.get("wildpc_root"),
|
||||
gateway_port=node_data.get("gateway_port", 9000),
|
||||
gateway_domain=node_data.get("gateway_domain"),
|
||||
role=node_data.get("role", "follower"),
|
||||
address=node_data.get("address"),
|
||||
)
|
||||
deployed: dict[str, Deployment] = {}
|
||||
for key, comp_data in data.get("deployed", {}).items():
|
||||
key_kind, name = key.split("/", 1) if "/" in key else (None, key)
|
||||
kind = comp_data.get("kind") or key_kind or "service"
|
||||
deployed[NodeRegistry.key(kind, name)] = Deployment(
|
||||
manager=comp_data.get("manager", "systemd"),
|
||||
launcher=comp_data.get("launcher"),
|
||||
run_cmd=comp_data.get("run_cmd", []),
|
||||
env=comp_data.get("env", {}),
|
||||
description=comp_data.get("description"),
|
||||
name=name,
|
||||
kind=kind,
|
||||
stack=comp_data.get("stack"),
|
||||
port=comp_data.get("port"),
|
||||
health_path=comp_data.get("health_path"),
|
||||
subdomain=comp_data.get("subdomain"),
|
||||
schedule=comp_data.get("schedule"),
|
||||
managed=comp_data.get("managed", False),
|
||||
tcp_port=comp_data.get("tcp_port"),
|
||||
base_url=comp_data.get("base_url"),
|
||||
requires=comp_data.get("requires", []),
|
||||
)
|
||||
return NodeRegistry(node=node, deployed=deployed)
|
||||
258
wildpc-api/src/wildpc_api/models.py
Normal file
258
wildpc-api/src/wildpc_api/models.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Response models for the dashboard API."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SystemdInfo(BaseModel):
|
||||
"""Systemd unit information for a managed component."""
|
||||
|
||||
unit_name: str
|
||||
unit_path: str
|
||||
timer: bool = False
|
||||
|
||||
|
||||
class DeploymentSummary(BaseModel):
|
||||
"""Summary of a single component."""
|
||||
|
||||
id: str
|
||||
category: str | None = None # "program", "service", or "job"
|
||||
description: str | None = None
|
||||
kind: str | None = None # derived: service|job|tool|static|reference
|
||||
stack: str | None = None
|
||||
manager: str | None = None # systemd|caddy|path|none
|
||||
launcher: str | None = None # python|command|container|compose|node (systemd only)
|
||||
port: int | None = None
|
||||
health_path: str | None = None
|
||||
subdomain: str | None = None # exposed at <subdomain>.<gateway.domain>, else None
|
||||
managed: bool = False
|
||||
systemd: SystemdInfo | None = None
|
||||
version: str | None = None
|
||||
source: str | None = None
|
||||
repo: str | None = None
|
||||
ref: str | None = None
|
||||
commands: dict[str, list[list[str]]] | None = None
|
||||
system_dependencies: list[str] = []
|
||||
schedule: str | None = None
|
||||
installed: bool | None = None
|
||||
active: bool | None = None # uniform lifecycle state (on PATH / running / served)
|
||||
enabled: bool = True # declared desired state; `apply` converges to it
|
||||
node: str | None = None
|
||||
|
||||
|
||||
class DeploymentDetail(DeploymentSummary):
|
||||
"""Full detail for a single component, including raw manifest."""
|
||||
|
||||
manifest: dict
|
||||
|
||||
|
||||
class ServiceSummary(BaseModel):
|
||||
"""Summary of a service — a systemd daemon OR a caddy-served static site.
|
||||
|
||||
Both are "services" (exposed, URL-reachable things); `kind`/`manager`
|
||||
distinguish them (service+systemd vs static+caddy).
|
||||
"""
|
||||
|
||||
id: str
|
||||
description: str | None = None
|
||||
stack: str | None = None
|
||||
kind: str | None = None # service | static
|
||||
manager: str | None = None # systemd | caddy
|
||||
launcher: str | None = None # python|command|container|compose|node (systemd only)
|
||||
run_target: str | None = None # what it runs: program name, argv, image, …
|
||||
port: int | None = None
|
||||
health_path: str | None = None
|
||||
subdomain: str | None = None # exposed at <subdomain>.<gateway.domain>, else None
|
||||
managed: bool = False
|
||||
systemd: SystemdInfo | None = None
|
||||
program: str | None = None # the program this deployment references, if any
|
||||
source: str | None = None
|
||||
enabled: bool = True # declared desired state; `apply` converges to it
|
||||
node: str | None = None
|
||||
|
||||
|
||||
class ServiceDetail(ServiceSummary):
|
||||
"""Full detail for a service, including raw manifest."""
|
||||
|
||||
manifest: dict
|
||||
|
||||
|
||||
class JobSummary(BaseModel):
|
||||
"""Summary of a job (scheduled task)."""
|
||||
|
||||
id: str
|
||||
description: str | None = None
|
||||
stack: str | None = None
|
||||
launcher: str | None = None # python|command|container|compose|node
|
||||
run_target: str | None = None # what it runs: program name, argv, …
|
||||
schedule: str | None = None
|
||||
managed: bool = False
|
||||
systemd: SystemdInfo | None = None
|
||||
program: str | None = None # the program this deployment references, if any
|
||||
source: str | None = None
|
||||
enabled: bool = True # declared desired state; `apply` converges to it
|
||||
node: str | None = None
|
||||
|
||||
|
||||
class JobDetail(JobSummary):
|
||||
"""Full detail for a job, including raw manifest."""
|
||||
|
||||
manifest: dict
|
||||
|
||||
|
||||
class DeploymentRef(BaseModel):
|
||||
"""A reference to one of a program's deployments (name + its derived kind)."""
|
||||
|
||||
name: str
|
||||
kind: str # service | job | tool | static | reference
|
||||
|
||||
|
||||
class ProgramSummary(BaseModel):
|
||||
"""Summary of a program (software catalog entry).
|
||||
|
||||
A program has NO kind of its own — it *has deployments*, each with a kind
|
||||
(a program can be a tool AND a job). `deployments` is that list.
|
||||
"""
|
||||
|
||||
id: str
|
||||
description: str | None = None
|
||||
stack: str | None = None
|
||||
version: str | None = None
|
||||
source: str | None = None
|
||||
repo: str | None = None
|
||||
ref: str | None = None
|
||||
commands: dict[str, list[list[str]]] | None = None
|
||||
system_dependencies: list[str] = []
|
||||
installed: bool | None = None
|
||||
active: bool | None = None # uniform lifecycle state (on PATH / running / served)
|
||||
actions: list[str] = []
|
||||
deployments: list[DeploymentRef] = [] # this program's deployments (name + kind)
|
||||
node: str | None = None
|
||||
|
||||
|
||||
class ProgramDetail(ProgramSummary):
|
||||
"""Full detail for a program, including raw manifest."""
|
||||
|
||||
manifest: dict
|
||||
|
||||
|
||||
class ToolStatusModel(BaseModel):
|
||||
"""One host toolchain a stack needs, and whether it's present where used."""
|
||||
|
||||
command: str
|
||||
purpose: str
|
||||
phase: str # "run" | "build" | "both"
|
||||
present: bool
|
||||
install_hint: str
|
||||
version: str | None = None
|
||||
|
||||
|
||||
class StackStatusModel(BaseModel):
|
||||
"""A stack's dependency health — its tools + who uses it. Powers the Stacks page."""
|
||||
|
||||
name: str
|
||||
tools: list[ToolStatusModel]
|
||||
programs: list[str]
|
||||
deployments: list[str]
|
||||
verbs: list[str]
|
||||
has_enabled_deployment: bool
|
||||
in_use: bool
|
||||
ok: bool
|
||||
|
||||
|
||||
class HealthStatus(BaseModel):
|
||||
"""Health status of a single component."""
|
||||
|
||||
id: str
|
||||
status: str # "up", "down", "unknown"
|
||||
latency_ms: int | None = None
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
"""Aggregated health status for all exposed components."""
|
||||
|
||||
statuses: list[HealthStatus]
|
||||
|
||||
|
||||
class GatewayRoute(BaseModel):
|
||||
"""One gateway route: a public address mapped to a target.
|
||||
|
||||
kind is `static` (Caddy serves a built dir), `proxy` (reverse-proxy a local
|
||||
service), or `remote` (reverse-proxy another node). address is a path prefix
|
||||
(`/foo`) or a host (`foo.lan`); target is the serve dir or `host:port`.
|
||||
"""
|
||||
|
||||
address: str
|
||||
kind: str
|
||||
target: str
|
||||
name: str | None = None
|
||||
node: str
|
||||
# Public exposure via the tunnel: the public URL, or None if this route is
|
||||
# LAN-only. Set when the backing service has `public: true`.
|
||||
public_url: str | None = None
|
||||
|
||||
|
||||
class GatewayInfo(BaseModel):
|
||||
"""Gateway configuration summary."""
|
||||
|
||||
port: int
|
||||
hostname: str
|
||||
deployment_count: int
|
||||
service_count: int
|
||||
managed_count: int
|
||||
routes: list[GatewayRoute] = []
|
||||
# TLS mode: None/"off" → HTTP-only; "acme" → Let's Encrypt wildcard (publicly
|
||||
# trusted, no client CA setup) for host routes.
|
||||
tls: str | None = None
|
||||
# Routing/exposure config (editable from the dashboard).
|
||||
domain: str | None = None # acme zone → <service>.<domain>
|
||||
public_domain: str | None = None # tunnel zone → <service>.<public_domain>
|
||||
tunnel_id: str | None = None
|
||||
tunnel_connected: bool = False # cloudflared service active
|
||||
|
||||
|
||||
class GatewayConfigRequest(BaseModel):
|
||||
"""Editable gateway settings (saved to wildpc.yaml; deploy to apply)."""
|
||||
|
||||
tls: str | None = None
|
||||
domain: str | None = None
|
||||
public_domain: str | None = None
|
||||
tunnel_id: str | None = None
|
||||
|
||||
|
||||
class NodeSummary(BaseModel):
|
||||
"""Summary of a discovered node in the mesh."""
|
||||
|
||||
hostname: str
|
||||
gateway_port: int
|
||||
gateway_domain: str | None = None # acme domain → dashboard at wildpc.<domain>
|
||||
deployed_count: int
|
||||
service_count: int
|
||||
is_local: bool = False
|
||||
online: bool = True
|
||||
is_stale: bool = False
|
||||
last_seen: float | None = None
|
||||
|
||||
|
||||
class NodeDetail(NodeSummary):
|
||||
"""Full detail for a node, including its deployed components."""
|
||||
|
||||
deployed: list[DeploymentSummary] = []
|
||||
|
||||
|
||||
class MeshStatus(BaseModel):
|
||||
"""Current state of the mesh coordination layer."""
|
||||
|
||||
enabled: bool = False
|
||||
connected: bool = False
|
||||
nats_url: str | None = None
|
||||
mdns_enabled: bool = False
|
||||
peer_count: int = 0
|
||||
peers: list[str] = []
|
||||
|
||||
|
||||
class ServiceActionResponse(BaseModel):
|
||||
"""Response from a service management action."""
|
||||
|
||||
program: str
|
||||
action: str
|
||||
status: str
|
||||
276
wildpc-api/src/wildpc_api/nats_client.py
Normal file
276
wildpc-api/src/wildpc_api/nats_client.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""NATS JetStream client for inter-node mesh coordination.
|
||||
|
||||
Replaces the MQTT transport. State lives in a JetStream **KV bucket** rather than
|
||||
retained MQTT messages:
|
||||
|
||||
wildpc-registry — key=<hostname>, value=secret-stripped NodeRegistry JSON
|
||||
|
||||
Lifecycle:
|
||||
* on connect: ensure the bucket, PUT our registry, seed local state from every
|
||||
existing key, then watch the bucket for peer changes.
|
||||
* heartbeat: re-PUT our registry every HEARTBEAT_SEC so peers refresh their
|
||||
last-seen clock (crash liveness rides the existing stale-TTL).
|
||||
* graceful stop: DELETE our key → peers get an immediate offline signal.
|
||||
|
||||
Being asyncio-native, the watch callback calls ``broadcast`` directly — no
|
||||
cross-thread ``run_coroutine_threadsafe`` hop (which the paho client needed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
import nats
|
||||
from nats.js.api import KeyValueConfig
|
||||
|
||||
from wildpc_core.registry import NodeRegistry
|
||||
|
||||
from wildpc_api.mesh import mesh_state
|
||||
from wildpc_api.mesh_gateway import refresh_remote_routes
|
||||
from wildpc_api.mesh_wire import json_to_registry, registry_to_json
|
||||
from wildpc_api.stream import broadcast
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REGISTRY_BUCKET = "wildpc-registry"
|
||||
PRESENCE_BUCKET = "wildpc-presence"
|
||||
CONFIG_BUCKET = "wildpc-config"
|
||||
HEARTBEAT_SEC = 30.0
|
||||
PRUNE_SEC = 30.0
|
||||
PRESENCE_TTL = 90.0 # a node whose presence key expires within this is gone
|
||||
|
||||
|
||||
class WildpcNATSClient:
|
||||
"""Async NATS/JetStream mesh client."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
local_hostname: str,
|
||||
local_registry: NodeRegistry,
|
||||
servers: str | list[str] = "nats://localhost:4222",
|
||||
token: str | None = None,
|
||||
) -> None:
|
||||
self._local_hostname = local_hostname
|
||||
self._local_registry = local_registry
|
||||
self._servers = servers
|
||||
self._token = token or None
|
||||
self._nc: nats.NATS | None = None
|
||||
self._kv = None
|
||||
self._presence_kv = None
|
||||
self._config_kv = None
|
||||
self._tasks: list[asyncio.Task] = []
|
||||
self._last_json: dict[str, str] = {}
|
||||
self._online: set[str] = set()
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._nc is not None and self._nc.is_connected
|
||||
|
||||
@property
|
||||
def servers(self) -> str | list[str]:
|
||||
return self._servers
|
||||
|
||||
@property
|
||||
def role(self) -> str:
|
||||
"""This node's fleet role — 'authority' or 'follower'."""
|
||||
return self._local_registry.node.role
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Connect, publish our registry, seed state, and start watchers."""
|
||||
# A `tls://` server URL makes nats-py verify the server against the system
|
||||
# CA bundle — which trusts the wildcard's Let's Encrypt issuer, so no custom
|
||||
# CA is needed. `token` authenticates this node to the broker.
|
||||
self._nc = await nats.connect(
|
||||
self._servers,
|
||||
name=f"wildpc-{self._local_hostname}",
|
||||
token=self._token,
|
||||
max_reconnect_attempts=-1, # reconnect forever — nodes come and go
|
||||
)
|
||||
js = self._nc.jetstream()
|
||||
self._kv = await self._ensure_bucket(js, REGISTRY_BUCKET, history=1)
|
||||
# Presence: a short-TTL key each node renews; its expiry = the node is gone.
|
||||
self._presence_kv = await self._ensure_bucket(
|
||||
js, PRESENCE_BUCKET, history=1, ttl=PRESENCE_TTL
|
||||
)
|
||||
# Shared config: authority-written, followers watch + reconcile.
|
||||
self._config_kv = await self._ensure_bucket(js, CONFIG_BUCKET, history=5)
|
||||
|
||||
await self.publish_registry(self._local_registry)
|
||||
await self._presence_kv.put(self._local_hostname, b"online")
|
||||
await self._seed_existing()
|
||||
await refresh_remote_routes() # establish any cross-node routes on startup
|
||||
|
||||
self._tasks = [
|
||||
asyncio.create_task(self._watch_loop()),
|
||||
asyncio.create_task(self._config_watch_loop()),
|
||||
asyncio.create_task(self._heartbeat_loop()),
|
||||
asyncio.create_task(self._prune_loop()),
|
||||
]
|
||||
logger.info(
|
||||
"NATS mesh client started (servers=%s, role=%s)",
|
||||
self._servers,
|
||||
self.role,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _ensure_bucket(js, bucket: str, *, history: int = 1, ttl=None):
|
||||
"""Bind an existing KV bucket or create it."""
|
||||
try:
|
||||
return await js.key_value(bucket)
|
||||
except Exception:
|
||||
return await js.create_key_value(
|
||||
config=KeyValueConfig(bucket=bucket, history=history, ttl=ttl)
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Delete our key (immediate offline to peers) and disconnect."""
|
||||
for t in self._tasks:
|
||||
t.cancel()
|
||||
for t in self._tasks:
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await t
|
||||
self._tasks = []
|
||||
if self._kv is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._kv.delete(self._local_hostname)
|
||||
if self._presence_kv is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._presence_kv.delete(self._local_hostname)
|
||||
if self._nc is not None:
|
||||
# Bound the drain so a wedged connection can't hang systemd shutdown.
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.wait_for(self._nc.drain(), timeout=5.0)
|
||||
with contextlib.suppress(Exception):
|
||||
await self._nc.close()
|
||||
self._nc = None
|
||||
logger.info("NATS mesh client stopped")
|
||||
|
||||
async def publish_registry(self, registry: NodeRegistry) -> None:
|
||||
"""PUT (or refresh) our local registry into the KV bucket."""
|
||||
self._local_registry = registry
|
||||
if self._kv is None:
|
||||
return
|
||||
await self._kv.put(
|
||||
self._local_hostname, registry_to_json(registry).encode()
|
||||
)
|
||||
|
||||
async def _seed_existing(self) -> None:
|
||||
"""Load every peer key already present in the bucket."""
|
||||
if self._kv is None:
|
||||
return
|
||||
try:
|
||||
keys = await self._kv.keys()
|
||||
except Exception:
|
||||
keys = [] # empty bucket raises NoKeysError in nats-py
|
||||
for key in keys:
|
||||
if key == self._local_hostname:
|
||||
continue
|
||||
with contextlib.suppress(Exception):
|
||||
entry = await self._kv.get(key)
|
||||
if entry.value:
|
||||
self._apply_put(key, entry.value.decode())
|
||||
|
||||
async def _watch_loop(self) -> None:
|
||||
assert self._kv is not None
|
||||
watcher = await self._kv.watchall()
|
||||
async for entry in watcher:
|
||||
if entry is None: # "caught up with current values" sentinel
|
||||
continue
|
||||
key = entry.key
|
||||
if key == self._local_hostname:
|
||||
continue
|
||||
try:
|
||||
if entry.operation in ("DEL", "PURGE"):
|
||||
await self._apply_delete(key)
|
||||
elif entry.value:
|
||||
changed = self._apply_put(key, entry.value.decode())
|
||||
if changed:
|
||||
await broadcast(
|
||||
"mesh", {"event": "node_updated", "hostname": key}
|
||||
)
|
||||
asyncio.create_task(refresh_remote_routes())
|
||||
except Exception:
|
||||
logger.exception("Error handling mesh entry for %s", key)
|
||||
|
||||
def _apply_put(self, hostname: str, payload: str) -> bool:
|
||||
"""Update mesh state from a peer PUT. Returns True if content changed."""
|
||||
registry = json_to_registry(payload)
|
||||
mesh_state.update_node(hostname, registry) # always refresh last-seen
|
||||
self._online.add(hostname)
|
||||
changed = self._last_json.get(hostname) != payload
|
||||
self._last_json[hostname] = payload
|
||||
return changed
|
||||
|
||||
async def _apply_delete(self, hostname: str) -> None:
|
||||
mesh_state.set_offline(hostname)
|
||||
self._online.discard(hostname)
|
||||
self._last_json.pop(hostname, None)
|
||||
await broadcast("mesh", {"event": "node_offline", "hostname": hostname})
|
||||
asyncio.create_task(refresh_remote_routes())
|
||||
|
||||
async def _heartbeat_loop(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(HEARTBEAT_SEC)
|
||||
with contextlib.suppress(Exception):
|
||||
await self.publish_registry(self._local_registry)
|
||||
if self._presence_kv is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._presence_kv.put(self._local_hostname, b"online")
|
||||
|
||||
# --- Shared config (authority writes, followers reconcile) ---
|
||||
|
||||
async def get_shared_config(self, key: str) -> str | None:
|
||||
"""Read a shared-config value from the mesh (None if unset)."""
|
||||
if self._config_kv is None:
|
||||
return None
|
||||
try:
|
||||
entry = await self._config_kv.get(key)
|
||||
return entry.value.decode() if entry.value else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def put_shared_config(self, key: str, value: str) -> None:
|
||||
"""Write a shared-config value. Only the authority may write."""
|
||||
if self.role != "authority":
|
||||
raise PermissionError("only the authority node may write shared config")
|
||||
if self._config_kv is None:
|
||||
raise RuntimeError("config bucket not available")
|
||||
await self._config_kv.put(key, value.encode())
|
||||
|
||||
async def list_shared_config(self) -> list[str]:
|
||||
"""All shared-config keys (empty if none/unavailable)."""
|
||||
if self._config_kv is None:
|
||||
return []
|
||||
try:
|
||||
return sorted(await self._config_kv.keys())
|
||||
except Exception:
|
||||
return [] # empty bucket raises NoKeysError in nats-py
|
||||
|
||||
async def _config_watch_loop(self) -> None:
|
||||
"""Watch shared config; announce changes so followers can reconcile.
|
||||
|
||||
The reconcile action (trigger `wildpc apply` on a follower) hangs off this
|
||||
SSE event; it's inert on the authority and on a single node.
|
||||
"""
|
||||
if self._config_kv is None:
|
||||
return
|
||||
watcher = await self._config_kv.watchall()
|
||||
async for entry in watcher:
|
||||
if entry is None:
|
||||
continue
|
||||
op = "delete" if entry.operation in ("DEL", "PURGE") else "put"
|
||||
await broadcast(
|
||||
"mesh", {"event": "config_changed", "key": entry.key, "op": op}
|
||||
)
|
||||
|
||||
async def _prune_loop(self) -> None:
|
||||
"""Mark crashed peers (no refresh within the stale TTL) offline."""
|
||||
while True:
|
||||
await asyncio.sleep(PRUNE_SEC)
|
||||
all_nodes = mesh_state.all_nodes(include_stale=True)
|
||||
for host in list(self._online):
|
||||
node = all_nodes.get(host)
|
||||
if node is None or node.is_stale:
|
||||
await self._apply_delete(host)
|
||||
201
wildpc-api/src/wildpc_api/nodes.py
Normal file
201
wildpc-api/src/wildpc_api/nodes.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Nodes router — discover and inspect mesh nodes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from wildpc_api.config import get_registry, settings
|
||||
from wildpc_api.mesh import mesh_state
|
||||
from wildpc_api.models import DeploymentSummary, MeshStatus, NodeDetail, NodeSummary
|
||||
|
||||
router = APIRouter(tags=["nodes"])
|
||||
|
||||
|
||||
class ConfigValue(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
@router.get("/mesh/config")
|
||||
async def list_mesh_config(request: Request) -> dict:
|
||||
"""List shared-config keys + this node's role (only the authority may write)."""
|
||||
client = getattr(request.app.state, "nats_client", None)
|
||||
if client is None:
|
||||
return {"keys": [], "role": get_registry().node.role}
|
||||
return {"keys": await client.list_shared_config(), "role": client.role}
|
||||
|
||||
|
||||
@router.get("/mesh/config/{key:path}")
|
||||
async def get_mesh_config(key: str, request: Request) -> dict:
|
||||
"""Read a shared-config value."""
|
||||
client = getattr(request.app.state, "nats_client", None)
|
||||
value = await client.get_shared_config(key) if client else None
|
||||
if value is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, f"config key '{key}' not set")
|
||||
return {"key": key, "value": value}
|
||||
|
||||
|
||||
@router.put("/mesh/config/{key:path}")
|
||||
async def set_mesh_config(key: str, body: ConfigValue, request: Request) -> dict:
|
||||
"""Write a shared-config value (authority only)."""
|
||||
client = getattr(request.app.state, "nats_client", None)
|
||||
if client is None:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "mesh not enabled")
|
||||
try:
|
||||
await client.put_shared_config(key, body.value)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(exc)) from exc
|
||||
return {"key": key, "ok": True}
|
||||
|
||||
|
||||
def _local_node_summary(registry: object) -> NodeSummary:
|
||||
"""Build a NodeSummary for the local node from the registry."""
|
||||
return NodeSummary(
|
||||
hostname=registry.node.hostname,
|
||||
gateway_port=registry.node.gateway_port,
|
||||
gateway_domain=registry.node.gateway_domain,
|
||||
deployed_count=len(registry.deployed),
|
||||
service_count=sum(1 for d in registry.deployed.values() if d.port is not None),
|
||||
is_local=True,
|
||||
online=True,
|
||||
is_stale=False,
|
||||
)
|
||||
|
||||
|
||||
def _remote_node_summary(hostname: str, remote: object) -> NodeSummary:
|
||||
"""Build a NodeSummary from a RemoteNode."""
|
||||
reg = remote.registry
|
||||
return NodeSummary(
|
||||
hostname=hostname,
|
||||
gateway_port=reg.node.gateway_port,
|
||||
gateway_domain=getattr(reg.node, "gateway_domain", None),
|
||||
deployed_count=len(reg.deployed),
|
||||
service_count=sum(1 for d in reg.deployed.values() if d.port is not None),
|
||||
is_local=False,
|
||||
online=remote.online,
|
||||
is_stale=remote.is_stale,
|
||||
last_seen=remote.last_seen,
|
||||
)
|
||||
|
||||
|
||||
def _deployed_to_summaries(registry: object, hostname: str) -> list[DeploymentSummary]:
|
||||
"""Convert deployed components from a registry into DeploymentSummary list."""
|
||||
summaries = []
|
||||
for _kind, name, d in registry.all():
|
||||
summaries.append(
|
||||
DeploymentSummary(
|
||||
id=name,
|
||||
category="job" if d.schedule else "service",
|
||||
description=d.description,
|
||||
kind=d.kind,
|
||||
stack=d.stack,
|
||||
manager=d.manager,
|
||||
launcher=d.launcher,
|
||||
port=d.port,
|
||||
health_path=d.health_path,
|
||||
subdomain=d.subdomain,
|
||||
managed=d.managed,
|
||||
schedule=d.schedule,
|
||||
node=hostname,
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
@router.get("/mesh/status", response_model=MeshStatus)
|
||||
def get_mesh_status(request: Request) -> MeshStatus:
|
||||
"""Get the current state of the mesh coordination layer."""
|
||||
nats_client = getattr(request.app.state, "nats_client", None)
|
||||
|
||||
peers = list(mesh_state.all_nodes(include_stale=True).keys())
|
||||
|
||||
return MeshStatus(
|
||||
enabled=settings.nats_enabled,
|
||||
connected=nats_client.connected if nats_client else False,
|
||||
nats_url=str(nats_client.servers) if nats_client else None,
|
||||
mdns_enabled=settings.mdns_enabled,
|
||||
peer_count=len(peers),
|
||||
peers=peers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/nodes", response_model=list[NodeSummary])
|
||||
def list_nodes() -> list[NodeSummary]:
|
||||
"""List all known nodes (local + discovered remote)."""
|
||||
registry = get_registry()
|
||||
nodes = [_local_node_summary(registry)]
|
||||
|
||||
for hostname, remote in mesh_state.all_nodes(include_stale=True).items():
|
||||
nodes.append(_remote_node_summary(hostname, remote))
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
_TCP_PROTOCOL = {5432: "pg", 7687: "bolt", 1883: "mqtt", 6379: "redis"}
|
||||
|
||||
|
||||
def _endpoints_of_registry(d: object) -> list[dict]:
|
||||
"""Derive display endpoints from a registry deployment. Mirrors the local
|
||||
relations derivation, which gates the http endpoint on being exposed — here the
|
||||
registry's `subdomain` is that signal (a reach:off service has none). Without
|
||||
this, remote reach:off services show a phantom port (e.g. wildpc-gateway :9000)."""
|
||||
eps: list[dict] = []
|
||||
port = getattr(d, "port", None)
|
||||
if port is not None and getattr(d, "subdomain", None):
|
||||
eps.append({"protocol": "http", "port": port})
|
||||
tcp = getattr(d, "tcp_port", None)
|
||||
if tcp is not None:
|
||||
eps.append({"protocol": _TCP_PROTOCOL.get(tcp, "tcp"), "port": tcp})
|
||||
return eps
|
||||
|
||||
|
||||
@router.get("/mesh/deployments")
|
||||
def mesh_deployments() -> dict:
|
||||
"""Flattened remote (mesh-discovered) deployments with derived endpoints — the
|
||||
data the System Map needs to render other machines. Local node excluded (it's
|
||||
already in /graph). Each entry carries its node's `domain` (gateway acme domain)
|
||||
so peers can build launch URLs `<subdomain>.<domain>` for exposed apps."""
|
||||
out: list[dict] = []
|
||||
for hostname, remote in mesh_state.all_nodes(include_stale=True).items():
|
||||
domain = getattr(remote.registry.node, "gateway_domain", None)
|
||||
for _kind, name, d in remote.registry.all():
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"kind": d.kind,
|
||||
"node": hostname,
|
||||
"domain": domain,
|
||||
"port": d.port,
|
||||
"base_url": getattr(d, "base_url", None),
|
||||
"subdomain": d.subdomain,
|
||||
"endpoints": _endpoints_of_registry(d),
|
||||
"requires": [
|
||||
r.get("ref") for r in (getattr(d, "requires", None) or []) if r.get("ref")
|
||||
],
|
||||
}
|
||||
)
|
||||
return {"deployments": out}
|
||||
|
||||
|
||||
@router.get("/nodes/{hostname}", response_model=NodeDetail)
|
||||
def get_node(hostname: str) -> NodeDetail:
|
||||
"""Get detailed info for a specific node."""
|
||||
registry = get_registry()
|
||||
|
||||
# Local node
|
||||
if hostname == registry.node.hostname:
|
||||
summary = _local_node_summary(registry)
|
||||
deployed = _deployed_to_summaries(registry, hostname)
|
||||
return NodeDetail(**summary.model_dump(), deployed=deployed)
|
||||
|
||||
# Remote node
|
||||
remote = mesh_state.get_node(hostname)
|
||||
if remote is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Node '{hostname}' not found",
|
||||
)
|
||||
|
||||
summary = _remote_node_summary(hostname, remote)
|
||||
deployed = _deployed_to_summaries(remote.registry, hostname)
|
||||
return NodeDetail(**summary.model_dump(), deployed=deployed)
|
||||
303
wildpc-api/src/wildpc_api/programs.py
Normal file
303
wildpc-api/src/wildpc_api/programs.py
Normal file
@@ -0,0 +1,303 @@
|
||||
"""Program action endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from wildpc_core import git
|
||||
from wildpc_core.adopt import (
|
||||
AdoptError,
|
||||
build_adopted_program,
|
||||
is_git_url,
|
||||
looks_like_program,
|
||||
)
|
||||
from wildpc_core.config import write_program_file
|
||||
from wildpc_core.stacks import available_actions, available_stacks, run_action
|
||||
|
||||
from wildpc_api import stream
|
||||
from wildpc_api.config import get_config
|
||||
from wildpc_api.models import StackStatusModel, ToolStatusModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wildpc_core.stack_status import StackStatus
|
||||
|
||||
programs_router = APIRouter(tags=["programs"])
|
||||
|
||||
|
||||
@programs_router.get("/stacks")
|
||||
def list_stacks() -> list[str]:
|
||||
"""Stack names wildpc has handlers for — populates the dashboard's stack select
|
||||
and keeps it in sync with the backend (no hardcoded frontend list)."""
|
||||
return available_stacks()
|
||||
|
||||
|
||||
def _stack_model(st: StackStatus) -> StackStatusModel:
|
||||
return StackStatusModel(
|
||||
name=st.name,
|
||||
tools=[ToolStatusModel(**asdict(t)) for t in st.tools],
|
||||
programs=st.programs,
|
||||
deployments=st.deployments,
|
||||
verbs=st.verbs,
|
||||
has_enabled_deployment=st.has_enabled_deployment,
|
||||
in_use=st.in_use,
|
||||
ok=st.ok,
|
||||
)
|
||||
|
||||
|
||||
@programs_router.get("/stacks/status")
|
||||
def stacks_status() -> list[StackStatusModel]:
|
||||
"""Every stack's dependency health — tools present-where-needed (run-phase tools
|
||||
against the service runtime PATH), who uses it, and the fix for anything missing.
|
||||
The Stacks page renders this; `wildpc stack list` is its CLI twin."""
|
||||
from wildpc_core.stack_status import all_stack_status
|
||||
|
||||
return [_stack_model(s) for s in all_stack_status(get_config())]
|
||||
|
||||
|
||||
@programs_router.get("/stacks/{name}")
|
||||
def stack_detail(name: str) -> StackStatusModel:
|
||||
"""One stack's dependency detail (tool versions included)."""
|
||||
from wildpc_core.stack_status import stack_status
|
||||
|
||||
st = stack_status(get_config(), name)
|
||||
if st is None:
|
||||
raise HTTPException(status_code=404, detail=f"No stack '{name}'")
|
||||
return _stack_model(st)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filesystem browse + adopt — powers the dashboard's "Add program" flow, the
|
||||
# web equivalent of `wildpc program add <path|git-url>`. Programs live on the
|
||||
# server's filesystem, so the picker browses the *server's* dirs (a browser's
|
||||
# native file dialog only sees the client machine).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@programs_router.get("/fs/browse")
|
||||
def browse_filesystem(path: str | None = None) -> dict:
|
||||
"""List sub-directories of ``path`` (default: the repos dir) so the dashboard
|
||||
can browse to a program on the server. Directories only; hidden dirs skipped.
|
||||
Each entry is flagged when it looks adoptable (a project manifest or git repo).
|
||||
"""
|
||||
config = get_config()
|
||||
base = Path(path).expanduser() if path else config.repos_dir
|
||||
try:
|
||||
base = base.resolve()
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid path: {e}")
|
||||
if not base.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Path does not exist: {base}")
|
||||
if not base.is_dir():
|
||||
raise HTTPException(status_code=400, detail=f"Not a directory: {base}")
|
||||
|
||||
try:
|
||||
children = sorted(base.iterdir(), key=lambda p: p.name.lower())
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: {base}")
|
||||
|
||||
entries: list[dict] = []
|
||||
for child in children:
|
||||
if child.name.startswith("."):
|
||||
continue
|
||||
# A single unreadable child (can't stat/traverse) shouldn't sink the whole
|
||||
# listing — skip it rather than 403 the directory.
|
||||
try:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"name": child.name,
|
||||
"path": str(child),
|
||||
"is_program": looks_like_program(child),
|
||||
"is_git": (child / ".git").exists(),
|
||||
}
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
parent = str(base.parent) if base.parent != base else None
|
||||
return {
|
||||
"path": str(base),
|
||||
"parent": parent,
|
||||
"repos_dir": str(config.repos_dir),
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
|
||||
class AdoptRequest(BaseModel):
|
||||
target: str # a local server path or a git URL
|
||||
name: str | None = None
|
||||
description: str = ""
|
||||
|
||||
|
||||
@programs_router.post("/programs/adopt")
|
||||
def adopt_program(request: AdoptRequest) -> dict:
|
||||
"""Adopt an existing repo as a program (the web `wildpc program add`).
|
||||
|
||||
``target`` is a local server path or a git URL. Writes just the new program's
|
||||
file; declaring a deployment (service/job/tool/static) stays a separate step.
|
||||
"""
|
||||
target = request.target.strip()
|
||||
if not target:
|
||||
raise HTTPException(status_code=422, detail="A path or git URL is required.")
|
||||
|
||||
config = get_config()
|
||||
try:
|
||||
adopted = build_adopted_program(
|
||||
config, target, name=request.name, description=request.description
|
||||
)
|
||||
except AdoptError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
config.programs[adopted.name] = adopted.spec
|
||||
write_program_file(config, adopted.name)
|
||||
return {
|
||||
"ok": True,
|
||||
"program": adopted.name,
|
||||
"source": adopted.source,
|
||||
"stack": adopted.stack,
|
||||
"repo": adopted.repo,
|
||||
"commands": adopted.commands,
|
||||
"is_git_url": is_git_url(target),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git sync — pull a program's source working copy up to date (pull only; no
|
||||
# build/apply/restart — converge stays an explicit, separate step). Declared
|
||||
# BEFORE the generic /{action} route below so the literal `git`/`sync` segments
|
||||
# win over the `{action}` path param (Starlette matches in declaration order).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _program_source(name: str):
|
||||
"""The (program, config) for a named program, or raise the standard 404/400s."""
|
||||
config = get_config()
|
||||
if name not in config.programs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
|
||||
)
|
||||
return config.programs[name], config
|
||||
|
||||
|
||||
@programs_router.get("/programs/{name}/git")
|
||||
def program_git_status(name: str) -> dict:
|
||||
"""Git status of a program's working copy (branch, dirty, ahead/behind).
|
||||
|
||||
Fetches from the remote first so ``behind`` is current. A program with no
|
||||
source or a non-git source returns a benign ``{"is_repo": false}`` (not an
|
||||
error) so the dashboard can simply hide the sync control."""
|
||||
comp, config = _program_source(name)
|
||||
if not comp.source:
|
||||
return {"is_repo": False}
|
||||
out = asdict(git.git_status(comp.source, fetch=True))
|
||||
# Repo context: which repo this program's source lives in and who else shares it
|
||||
# (a monorepo). Sync is a repo operation — the UI labels it and lists siblings.
|
||||
from wildpc_core.relations import derive_repos
|
||||
|
||||
for key, repo in derive_repos(config).items():
|
||||
if name in repo.programs:
|
||||
out["repo"] = {
|
||||
"key": key,
|
||||
"programs": repo.programs,
|
||||
"multi": repo.multi,
|
||||
"deployments": repo.deployments,
|
||||
}
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
@programs_router.post("/programs/{name}/sync")
|
||||
async def program_sync(name: str) -> dict:
|
||||
"""Fast-forward a program's working copy (``git pull --ff-only``).
|
||||
|
||||
Pull-only: it updates the source on disk and reports which deployments may now
|
||||
need a restart/apply, but does not build, apply, or restart anything itself."""
|
||||
comp, config = _program_source(name)
|
||||
if not comp.source:
|
||||
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
|
||||
if not git.is_git_repo(comp.source):
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"'{name}' source is not a git repository"
|
||||
)
|
||||
|
||||
before = git.head(comp.source)
|
||||
ok, output = git.pull(comp.source)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail=output or "git pull failed")
|
||||
|
||||
pulled = git.head(comp.source) != before
|
||||
deployments = [dname for dname, _ in config.deployments_of(name)]
|
||||
if pulled:
|
||||
# Nudge other clients to refresh this program's git status.
|
||||
await stream.broadcast(
|
||||
"program-sync", {"program": name, "deployments": deployments}
|
||||
)
|
||||
|
||||
return {
|
||||
"program": name,
|
||||
"status": "ok",
|
||||
"output": output,
|
||||
"pulled": pulled,
|
||||
"deployments": deployments if pulled else [],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unified program action endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Dev verbs only. Activation (install/uninstall of tools/statics) is convergence:
|
||||
# it happens through `POST /apply`, not as a program action.
|
||||
_VALID_ACTIONS = {
|
||||
"build",
|
||||
"test",
|
||||
"lint",
|
||||
"type-check",
|
||||
"check",
|
||||
}
|
||||
|
||||
|
||||
@programs_router.post("/programs/{name}/{action}")
|
||||
async def program_action(name: str, action: str) -> dict:
|
||||
"""Run a lifecycle action on a program.
|
||||
|
||||
Resolution-aware: a declared `commands:` entry overrides the stack default,
|
||||
so a program with no stack can still be linted/tested/built/installed.
|
||||
"""
|
||||
if action not in _VALID_ACTIONS:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
|
||||
|
||||
config = get_config()
|
||||
if name not in config.programs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
|
||||
)
|
||||
|
||||
comp = config.programs[name]
|
||||
if not comp.source:
|
||||
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
|
||||
|
||||
if action not in available_actions(comp):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Action '{action}' not available for '{name}' "
|
||||
f"(no declared command and no stack handler provides it)",
|
||||
)
|
||||
|
||||
result = await run_action(action, name, comp, config.root)
|
||||
|
||||
if result.status != "ok":
|
||||
raise HTTPException(status_code=500, detail=result.output or f"{action} failed")
|
||||
|
||||
return {
|
||||
"program": result.program,
|
||||
"action": result.action,
|
||||
"status": result.status,
|
||||
"output": result.output,
|
||||
}
|
||||
172
wildpc-api/src/wildpc_api/pty_session.py
Normal file
172
wildpc-api/src/wildpc_api/pty_session.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Run a command inside a pseudo-terminal and stream it over asyncio.
|
||||
|
||||
Wild PC stays assistant-agnostic: it just launches a command in a real TTY and
|
||||
shunts raw bytes both ways. The command's own TUI (an agent CLI, a shell, …)
|
||||
renders exactly as it would in a terminal — Wild PC never parses its output.
|
||||
|
||||
The PTY master is a raw OS fd, not an asyncio stream, so we watch it with
|
||||
``loop.add_reader`` (no extra thread, no busy-poll). Output is delivered to an
|
||||
``on_output`` callback (a fan-out layer above can broadcast it to any number of
|
||||
attached clients and buffer scrollback), so a session's lifetime is decoupled
|
||||
from any one WebSocket connection. Keystroke writes are tiny, so a plain
|
||||
``os.write`` is fine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import struct
|
||||
import termios
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _set_winsize(fd: int, rows: int, cols: int) -> None:
|
||||
"""Apply a terminal window size via TIOCSWINSZ (struct winsize)."""
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
|
||||
def _acquire_controlling_tty() -> None:
|
||||
"""Child-side setup (preexec), run after the pty slave is dup'd to fd 0/1/2.
|
||||
|
||||
``setsid()`` starts a new session (own process group, so cleanup can
|
||||
``killpg`` the whole tree). ``TIOCSCTTY`` then makes the slave our
|
||||
*controlling terminal* — without it the kernel has no foreground process
|
||||
group to deliver ``SIGWINCH`` to, so a later winsize change (a browser resize)
|
||||
is silently dropped and the app never reflows. This is what makes live resize
|
||||
work, not just the initial size.
|
||||
"""
|
||||
os.setsid()
|
||||
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PtySession:
|
||||
"""A child process attached to a pty master.
|
||||
|
||||
Output is pushed to ``on_output(bytes)`` as it arrives and ``on_exit()`` is
|
||||
fired once when the child closes the tty. Both run in the event-loop thread.
|
||||
"""
|
||||
|
||||
proc: asyncio.subprocess.Process
|
||||
master_fd: int
|
||||
on_output: Callable[[bytes], None] | None = None
|
||||
on_exit: Callable[[], None] | None = None
|
||||
cols: int = 80
|
||||
rows: int = 24
|
||||
_closed: bool = field(default=False)
|
||||
|
||||
@classmethod
|
||||
async def start(
|
||||
cls,
|
||||
command: str,
|
||||
args: list[str] | None = None,
|
||||
cwd: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
on_output: Callable[[bytes], None] | None = None,
|
||||
on_exit: Callable[[], None] | None = None,
|
||||
) -> PtySession:
|
||||
"""Spawn ``command args`` on a fresh pty, sized ``cols`` x ``rows``."""
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
# Size the tty before exec so the first TUI render lays out correctly.
|
||||
_set_winsize(master_fd, rows, cols)
|
||||
|
||||
proc_env = {**os.environ, "TERM": "xterm-256color"}
|
||||
if env:
|
||||
proc_env.update(env)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
command,
|
||||
*(args or []),
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
env=proc_env,
|
||||
# setsid + TIOCSCTTY in the child: own session/group (for killpg) AND
|
||||
# the slave as controlling tty (for SIGWINCH resize delivery).
|
||||
preexec_fn=_acquire_controlling_tty,
|
||||
close_fds=True,
|
||||
)
|
||||
os.close(slave_fd) # parent keeps only the master
|
||||
os.set_blocking(master_fd, False)
|
||||
|
||||
session = cls(proc=proc, master_fd=master_fd, cols=cols, rows=rows)
|
||||
if on_output is not None:
|
||||
session.on_output = on_output
|
||||
if on_exit is not None:
|
||||
session.on_exit = on_exit
|
||||
asyncio.get_running_loop().add_reader(master_fd, session._drain_master)
|
||||
return session
|
||||
|
||||
def _drain_master(self) -> None:
|
||||
"""Reader callback: pull available bytes off the master and fan them out."""
|
||||
try:
|
||||
data = os.read(self.master_fd, 65536)
|
||||
except (BlockingIOError, InterruptedError):
|
||||
return
|
||||
except OSError:
|
||||
data = b"" # slave closed → child gone
|
||||
if not data:
|
||||
try:
|
||||
asyncio.get_running_loop().remove_reader(self.master_fd)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
if self.on_exit is not None:
|
||||
self.on_exit()
|
||||
return
|
||||
if self.on_output is not None:
|
||||
self.on_output(data)
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
"""Forward keystroke bytes to the child's tty."""
|
||||
try:
|
||||
os.write(self.master_fd, data)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def resize(self, cols: int, rows: int) -> None:
|
||||
"""Resize the tty (browser terminal changed dimensions)."""
|
||||
self.cols, self.rows = cols, rows
|
||||
try:
|
||||
_set_winsize(self.master_fd, rows, cols)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self.proc.returncode is None
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Kill the whole process group and release the master fd (idempotent)."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
asyncio.get_running_loop().remove_reader(self.master_fd)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
if self.proc.returncode is None:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(self.proc.wait(), timeout=3)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
os.close(self.master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
83
wildpc-api/src/wildpc_api/repos.py
Normal file
83
wildpc-api/src/wildpc_api/repos.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Repo endpoints — a repo (git working copy) is the unit of sync. A monorepo backs
|
||||
several programs; an adopted program is a repo of one. See docs/relationships.md.
|
||||
|
||||
Sync is pull-only (fast-forward); converge (build/apply/restart) stays a separate,
|
||||
explicit step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from wildpc_core import git
|
||||
from wildpc_core.relations import Repo, derive_repos
|
||||
|
||||
from wildpc_api import stream
|
||||
from wildpc_api.config import get_config
|
||||
|
||||
repos_router = APIRouter(tags=["repos"])
|
||||
|
||||
|
||||
def _resolve(key: str) -> tuple[Repo, object]:
|
||||
config = get_config()
|
||||
repos = derive_repos(config)
|
||||
if key not in repos:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"repo '{key}' not found"
|
||||
)
|
||||
return repos[key], config
|
||||
|
||||
|
||||
@repos_router.get("/repos")
|
||||
def list_repos() -> list[dict]:
|
||||
"""Every repo with members and last-known git state (no fetch — fast)."""
|
||||
out: list[dict] = []
|
||||
for repo in derive_repos(get_config()).values():
|
||||
st = git.git_status(Path(repo.path), fetch=False)
|
||||
out.append(
|
||||
{
|
||||
**dataclasses.asdict(repo),
|
||||
"branch": st.branch,
|
||||
"behind": st.behind,
|
||||
"dirty": st.dirty,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@repos_router.get("/repos/{key}/git")
|
||||
def repo_git(key: str) -> dict:
|
||||
"""A repo's git status (fetches, so ``behind`` is current) plus its members."""
|
||||
repo, _ = _resolve(key)
|
||||
return {
|
||||
**dataclasses.asdict(git.git_status(Path(repo.path), fetch=True)),
|
||||
"key": key,
|
||||
"programs": repo.programs,
|
||||
"deployments": repo.deployments,
|
||||
}
|
||||
|
||||
|
||||
@repos_router.post("/repos/{key}/sync")
|
||||
async def repo_sync(key: str) -> dict:
|
||||
"""Fast-forward the repo's working copy. Pull-only — reports which deployments
|
||||
may now need a restart/apply, but does not converge them."""
|
||||
repo, _ = _resolve(key)
|
||||
before = git.head(Path(repo.path))
|
||||
ok, output = git.pull(Path(repo.path))
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail=output or "git pull failed")
|
||||
pulled = git.head(Path(repo.path)) != before
|
||||
if pulled:
|
||||
await stream.broadcast(
|
||||
"repo-sync", {"repo": key, "deployments": repo.deployments}
|
||||
)
|
||||
return {
|
||||
"repo": key,
|
||||
"status": "ok",
|
||||
"output": output,
|
||||
"pulled": pulled,
|
||||
"deployments": repo.deployments if pulled else [],
|
||||
}
|
||||
1005
wildpc-api/src/wildpc_api/routes.py
Normal file
1005
wildpc-api/src/wildpc_api/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
125
wildpc-api/src/wildpc_api/secrets.py
Normal file
125
wildpc-api/src/wildpc_api/secrets.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Secrets management — routes through the active backend (file or OpenBao)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from wildpc_core.config import SECRETS_DIR, _secrets_settings
|
||||
from wildpc_core.secret_backends import OpenBaoBackend, build_backend
|
||||
|
||||
from wildpc_api.config import get_registry
|
||||
|
||||
router = APIRouter(prefix="/secrets", tags=["secrets"])
|
||||
|
||||
|
||||
def _backend():
|
||||
# Same selection as the rest of wildpc (wildpc.yaml `secrets:` block).
|
||||
return build_backend(SECRETS_DIR, _secrets_settings())
|
||||
|
||||
|
||||
class SecretValue(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_secrets() -> list[str]:
|
||||
"""List all secret names (not values)."""
|
||||
return _backend().list_names()
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
def secrets_info() -> dict:
|
||||
"""The active backend + whether this node may write (for the UI)."""
|
||||
settings = _secrets_settings()
|
||||
backend = _backend()
|
||||
kind = "openbao" if isinstance(backend, OpenBaoBackend) else "file"
|
||||
role = get_registry().node.role
|
||||
# File is always writable; a vault follower holds a read-only token.
|
||||
writable = kind == "file" or role == "authority"
|
||||
return {
|
||||
"backend": kind,
|
||||
"addr": settings.get("addr") if kind == "openbao" else None,
|
||||
"role": role,
|
||||
"writable": writable,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/overrides")
|
||||
def list_overrides() -> dict:
|
||||
"""Per-node secret overrides ({host: [names]}). Empty unless OpenBao."""
|
||||
backend = _backend()
|
||||
if not isinstance(backend, OpenBaoBackend):
|
||||
return {"overrides": {}}
|
||||
return {"overrides": backend.list_node_overrides()}
|
||||
|
||||
|
||||
@router.get("/overrides/{node}/{name:path}")
|
||||
def get_override(node: str, name: str) -> dict:
|
||||
"""Read a node's override value."""
|
||||
value = _backend().read(f"nodes/{node}/{name}")
|
||||
if value is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, f"no override '{name}' for node '{node}'"
|
||||
)
|
||||
return {"node": node, "name": name, "value": value}
|
||||
|
||||
|
||||
@router.put("/overrides/{node}/{name:path}")
|
||||
def set_override(node: str, name: str, body: SecretValue) -> dict:
|
||||
"""Set a per-node override (authority only; needs the OpenBao backend)."""
|
||||
backend = _backend()
|
||||
if not isinstance(backend, OpenBaoBackend):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "node overrides require the OpenBao backend"
|
||||
)
|
||||
try:
|
||||
backend.write(f"nodes/{node}/{name}", body.value)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(exc)) from exc
|
||||
return {"node": node, "name": name, "ok": True}
|
||||
|
||||
|
||||
@router.delete("/overrides/{node}/{name:path}")
|
||||
def delete_override(node: str, name: str) -> dict:
|
||||
"""Remove a per-node override."""
|
||||
_backend().delete(f"nodes/{node}/{name}")
|
||||
return {"node": node, "name": name, "ok": True}
|
||||
|
||||
|
||||
@router.get("/{name}")
|
||||
def get_secret(name: str) -> dict:
|
||||
"""Get a secret value."""
|
||||
_validate_name(name)
|
||||
value = _backend().read(name)
|
||||
if value is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Secret '{name}' not found",
|
||||
)
|
||||
return {"name": name, "value": value}
|
||||
|
||||
|
||||
@router.put("/{name}")
|
||||
def set_secret(name: str, body: SecretValue) -> dict:
|
||||
"""Set a secret value."""
|
||||
_validate_name(name)
|
||||
_backend().write(name, body.value)
|
||||
return {"name": name, "ok": True}
|
||||
|
||||
|
||||
@router.delete("/{name}")
|
||||
def delete_secret(name: str) -> dict:
|
||||
"""Delete a secret."""
|
||||
_validate_name(name)
|
||||
_backend().delete(name)
|
||||
return {"name": name, "ok": True}
|
||||
|
||||
|
||||
def _validate_name(name: str) -> None:
|
||||
"""Reject path traversal attempts."""
|
||||
if "/" in name or "\\" in name or ".." in name or not name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid secret name",
|
||||
)
|
||||
171
wildpc-api/src/wildpc_api/services.py
Normal file
171
wildpc-api/src/wildpc_api/services.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""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 wildpc_core.generators.systemd import (
|
||||
generate_timer,
|
||||
generate_unit_from_deployed,
|
||||
unit_name,
|
||||
)
|
||||
|
||||
from wildpc_api.config import get_wildpc_root, get_registry
|
||||
from wildpc_api.health import check_all_health
|
||||
from wildpc_api.models import HealthStatus
|
||||
from wildpc_api.stream import broadcast
|
||||
|
||||
router = APIRouter(prefix="/services", tags=["services"])
|
||||
|
||||
UNIT_PREFIX = "wildpc-"
|
||||
SELF_NAME = "wildpc-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 _managed(name: str):
|
||||
"""The managed deployment with this name (a name may span kinds; take the
|
||||
managed systemd one — service or job), or None."""
|
||||
return next((d for d in get_registry().named(name) if d.managed), None)
|
||||
|
||||
|
||||
def _validate_managed(name: str) -> None:
|
||||
"""Raise 404 if the component isn't managed in the registry."""
|
||||
if _managed(name) is None:
|
||||
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."""
|
||||
registry = get_registry()
|
||||
statuses = await check_all_health(registry)
|
||||
|
||||
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."""
|
||||
deployed = _managed(name)
|
||||
unit = (
|
||||
unit_name(name, deployed.kind) if deployed else 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={"program": 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}")
|
||||
|
||||
await _broadcast_health_with_override(name, unit_status)
|
||||
|
||||
return JSONResponse(
|
||||
content={"program": 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."""
|
||||
_validate_managed(name)
|
||||
deployed = _managed(name)
|
||||
|
||||
# Get systemd spec from config if repo available
|
||||
systemd_spec = None
|
||||
schedule = None
|
||||
description = None
|
||||
root = get_wildpc_root()
|
||||
if root:
|
||||
from wildpc_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
dep = config.deployment(deployed.kind, name)
|
||||
if dep is not None:
|
||||
manage = getattr(dep, "manage", None)
|
||||
if manage and manage.systemd:
|
||||
systemd_spec = manage.systemd
|
||||
description = dep.description
|
||||
schedule = getattr(dep, "schedule", None)
|
||||
|
||||
unit = generate_unit_from_deployed(name, deployed, systemd_spec)
|
||||
timer = generate_timer(name, schedule, description) if schedule else None
|
||||
return {"service": unit, "timer": timer}
|
||||
|
||||
|
||||
@router.post("/{name}/restart")
|
||||
async def restart_service(name: str) -> JSONResponse:
|
||||
"""Restart a systemd-managed service — the imperative bounce.
|
||||
|
||||
Lifecycle (start/stop/enable/disable) is convergence now: set `enabled` in the
|
||||
deployment config and POST /apply. Restart stays as a way to re-actualize the
|
||||
current desired state without changing it.
|
||||
"""
|
||||
return await _do_action(name, "restart")
|
||||
72
wildpc-api/src/wildpc_api/stream.py
Normal file
72
wildpc-api/src/wildpc_api/stream.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""SSE stream — pushes health updates and service action events to connected clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from wildpc_api.config import get_registry
|
||||
from wildpc_api.health import check_all_health
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# All connected SSE clients receive events through this queue-based broadcast.
|
||||
_subscribers: list[asyncio.Queue[str]] = []
|
||||
|
||||
|
||||
def subscribe() -> asyncio.Queue[str]:
|
||||
"""Register a new SSE client. Returns a queue to read events from."""
|
||||
q: asyncio.Queue[str] = asyncio.Queue(maxsize=64)
|
||||
_subscribers.append(q)
|
||||
return q
|
||||
|
||||
|
||||
def unsubscribe(q: asyncio.Queue[str]) -> None:
|
||||
"""Remove a disconnected SSE client."""
|
||||
try:
|
||||
_subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def close_all_subscribers() -> None:
|
||||
"""Unblock all SSE generators so they exit during shutdown."""
|
||||
for q in list(_subscribers):
|
||||
try:
|
||||
q.put_nowait("")
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
_subscribers.clear()
|
||||
|
||||
|
||||
async def broadcast(event_type: str, data: dict) -> None:
|
||||
"""Send an event to all connected SSE clients."""
|
||||
payload = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
||||
dead: list[asyncio.Queue[str]] = []
|
||||
for q in _subscribers:
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
unsubscribe(q)
|
||||
|
||||
|
||||
async def health_poll_loop(interval: float = 10.0) -> None:
|
||||
"""Background task that polls health and broadcasts updates."""
|
||||
while True:
|
||||
try:
|
||||
registry = get_registry()
|
||||
statuses = await check_all_health(registry)
|
||||
await broadcast(
|
||||
"health",
|
||||
{
|
||||
"statuses": [s.model_dump() for s in statuses],
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Health poll failed")
|
||||
await asyncio.sleep(interval)
|
||||
Reference in New Issue
Block a user