mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-08-01 12:34:50 +00:00
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>
69 lines
2.7 KiB
Python
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()
|