From 59037f4287c57edaa0a54886ace264b63fa96ab1 Mon Sep 17 00:00:00 2001 From: menzelj Date: Sun, 7 Jun 2026 21:23:17 +0000 Subject: [PATCH] Phase 5: multi-host agents (0.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 46 ++- agent/.env.example | 7 + agent/Dockerfile | 14 + agent/docker-compose.yml | 23 ++ backend/agent_app.py | 231 ++++++++++++++ backend/config.py | 4 + backend/main.py | 4 +- backend/models/__init__.py | 3 +- backend/models/agent.py | 49 +++ backend/routers/agents.py | 286 ++++++++++++++++++ backend/services/agent_service.py | 115 +++++++ frontend/package.json | 2 +- frontend/src/App.tsx | 2 + frontend/src/api/agents.ts | 30 ++ frontend/src/components/hosts/HostDot.tsx | 17 ++ .../components/stacks/AgentStacksSection.tsx | 78 +++++ frontend/src/components/stacks/StackCard.tsx | 22 +- frontend/src/pages/RemoteStackDetail.tsx | 272 +++++++++++++++++ frontend/src/pages/Settings.tsx | 136 ++++++++- frontend/src/pages/Stacks.tsx | 64 ++-- frontend/src/types/index.ts | 14 + 21 files changed, 1380 insertions(+), 39 deletions(-) create mode 100644 agent/.env.example create mode 100644 agent/Dockerfile create mode 100644 agent/docker-compose.yml create mode 100644 backend/agent_app.py create mode 100644 backend/models/agent.py create mode 100644 backend/routers/agents.py create mode 100644 backend/services/agent_service.py create mode 100644 frontend/src/api/agents.ts create mode 100644 frontend/src/components/hosts/HostDot.tsx create mode 100644 frontend/src/components/stacks/AgentStacksSection.tsx create mode 100644 frontend/src/pages/RemoteStackDetail.tsx diff --git a/README.md b/README.md index 997ad76..97b8d0d 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ A self-hosted Docker Compose manager for power users and homelab enthusiasts — as intuitive as Dockge, as capable as Portainer for Compose workflows. > **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of -> Life) + Phase 4 (Operations) complete. Multi-host agents are planned for a -> later phase. +> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) complete. Remote backup +> destinations (SFTP/S3) are planned for a later phase. ## What works today (Phase 1) @@ -70,9 +70,30 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. - **Audit log page**: searchable, paginated view of all recorded actions. - **Mobile-responsive layout**: off-canvas sidebar + adaptive spacing. -> **Not yet:** multi-host agents (a second deployable agent app + remote proxying) -> and remote backup destinations (SFTP/S3) are intentionally deferred to a future -> phase — backups currently download to / upload from the browser. +### Phase 5 — Multi-host + +- **Remote agents**: deploy `stackpilot-agent` (same image, different CMD) on any + host — it needs only the Docker socket and a shared `AGENT_TOKEN`, and exposes a + slim, token-guarded stack/system API (no UI, no DB). +- **Central management**: add hosts under **Settings → Remote hosts** (name, agent + URL, token) with a live connectivity dot. The Stacks page groups stacks by host + ("This host" + one section per agent); remote stacks have their own detail view + with full lifecycle (start/stop/restart/pull/update/down), live logs, and + compose/.env editing — all proxied to the agent. + +> **Not yet:** remote backup destinations (SFTP/S3) — backups currently download +> to / upload from the browser. + +## Deploying an agent on another host + +```bash +cd agent +cp .env.example .env # set a strong AGENT_TOKEN +docker compose up -d # exposes the agent on :5010 +``` + +Then in the central UI: **Settings → Remote hosts → Add host** with +`http://:5010` and the same `AGENT_TOKEN`. ## Architecture @@ -174,6 +195,21 @@ GET /api/auth/users POST /api/auth/users PATCH /api/auth/users/{id} DELETE /api/auth/users/{id} ``` +### Phase 5 endpoints + +``` +GET /api/agents POST /api/agents +PUT /api/agents/{id} DELETE /api/agents/{id} +POST /api/agents/{id}/ping GET /api/agents/{id}/system +GET /api/agents/{id}/stacks | /{sid} GET /api/agents/{id}/stacks/{sid}/logs +POST /api/agents/{id}/stacks PUT /api/agents/{id}/stacks/{sid} +DELETE /api/agents/{id}/stacks/{sid} POST /api/agents/{id}/stacks/{sid}/{action} + +agent (on the remote host, Bearer AGENT_TOKEN): +GET /agent/ping | /system | /stacks | /stacks/{id} | /stacks/{id}/logs +POST /agent/stacks | /stacks/{id}/{action} PUT/DELETE /agent/stacks/{id} +``` + ## Security notes - The Docker socket is only ever touched by the backend process; it is never diff --git a/agent/.env.example b/agent/.env.example new file mode 100644 index 0000000..e3e518a --- /dev/null +++ b/agent/.env.example @@ -0,0 +1,7 @@ +# Shared secret the central StackPilot must present to manage this host. +# Generate with: openssl rand -base64 32 +# Enter the SAME value when adding this host under Settings → Remote hosts. +AGENT_TOKEN=change-me-to-a-long-random-shared-secret + +# Host directory where this host's stack folders live. +STACKS_HOST_DIR=./data/stacks diff --git a/agent/Dockerfile b/agent/Dockerfile new file mode 100644 index 0000000..0ade2b7 --- /dev/null +++ b/agent/Dockerfile @@ -0,0 +1,14 @@ +# The agent reuses the backend image (same compose/Docker code + deps) and +# just runs a different ASGI app. Build the backend image first. +ARG BACKEND_IMAGE=10.10.5.10:3020/menzelj/stackpilot-backend:latest +FROM ${BACKEND_IMAGE} + +ENV STACKS_DIR=/opt/stacks \ + PORT=5010 + +EXPOSE 5010 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ + CMD curl -fsS http://localhost:5010/agent/health || exit 1 + +CMD ["uvicorn", "agent_app:app", "--host", "0.0.0.0", "--port", "5010"] diff --git a/agent/docker-compose.yml b/agent/docker-compose.yml new file mode 100644 index 0000000..b401cad --- /dev/null +++ b/agent/docker-compose.yml @@ -0,0 +1,23 @@ +# StackPilot agent — deploy this on each remote host you want to manage. +# It needs only the Docker socket and a shared AGENT_TOKEN (must match the +# token you enter when adding this host in the central StackPilot UI). +services: + agent: + image: 10.10.5.10:3020/menzelj/stackpilot-agent:latest + build: + context: . + args: + BACKEND_IMAGE: 10.10.5.10:3020/menzelj/stackpilot-backend:latest + restart: unless-stopped + environment: + - AGENT_TOKEN=${AGENT_TOKEN:?set AGENT_TOKEN in .env} + - STACKS_DIR=/opt/stacks + - HOST_PROC_PATH=/host_proc + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${STACKS_HOST_DIR:-./data/stacks}:/opt/stacks + - /proc:/host_proc:ro + # Read-only host devices for status/detection parity with the main host. + - /dev:/dev:ro + ports: + - "5010:5010" diff --git a/backend/agent_app.py b/backend/agent_app.py new file mode 100644 index 0000000..edf6d1a --- /dev/null +++ b/backend/agent_app.py @@ -0,0 +1,231 @@ +"""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"} diff --git a/backend/config.py b/backend/config.py index bc11535..618e9e3 100644 --- a/backend/config.py +++ b/backend/config.py @@ -35,6 +35,10 @@ class Settings(BaseSettings): # Throwaway image used to read/write named-volume contents during backup. BACKUP_HELPER_IMAGE: str = "alpine:latest" + # Multi-host agent: shared bearer token the agent requires on every request. + # Only used when running the agent app (agent_app:app). + AGENT_TOKEN: str = "" + # Host browser sandbox roots ALLOWED_BROWSE_ROOTS: Annotated[list[str], NoDecode] = [ "/", "/mnt", "/media", "/srv", "/opt", diff --git a/backend/main.py b/backend/main.py index 5e38efb..3f05b73 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ from config import settings from database import engine, init_db from docker_client import DockerError from routers import ( + agents, audit, auth, backups, @@ -48,7 +49,7 @@ async def lifespan(app: FastAPI): update_task.cancel() -app = FastAPI(title="StackPilot", version="0.4.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.5.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -78,6 +79,7 @@ app.include_router(templates.router) app.include_router(audit.router) app.include_router(settings_router.router) app.include_router(backups.router) +app.include_router(agents.router) app.include_router(ws.router) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index bd0afd5..946315b 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -1,8 +1,9 @@ """SQLModel table models. Importing this package registers all tables.""" +from models.agent import Agent from models.audit import AuditLog from models.setting import Setting, Webhook from models.stack import Stack from models.template import Template from models.user import User -__all__ = ["User", "Stack", "AuditLog", "Template", "Setting", "Webhook"] +__all__ = ["User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent"] diff --git a/backend/models/agent.py b/backend/models/agent.py new file mode 100644 index 0000000..4ba4f4e --- /dev/null +++ b/backend/models/agent.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from sqlmodel import Field, SQLModel + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class Agent(SQLModel, table=True): + """A remote host running stackpilot-agent.""" + + id: Optional[int] = Field(default=None, primary_key=True) + name: str + url: str # e.g. http://10.0.0.5:5010 + token: str # shared AGENT_TOKEN of that host + status: str = "unknown" # online | offline | unauthorized | unknown + hostname: Optional[str] = None # reported by the agent on ping + last_seen: Optional[datetime] = None + created_at: datetime = Field(default_factory=_now) + + +# --- API schemas --- + + +class AgentCreate(SQLModel): + name: str + url: str + token: str + + +class AgentUpdate(SQLModel): + name: Optional[str] = None + url: Optional[str] = None + token: Optional[str] = None + + +class AgentRead(SQLModel): + id: int + name: str + url: str + status: str + hostname: Optional[str] + last_seen: Optional[datetime] + created_at: datetime + token_set: bool diff --git a/backend/routers/agents.py b/backend/routers/agents.py new file mode 100644 index 0000000..2a461c2 --- /dev/null +++ b/backend/routers/agents.py @@ -0,0 +1,286 @@ +"""Remote host (agent) management + proxied stack/system operations.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlmodel import Session, select + +from auth import get_current_user, require_admin +from database import get_session +from models.agent import Agent, AgentCreate, AgentRead, AgentUpdate +from models.stack import StackCreate, StackUpdate +from models.user import User +from services import agent_service, audit_service +from services.agent_service import AgentError + +router = APIRouter(prefix="/api/agents", tags=["agents"]) + +_ACTIONS = {"start", "stop", "restart", "pull", "update", "down"} + + +def _ip(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +def _to_read(a: Agent) -> AgentRead: + return AgentRead( + id=a.id, + name=a.name, + url=a.url, + status=a.status, + hostname=a.hostname, + last_seen=a.last_seen, + created_at=a.created_at, + token_set=bool(a.token), + ) + + +def _get_or_404(session: Session, agent_id: int) -> Agent: + agent = session.get(Agent, agent_id) + if not agent: + raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found") + return agent + + +def _raise(exc: AgentError): + raise HTTPException( + status_code=exc.status if exc.status >= 400 else 502, + detail={"error": exc.error, "detail": exc.detail}, + ) + + +async def _proxy(session: Session, agent: Agent, method: str, path: str, **kw): + try: + return await agent_service.call(session, agent, method, path, **kw) + except AgentError as exc: + _raise(exc) + + +# --------------------------------------------------------------------------- # +# CRUD +# --------------------------------------------------------------------------- # + + +@router.get("", response_model=list[AgentRead]) +async def list_agents( + refresh: bool = Query(True), + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> list[AgentRead]: + agents = session.exec(select(Agent).order_by(Agent.id)).all() + if refresh: + for agent in agents: + await agent_service.ping(session, agent) + return [_to_read(a) for a in agents] + + +@router.post("", response_model=AgentRead, status_code=201) +async def create_agent( + body: AgentCreate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> AgentRead: + agent = Agent(name=body.name, url=body.url.rstrip("/"), token=body.token) + session.add(agent) + session.commit() + session.refresh(agent) + # Validate connectivity immediately (best-effort; agent is saved regardless). + await agent_service.ping(session, agent) + audit_service.record( + session, user=user.username, action="agent.create", target=agent.name, + detail=agent.url, ip=_ip(request), + ) + return _to_read(agent) + + +@router.put("/{agent_id}", response_model=AgentRead) +async def update_agent( + agent_id: int, + body: AgentUpdate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> AgentRead: + agent = _get_or_404(session, agent_id) + if body.name is not None: + agent.name = body.name + if body.url is not None: + agent.url = body.url.rstrip("/") + if body.token: + agent.token = body.token + agent.status = "unknown" + session.add(agent) + session.commit() + session.refresh(agent) + await agent_service.ping(session, agent) + audit_service.record( + session, user=user.username, action="agent.update", target=agent.name, + ip=_ip(request), + ) + return _to_read(agent) + + +@router.delete("/{agent_id}") +def delete_agent( + agent_id: int, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + name = agent.name + session.delete(agent) + session.commit() + audit_service.record( + session, user=user.username, action="agent.delete", target=name, ip=_ip(request), + ) + return {"ok": True} + + +@router.post("/{agent_id}/ping") +async def ping_agent( + agent_id: int, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + agent = _get_or_404(session, agent_id) + return await agent_service.ping(session, agent) + + +# --------------------------------------------------------------------------- # +# Proxied stack + system operations +# --------------------------------------------------------------------------- # + + +@router.get("/{agent_id}/system") +async def agent_system( + agent_id: int, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +): + agent = _get_or_404(session, agent_id) + return await _proxy(session, agent, "GET", "/agent/system") + + +@router.get("/{agent_id}/stacks") +async def agent_stacks( + agent_id: int, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> list[dict]: + agent = _get_or_404(session, agent_id) + stacks = await _proxy(session, agent, "GET", "/agent/stacks") or [] + for s in stacks: + s["agent_id"] = agent.id + s["agent_name"] = agent.name + return stacks + + +@router.get("/{agent_id}/stacks/{stack_id}") +async def agent_stack_detail( + agent_id: int, + stack_id: str, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + agent = _get_or_404(session, agent_id) + data = await _proxy(session, agent, "GET", f"/agent/stacks/{stack_id}") + data["agent_id"] = agent.id + data["agent_name"] = agent.name + return data + + +@router.get("/{agent_id}/stacks/{stack_id}/logs") +async def agent_stack_logs( + agent_id: int, + stack_id: str, + tail: int = Query(200, le=2000), + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + agent = _get_or_404(session, agent_id) + return await _proxy( + session, agent, "GET", f"/agent/stacks/{stack_id}/logs", params={"tail": tail} + ) + + +@router.post("/{agent_id}/stacks", status_code=201) +async def agent_create_stack( + agent_id: int, + body: StackCreate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy( + session, agent, "POST", "/agent/stacks", + json={"name": body.name, "yaml": body.yaml, "env": body.env}, + ) + audit_service.record( + session, user=user.username, action="agent.stack.create", + target=f"{agent.name}/{result.get('id')}", ip=_ip(request), + ) + return result + + +@router.put("/{agent_id}/stacks/{stack_id}") +async def agent_update_stack( + agent_id: int, + stack_id: str, + body: StackUpdate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy( + session, agent, "PUT", f"/agent/stacks/{stack_id}", + json={"yaml": body.yaml, "env": body.env}, + ) + audit_service.record( + session, user=user.username, action="agent.stack.update", + target=f"{agent.name}/{stack_id}", ip=_ip(request), + ) + return result + + +@router.delete("/{agent_id}/stacks/{stack_id}") +async def agent_delete_stack( + agent_id: int, + stack_id: str, + request: Request, + delete_files: bool = Query(True), + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy( + session, agent, "DELETE", f"/agent/stacks/{stack_id}", + params={"delete_files": delete_files}, + ) + audit_service.record( + session, user=user.username, action="agent.stack.delete", + target=f"{agent.name}/{stack_id}", ip=_ip(request), + ) + return result + + +@router.post("/{agent_id}/stacks/{stack_id}/{action}") +async def agent_lifecycle( + agent_id: int, + stack_id: str, + action: str, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + if action not in _ACTIONS: + raise HTTPException(status_code=400, detail=f"Unknown action '{action}'") + agent = _get_or_404(session, agent_id) + result = await _proxy(session, agent, "POST", f"/agent/stacks/{stack_id}/{action}") + audit_service.record( + session, user=user.username, action=f"agent.stack.{action}", + target=f"{agent.name}/{stack_id}", ip=_ip(request), + ) + return result diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py new file mode 100644 index 0000000..acea054 --- /dev/null +++ b/backend/services/agent_service.py @@ -0,0 +1,115 @@ +"""Talk to remote stackpilot-agent hosts over HTTP. + +The central app stores an ``Agent`` row per remote host and proxies stack / +system calls to it using the agent's shared token. Connectivity state +(``status``, ``hostname``, ``last_seen``) is refreshed on every successful or +failed call so the UI can show a live dot per host. +""" +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any, Optional + +import httpx +from sqlmodel import Session + +from models.agent import Agent + +logger = logging.getLogger("stackpilot.agent_proxy") + +_TIMEOUT = 30.0 + + +class AgentError(Exception): + def __init__(self, status: int, error: str, detail: str = ""): + self.status = status + self.error = error + self.detail = detail + super().__init__(f"{error}: {detail}" if detail else error) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _mark(session: Session, agent: Agent, status: str, hostname: Optional[str] = None) -> None: + agent.status = status + if status == "online": + agent.last_seen = _now() + if hostname: + agent.hostname = hostname + session.add(agent) + session.commit() + session.refresh(agent) + + +async def _request( + agent: Agent, + method: str, + path: str, + *, + params: Optional[dict] = None, + json: Any = None, +) -> httpx.Response: + url = agent.url.rstrip("/") + path + headers = {"Authorization": f"Bearer {agent.token}"} + async with httpx.AsyncClient(follow_redirects=True) as client: + return await client.request( + method, url, headers=headers, params=params, json=json, timeout=_TIMEOUT + ) + + +async def call( + session: Session, + agent: Agent, + method: str, + path: str, + *, + params: Optional[dict] = None, + json: Any = None, +) -> Any: + """Proxy a request to the agent, updating its status, returning parsed JSON.""" + try: + resp = await _request(agent, method, path, params=params, json=json) + except httpx.HTTPError as exc: + _mark(session, agent, "offline") + raise AgentError(502, "agent_unreachable", str(exc)) from exc + + if resp.status_code in (401, 403): + _mark(session, agent, "unauthorized") + raise AgentError(resp.status_code, "agent_unauthorized", "Invalid agent token") + + _mark(session, agent, "online") + + if resp.status_code >= 400: + detail = "" + try: + body = resp.json() + detail = body.get("detail") if isinstance(body, dict) else str(body) + if isinstance(detail, dict): + detail = detail.get("detail") or detail.get("error") or str(detail) + except ValueError: + detail = resp.text[:500] + raise AgentError(resp.status_code, "agent_error", str(detail)) + + if resp.content: + try: + return resp.json() + except ValueError: + return resp.text + return None + + +async def ping(session: Session, agent: Agent) -> dict: + """Health-check an agent and refresh its status + hostname. Never raises.""" + try: + data = await call(session, agent, "GET", "/agent/ping") + if isinstance(data, dict) and data.get("hostname"): + agent.hostname = data["hostname"] + session.add(agent) + session.commit() + session.refresh(agent) + return {"status": agent.status, "hostname": agent.hostname, "data": data} + except AgentError: + return {"status": agent.status, "hostname": agent.hostname, "data": None} diff --git a/frontend/package.json b/frontend/package.json index 0b2d404..faf234f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.4.0", + "version": "0.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f87b466..ad93a21 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { Dashboard } from "@/pages/Dashboard"; import { Stacks } from "@/pages/Stacks"; import { StackDetail } from "@/pages/StackDetail"; import { StackEditor } from "@/pages/StackEditor"; +import { RemoteStackDetail } from "@/pages/RemoteStackDetail"; import { Images } from "@/pages/Images"; import { Templates } from "@/pages/Templates"; import { Settings } from "@/pages/Settings"; @@ -43,6 +44,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts new file mode 100644 index 0000000..884ebb9 --- /dev/null +++ b/frontend/src/api/agents.ts @@ -0,0 +1,30 @@ +import api from "./client"; +import type { Agent, StackDetail, StackSummary } from "@/types"; + +export type RemoteStackSummary = StackSummary & { agent_id: number; agent_name: string }; +export type RemoteStackDetail = StackDetail & { agent_id: number; agent_name: string }; + +export const agentsApi = { + list: (refresh = true) => + api.get(`/api/agents?refresh=${refresh}`).then((r) => r.data), + create: (body: { name: string; url: string; token: string }) => + api.post("/api/agents", body).then((r) => r.data), + update: (id: number, body: { name?: string; url?: string; token?: string }) => + api.put(`/api/agents/${id}`, body).then((r) => r.data), + remove: (id: number) => api.delete(`/api/agents/${id}`).then((r) => r.data), + ping: (id: number) => + api.post<{ status: string; hostname?: string }>(`/api/agents/${id}/ping`).then((r) => r.data), + + stacks: (id: number) => + api.get(`/api/agents/${id}/stacks`).then((r) => r.data), + stack: (id: number, stackId: string) => + api.get(`/api/agents/${id}/stacks/${stackId}`).then((r) => r.data), + logs: (id: number, stackId: string, tail = 200) => + api + .get<{ logs: string }>(`/api/agents/${id}/stacks/${stackId}/logs?tail=${tail}`) + .then((r) => r.data), + update_stack: (id: number, stackId: string, body: { yaml?: string; env?: string }) => + api.put(`/api/agents/${id}/stacks/${stackId}`, body).then((r) => r.data), + action: (id: number, stackId: string, action: string) => + api.post(`/api/agents/${id}/stacks/${stackId}/${action}`).then((r) => r.data), +}; diff --git a/frontend/src/components/hosts/HostDot.tsx b/frontend/src/components/hosts/HostDot.tsx new file mode 100644 index 0000000..c0a7433 --- /dev/null +++ b/frontend/src/components/hosts/HostDot.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/utils"; + +const color: Record = { + online: "bg-green-500", + offline: "bg-red-500", + unauthorized: "bg-amber-500", + unknown: "bg-slate-400", +}; + +export function HostDot({ status }: { status: string }) { + return ( + + ); +} diff --git a/frontend/src/components/stacks/AgentStacksSection.tsx b/frontend/src/components/stacks/AgentStacksSection.tsx new file mode 100644 index 0000000..e3947ef --- /dev/null +++ b/frontend/src/components/stacks/AgentStacksSection.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Server } from "lucide-react"; +import { toast } from "sonner"; +import { Card } from "@/components/ui"; +import { StackCard } from "@/components/stacks/StackCard"; +import { HostDot } from "@/components/hosts/HostDot"; +import { agentsApi } from "@/api/agents"; +import { apiErrorMessage } from "@/api/client"; +import type { Agent } from "@/types"; + +export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) { + const qc = useQueryClient(); + const [busyId, setBusyId] = useState(null); + const online = agent.status === "online"; + + const stacks = useQuery({ + queryKey: ["agent-stacks", agent.id], + queryFn: () => agentsApi.stacks(agent.id), + enabled: online, + refetchInterval: 8000, + }); + + const run = async (action: string, label: string, id: string) => { + setBusyId(id); + const t = toast.loading(`${label} ${id} on ${agent.name}…`); + try { + await agentsApi.action(agent.id, id, action); + toast.success(`${label} ${id} ✓`, { id: t }); + qc.invalidateQueries({ queryKey: ["agent-stacks", agent.id] }); + } catch (e) { + toast.error(apiErrorMessage(e), { id: t }); + } finally { + setBusyId(null); + } + }; + + return ( +
+

