Phase 5: multi-host agents (0.5.0)
- 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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8d19b09abd
commit
59037f4287
@@ -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}
|
||||
Reference in New Issue
Block a user