Files
stackpilot/backend/routers/agents.py
T
menzeljandClaude Opus 5 5347a36eaf Back up bind-mount data, not just the compose file (0.40.0)
A stack's real state lives in its bind-mounted config directories, and those
were never captured: the backup only tarred the stack folder as this container
sees it. When STACKS_HOST_DIR differs from the container's STACKS_DIR, compose
resolves ./config against the container path and the daemon creates it at that
path on the *host* — invisible here, so the archive held little more than
compose.yaml and .env.

New services/stack_assets_service.py inventories a stack's data (bind sources
merged from container mounts + the compose file, named volumes) and does all
data I/O through a throwaway helper container, i.e. by host path, so unseen
directories are captured anyway. It also detects the host/container stacks-path
mismatch and reports it.

- manifest v2: full inventory, per-asset capture result, skip reasons (v1 still
  restores)
- NFS/CIFS-backed volumes are skipped by default and never wiped on restore
- deselected data inside the stack folder no longer sneaks in via compose/
- volume/bind archives stream through temp files instead of RAM
- restore preserves mode, ownership, mtime and symlinks, and writes bind folders
  back to their host paths (rewritten when the stack is renamed)
- backup dialog shows the inventory with sizes and per-item checkboxes; restore
  gained a "restore bind folders" toggle
- new GET /api/stacks/{id}/backup/inventory (+ agent + proxy), backup endpoints
  take include_binds/binds/volumes, restore takes restore_binds

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:20:19 +00:00

1186 lines
38 KiB
Python

