Files
docker-volume-backup/tests/unit/backup/test_policy.py
Kevin Veen-Birkenbach d4317827bd feat(backup): capture volumes from a filesystem snapshot
Backing up a live volume with rsync copies a moving target: a database
written to mid-copy lands on disk in a state no engine ever committed.
Stopping the container avoids that at the cost of downtime.

A snapshot removes both. `--snapshot {btrfs,zfs}` with `--snapshot-subject`
freezes the docker root once per run, and every volume copy is then read
from that frozen tree while the containers keep serving. A restore of such
a copy is an ordinary crash recovery, which every supported engine performs
on its own at startup.

An unsupported filesystem or an unknown snapshot kind fails loudly rather
than degrading to a live copy, since a silent fallback would return exactly
the torn backup the mode exists to prevent. `--shutdown` is rejected
alongside `--snapshot` instead of being ignored: under a snapshot no
container is ever stopped, so accepting the flag would promise downtime
semantics the run does not deliver.

Copies out of a snapshot skip rsync's --checksum verification. The source
is immutable for the lifetime of the copy, so size-and-mtime cannot race,
and dropping the second full read roughly halves the I/O per volume.

backup/app.py grew past what one module could carry and is split into
layout, policy and dumps along the lines it already had internally.

Tests: unit coverage for the new snapshot, layout, policy, volume and cli
units; e2e cases drive real btrfs, zfs and ext4 filesystems on loop devices
in a privileged container, including a MariaDB that is written to across
the snapshot and must recover from the restored copy without losing a
committed row. CI installs zfs and sets E2E_REQUIRE_FILESYSTEMS so a
missing kernel module fails the build instead of silently skipping a
filesystem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:01:43 +02:00

69 lines
2.7 KiB
Python

"""Contract of the rules deciding what is backed up and what must stop."""
from __future__ import annotations
import unittest
from unittest import mock
from baudolo.backup import policy as mod
class TestIsImageIgnored(unittest.TestCase):
def test_an_empty_whitelist_ignores_nothing(self) -> None:
self.assertFalse(mod.is_image_ignored("c1", []))
def test_a_listed_image_is_ignored(self) -> None:
with mock.patch.object(mod, "get_image_info", return_value="alpine:3.20"):
self.assertTrue(mod.is_image_ignored("c1", ["alpine:3.20"]))
def test_matching_is_exact(self) -> None:
with mock.patch.object(mod, "get_image_info", return_value="alpine:3.21"):
self.assertFalse(mod.is_image_ignored("c1", ["alpine:3.20"]))
class TestVolumeIsFullyIgnored(unittest.TestCase):
def test_a_volume_without_containers_is_kept(self) -> None:
self.assertFalse(mod.volume_is_fully_ignored([], ["alpine:3.20"]))
def test_it_needs_every_container_to_be_ignored(self) -> None:
with mock.patch.object(mod, "get_image_info", side_effect=["a", "b"]):
self.assertFalse(mod.volume_is_fully_ignored(["c1", "c2"], ["a"]))
def test_all_ignored_skips_the_volume(self) -> None:
with mock.patch.object(mod, "get_image_info", side_effect=["a", "a"]):
self.assertTrue(mod.volume_is_fully_ignored(["c1", "c2"], ["a"]))
class TestRequiresStop(unittest.TestCase):
def test_no_containers_means_no_stop(self) -> None:
self.assertFalse(mod.requires_stop([], []))
def test_a_swarm_task_never_forces_a_stop(self) -> None:
with mock.patch.object(mod, "is_swarm_task", return_value=True):
self.assertFalse(mod.requires_stop(["c1"], []))
def test_a_whitelisted_image_does_not_force_a_stop(self) -> None:
with (
mock.patch.object(mod, "is_swarm_task", return_value=False),
mock.patch.object(mod, "get_image_info", return_value="alpine:3.20"),
):
self.assertFalse(mod.requires_stop(["c1"], ["alpine:3.20"]))
def test_an_unlisted_image_forces_a_stop(self) -> None:
with (
mock.patch.object(mod, "is_swarm_task", return_value=False),
mock.patch.object(mod, "get_image_info", return_value="postgres:17"),
):
self.assertTrue(mod.requires_stop(["c1"], ["alpine:3.20"]))
def test_one_unlisted_container_is_enough(self) -> None:
with (
mock.patch.object(mod, "is_swarm_task", return_value=False),
mock.patch.object(mod, "get_image_info", side_effect=["alpine:3.20", "x"]),
):
self.assertTrue(mod.requires_stop(["c1", "c2"], ["alpine:3.20"]))
if __name__ == "__main__":
unittest.main()