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:
co-authored by
Claude Opus 4.8
parent
84ef3df59e
commit
5cd55382ed
@@ -11,16 +11,19 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from database import engine
|
||||
from models.agent import Agent
|
||||
from models.backup_destination import BackupDestination
|
||||
from models.backup_schedule import BackupSchedule
|
||||
from models.setting import EVENT_BACKUP_FAILED
|
||||
from models.stack import Stack
|
||||
from services import (
|
||||
agent_service,
|
||||
backup_destination_service as dest_service,
|
||||
backup_service,
|
||||
compose_service,
|
||||
@@ -58,17 +61,11 @@ def compute_next_run(
|
||||
return base
|
||||
|
||||
|
||||
def _backup_filename(stack_id: str, include_volumes: bool) -> str:
|
||||
date = compose_service.now().strftime("%Y%m%d-%H%M%S")
|
||||
suffix = "full" if include_volumes else "config"
|
||||
return f"backup-{stack_id}-{suffix}-{date}.tar.gz"
|
||||
|
||||
|
||||
def _prune(dest: BackupDestination, stack_id: str, keep: int) -> int:
|
||||
def _prune(dest: BackupDestination, basename: str, keep: int) -> int:
|
||||
if keep <= 0:
|
||||
return 0
|
||||
items = dest_service.list_backups(dest)
|
||||
mine = [i for i in items if i["name"].startswith(f"backup-{stack_id}-")]
|
||||
mine = [i for i in items if i["name"].startswith(f"{basename}-")]
|
||||
mine.sort(key=lambda x: x.get("modified") or 0, reverse=True)
|
||||
removed = 0
|
||||
for old in mine[keep:]:
|
||||
@@ -83,22 +80,40 @@ def _prune(dest: BackupDestination, stack_id: str, keep: int) -> int:
|
||||
async def run_schedule(session: Session, schedule: BackupSchedule) -> dict:
|
||||
"""Execute one schedule now. Updates status + next_run. Returns a summary."""
|
||||
now = _now()
|
||||
stack = session.get(Stack, schedule.stack_id)
|
||||
dest = session.get(BackupDestination, schedule.destination_id)
|
||||
result: dict = {"ok": False}
|
||||
path = None
|
||||
try:
|
||||
if not stack:
|
||||
raise RuntimeError(f"stack '{schedule.stack_id}' not found")
|
||||
if not dest:
|
||||
raise RuntimeError(f"destination {schedule.destination_id} not found")
|
||||
path = await backup_service.create_backup(
|
||||
schedule.stack_id, stack.name,
|
||||
include_volumes=schedule.include_volumes, stop_first=schedule.stop_first,
|
||||
)
|
||||
filename = _backup_filename(schedule.stack_id, schedule.include_volumes)
|
||||
|
||||
# Produce the backup archive — locally or by streaming it from an agent.
|
||||
if schedule.agent_id is not None:
|
||||
agent = session.get(Agent, schedule.agent_id)
|
||||
if not agent:
|
||||
raise RuntimeError(f"agent {schedule.agent_id} not found")
|
||||
prefix = compose_service.slugify(agent.name)
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
|
||||
tmp.close()
|
||||
path = tmp.name
|
||||
await agent_service.download_to_file(
|
||||
session, agent, f"/agent/stacks/{schedule.stack_id}/backup", path,
|
||||
params={"include_volumes": schedule.include_volumes, "stop_first": schedule.stop_first},
|
||||
)
|
||||
else:
|
||||
stack = session.get(Stack, schedule.stack_id)
|
||||
if not stack:
|
||||
raise RuntimeError(f"stack '{schedule.stack_id}' not found")
|
||||
prefix = None
|
||||
path = await backup_service.create_backup(
|
||||
schedule.stack_id, stack.name,
|
||||
include_volumes=schedule.include_volumes, stop_first=schedule.stop_first,
|
||||
)
|
||||
|
||||
filename = backup_service.backup_filename(schedule.stack_id, schedule.include_volumes, prefix=prefix)
|
||||
basename = backup_service.backup_basename(schedule.stack_id, prefix)
|
||||
await asyncio.to_thread(dest_service.upload, dest, path, filename)
|
||||
pruned = await asyncio.to_thread(_prune, dest, schedule.stack_id, schedule.keep)
|
||||
pruned = await asyncio.to_thread(_prune, dest, basename, schedule.keep)
|
||||
schedule.last_status = "ok"
|
||||
result = {"ok": True, "name": filename, "destination": dest.name, "pruned": pruned}
|
||||
logger.info("Scheduled backup %s → %s ok (pruned %d)", schedule.stack_id, dest.name, pruned)
|
||||
|
||||
Reference in New Issue
Block a user