- 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>
116 lines
3.5 KiB
Python
116 lines
3.5 KiB
Python
"""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}
|