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>
1186 lines
38 KiB
Python
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
|