"""Remote host (agent) management + proxied stack/system operations."""
from __future__ import annotations
import asyncio
import os
import tempfile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel
from sqlmodel import Session, select
from starlette.background import BackgroundTask
from auth import get_current_user, require_admin
from database import get_session
from models.agent import Agent, AgentCreate, AgentRead, AgentUpdate
from models.backup_destination import BackupDestination
from models.stack import StackCreate, StackUpdate
from models.user import User
from routers.files import NameBody, RenameBody, TransferBody, WriteBody
from routers.networks import ContainerRef, NetworkCreate
from routers.secrets import AttachBody, DetachBody, SecretWrite
from models.auto_update import AutoUpdateRead, AutoUpdateWrite
from services import (
agent_service,
audit_service,
auto_update_service,
backup_destination_service as dest_service,
backup_service,
compose_service,
)
from services.agent_service import AgentError
router = APIRouter(prefix="/api/agents", tags=["agents"])
_ACTIONS = {"start", "stop", "restart", "pull", "update", "down"}
class AgentPushBody(BaseModel):
destination_id: int
include_volumes: bool = True
include_binds: bool = True
stop_first: bool = True
binds: list[str] | None = None
volumes: list[str] | None = None
class AgentRestoreFromBody(BaseModel):
destination_id: int
name: str
target_id: str | None = None
overwrite: bool = False
restore_binds: bool = True
restore_volumes: bool = True
def _get_dest(session: Session, dest_id: int) -> BackupDestination:
d = session.get(BackupDestination, dest_id)
if not d:
raise HTTPException(status_code=404, detail=f"Destination {dest_id} not found")
return d
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/stats")
async def agent_stacks_stats(
agent_id: int,
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", "/agent/stacks/stats")
@router.get("/{agent_id}/stacks/updates")
async def agent_stacks_updates(
agent_id: int,
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", "/agent/stacks/updates")
@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
# --------------------------------------------------------------------------- #
# Backup / restore of remote stacks (streamed agent <-> main <-> destination)
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/stacks/{stack_id}/backup/inventory")
async def agent_backup_inventory(
agent_id: int,
stack_id: str,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", f"/agent/stacks/{stack_id}/backup/inventory")
@router.get("/{agent_id}/stacks/{stack_id}/backup")
async def agent_backup_download(
agent_id: int,
stack_id: str,
request: Request,
include_volumes: bool = Query(True),
include_binds: bool = Query(True),
stop_first: bool = Query(True),
binds: list[str] | None = Query(None),
volumes: list[str] | None = Query(None),
session: Session = Depends(get_session),
user: User = Depends(require_admin),
):
agent = _get_or_404(session, agent_id)
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
tmp.close()
try:
await agent_service.download_to_file(
session, agent, f"/agent/stacks/{stack_id}/backup", tmp.name,
params={
"include_volumes": include_volumes,
"include_binds": include_binds,
"stop_first": stop_first,
**({"binds": binds} if binds else {}),
**({"volumes": volumes} if volumes else {}),
},
)
except AgentError as exc:
if os.path.exists(tmp.name):
os.unlink(tmp.name)
_raise(exc)
audit_service.record(
session, user=user.username, action="agent.stack.backup",
target=f"{agent.name}/{stack_id}", ip=_ip(request),
)
fname = backup_service.backup_filename(
stack_id, include_volumes, prefix=compose_service.slugify(agent.name)
)
return FileResponse(
tmp.name, media_type="application/gzip", filename=fname,
background=BackgroundTask(os.unlink, tmp.name),
)
@router.post("/{agent_id}/stacks/{stack_id}/backup/push")
async def agent_backup_push(
agent_id: int,
stack_id: str,
body: AgentPushBody,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
dest = _get_dest(session, body.destination_id)
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
tmp.close()
try:
try:
await agent_service.download_to_file(
session, agent, f"/agent/stacks/{stack_id}/backup", tmp.name,
params={
"include_volumes": body.include_volumes,
"include_binds": body.include_binds,
"stop_first": body.stop_first,
**({"binds": body.binds} if body.binds else {}),
**({"volumes": body.volumes} if body.volumes else {}),
},
)
except AgentError as exc:
_raise(exc)
fname = backup_service.backup_filename(
stack_id, body.include_volumes, prefix=compose_service.slugify(agent.name)
)
try:
await asyncio.to_thread(dest_service.upload, dest, tmp.name, fname)
except dest_service.DestinationError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
finally:
if os.path.exists(tmp.name):
os.unlink(tmp.name)
audit_service.record(
session, user=user.username, action="agent.stack.backup.push",
target=f"{agent.name}/{stack_id}", detail=f"{dest.name}:{fname}", ip=_ip(request),
)
return {"ok": True, "destination": dest.name, "name": fname}
@router.post("/{agent_id}/stacks/restore")
async def agent_restore_upload(
agent_id: int,
request: Request,
file: UploadFile = File(...),
target_id: str | None = Form(None),
overwrite: bool = Form(False),
restore_volumes: bool = Form(True),
restore_binds: bool = Form(True),
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
try:
while chunk := await file.read(1024 * 1024):
tmp.write(chunk)
tmp.close()
result = await agent_service.upload_file(
session, agent, "/agent/stacks/restore", tmp.name,
file.filename or "backup.tar.gz",
{
"target_id": target_id or "",
"overwrite": str(overwrite).lower(),
"restore_volumes": str(restore_volumes).lower(),
"restore_binds": str(restore_binds).lower(),
},
)
except AgentError as exc:
_raise(exc)
finally:
if os.path.exists(tmp.name):
os.unlink(tmp.name)
audit_service.record(
session, user=user.username, action="agent.stack.restore",
target=f"{agent.name}/{result.get('stack_id')}", ip=_ip(request),
)
return result
@router.post("/{agent_id}/stacks/restore-from")
async def agent_restore_from(
agent_id: int,
body: AgentRestoreFromBody,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
dest = _get_dest(session, body.destination_id)
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
tmp.close()
try:
try:
await asyncio.to_thread(dest_service.download, dest, body.name, tmp.name)
except dest_service.DestinationError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
result = await agent_service.upload_file(
session, agent, "/agent/stacks/restore", tmp.name, body.name,
{
"target_id": body.target_id or "",
"overwrite": str(body.overwrite).lower(),
"restore_volumes": str(body.restore_volumes).lower(),
"restore_binds": str(body.restore_binds).lower(),
},
)
except AgentError as exc:
_raise(exc)
finally:
if os.path.exists(tmp.name):
os.unlink(tmp.name)
audit_service.record(
session, user=user.username, action="agent.stack.restore",
target=f"{agent.name}/{result.get('stack_id')}", detail=f"from {dest.name}:{body.name}",
ip=_ip(request),
)
return result
# --------------------------------------------------------------------------- #
# Networks (proxied)
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/networks")
async def agent_networks(
agent_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[dict]:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", "/agent/networks") or []
@router.get("/{agent_id}/networks/{network_id}")
async def agent_network_inspect(
agent_id: int,
network_id: str,
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/networks/{network_id}")
@router.get("/{agent_id}/networks/{network_id}/containers")
async def agent_network_containers(
agent_id: int,
network_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[dict]:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", f"/agent/networks/{network_id}/containers") or []
@router.post("/{agent_id}/networks/prune")
async def agent_network_prune(
agent_id: int,
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/networks/prune")
audit_service.record(
session, user=user.username, action="agent.network.prune", target=agent.name,
detail=str(result.get("NetworksDeleted") or []), ip=_ip(request),
)
return result
@router.post("/{agent_id}/networks", status_code=201)
async def agent_network_create(
agent_id: int,
body: NetworkCreate,
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/networks", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.network.create",
target=f"{agent.name}/{body.name}", ip=_ip(request),
)
return result
@router.delete("/{agent_id}/networks/{network_id}")
async def agent_network_delete(
agent_id: int,
network_id: str,
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, "DELETE", f"/agent/networks/{network_id}")
audit_service.record(
session, user=user.username, action="agent.network.delete",
target=f"{agent.name}/{network_id}", ip=_ip(request),
)
return result
@router.post("/{agent_id}/networks/{network_id}/connect")
async def agent_network_connect(
agent_id: int,
network_id: str,
body: ContainerRef,
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", f"/agent/networks/{network_id}/connect", json=body.model_dump()
)
audit_service.record(
session, user=user.username, action="agent.network.connect",
target=f"{agent.name}/{network_id}", detail=body.container, ip=_ip(request),
)
return result
@router.post("/{agent_id}/networks/{network_id}/disconnect")
async def agent_network_disconnect(
agent_id: int,
network_id: str,
body: ContainerRef,
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", f"/agent/networks/{network_id}/disconnect", json=body.model_dump()
)
audit_service.record(
session, user=user.username, action="agent.network.disconnect",
target=f"{agent.name}/{network_id}", detail=body.container, ip=_ip(request),
)
return result
# --------------------------------------------------------------------------- #
# Images (proxied)
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/images")
async def agent_images(
agent_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[dict]:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", "/agent/images") or []
@router.get("/{agent_id}/images/updates")
async def agent_image_updates(
agent_id: int,
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", "/agent/images/updates")
@router.post("/{agent_id}/images/check")
async def agent_image_check(
agent_id: int,
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/images/check")
audit_service.record(
session, user=user.username, action="agent.image.check", target=agent.name,
ip=_ip(request),
)
return result
@router.post("/{agent_id}/images/prune")
async def agent_image_prune(
agent_id: int,
request: Request,
all_unused: bool = Query(False, alias="all"),
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/images/prune", params={"all": all_unused}
)
audit_service.record(
session, user=user.username, action="agent.image.prune", target=agent.name,
detail=f"all={all_unused} reclaimed={(result or {}).get('SpaceReclaimed')}",
ip=_ip(request),
)
return result
# --------------------------------------------------------------------------- #
# Volumes (proxied)
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/volumes")
async def agent_volumes(
agent_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[dict]:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", "/agent/volumes") or []
@router.get("/{agent_id}/volumes/sizes")
async def agent_volume_sizes(
agent_id: int,
force: bool = Query(False),
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", "/agent/volumes/sizes", params={"force": force}
)
@router.post("/{agent_id}/volumes/prune")
async def agent_volumes_prune(
agent_id: int,
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/volumes/prune")
audit_service.record(
session, user=user.username, action="agent.volume.prune", target=agent.name,
detail=str(result.get("VolumesDeleted") or []), ip=_ip(request),
)
return result
@router.delete("/{agent_id}/volumes/{name}")
async def agent_volume_delete(
agent_id: int,
name: str,
request: Request,
force: bool = Query(False),
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/volumes/{name}", params={"force": force}
)
audit_service.record(
session, user=user.username, action="agent.volume.delete",
target=f"{agent.name}/{name}", ip=_ip(request),
)
return result
# --------------------------------------------------------------------------- #
# File browser (proxied)
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/files/list")
async def agent_files_list(
agent_id: int,
path: str = Query("/"),
show_hidden: bool = Query(False),
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", "/agent/files/list",
params={"path": path, "show_hidden": show_hidden},
)
@router.get("/{agent_id}/files/read")
async def agent_files_read(
agent_id: int,
path: str = Query(...),
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", "/agent/files/read", params={"path": path})
@router.get("/{agent_id}/files/download")
async def agent_files_download(
agent_id: int,
path: str = Query(...),
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
):
agent = _get_or_404(session, agent_id)
# Stream the agent's response straight through (works for single files and
# for on-the-fly folder zips), so nothing is staged to disk and the
# download starts immediately. Pull the first chunk eagerly so a failed
# agent (offline / bad token / 404) still surfaces a clean HTTP status
# before we commit to a 200 streaming response.
chunks = agent_service.stream_download(
session, agent, "/agent/files/download", params={"path": path}
)
try:
first = await chunks.__anext__()
except StopAsyncIteration:
first = b""
except AgentError as exc:
_raise(exc)
async def body():
yield first
async for chunk in chunks:
yield chunk
return StreamingResponse(
body(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{os.path.basename(path)}"'},
)
@router.put("/{agent_id}/files/write")
async def agent_files_write(
agent_id: int,
body: WriteBody,
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", "/agent/files/write", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.file.write",
target=f"{agent.name}:{body.path}", ip=_ip(request),
)
return result
@router.post("/{agent_id}/files/mkdir")
async def agent_files_mkdir(
agent_id: int,
body: NameBody,
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/files/mkdir", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.file.mkdir",
target=f"{agent.name}:{result.get('path')}", ip=_ip(request),
)
return result
@router.post("/{agent_id}/files/touch")
async def agent_files_touch(
agent_id: int,
body: NameBody,
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/files/touch", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.file.create",
target=f"{agent.name}:{result.get('path')}", ip=_ip(request),
)
return result
@router.post("/{agent_id}/files/rename")
async def agent_files_rename(
agent_id: int,
body: RenameBody,
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/files/rename", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.file.rename",
target=f"{agent.name}:{body.path}", detail=f"-> {result.get('path')}", ip=_ip(request),
)
return result
@router.post("/{agent_id}/files/copy")
async def agent_files_copy(
agent_id: int,
body: TransferBody,
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/files/copy", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.file.copy",
target=f"{agent.name}:{body.src}", detail=f"-> {result.get('path')}", ip=_ip(request),
)
return result
@router.post("/{agent_id}/files/move")
async def agent_files_move(
agent_id: int,
body: TransferBody,
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/files/move", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.file.move",
target=f"{agent.name}:{body.src}", detail=f"-> {result.get('path')}", ip=_ip(request),
)
return result
@router.delete("/{agent_id}/files")
async def agent_files_delete(
agent_id: int,
request: Request,
path: str = Query(...),
recursive: bool = Query(False),
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
result = await _proxy(
session, agent, "DELETE", "/agent/files", params={"path": path, "recursive": recursive}
)
audit_service.record(
session, user=user.username, action="agent.file.delete",
target=f"{agent.name}:{path}", detail="recursive" if recursive else None, ip=_ip(request),
)
return result
@router.post("/{agent_id}/files/upload")
async def agent_files_upload(
agent_id: int,
request: Request,
path: str = Form(...),
overwrite: bool = Form(False),
rel_path: str = Form(""),
file: UploadFile = File(...),
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
tmp = tempfile.NamedTemporaryFile(delete=False)
try:
while chunk := await file.read(1024 * 1024):
tmp.write(chunk)
tmp.close()
result = await agent_service.upload_file(
session, agent, "/agent/files/upload", tmp.name, file.filename or "upload",
{"path": path, "overwrite": str(overwrite).lower(), "rel_path": rel_path},
)
except AgentError as exc:
_raise(exc)
finally:
if os.path.exists(tmp.name):
os.unlink(tmp.name)
audit_service.record(
session, user=user.username, action="agent.file.upload",
target=f"{agent.name}:{path}", detail=rel_path or file.filename, ip=_ip(request),
)
return result
# --------------------------------------------------------------------------- #
# Containers (proxied)
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/containers/{container_id}")
async def agent_container_inspect(
agent_id: int,
container_id: str,
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/containers/{container_id}")
@router.post("/{agent_id}/containers/{container_id}/{action}")
async def agent_container_action(
agent_id: int,
container_id: str,
action: str,
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", f"/agent/containers/{container_id}/{action}"
)
audit_service.record(
session, user=user.username, action=f"agent.container.{action}",
target=f"{agent.name}/{container_id[:12]}", ip=_ip(request),
)
return result
# --------------------------------------------------------------------------- #
# Auto-update policy (Watchtower-style) — remote stacks (stored centrally)
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/stacks/{stack_id}/auto-update", response_model=AutoUpdateRead)
def agent_get_auto_update(
agent_id: int,
stack_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
_get_or_404(session, agent_id)
policy = auto_update_service.get_policy(session, stack_id, agent_id)
return auto_update_service.to_read(session, policy, stack_id, agent_id)
@router.put("/{agent_id}/stacks/{stack_id}/auto-update", response_model=AutoUpdateRead)
def agent_set_auto_update(
agent_id: int,
stack_id: str,
body: AutoUpdateWrite,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
policy = auto_update_service.upsert_policy(
session, stack_id, body.enabled, body.redeploy, agent_id=agent_id
)
audit_service.record(
session, user=user.username, action="agent.stack.auto_update",
target=f"{agent.name}/{stack_id}",
detail=f"enabled={body.enabled} redeploy={body.redeploy}", ip=_ip(request),
)
return auto_update_service.to_read(session, policy, stack_id, agent_id)
@router.post("/{agent_id}/stacks/{stack_id}/auto-update/run", response_model=AutoUpdateRead)
async def agent_run_auto_update(
agent_id: int,
stack_id: str,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
_get_or_404(session, agent_id)
policy = auto_update_service.get_policy(session, stack_id, agent_id)
if policy is None:
raise HTTPException(status_code=404, detail="No auto-update policy for this stack")
await auto_update_service.run_policy(session, policy)
session.refresh(policy)
return auto_update_service.to_read(session, policy, stack_id, agent_id)
# --------------------------------------------------------------------------- #
# Secrets & configs (proxied) — remote stacks
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/stacks/{stack_id}/secrets")
async def agent_list_secrets(
agent_id: int,
stack_id: str,
session: Session = Depends(get_session),
_user: User = Depends(require_admin),
) -> list:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", f"/agent/stacks/{stack_id}/secrets") or []
@router.put("/{agent_id}/stacks/{stack_id}/secrets")
async def agent_write_secret(
agent_id: int,
stack_id: str,
body: SecretWrite,
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}/secrets", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.secret.write",
target=f"{agent.name}/{stack_id}/{body.kind}/{body.name}", ip=_ip(request),
)
return result
@router.delete("/{agent_id}/stacks/{stack_id}/secrets/{kind}/{name}")
async def agent_delete_secret(
agent_id: int,
stack_id: str,
kind: str,
name: str,
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, "DELETE", f"/agent/stacks/{stack_id}/secrets/{kind}/{name}")
audit_service.record(
session, user=user.username, action="agent.secret.delete",
target=f"{agent.name}/{stack_id}/{kind}/{name}", ip=_ip(request),
)
return result
@router.post("/{agent_id}/stacks/{stack_id}/secrets/attach")
async def agent_attach_secret(
agent_id: int,
stack_id: str,
body: AttachBody,
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", f"/agent/stacks/{stack_id}/secrets/attach", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.secret.attach",
target=f"{agent.name}/{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request),
)
return result
@router.post("/{agent_id}/stacks/{stack_id}/secrets/detach")
async def agent_detach_secret(
agent_id: int,
stack_id: str,
body: DetachBody,
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", f"/agent/stacks/{stack_id}/secrets/detach", json=body.model_dump())
audit_service.record(
session, user=user.username, action="agent.secret.detach",
target=f"{agent.name}/{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request),
)
return result