Files
stackpilot/backend/services/schedule_service.py
T
menzeljandClaude Opus 4.8 5cd55382ed 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>
2026-06-07 22:26:20 +00:00

172 lines
6.1 KiB
Python

"""Scheduled (recurring) stack backups to a destination.
A background loop wakes every minute, runs any schedules whose ``next_run`` has
passed, uploads the backup to the destination, prunes old backups per the
retention setting, and records status + the next run time.
Times are stored as naive UTC to match SQLite's datetime handling.
"""
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,
notify_service,
)
logger = logging.getLogger("stackpilot.schedule")
def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def compute_next_run(
frequency: str, hour: int, minute: int, weekday: int, after: datetime
) -> datetime:
"""Next run strictly after ``after`` (naive UTC)."""
if frequency == "hourly":
nxt = after.replace(minute=minute % 60, second=0, microsecond=0)
if nxt <= after:
nxt += timedelta(hours=1)
return nxt
base = after.replace(hour=hour % 24, minute=minute % 60, second=0, microsecond=0)
if frequency == "weekly":
days_ahead = (weekday - base.weekday()) % 7
cand = base + timedelta(days=days_ahead)
if cand <= after:
cand += timedelta(days=7)
return cand
# daily (default)
if base <= after:
base += timedelta(days=1)
return base
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"{basename}-")]
mine.sort(key=lambda x: x.get("modified") or 0, reverse=True)
removed = 0
for old in mine[keep:]:
try:
dest_service.delete(dest, old["name"])
removed += 1
except dest_service.DestinationError as exc:
logger.warning("retention delete failed for %s: %s", old["name"], exc)
return removed
async def run_schedule(session: Session, schedule: BackupSchedule) -> dict:
"""Execute one schedule now. Updates status + next_run. Returns a summary."""
now = _now()
dest = session.get(BackupDestination, schedule.destination_id)
result: dict = {"ok": False}
path = None
try:
if not dest:
raise RuntimeError(f"destination {schedule.destination_id} not found")
# 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, 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)
except Exception as exc: # noqa: BLE001
schedule.last_status = f"error: {exc}"[:300]
result = {"ok": False, "error": str(exc)}
logger.warning("Scheduled backup %s failed: %s", schedule.stack_id, exc)
try:
await notify_service.notify(
EVENT_BACKUP_FAILED,
f"Scheduled backup of '{schedule.stack_id}' failed",
str(exc),
session,
)
except Exception: # noqa: BLE001
pass
finally:
if path and os.path.exists(path):
os.unlink(path)
schedule.last_run = now
schedule.next_run = compute_next_run(
schedule.frequency, schedule.hour, schedule.minute, schedule.weekday, now
)
session.add(schedule)
session.commit()
session.refresh(schedule)
return result
async def run_due() -> None:
now = _now()
with Session(engine) as session:
schedules = session.exec(
select(BackupSchedule).where(BackupSchedule.enabled == True) # noqa: E712
).all()
due = []
for s in schedules:
if s.next_run is None:
s.next_run = compute_next_run(s.frequency, s.hour, s.minute, s.weekday, now)
session.add(s)
elif s.next_run <= now:
due.append(s)
session.commit()
for s in due:
await run_schedule(session, s)
async def scheduler_loop() -> None:
await asyncio.sleep(20) # let startup settle
while True:
try:
await run_due()
except Exception as exc: # noqa: BLE001
logger.warning("scheduler tick failed: %s", exc)
await asyncio.sleep(60)