Files
docker-volume-backup/tests/e2e/test_e2e_snapshot.py
Kevin Veen-Birkenbach 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

175 lines
6.1 KiB
Python

"""Snapshot capture against real filesystems.
Loop devices are only available to a privileged container, so each case builds
its filesystem inside one and drives snapshot_driver.py there. A filesystem
without snapshot support must fail loudly rather than degrade to a live copy,
which is the property that makes the mode safe to offer at all.
"""
from __future__ import annotations
import os
import shutil
import unittest
from pathlib import Path
from .helpers import require_docker, run, unique
REPO_SRC = Path(__file__).resolve().parents[2] / "src"
DRIVER = Path(__file__).resolve().parent / "snapshot_driver.py"
FAITHFUL_DRIVER = Path(__file__).resolve().parent / "faithful_driver.py"
IMAGE = "alpine:3.20"
PACKAGES = "apk add -q btrfs-progs e2fsprogs zfs python3 util-linux"
ATTACH = (
"LOOP=$(losetup -f | awk '{print $1}') "
'&& { [ -b "$LOOP" ] || mknod "$LOOP" b 7 "${LOOP#/dev/loop}"; }; '
'losetup "$LOOP" /img'
)
LOOP_FS = {
"btrfs": "btrfs subvolume create /subject/docker >/dev/null",
"ext4": "mkdir -p /subject/docker",
}
# A container carries no /lib/modules, so modprobe fails even on a loaded module.
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 && {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 (
f"{PACKAGES} && truncate -s 400M /img && mkfs.{fstype} -q /img "
f'&& mkdir -p /subject && {ATTACH} && mount -t {fstype} "$LOOP" /subject '
f"&& {LOOP_FS[fstype]}"
)
def zfs_usable() -> bool:
"""Whether this host's kernel can serve zfs to a privileged container."""
proc = run(
[
"docker",
"run",
"--rm",
"--privileged",
IMAGE,
"sh",
"-lc",
f"apk add -q zfs >/dev/null 2>&1 && {ZFS_READY}",
],
capture=True,
check=False,
)
return proc.returncode == 0
def required(fstype: str) -> bool:
"""Whether this run must cover ``fstype`` instead of skipping it.
CI sets E2E_REQUIRE_FILESYSTEMS so a missing kernel module fails the build
rather than passing it with a filesystem silently untested.
"""
demanded = os.environ.get("E2E_REQUIRE_FILESYSTEMS", "")
return fstype in demanded.replace(",", " ").split()
def stage(driver: Path) -> Path:
"""Copy source and driver under /tmp, the only path the DinD daemon shares."""
staged = Path("/tmp") / unique("baudolo-e2e-snapshot")
shutil.copytree(REPO_SRC, staged / "src")
shutil.copy(driver, staged / "driver.py")
return staged
def drive(fstype: str, arguments: str, *, driver: Path = DRIVER) -> str:
staged = stage(driver)
script = f"set -e; {mount_script(fstype)}; python3 /driver.py {arguments}"
try:
proc = run(
[
"docker",
"run",
"--rm",
"--privileged",
"--name",
staged.name,
"-v",
f"{staged / 'src'}:/src:ro",
"-v",
f"{staged / 'driver.py'}:/driver.py:ro",
IMAGE,
"sh",
"-lc",
script,
],
capture=True,
check=False,
)
finally:
shutil.rmtree(staged, ignore_errors=True)
if proc.returncode != 0:
raise AssertionError(
f"{fstype} driver failed on {arguments}:\n{proc.stdout}\n{proc.stderr}"
)
return proc.stdout
class TestE2ESnapshot(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
def assert_freezes(self, fstype: str) -> None:
output = drive(fstype, f"{fstype} /subject/docker supported")
self.assertIn("PASS the snapshot exposes the volume", output)
self.assertIn("PASS a later write does not reach the snapshot", output)
self.assertIn("PASS the snapshot is removed afterwards", output)
self.assertIn("ALL OK", output)
def test_btrfs_snapshot_freezes_the_volume(self) -> None:
self.assert_freezes("btrfs")
def test_zfs_snapshot_freezes_the_volume(self) -> None:
if not zfs_usable():
if required("zfs"):
self.fail(
"E2E_REQUIRE_FILESYSTEMS demands zfs, but this kernel provides no "
"zfs module; load it before running the suite"
)
self.skipTest(
"this kernel provides no zfs module, so no pool can be created"
)
self.assert_freezes("zfs")
def test_ext4_has_no_snapshot_and_says_so(self) -> None:
output = drive("ext4", "btrfs /subject/docker unsupported")
self.assertIn("PASS refused loudly", output)
def test_an_unknown_kind_is_refused_before_touching_the_filesystem(self) -> None:
output = drive("ext4", "lvm /subject/docker unsupported")
self.assertIn("PASS refused loudly", output)
def test_a_volume_with_its_own_storage_is_copied_live_not_from_the_snapshot(
self,
) -> None:
output = drive("btrfs", "/subject/docker", driver=FAITHFUL_DRIVER)
self.assertIn(
"PASS the snapshot shows the other volume as an empty directory", output
)
self.assertIn("PASS the other volume degrades to live", output)
self.assertIn("PASS the plain volume is read from the snapshot", output)
self.assertIn("ALL OK", output)
if __name__ == "__main__":
unittest.main()