+ + {agent.name} + + {agent.hostname && ( + {agent.hostname} + )} +

+ + {!online ? ( + +

+ Host is {agent.status}. Check it under Settings → Remote hosts. +

+
+ ) : stacks.data && stacks.data.length > 0 ? ( +
+ {stacks.data.map((s) => ( + run("start", "Starting", id)} + onStop={(id) => run("stop", "Stopping", id)} + onRestart={(id) => run("restart", "Restarting", id)} + /> + ))} +
+ ) : ( + +

No stacks on this host.

+
+ )} +
+ ); +} diff --git a/frontend/src/components/stacks/StackCard.tsx b/frontend/src/components/stacks/StackCard.tsx index 517e675..6b1884f 100644 --- a/frontend/src/components/stacks/StackCard.tsx +++ b/frontend/src/components/stacks/StackCard.tsx @@ -11,6 +11,8 @@ interface Props { onRestart: (id: string) => void; busy?: boolean; isAdmin?: boolean; + linkBase?: string; // detail/edit route prefix, default "/stacks" + showEdit?: boolean; // hide edit for remote stacks (no remote editor yet) } export function StackCard({ @@ -20,11 +22,13 @@ export function StackCard({ onRestart, busy, isAdmin, + linkBase = "/stacks", + showEdit = true, }: Props) { return (
- +
@@ -59,13 +63,15 @@ export function StackCard({ onRestart(stack.id)} disabled={busy}> - - - + {showEdit && ( + + + + )}
)} diff --git a/frontend/src/pages/RemoteStackDetail.tsx b/frontend/src/pages/RemoteStackDetail.tsx new file mode 100644 index 0000000..fc84ee3 --- /dev/null +++ b/frontend/src/pages/RemoteStackDetail.tsx @@ -0,0 +1,272 @@ +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Play, + Square, + RotateCw, + DownloadCloud, + ArrowUpCircle, + Power, + ArrowLeft, + Save, +} from "lucide-react"; +import { toast } from "sonner"; +import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui"; +import { HostDot } from "@/components/hosts/HostDot"; +import { agentsApi } from "@/api/agents"; +import { apiErrorMessage } from "@/api/client"; +import { useAuthStore } from "@/store/auth"; + +const TABS = ["Overview", "Logs", "Environment", "Compose"] as const; +type Tab = (typeof TABS)[number]; + +export function RemoteStackDetail() { + const { agentId = "", id = "" } = useParams(); + const aid = Number(agentId); + const qc = useQueryClient(); + const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const [tab, setTab] = useState("Overview"); + const [busy, setBusy] = useState(false); + + const { data, isLoading } = useQuery({ + queryKey: ["agent-stack", aid, id], + queryFn: () => agentsApi.stack(aid, id), + refetchInterval: 5000, + }); + + const run = async (action: string, label: string) => { + setBusy(true); + const t = toast.loading(`${label} ${id}…`); + try { + await agentsApi.action(aid, id, action); + toast.success(`${label} ${id} ✓`, { id: t }); + qc.invalidateQueries({ queryKey: ["agent-stack", aid, id] }); + } catch (e) { + toast.error(apiErrorMessage(e), { id: t }); + } finally { + setBusy(false); + } + }; + + if (isLoading || !data) return ; + + return ( +
+ + All stacks + + +
+
+
+ +

