Phase 8: back up & restore remote (agent) stacks (0.8.0)

- Agent: GET /agent/stacks/{id}/backup + POST /agent/stacks/restore (reuse
  backup_service). backup_service gains backup_basename/backup_filename helpers.
- Main proxy streams agent <-> main <-> destination (creds stay central):
  agent_service download_to_file/upload_file; routers/agents.py backup download,
  backup/push, restore upload, restore-from.
- Schedules: BackupSchedule.agent_id; schedule_service downloads from the agent
  when set; per-host filename prefix isolates retention across hosts.
- Frontend: agents api backup/restore; BackupButton/RestoreButton agent-aware
  (Backup on remote stack detail, Restore per host section); schedule form host
  selector (local or an online agent) + host shown on schedule rows.

Rough-verified (per request): py_compile, frontend tsc build, image imports
(main 99 / agent 16 routes). Full live agent round-trip to be tested post-deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 22:26:20 +00:00
co-authored by Claude Opus 4.8
parent 84ef3df59e
commit 5cd55382ed
16 changed files with 561 additions and 59 deletions
+191 -2
View File
@@ -1,15 +1,29 @@
"""Remote host (agent) management + proxied stack/system operations."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request
import asyncio
import os
import tempfile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse
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 services import agent_service, audit_service
from services import (
agent_service,
audit_service,
backup_destination_service as dest_service,
backup_service,
compose_service,
)
from services.agent_service import AgentError
router = APIRouter(prefix="/api/agents", tags=["agents"])
@@ -17,6 +31,27 @@ router = APIRouter(prefix="/api/agents", tags=["agents"])
_ACTIONS = {"start", "stop", "restart", "pull", "update", "down"}
class AgentPushBody(BaseModel):
destination_id: int
include_volumes: bool = True
stop_first: bool = True
class AgentRestoreFromBody(BaseModel):
destination_id: int
name: str
target_id: str | None = None
overwrite: bool = False
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"
@@ -284,3 +319,157 @@ async def agent_lifecycle(
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")
async def agent_backup_download(
agent_id: int,
stack_id: str,
request: Request,
include_volumes: bool = Query(True),
stop_first: bool = Query(True),
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, "stop_first": stop_first},
)
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, "stop_first": body.stop_first},
)
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),
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(),
},
)
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(),
},
)
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