Compare commits

..
5 Commits
Author SHA1 Message Date
dependabot[bot] 4c51da009c build(deps-dev): bump ruff from 0.16.7 to 0.17.1 (#12)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.16.7 to 0.17.1.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.16.7...0.17.1)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.17.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-24 00:45:16 +00:00
kevinveenbirkenbach b43177f0e7 Release version 7.1.2 2026-10-18 00:43:44 +02:00
kevinveenbirkenbachandClaude Opus 5.5 0735e5fa7b fix(backup): mount the zfs snapshot itself so a container can read it
The zfs snapshot was read through <subject>/.zfs/snapshot/<name>. The
kernel automounts that directory through a usermode helper that runs in
the host's mount namespace, so a containerised baudolo never sees the
mount and finds no snapshot. The snapshot is now mounted explicitly at
<subject>/.baudolo-<tag>, beside where btrfs carves its own, and removed
in order: umount, rmdir, zfs destroy. A failed mount destroys the
snapshot at once instead of leaking one per run.

The e2e suite could not reach this code at all: zpool create on the /img
file failed with "no such pool or dataset", because the kernel opens
vdevs from a taskq thread that resolves paths in the host's root, where
the container's /img does not exist. The pool now sits on a loop device,
whose /dev/loopN node the kernel creates on the host as well, and it is
destroyed on exit once this script created it, since a pool is
kernel-wide.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 00:22:53 +02:00
kevinveenbirkenbach 389b418f42 Release version 7.1.1 2026-10-17 23:31:28 +02:00
kevinveenbirkenbachandClaude Opus 5.5 948b8347f4 fix(test): wait for MariaDB with the root password, not the init window
The entrypoint installs root@localhost without unix_socket auth and sets
its password during init, so the passwordless socket probe only passed
when it landed in the window before SET PASSWORD. Missing that window
timed the e2e MariaDB suites out at random.

The probe now authenticates with the root password it already received
and requires @@skip_networking = 0, so the entrypoint's temporary server
no longer counts as ready. The anonymous-preemption bootstrap logs in
with the root password as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 23:30:00 +02:00
7 changed files with 132 additions and 25 deletions

No files matched your search

+25
View File
@@ -1,5 +1,30 @@
# Changelog
## [7.1.2] - 2026-10-18
Fixed:
- Backup: *--snapshot zfs* found no snapshot inside a container; now mounts it
- Tests: e2e zfs pool now sits on a loop device; a file vdev never worked
Upgrade notes:
- Backup: zfs snapshots mount at *.baudolo-TAG* in the subject, not *.zfs*
- Image: first ghcr image since 3.1.2; the 4.0.0 to 7.1.1 notes apply
## [7.1.1] - 2026-10-17
Fixed:
- Tests: e2e MariaDB probe ran root passwordless and timed out at random
- Tests: probe now logs in with the root password, skips the temp server
- Tests: anonymous-preemption bootstrap now uses the root password too
Upgrade notes:
- 7.1.0 never reached stable: its CI failed, no image was published
- 7.1.1 is the first published -aHAX build; 7.1.0 upgrade notes apply
## [7.1.0] - 2026-10-17
Fixed:
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "backup-docker-to-local"
version = "7.1.0"
version = "7.1.2"
description = "Backup Docker volumes to local with rsync and optional DB dumps."
readme = "README.md"
requires-python = ">=3.9"
@@ -19,7 +19,7 @@ dependencies = [
[project.optional-dependencies]
# Pinned: a ruff minor bump changes which rules fire, and `make test` gates on
# a clean run, so an unpinned lint would fail the suite on an unrelated day.
lint = ["ruff==0.16.7"]
lint = ["ruff==0.17.1"]
[project.scripts]
baudolo = "baudolo.backup.__main__:main"
+30 -11
View File
@@ -58,27 +58,50 @@ def _resolver(subject: str, root: str) -> Callable[[str], str]:
return resolve
def _remove(
root: str, commands: list[list[str]], run: Callable[[list[str]], list[str]]
) -> None:
try:
for command in commands:
run(command)
except BackupError as error:
# Raising here would also mask whatever the body raised.
print(f"WARNING: {root} could not be removed: {error}", flush=True)
def _btrfs(
subject: str, name: str, run: Callable[[list[str]], list[str]]
) -> tuple[str, list[str]]:
) -> tuple[str, list[list[str]]]:
# The snapshot goes inside the subject, never beside it: the kernel rejects
# a snapshot whose destination is on another filesystem, which is exactly
# what the parent directory is when the subject is a mountpoint of its own.
target = str(Path(os.path.abspath(subject)) / f".{name}") # noqa: PTH100 - see _resolver
run(["btrfs", "subvolume", "snapshot", "-r", subject, target])
return target, ["btrfs", "subvolume", "delete", target]
return target, [["btrfs", "subvolume", "delete", target]]
def _zfs(
subject: str, name: str, run: Callable[[list[str]], list[str]]
) -> tuple[str, list[str]]:
) -> tuple[str, list[list[str]]]:
output = run(["zfs", "list", "-H", "-o", "name", subject])
dataset = (output[0] if output else "").strip()
if not dataset:
raise SnapshotError(f"no zfs dataset is mounted at {subject}")
run(["zfs", "snapshot", f"{dataset}@{name}"])
root = str(Path(subject) / ".zfs" / "snapshot" / name)
return root, ["zfs", "destroy", f"{dataset}@{name}"]
snapshot = f"{dataset}@{name}"
# Exception: mounted here rather than read through .zfs/snapshot, which the
# kernel automounts via a usermode helper in the host's mount namespace,
# so a containerised run never sees it.
root = str(Path(os.path.abspath(subject)) / f".{name}") # noqa: PTH100 - see _resolver
run(["zfs", "snapshot", snapshot])
remove = [["zfs", "destroy", snapshot]]
try:
run(["mkdir", root])
remove.insert(0, ["rmdir", root])
run(["mount", "-t", "zfs", snapshot, root])
except BaseException:
_remove(root, remove, run)
raise
return root, [["umount", root], *remove]
_CREATE = {"btrfs": _btrfs, "zfs": _zfs}
@@ -169,8 +192,4 @@ def volume_snapshot(
try:
yield _resolver(subject, root)
finally:
try:
run(remove)
except BackupError as error:
# Raising here would also mask whatever the body raised.
print(f"WARNING: {root} could not be removed: {error}", flush=True)
_remove(root, remove, run)
+11 -7
View File
@@ -96,12 +96,13 @@ def wait_for_mariadb(
container: str, *, root_password: str, timeout_s: int = 90
) -> None:
"""
Liveness probe for MariaDB.
Readiness probe for MariaDB, as root over the socket.
IMPORTANT (MariaDB 11):
Root TCP auth is often restricted (unix_socket auth), so a TCP ping like
`mariadb-admin -uroot -p... -h localhost ping` can fail even though the server is up.
We therefore check readiness via a socket-based query.
The entrypoint drops root@127.0.0.1, so root is socket-only, and it
installs root@localhost without unix_socket auth: a probe without the
password only passes in the init window before the password is set.
`@@skip_networking` must be 0 so the entrypoint's temporary server,
which shuts down right after init, does not count as ready.
"""
deadline = time.time() + timeout_s
while time.time() < deadline:
@@ -112,12 +113,15 @@ def wait_for_mariadb(
container,
"sh",
"-lc",
'mariadb -uroot --protocol=socket -e "SELECT 1;"',
(
f"mariadb -uroot -p{root_password} --protocol=socket "
'-N -e "SELECT @@skip_networking;"'
),
],
capture=True,
check=False,
)
if p.returncode == 0:
if p.returncode == 0 and p.stdout.strip() == "0":
return
time.sleep(1)
raise TimeoutError(
@@ -100,7 +100,10 @@ class TestE2EMariaDBAnonymousPreemption(unittest.TestCase):
cls.db_container,
"sh",
"-lc",
f'mariadb -uroot --protocol=socket -e "{bootstrap_sql}"',
(
f"mariadb -uroot -p{cls.root_password} --protocol=socket "
f'-e "{bootstrap_sql}"'
),
]
)
+7 -2
View File
@@ -36,9 +36,14 @@ ZFS_READY = "{ [ -c /dev/zfs ] || modprobe zfs 2>/dev/null; }; [ -c /dev/zfs ]"
def mount_script(fstype: str) -> str:
"""Build a filesystem on a loop device and carve out the snapshot subject."""
if fstype == "zfs":
# Exception: a loop device, not the /img file, because the kernel opens
# vdevs from a taskq thread that resolves paths in the host's root.
# The pool is kernel-wide, so it is destroyed on exit, but only once
# this script created it.
return (
f"{PACKAGES} && {ZFS_READY} && truncate -s 400M /img "
"&& zpool create -m none baudolo /img "
f"{PACKAGES} && {ZFS_READY} && truncate -s 400M /img && {ATTACH} "
'&& zpool create -m none baudolo "$LOOP" '
"&& trap 'zpool destroy -f baudolo; losetup -d \"$LOOP\"' EXIT "
"&& zfs create -o mountpoint=/subject/docker baudolo/docker"
)
return (
+53 -2
View File
@@ -21,6 +21,14 @@ class Runner:
return []
class Unmountable(Runner):
def __call__(self, command: list[str]) -> list[str]:
result = super().__call__(command)
if command[0] == "mount":
raise BackupError("permission denied")
return result
class TestBtrfs(unittest.TestCase):
def test_it_creates_a_read_only_snapshot_inside_the_subject(self) -> None:
run = Runner()
@@ -95,14 +103,57 @@ class TestZfs(unittest.TestCase):
run.calls[-1], ["zfs", "destroy", "tank/docker@baudolo-20260731"]
)
def test_it_maps_a_volume_path_through_the_dot_zfs_directory(self) -> None:
def test_it_mounts_the_snapshot_inside_the_subject(self) -> None:
run = self._run()
with volume_snapshot("zfs", "/var/lib/docker", "20260731", run=run):
pass
self.assertIn(
[
"mount",
"-t",
"zfs",
"tank/docker@baudolo-20260731",
"/var/lib/docker/.baudolo-20260731",
],
run.calls,
)
def test_it_maps_a_volume_path_into_the_mounted_snapshot(self) -> None:
run = self._run()
with volume_snapshot("zfs", "/var/lib/docker", "20260731", run=run) as resolve:
self.assertEqual(
resolve("/var/lib/docker/volumes/postgres_data/_data"),
"/var/lib/docker/.zfs/snapshot/baudolo-20260731/volumes/postgres_data/_data",
"/var/lib/docker/.baudolo-20260731/volumes/postgres_data/_data",
)
def test_it_unmounts_before_it_destroys(self) -> None:
run = self._run()
with volume_snapshot("zfs", "/var/lib/docker", "20260731", run=run):
pass
self.assertEqual(
run.calls[-3:],
[
["umount", "/var/lib/docker/.baudolo-20260731"],
["rmdir", "/var/lib/docker/.baudolo-20260731"],
["zfs", "destroy", "tank/docker@baudolo-20260731"],
],
)
def test_a_failed_mount_leaves_no_snapshot_behind(self) -> None:
run = Unmountable({"zfs list": ["tank/docker"]})
with (
self.assertRaises(BackupError),
volume_snapshot("zfs", "/var/lib/docker", "20260731", run=run),
):
pass
self.assertEqual(
run.calls[-2:],
[
["rmdir", "/var/lib/docker/.baudolo-20260731"],
["zfs", "destroy", "tank/docker@baudolo-20260731"],
],
)
def test_an_unmounted_dataset_is_an_error(self) -> None:
run = Runner({"zfs list": [""]})
with (