Back up several stacks, or all of them, from one schedule (0.65.0)
CI / check (push) Successful in 12m46s
CI / build-and-push (push) Successful in 2m4s

A schedule covered exactly one stack, so backing up a host meant one
schedule per stack, each edited separately, and new stacks were only
backed up once someone remembered to add a schedule.

A schedule now takes a list of stacks, or all_stacks, which includes
stacks added later. They are backed up one after another, each with its
own file, upload, retention and status, so one failing stack does not
cost the others their backup:

- stack_status records each stack's result; last_status summarises
  ("error: 2 of 5 stacks - a: ...; c: ...") and one notification per run
  names the stacks with a problem. The dashboard names them too and shows
  an incomplete backup as a warning rather than an error.
- A busy stack (deploy, update) is retried after the others, up to three
  times two minutes apart, replacing the 10-minute postponement of the
  whole schedule.
- Run now starts the run in the background (202) instead of holding the
  request, which a multi-stack run would outlast; the list reports
  running and polls, and the row toasts the outcome. A schedule never
  runs twice at once.
- Settings: "All stacks" or a checklist with select all/none; stacks can
  be changed when editing; deleted stacks stay listed to be unticked.

Schedules from before keep working without a data migration: stack_ids,
all_stacks and stack_status are added with defaults on startup and a row
with only stack_id reads as that one stack. The API still accepts
stack_id and reports it for single-stack schedules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzeljandClaude Opus 5 committed 2026-09-18 12:44:28 +02:00
1 parent 3c187e2e80
commit 8a0d673798
11 files changed
+754 -220

No files matched your search

+1 -72
View File
@@ -574,7 +574,7 @@ def test_verify_accepts_a_good_archive_and_rejects_garbage(stack, tmp_path):
# --------------------------------------------------------------------------- #
# Retention and schedules
# Retention (schedules: test_backup_schedules.py)
# --------------------------------------------------------------------------- #
@@ -606,77 +606,6 @@ def test_retention_leaves_other_stacks_alone(monkeypatch):
assert deleted == ["backup-app-full-20260910-030000.tar.gz"]
def _schedule_env(monkeypatch, db, report=None, error=None):
from sqlmodel import Session
from models.backup_destination import BackupDestination
from models.backup_schedule import BackupSchedule
from models.stack import Stack
from services import schedule_service
stack_id = f"s{uuid.uuid4().hex[:8]}"
alerts: list[str] = []
pruned: list[str] = []
async def fake_create(*args, **kwargs):
if error:
raise error
path = os.path.join(compose_service.stacks_root(), f".{stack_id}.tar.gz")
open(path, "wb").close()
return path, report
async def fake_notify(event, title, body, session):
alerts.append(title)
monkeypatch.setattr(schedule_service.backup_service, "create_backup_ex", fake_create)
monkeypatch.setattr(schedule_service.dest_service, "upload", lambda d, p, f: f)
monkeypatch.setattr(schedule_service, "_prune", lambda d, s, k: pruned.append(s) or 0)
monkeypatch.setattr(schedule_service.notify_service, "notify", fake_notify)
session = Session(db)
session.add(Stack(id=stack_id, name=stack_id))
dest = BackupDestination(name=f"d-{stack_id}", type="sftp", config="{}")
session.add(dest)
session.commit()
schedule = BackupSchedule(stack_id=stack_id, destination_id=dest.id, frequency="daily")
session.add(schedule)
session.commit()
return schedule_service, session, schedule, alerts, pruned
def test_incomplete_scheduled_backup_warns_and_keeps_old_backups(monkeypatch, db):
report = {"errors": ["db: helper failed"], "restart_error": None}
svc, session, schedule, alerts, pruned = _schedule_env(monkeypatch, db, report=report)
result = run(svc.run_schedule(session, schedule))
assert result["ok"] is False
assert schedule.last_status.startswith("warning:")
assert pruned == [] # the last good backup must not be rotated out
assert alerts and "incomplete" in alerts[0]
def test_complete_scheduled_backup_is_ok_and_rotates(monkeypatch, db):
report = {"errors": [], "restart_error": None}
svc, session, schedule, alerts, pruned = _schedule_env(monkeypatch, db, report=report)
result = run(svc.run_schedule(session, schedule))
assert result["ok"] is True and schedule.last_status == "ok"
assert pruned == [schedule.stack_id] and alerts == []
def test_busy_stack_is_retried_soon(monkeypatch, db):
error = backup_service.StackBusyError("Stack 'x' is busy: update in progress")
svc, session, schedule, alerts, _ = _schedule_env(monkeypatch, db, error=error)
run(svc.run_schedule(session, schedule))
assert schedule.last_status.startswith("retrying:")
assert (schedule.next_run - schedule.last_run) == svc.BUSY_RETRY
assert alerts == []
# --------------------------------------------------------------------------- #
# Routes
# --------------------------------------------------------------------------- #