fix(backup): make snapshot backups restorable and non-fatal to teardown

Four defects in the snapshot mode 3.2.0 introduced.

The resolver dropped the trailing separator get_storage_path puts on a
volume path, because os.path.abspath strips it. rsync reads "dir" as
"copy the directory" where "dir/" means "copy its contents", so every
snapshot generation landed at <volume>/files/_data/... while the live
path lands at <volume>/files/... . Restores read the live layout, and
--link-dest found nothing to match against the previous generation. The
e2e never caught it because its driver appended the separator by hand.

Snapshot teardown was fatal and masking. A busy `btrfs subvolume delete`
raised out of the finally, which skipped the generation stamp and the
compose handling on a run whose data was already complete, and replaced
whatever the body had raised. The leftover is reported instead; removing
it is a cleanup problem, not a reason to discard a good generation.

A volume created after the snapshot was taken aborted the whole run: the
volume list is enumerated inside the snapshot context, and nothing is
stopped in snapshot mode, so the host keeps creating volumes for the
duration of the copy. Such a volume is now copied live with a warning,
which is exactly what the pre-snapshot code did for it.

The snapshot pass compares by content again. 3.2.0 dropped --checksum
because a snapshot source cannot move, which is true, but the comparison
that matters is against --link-dest: a file that changed while keeping
its size and whole-second mtime was hard-linked stale out of the previous
generation, and the single snapshot pass had no authoritative pass to
repair it the way the live path does. It is still one pass against two.

--hard-restart-projects is refused alongside --snapshot, the same way
--shutdown already is: the flag exists for stacks whose database cannot
be backed up hot, which is what a snapshot removes.

Tests: the trailing separator, both teardown behaviours, the new refusal,
and app.main driving the snapshot branch - the caller that runs in
production, which no test had exercised and where the layout defect
therefore stayed invisible. The e2e driver now feeds the resolver the
string shape get_storage_path really produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:23:25 +02:00
parent 7e815bfcf7
commit b10d50efbe
7 changed files with 152 additions and 7 deletions

View File

@@ -48,6 +48,22 @@ class TestSnapshotFlags(unittest.TestCase):
def test_shutdown_stays_available_without_a_snapshot(self) -> None:
self.assertTrue(parse("--shutdown").shutdown)
def test_hard_restart_is_rejected_because_nothing_is_stopped(self) -> None:
with self.assertRaises(SystemExit):
parse(
"--snapshot",
"btrfs",
"--snapshot-subject",
"/d",
"--hard-restart-projects",
"mailu",
)
def test_hard_restart_stays_available_without_a_snapshot(self) -> None:
self.assertEqual(
parse("--hard-restart-projects", "mailu").hard_restart_projects, ["mailu"]
)
class TestRequiredFlags(unittest.TestCase):
def test_backups_dir_is_required(self) -> None: