- stackpilot-agent: slim token-guarded FastAPI (reuses compose_service) exposing stack CRUD/lifecycle/logs + system info; same image, different CMD. agent/ Dockerfile + compose + .env.example. - Central proxy: Agent model, agent_service (httpx ping/proxy + live status: online/offline/unauthorized + hostname/last_seen), routers/agents.py (CRUD + ping + proxied stacks/lifecycle/logs/system). - Frontend: Settings → Remote hosts (add/check/remove, connectivity dot); Stacks grouped by host; remote stack detail with lifecycle, live logs, compose/.env edit. Verified end-to-end: agent+main on a shared network — register (good/bad token), list/create/start/logs/delete remote stacks, offline detection (502). Remote backup destinations (SFTP/S3) deferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
232 lines
7.8 KiB
Python
232 lines
7.8 KiB
Python
"""StackPilot agent — a slim, token-guarded Docker Compose API for one host.
|
|
|
|
The agent runs on each remote host (same image as the backend, different CMD).
|
|
It has no users, no database and no UI: it exposes just enough of the stack /
|
|
system surface for a central StackPilot to manage this host's compose stacks,
|
|
authenticated by a single shared bearer token (``AGENT_TOKEN``).
|
|
|
|
All compose/Docker logic is reused from the backend's ``compose_service`` and
|
|
``docker_client`` so behaviour matches the local host exactly.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import asdict
|
|
|
|
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
|
|
from config import settings
|
|
from docker_client import DockerError, get_client, safe_call
|
|
from services import compose_service
|
|
|
|
logger = logging.getLogger("stackpilot.agent")
|
|
|
|
AGENT_VERSION = "0.5.0"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Auth
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def verify_token(authorization: str = Header(default="")) -> None:
|
|
expected = settings.AGENT_TOKEN
|
|
if not expected:
|
|
raise HTTPException(status_code=503, detail="Agent token not configured")
|
|
if authorization != f"Bearer {expected}":
|
|
raise HTTPException(status_code=401, detail="Invalid agent token")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Schemas
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class StackBody(BaseModel):
|
|
name: str | None = None
|
|
yaml: str | None = None
|
|
env: str | None = None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Helpers
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _summary(stack_id: str) -> dict:
|
|
try:
|
|
containers = compose_service.containers_for_stack(stack_id)
|
|
status = compose_service.compute_status(stack_id)
|
|
except DockerError:
|
|
containers = []
|
|
status = "unknown"
|
|
return {
|
|
"id": stack_id,
|
|
"name": stack_id,
|
|
"description": None,
|
|
"status": status,
|
|
"service_count": len(containers),
|
|
"running_count": sum(1 for c in containers if c.state == "running"),
|
|
"created_at": None,
|
|
"updated_at": None,
|
|
}
|
|
|
|
|
|
def _hostname() -> str:
|
|
return os.uname().nodename
|
|
|
|
|
|
def _system_info() -> dict:
|
|
docker_version = ""
|
|
host_os = ""
|
|
running = total = 0
|
|
try:
|
|
client = get_client()
|
|
docker_version = safe_call(client.version).get("Version", "")
|
|
info = safe_call(client.info)
|
|
host_os = info.get("OperatingSystem", "")
|
|
running = info.get("ContainersRunning", 0)
|
|
total = info.get("Containers", 0)
|
|
except DockerError as exc:
|
|
docker_version = f"unavailable ({exc.error})"
|
|
return {
|
|
"hostname": _hostname(),
|
|
"docker_version": docker_version,
|
|
"host_os": host_os,
|
|
"containers_running": running,
|
|
"containers_total": total,
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# App
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
app = FastAPI(title="StackPilot Agent", version=AGENT_VERSION)
|
|
|
|
|
|
@app.exception_handler(DockerError)
|
|
async def _docker_error(_request: Request, exc: DockerError):
|
|
return JSONResponse(status_code=502, content={"error": exc.error, "detail": exc.detail})
|
|
|
|
|
|
@app.get("/agent/ping", dependencies=[Depends(verify_token)])
|
|
def ping() -> dict:
|
|
return {"ok": True, "hostname": _hostname(), "version": AGENT_VERSION}
|
|
|
|
|
|
@app.get("/agent/system", dependencies=[Depends(verify_token)])
|
|
def system() -> dict:
|
|
return _system_info()
|
|
|
|
|
|
@app.get("/agent/stacks", dependencies=[Depends(verify_token)])
|
|
def list_stacks() -> list[dict]:
|
|
return [_summary(sid) for sid in compose_service.discover_stacks()]
|
|
|
|
|
|
@app.get("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)])
|
|
def get_stack(stack_id: str) -> dict:
|
|
directory = compose_service.stack_dir(stack_id)
|
|
if not os.path.isdir(directory):
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
try:
|
|
containers = [asdict(c) for c in compose_service.containers_for_stack(stack_id)]
|
|
status = compose_service.compute_status(stack_id)
|
|
except DockerError:
|
|
containers = []
|
|
status = "unknown"
|
|
return {
|
|
"id": stack_id,
|
|
"name": stack_id,
|
|
"description": None,
|
|
"status": status,
|
|
"yaml": compose_service.read_compose(stack_id),
|
|
"env": compose_service.read_env(stack_id),
|
|
"containers": containers,
|
|
"created_at": None,
|
|
"updated_at": None,
|
|
}
|
|
|
|
|
|
@app.post("/agent/stacks", dependencies=[Depends(verify_token)], status_code=201)
|
|
def create_stack(body: StackBody) -> dict:
|
|
if not body.name:
|
|
raise HTTPException(status_code=400, detail="name is required")
|
|
stack_id = compose_service.slugify(body.name)
|
|
if os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists")
|
|
compose_service.write_compose(stack_id, body.yaml or "services:\n")
|
|
if body.env:
|
|
compose_service.write_env(stack_id, body.env)
|
|
return _summary(stack_id)
|
|
|
|
|
|
@app.put("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)])
|
|
def update_stack(stack_id: str, body: StackBody) -> dict:
|
|
if not os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
if body.yaml is not None:
|
|
compose_service.write_compose(stack_id, body.yaml)
|
|
if body.env is not None:
|
|
compose_service.write_env(stack_id, body.env)
|
|
return _summary(stack_id)
|
|
|
|
|
|
@app.delete("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)])
|
|
async def delete_stack(stack_id: str, delete_files: bool = Query(True)) -> dict:
|
|
if not os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
try:
|
|
await compose_service.down(stack_id)
|
|
except Exception: # noqa: BLE001 - best-effort teardown
|
|
pass
|
|
if delete_files:
|
|
compose_service.delete_stack_files(stack_id)
|
|
return {"ok": True}
|
|
|
|
|
|
_ACTIONS = {
|
|
"start": compose_service.up,
|
|
"stop": compose_service.stop,
|
|
"restart": compose_service.restart,
|
|
"pull": compose_service.pull,
|
|
"update": compose_service.update,
|
|
"down": compose_service.down,
|
|
}
|
|
|
|
|
|
@app.post("/agent/stacks/{stack_id}/{action}", dependencies=[Depends(verify_token)])
|
|
async def lifecycle(stack_id: str, action: str) -> dict:
|
|
fn = _ACTIONS.get(action)
|
|
if not fn:
|
|
raise HTTPException(status_code=400, detail=f"Unknown action '{action}'")
|
|
if not os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
result = await fn(stack_id)
|
|
if result.get("returncode") not in (0, None):
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={
|
|
"error": f"compose {action} failed",
|
|
"detail": result.get("stderr", "").strip()[-2000:],
|
|
},
|
|
)
|
|
return result
|
|
|
|
|
|
@app.get("/agent/stacks/{stack_id}/logs", dependencies=[Depends(verify_token)])
|
|
async def stack_logs(stack_id: str, tail: int = Query(200, le=2000)) -> dict:
|
|
if not os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
result = await compose_service.logs(stack_id, tail=tail)
|
|
return {"logs": result.get("stdout", "") + result.get("stderr", "")}
|
|
|
|
|
|
@app.get("/agent/health")
|
|
def health() -> dict:
|
|
return {"status": "ok"}
|