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,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
|
||||
Reference in New Issue
Block a user