{data.name}

+ {data.status} +
+

+ on {data.agent_name} + +

+
+ {isAdmin && ( +
+ + + + + + +
+ )} +
+ +
+ {TABS.map((t) => ( + + ))} +
+ +
+ {tab === "Overview" && } + {tab === "Logs" && } + {tab === "Environment" && ( + + )} + {tab === "Compose" && ( + + )} +
+
+ ); +} + +function Overview({ containers }: { containers: any[] }) { + return ( +
+ {containers.length === 0 && ( + +

No containers running.

+
+ )} + {containers.map((c) => ( + +
+ +
+

{c.service}

+

{c.image}

+
+
+
+ {c.health && {c.health}} + {c.status} +
+
+ ))} +
+ ); +} + +function RemoteLogs({ agentId, stackId }: { agentId: number; stackId: string }) { + const { data, isLoading, refetch, isFetching } = useQuery({ + queryKey: ["agent-logs", agentId, stackId], + queryFn: () => agentsApi.logs(agentId, stackId, 400), + refetchInterval: 5000, + }); + return ( + +
+ +
+ {isLoading ? ( + + ) : ( +
+          {data?.logs || "No logs."}
+        
+ )} +
+ ); +} + +function RemoteEditor({ + agentId, + stackId, + field, + value, + canEdit, + queryKey, +}: { + agentId: number; + stackId: string; + field: "yaml" | "env"; + value: string; + canEdit: boolean; + queryKey: unknown[]; +}) { + const qc = useQueryClient(); + const [text, setText] = useState(value); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!editing) setText(value); + }, [value, editing]); + + const save = async () => { + setSaving(true); + try { + const body = field === "yaml" ? { yaml: text } : { env: text }; + await agentsApi.update_stack(agentId, stackId, body); + toast.success("Saved. Restart or update the stack to apply."); + setEditing(false); + qc.invalidateQueries({ queryKey }); + } catch (e) { + toast.error(apiErrorMessage(e)); + } finally { + setSaving(false); + } + }; + + if (!editing) { + return ( + + {canEdit && ( +
+ +
+ )} + {value ? ( +
{value}
+ ) : ( +

+ {field === "env" ? "No .env file for this stack." : "Empty compose file."} +

+ )} +
+ ); + } + + return ( + +