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>
858 lines
30 KiB
Python
858 lines
30 KiB
Python
"""StackPilot agent — a slim, token-guarded Docker Compose API for one host.
|
|
|
|
The agent runs on each remote host (same image as the backend, different CMD).
|
|
It has no users, no database and no UI: it exposes just enough of the stack /
|
|
system surface for a central StackPilot to manage this host's compose stacks,
|
|
authenticated by a single shared bearer token (``AGENT_TOKEN``).
|
|
|
|
All compose/Docker logic is reused from the backend's ``compose_service`` and
|
|
``docker_client`` so behaviour matches the local host exactly.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import shutil
|
|
from dataclasses import asdict
|
|
|
|
import tempfile
|
|
|
|
import json
|
|
|
|
from fastapi import (
|
|
Depends,
|
|
FastAPI,
|
|
File,
|
|
Form,
|
|
Header,
|
|
HTTPException,
|
|
Query,
|
|
Request,
|
|
UploadFile,
|
|
WebSocket,
|
|
WebSocketDisconnect,
|
|
)
|
|
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
|
from pydantic import BaseModel
|
|
|
|
from config import settings
|
|
from version import APP_VERSION
|
|
from docker_client import DockerError, get_client, safe_call
|
|
from services import (
|
|
backup_service,
|
|
compose_edit_service,
|
|
compose_service,
|
|
container_service,
|
|
device_service,
|
|
exec_service,
|
|
file_service,
|
|
image_service,
|
|
network_service,
|
|
secret_service,
|
|
stats_service,
|
|
update_service,
|
|
volume_service,
|
|
)
|
|
|
|
logger = logging.getLogger("stackpilot.agent")
|
|
|
|
# Map network_service's DockerError codes to HTTP status. forbidden is mapped to
|
|
# 400 (not 403) so the central proxy doesn't misread it as a token failure.
|
|
_DOCKER_STATUS = {"invalid_request": 400, "forbidden": 400, "not_found": 404}
|
|
|
|
|
|
def _map_docker(exc: DockerError):
|
|
code = _DOCKER_STATUS.get(exc.error)
|
|
if code:
|
|
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
|
raise exc # falls through to the global 502 DockerError handler
|
|
|
|
AGENT_VERSION = APP_VERSION
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Auth
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def verify_token(authorization: str = Header(default="")) -> None:
|
|
expected = settings.AGENT_TOKEN
|
|
if not expected:
|
|
raise HTTPException(status_code=503, detail="Agent token not configured")
|
|
if authorization != f"Bearer {expected}":
|
|
raise HTTPException(status_code=401, detail="Invalid agent token")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Schemas
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class StackBody(BaseModel):
|
|
name: str | None = None
|
|
yaml: str | None = None
|
|
env: str | None = None
|
|
|
|
|
|
class NetworkCreateBody(BaseModel):
|
|
name: str
|
|
driver: str = "bridge"
|
|
subnet: str | None = None
|
|
gateway: str | None = None
|
|
internal: bool = False
|
|
attachable: bool = True
|
|
|
|
|
|
class ContainerRefBody(BaseModel):
|
|
container: str
|
|
aliases: list[str] | None = None
|
|
force: bool = False
|
|
|
|
|
|
class FileWriteBody(BaseModel):
|
|
path: str
|
|
content: str
|
|
|
|
|
|
class FileNameBody(BaseModel):
|
|
path: str
|
|
name: str
|
|
|
|
|
|
class FileRenameBody(BaseModel):
|
|
path: str
|
|
new_name: str
|
|
|
|
|
|
class FileTransferBody(BaseModel):
|
|
src: str
|
|
dest_dir: str
|
|
overwrite: bool = False
|
|
|
|
|
|
def _file_guard(fn, *args, **kwargs):
|
|
try:
|
|
return fn(*args, **kwargs)
|
|
except file_service.BrowseError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Helpers
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _summary(stack_id: str, summaries: dict | None = None) -> dict:
|
|
if summaries is None:
|
|
try:
|
|
containers = compose_service.containers_for_stack(stack_id)
|
|
total = len(containers)
|
|
running = sum(1 for c in containers if c.state == "running")
|
|
status = compose_service.compute_status(stack_id, containers)
|
|
except DockerError:
|
|
total = running = 0
|
|
status = "unknown"
|
|
else:
|
|
info = summaries.get(stack_id)
|
|
total = info["total"] if info else 0
|
|
running = info["running"] if info else 0
|
|
if compose_service.is_busy(stack_id):
|
|
status = "updating"
|
|
else:
|
|
status = info["status"] if info else "stopped"
|
|
return {
|
|
"id": stack_id,
|
|
"name": stack_id,
|
|
"description": None,
|
|
"status": status,
|
|
"service_count": total,
|
|
"running_count": running,
|
|
"created_at": None,
|
|
"updated_at": None,
|
|
}
|
|
|
|
|
|
def _hostname() -> str:
|
|
return os.uname().nodename
|
|
|
|
|
|
def _mem_info() -> tuple[int, int]:
|
|
"""Return (total_bytes, used_bytes) from meminfo (used = total - available)."""
|
|
for base in (settings.HOST_PROC_PATH, "/proc"):
|
|
try:
|
|
vals: dict[str, int] = {}
|
|
with open(os.path.join(base, "meminfo"), "r", encoding="utf-8") as fh:
|
|
for line in fh:
|
|
parts = line.split(":")
|
|
if len(parts) == 2 and parts[0] in ("MemTotal", "MemAvailable", "MemFree"):
|
|
try:
|
|
vals[parts[0]] = int(parts[1].split()[0]) * 1024 # kB -> bytes
|
|
except ValueError:
|
|
pass
|
|
total = vals.get("MemTotal", 0)
|
|
available = vals.get("MemAvailable", vals.get("MemFree", 0))
|
|
return total, max(total - available, 0)
|
|
except OSError:
|
|
continue
|
|
return 0, 0
|
|
|
|
|
|
def _disk_info() -> tuple[int, int]:
|
|
"""Return (total_bytes, used_bytes) for the host disk backing the stacks dir."""
|
|
for path in (settings.STACKS_DIR, "/"):
|
|
try:
|
|
usage = shutil.disk_usage(path)
|
|
return usage.total, usage.used
|
|
except OSError:
|
|
continue
|
|
return 0, 0
|
|
|
|
|
|
def _system_info() -> dict:
|
|
docker_version = ""
|
|
host_os = ""
|
|
running = total = compose_running = 0
|
|
try:
|
|
client = get_client()
|
|
docker_version = safe_call(client.version).get("Version", "")
|
|
info = safe_call(client.info)
|
|
host_os = info.get("OperatingSystem", "")
|
|
running = info.get("ContainersRunning", 0)
|
|
total = info.get("Containers", 0)
|
|
# Running compose-managed containers — the dashboard's container card
|
|
# compares this across hosts; counting everything would include this
|
|
# agent itself and skew the bars.
|
|
compose_running = len(
|
|
safe_call(client.api.containers, filters={"label": compose_service.COMPOSE_LABEL})
|
|
)
|
|
except DockerError as exc:
|
|
docker_version = f"unavailable ({exc.error})"
|
|
mem_total, mem_used = _mem_info()
|
|
disk_total, disk_used = _disk_info()
|
|
return {
|
|
"hostname": _hostname(),
|
|
"docker_version": docker_version,
|
|
"host_os": host_os,
|
|
"cpu_cores": os.cpu_count() or 0,
|
|
"mem_total": mem_total,
|
|
"mem_used": mem_used,
|
|
"disk_total": disk_total,
|
|
"disk_used": disk_used,
|
|
"containers_running": running,
|
|
"containers_total": total,
|
|
"compose_running": compose_running,
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# App
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
app = FastAPI(title="StackPilot Agent", version=AGENT_VERSION)
|
|
|
|
|
|
@app.exception_handler(DockerError)
|
|
async def _docker_error(_request: Request, exc: DockerError):
|
|
return JSONResponse(status_code=502, content={"error": exc.error, "detail": exc.detail})
|
|
|
|
|
|
@app.get("/agent/ping", dependencies=[Depends(verify_token)])
|
|
def ping() -> dict:
|
|
return {"ok": True, "hostname": _hostname(), "version": AGENT_VERSION}
|
|
|
|
|
|
@app.get("/agent/system", dependencies=[Depends(verify_token)])
|
|
def system() -> dict:
|
|
return _system_info()
|
|
|
|
|
|
@app.get("/agent/stacks", dependencies=[Depends(verify_token)])
|
|
def list_stacks() -> list[dict]:
|
|
try:
|
|
summaries = compose_service.stack_status_summaries()
|
|
except DockerError:
|
|
summaries = {}
|
|
return [_summary(sid, summaries) for sid in compose_service.discover_stacks()]
|
|
|
|
|
|
@app.get("/agent/stacks/stats", dependencies=[Depends(verify_token)])
|
|
def stacks_stats() -> dict:
|
|
return stats_service.stack_stats()
|
|
|
|
|
|
@app.get("/agent/stacks/updates", dependencies=[Depends(verify_token)])
|
|
def stacks_updates() -> dict:
|
|
"""Per-stack image-update availability from the cached digests."""
|
|
return update_service.stacks_update_summary()
|
|
|
|
|
|
@app.get("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)])
|
|
def get_stack(stack_id: str) -> dict:
|
|
directory = compose_service.stack_dir(stack_id)
|
|
if not os.path.isdir(directory):
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
try:
|
|
raw = compose_service.containers_for_stack(stack_id)
|
|
containers = [asdict(c) for c in raw]
|
|
status = compose_service.compute_status(stack_id, raw)
|
|
except DockerError:
|
|
containers = []
|
|
status = "unknown"
|
|
return {
|
|
"id": stack_id,
|
|
"name": stack_id,
|
|
"description": None,
|
|
"status": status,
|
|
"yaml": compose_service.read_compose(stack_id),
|
|
"env": compose_service.read_env(stack_id),
|
|
"containers": containers,
|
|
"created_at": None,
|
|
"updated_at": None,
|
|
}
|
|
|
|
|
|
@app.post("/agent/stacks", dependencies=[Depends(verify_token)], status_code=201)
|
|
def create_stack(body: StackBody) -> dict:
|
|
if not body.name:
|
|
raise HTTPException(status_code=400, detail="name is required")
|
|
stack_id = compose_service.slugify(body.name)
|
|
if os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists")
|
|
compose_service.write_compose(stack_id, body.yaml or "services:\n")
|
|
if body.env:
|
|
compose_service.write_env(stack_id, body.env)
|
|
return _summary(stack_id)
|
|
|
|
|
|
@app.put("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)])
|
|
def update_stack(stack_id: str, body: StackBody) -> dict:
|
|
if not os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
if body.yaml is not None:
|
|
compose_service.write_compose(stack_id, body.yaml)
|
|
if body.env is not None:
|
|
compose_service.write_env(stack_id, body.env)
|
|
return _summary(stack_id)
|
|
|
|
|
|
@app.delete("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)])
|
|
async def delete_stack(stack_id: str, delete_files: bool = Query(True)) -> dict:
|
|
if not os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
try:
|
|
await compose_service.down(stack_id)
|
|
except Exception: # noqa: BLE001 - best-effort teardown
|
|
pass
|
|
if delete_files:
|
|
compose_service.delete_stack_files(stack_id)
|
|
return {"ok": True}
|
|
|
|
|
|
_ACTIONS = {
|
|
"start": compose_service.up,
|
|
"stop": compose_service.stop,
|
|
"restart": compose_service.restart,
|
|
"pull": compose_service.pull,
|
|
"update": compose_service.update,
|
|
"down": compose_service.down,
|
|
}
|
|
|
|
|
|
@app.post("/agent/stacks/{stack_id}/{action}", dependencies=[Depends(verify_token)])
|
|
async def lifecycle(stack_id: str, action: str) -> dict:
|
|
fn = _ACTIONS.get(action)
|
|
if not fn:
|
|
raise HTTPException(status_code=400, detail=f"Unknown action '{action}'")
|
|
if not os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
result = await fn(stack_id)
|
|
if result.get("returncode") not in (0, None):
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={
|
|
"error": f"compose {action} failed",
|
|
"detail": result.get("stderr", "").strip()[-2000:],
|
|
},
|
|
)
|
|
if action in ("pull", "update"):
|
|
update_service.refresh_stack_local(stack_id)
|
|
return result
|
|
|
|
|
|
@app.get("/agent/stacks/{stack_id}/logs", dependencies=[Depends(verify_token)])
|
|
async def stack_logs(stack_id: str, tail: int = Query(200, le=2000)) -> dict:
|
|
if not os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
result = await compose_service.logs(stack_id, tail=tail)
|
|
return {"logs": result.get("stdout", "") + result.get("stderr", "")}
|
|
|
|
|
|
@app.get("/agent/stacks/{stack_id}/updates", dependencies=[Depends(verify_token)])
|
|
async def stack_updates(stack_id: str, refresh: bool = Query(True)) -> dict:
|
|
"""Update status for this stack's images (used by central auto-update)."""
|
|
return await update_service.stack_updates(stack_id, refresh=refresh)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Secrets & configs (per-stack, file-based)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class SecretWriteBody(BaseModel):
|
|
kind: str = "secret"
|
|
name: str
|
|
content: str
|
|
|
|
|
|
class SecretAttachBody(BaseModel):
|
|
kind: str = "secret"
|
|
name: str
|
|
service: str
|
|
target: str | None = None
|
|
|
|
|
|
class SecretDetachBody(BaseModel):
|
|
kind: str = "secret"
|
|
name: str
|
|
service: str
|
|
|
|
|
|
def _ensure_stack(stack_id: str) -> None:
|
|
if not os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
|
|
|
|
def _secret_guard(fn, *args, **kwargs):
|
|
try:
|
|
return fn(*args, **kwargs)
|
|
except (secret_service.SecretError, compose_edit_service.EditError) as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
@app.get("/agent/stacks/{stack_id}/secrets", dependencies=[Depends(verify_token)])
|
|
def agent_list_secrets(stack_id: str) -> list:
|
|
_ensure_stack(stack_id)
|
|
return secret_service.list_all(stack_id)
|
|
|
|
|
|
@app.put("/agent/stacks/{stack_id}/secrets", dependencies=[Depends(verify_token)])
|
|
def agent_write_secret(stack_id: str, body: SecretWriteBody) -> dict:
|
|
_ensure_stack(stack_id)
|
|
return _secret_guard(secret_service.write_secret, stack_id, body.kind, body.name, body.content)
|
|
|
|
|
|
@app.delete("/agent/stacks/{stack_id}/secrets/{kind}/{name}", dependencies=[Depends(verify_token)])
|
|
def agent_delete_secret(stack_id: str, kind: str, name: str) -> dict:
|
|
_ensure_stack(stack_id)
|
|
_secret_guard(secret_service.delete_secret, stack_id, kind, name)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/agent/stacks/{stack_id}/secrets/attach", dependencies=[Depends(verify_token)])
|
|
def agent_attach_secret(stack_id: str, body: SecretAttachBody) -> dict:
|
|
_ensure_stack(stack_id)
|
|
if not secret_service.exists(stack_id, body.kind, body.name):
|
|
raise HTTPException(status_code=404, detail="secret not found")
|
|
new_yaml = _secret_guard(secret_service.attach, stack_id, body.kind, body.name, body.service, body.target)
|
|
return {"ok": True, "yaml": new_yaml}
|
|
|
|
|
|
@app.post("/agent/stacks/{stack_id}/secrets/detach", dependencies=[Depends(verify_token)])
|
|
def agent_detach_secret(stack_id: str, body: SecretDetachBody) -> dict:
|
|
_ensure_stack(stack_id)
|
|
new_yaml = _secret_guard(secret_service.detach, stack_id, body.kind, body.name, body.service)
|
|
return {"ok": True, "yaml": new_yaml}
|
|
|
|
|
|
@app.get("/agent/stacks/{stack_id}/backup/inventory", dependencies=[Depends(verify_token)])
|
|
async def backup_inventory(stack_id: str) -> dict:
|
|
"""What a backup of this stack would capture (see routers/backups.py)."""
|
|
_ensure_stack(stack_id)
|
|
return await asyncio.to_thread(backup_service.plan, stack_id)
|
|
|
|
|
|
@app.get("/agent/stacks/{stack_id}/backup", dependencies=[Depends(verify_token)])
|
|
async def backup_stack(
|
|
stack_id: str,
|
|
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),
|
|
):
|
|
if not os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
try:
|
|
path, report = await backup_service.create_backup_ex(
|
|
stack_id, stack_id, include_volumes=include_volumes, stop_first=stop_first,
|
|
include_binds=include_binds, binds=binds, volumes=volumes,
|
|
)
|
|
except backup_service.BackupError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
return FileResponse(
|
|
path,
|
|
media_type="application/gzip",
|
|
filename=backup_service.backup_filename(stack_id, include_volumes),
|
|
headers={"X-Stackpilot-Backup": json.dumps({
|
|
"size": report.get("size"),
|
|
"binds": report.get("binds", []),
|
|
"volumes": report.get("volumes", []),
|
|
"skipped": report.get("skipped", []),
|
|
"path_mismatch": report.get("path_mismatch"),
|
|
})},
|
|
)
|
|
|
|
|
|
@app.post("/agent/stacks/restore", dependencies=[Depends(verify_token)])
|
|
async def restore_stack(
|
|
file: UploadFile = File(...),
|
|
target_id: str | None = Form(None),
|
|
overwrite: bool = Form(False),
|
|
restore_volumes: bool = Form(True),
|
|
restore_binds: bool = Form(True),
|
|
) -> dict:
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
|
|
try:
|
|
while chunk := await file.read(1024 * 1024):
|
|
tmp.write(chunk)
|
|
tmp.close()
|
|
target = compose_service.slugify(target_id) if target_id else None
|
|
try:
|
|
return backup_service.restore_backup(
|
|
tmp.name, target_id=target, overwrite=overwrite,
|
|
restore_volumes=restore_volumes, restore_binds=restore_binds,
|
|
)
|
|
except backup_service.BackupError as exc:
|
|
code = 409 if "already exists" in str(exc) else 400
|
|
raise HTTPException(status_code=code, detail=str(exc)) from exc
|
|
finally:
|
|
if os.path.exists(tmp.name):
|
|
os.unlink(tmp.name)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Networks
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@app.get("/agent/networks", dependencies=[Depends(verify_token)])
|
|
def list_networks() -> list[dict]:
|
|
return network_service.list_networks()
|
|
|
|
|
|
@app.get("/agent/networks/{network_id}", dependencies=[Depends(verify_token)])
|
|
def inspect_network(network_id: str) -> dict:
|
|
try:
|
|
return network_service.inspect_network(network_id)
|
|
except DockerError as exc:
|
|
_map_docker(exc)
|
|
|
|
|
|
@app.get("/agent/networks/{network_id}/containers", dependencies=[Depends(verify_token)])
|
|
def network_containers(network_id: str) -> list[dict]:
|
|
try:
|
|
return network_service.connectable_containers(network_id)
|
|
except DockerError as exc:
|
|
_map_docker(exc)
|
|
|
|
|
|
@app.post("/agent/networks/{network_id}/connect", dependencies=[Depends(verify_token)])
|
|
def connect_container(network_id: str, body: ContainerRefBody) -> dict:
|
|
try:
|
|
network_service.connect_container(network_id, body.container, body.aliases)
|
|
except DockerError as exc:
|
|
_map_docker(exc)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/agent/networks/{network_id}/disconnect", dependencies=[Depends(verify_token)])
|
|
def disconnect_container(network_id: str, body: ContainerRefBody) -> dict:
|
|
try:
|
|
network_service.disconnect_container(network_id, body.container, body.force)
|
|
except DockerError as exc:
|
|
_map_docker(exc)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/agent/networks", dependencies=[Depends(verify_token)], status_code=201)
|
|
def create_network(body: NetworkCreateBody) -> dict:
|
|
try:
|
|
return network_service.create_network(body.model_dump())
|
|
except DockerError as exc:
|
|
_map_docker(exc)
|
|
|
|
|
|
@app.delete("/agent/networks/{network_id}", dependencies=[Depends(verify_token)])
|
|
def delete_network(network_id: str) -> dict:
|
|
try:
|
|
network_service.delete_network(network_id)
|
|
except DockerError as exc:
|
|
_map_docker(exc)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/agent/networks/prune", dependencies=[Depends(verify_token)])
|
|
def prune_networks() -> dict:
|
|
return network_service.prune_networks()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Images
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@app.get("/agent/images", dependencies=[Depends(verify_token)])
|
|
def list_images() -> list[dict]:
|
|
return image_service.list_images()
|
|
|
|
|
|
@app.get("/agent/images/updates", dependencies=[Depends(verify_token)])
|
|
def image_updates() -> dict:
|
|
return update_service.get_cache()
|
|
|
|
|
|
@app.post("/agent/images/check", dependencies=[Depends(verify_token)])
|
|
async def image_check() -> dict:
|
|
return await update_service.check_all()
|
|
|
|
|
|
@app.post("/agent/images/prune", dependencies=[Depends(verify_token)])
|
|
def image_prune(all_unused: bool = Query(False, alias="all")) -> dict:
|
|
return image_service.prune_images(all_unused)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Containers (single-container inspect + lifecycle)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@app.get("/agent/containers/{container_id}", dependencies=[Depends(verify_token)])
|
|
def inspect_container(container_id: str) -> dict:
|
|
return container_service.inspect_container(container_id)
|
|
|
|
|
|
@app.post("/agent/containers/{container_id}/{action}", dependencies=[Depends(verify_token)])
|
|
def container_action(container_id: str, action: str) -> dict:
|
|
return container_service.container_action(container_id, action)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Volumes
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@app.get("/agent/volumes", dependencies=[Depends(verify_token)])
|
|
def list_volumes() -> list[dict]:
|
|
return volume_service.list_volumes()
|
|
|
|
|
|
@app.get("/agent/volumes/sizes", dependencies=[Depends(verify_token)])
|
|
def volume_sizes(force: bool = Query(False)) -> dict:
|
|
return volume_service.volume_sizes(force=force)
|
|
|
|
|
|
@app.delete("/agent/volumes/{name}", dependencies=[Depends(verify_token)])
|
|
def delete_volume(name: str, force: bool = Query(False)) -> dict:
|
|
vols = {v["name"]: v for v in volume_service.list_volumes()}
|
|
if name in vols and vols[name]["in_use"] and not force:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"error": "volume_in_use",
|
|
"detail": f"Volume '{name}' is used by: {', '.join(vols[name]['used_by'])}",
|
|
},
|
|
)
|
|
volume_service.remove_volume(name, force=force)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/agent/volumes/prune", dependencies=[Depends(verify_token)])
|
|
def prune_volumes() -> dict:
|
|
return volume_service.prune_volumes()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# File browser (sandboxed by this agent's ALLOWED_BROWSE_ROOTS/HOST_ROOT_PREFIX)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@app.get("/agent/files/list", dependencies=[Depends(verify_token)])
|
|
def files_list(path: str = Query("/"), show_hidden: bool = Query(False)) -> dict:
|
|
return _file_guard(device_service.browse, path, show_hidden)
|
|
|
|
|
|
@app.get("/agent/files/read", dependencies=[Depends(verify_token)])
|
|
def files_read(path: str = Query(...)) -> dict:
|
|
return _file_guard(file_service.read_file, path)
|
|
|
|
|
|
@app.get("/agent/files/download", dependencies=[Depends(verify_token)])
|
|
def files_download(path: str = Query(...)):
|
|
if _file_guard(file_service.is_dir, path):
|
|
filename, chunks = _file_guard(file_service.open_archive, path)
|
|
# Stream the zip as it's built (no temp file, starts immediately).
|
|
return StreamingResponse(
|
|
chunks, media_type="application/zip",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
real, filename = _file_guard(file_service.resolve_download, path)
|
|
return FileResponse(real, filename=filename, media_type="application/octet-stream")
|
|
|
|
|
|
@app.put("/agent/files/write", dependencies=[Depends(verify_token)])
|
|
def files_write(body: FileWriteBody) -> dict:
|
|
return _file_guard(file_service.write_file, body.path, body.content)
|
|
|
|
|
|
@app.post("/agent/files/mkdir", dependencies=[Depends(verify_token)])
|
|
def files_mkdir(body: FileNameBody) -> dict:
|
|
return _file_guard(file_service.create_dir, body.path, body.name)
|
|
|
|
|
|
@app.post("/agent/files/touch", dependencies=[Depends(verify_token)])
|
|
def files_touch(body: FileNameBody) -> dict:
|
|
return _file_guard(file_service.create_file, body.path, body.name)
|
|
|
|
|
|
@app.post("/agent/files/rename", dependencies=[Depends(verify_token)])
|
|
def files_rename(body: FileRenameBody) -> dict:
|
|
return _file_guard(file_service.rename, body.path, body.new_name)
|
|
|
|
|
|
@app.post("/agent/files/copy", dependencies=[Depends(verify_token)])
|
|
def files_copy(body: FileTransferBody) -> dict:
|
|
return _file_guard(file_service.copy, body.src, body.dest_dir, body.overwrite)
|
|
|
|
|
|
@app.post("/agent/files/move", dependencies=[Depends(verify_token)])
|
|
def files_move(body: FileTransferBody) -> dict:
|
|
return _file_guard(file_service.move, body.src, body.dest_dir, body.overwrite)
|
|
|
|
|
|
@app.delete("/agent/files", dependencies=[Depends(verify_token)])
|
|
def files_delete(path: str = Query(...), recursive: bool = Query(False)) -> dict:
|
|
return _file_guard(file_service.delete, path, recursive)
|
|
|
|
|
|
@app.post("/agent/files/upload", dependencies=[Depends(verify_token)])
|
|
async def files_upload(
|
|
path: str = Form(...),
|
|
overwrite: bool = Form(False),
|
|
rel_path: str = Form(""),
|
|
file: UploadFile = File(...),
|
|
) -> dict:
|
|
real = _file_guard(
|
|
file_service.upload_target, path, file.filename or "", overwrite, rel_path or None
|
|
)
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, dir=os.path.dirname(real))
|
|
try:
|
|
while chunk := await file.read(1024 * 1024):
|
|
tmp.write(chunk)
|
|
tmp.close()
|
|
os.replace(tmp.name, real)
|
|
except OSError as exc:
|
|
if os.path.exists(tmp.name):
|
|
os.unlink(tmp.name)
|
|
raise HTTPException(status_code=400, detail=f"Upload failed: {exc}") from exc
|
|
return {"ok": True, "name": rel_path or file.filename}
|
|
|
|
|
|
@app.websocket("/agent/ws/logs/{stack_id}")
|
|
async def ws_logs(websocket: WebSocket, stack_id: str, token: str | None = Query(default=None)):
|
|
"""Stream `docker compose logs -f` to the central app (token via query param)."""
|
|
await websocket.accept()
|
|
expected = settings.AGENT_TOKEN
|
|
if not expected or token != expected:
|
|
await websocket.close(code=4401)
|
|
return
|
|
if not os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
await websocket.send_text(json.dumps({"type": "error", "detail": "stack not found"}))
|
|
await websocket.close()
|
|
return
|
|
args = ["logs", "--no-color", "--tail", "200", "--timestamps", "-f"]
|
|
try:
|
|
async for line in compose_service.stream_compose(stack_id, args):
|
|
await websocket.send_text(
|
|
json.dumps({"type": "log", "stack_id": stack_id, "service": None, "line": line})
|
|
)
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception as exc: # noqa: BLE001
|
|
try:
|
|
await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)}))
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
|
|
@app.websocket("/agent/ws/deploy/{stack_id}")
|
|
async def ws_deploy(websocket: WebSocket, stack_id: str, token: str | None = Query(default=None)):
|
|
"""Run `docker compose up -d` and stream its output to the central app so the
|
|
browser sees deploy progress live (token via query param)."""
|
|
await websocket.accept()
|
|
expected = settings.AGENT_TOKEN
|
|
if not expected or token != expected:
|
|
await websocket.close(code=4401)
|
|
return
|
|
if not os.path.isdir(compose_service.stack_dir(stack_id)):
|
|
await websocket.send_text(json.dumps({"type": "error", "detail": "stack not found"}))
|
|
await websocket.close()
|
|
return
|
|
compose_service.mark_busy(stack_id)
|
|
try:
|
|
async for kind, payload in compose_service.stream_up(stack_id):
|
|
if kind == "log":
|
|
await websocket.send_text(json.dumps({"type": "log", "line": payload}))
|
|
else:
|
|
await websocket.send_text(json.dumps({"type": "done", "returncode": payload}))
|
|
except WebSocketDisconnect:
|
|
# Browser navigated away; the compose subprocess keeps running.
|
|
pass
|
|
except Exception as exc: # noqa: BLE001
|
|
try:
|
|
await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)}))
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
finally:
|
|
compose_service.clear_busy(stack_id)
|
|
|
|
|
|
@app.websocket("/agent/ws/exec/{container_id}")
|
|
async def ws_exec(
|
|
websocket: WebSocket,
|
|
container_id: str,
|
|
token: str | None = Query(default=None),
|
|
cmd: str | None = Query(default=None),
|
|
):
|
|
"""Interactive shell into a compose-managed container (token via query)."""
|
|
await websocket.accept()
|
|
expected = settings.AGENT_TOKEN
|
|
if not expected or token != expected:
|
|
await websocket.close(code=4401)
|
|
return
|
|
shell = cmd or exec_service.DEFAULT_SHELL
|
|
try:
|
|
exec_id = exec_service.create_exec(container_id, [shell])
|
|
holder, raw = exec_service.start_exec(exec_id)
|
|
except Exception as exc: # noqa: BLE001
|
|
try:
|
|
await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)}))
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
await websocket.close()
|
|
return
|
|
try:
|
|
await exec_service.pump_exec(websocket, exec_id, holder, raw)
|
|
except WebSocketDisconnect:
|
|
pass
|
|
finally:
|
|
try:
|
|
await websocket.close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
|
|
@app.get("/agent/health")
|
|
def health() -> dict:
|
|
return {"status": "ok"}
|