mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-08-01 12:34:50 +00:00
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>
96 lines
3.6 KiB
Python
96 lines
3.6 KiB
Python
"""Capture every volume from one atomic filesystem snapshot.
|
|
|
|
Copying a live tree file by file cannot produce a point in time: a database can
|
|
write between two files and leave a control file and its write-ahead log
|
|
disagreeing, which no recovery can repair. A snapshot freezes the whole subject
|
|
at once, so a database reads it as a crash and replays its log - a case it is
|
|
built for. That also removes the reason to stop containers at all.
|
|
|
|
The snapshot kind is stated by the caller rather than probed, because falling
|
|
back to a live copy when a probe is inconclusive would hand out backups that
|
|
look consistent and are not.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from collections.abc import Callable, Iterator
|
|
from contextlib import contextmanager
|
|
|
|
from .shell import BackupException, execute_shell_command
|
|
|
|
KINDS = ("btrfs", "zfs")
|
|
|
|
|
|
class SnapshotError(RuntimeError):
|
|
"""A snapshot could not be created, resolved or removed."""
|
|
|
|
|
|
def _resolver(subject: str, root: str) -> Callable[[str], str]:
|
|
def resolve(path: str) -> str:
|
|
relative = os.path.relpath(os.path.abspath(path), os.path.abspath(subject))
|
|
if relative.startswith(".."):
|
|
raise SnapshotError(f"{path} lies outside the snapshot subject {subject}")
|
|
resolved = root if relative == "." else os.path.join(root, relative)
|
|
|
|
# abspath drops a trailing separator, and rsync reads "dir/" as its
|
|
# contents where "dir" means the directory itself.
|
|
return resolved + os.sep if path.endswith(os.sep) else resolved
|
|
|
|
return resolve
|
|
|
|
|
|
def _btrfs(subject: str, name: str, run: Callable[[str], list[str]]) -> tuple[str, str]:
|
|
target = os.path.join(os.path.dirname(os.path.abspath(subject)), f".{name}")
|
|
run(f"btrfs subvolume snapshot -r {subject} {target}")
|
|
return target, f"btrfs subvolume delete {target}"
|
|
|
|
|
|
def _zfs(subject: str, name: str, run: Callable[[str], list[str]]) -> tuple[str, str]:
|
|
output = run(f"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(f"zfs snapshot {dataset}@{name}")
|
|
root = os.path.join(subject, ".zfs", "snapshot", name)
|
|
return root, f"zfs destroy {dataset}@{name}"
|
|
|
|
|
|
_CREATE = {"btrfs": _btrfs, "zfs": _zfs}
|
|
|
|
|
|
@contextmanager
|
|
def volume_snapshot(
|
|
kind: str,
|
|
subject: str,
|
|
tag: str,
|
|
run: Callable[[str], list[str]] = execute_shell_command,
|
|
) -> Iterator[Callable[[str], str]]:
|
|
"""Yield a resolver mapping a path under ``subject`` into a snapshot of it.
|
|
|
|
Args:
|
|
kind: ``btrfs`` or ``zfs``; the caller states it, nothing is probed.
|
|
subject: the btrfs subvolume or zfs dataset mountpoint holding the
|
|
volumes, e.g. ``/var/lib/docker``.
|
|
tag: unique suffix for the snapshot name, e.g. the backup timestamp.
|
|
run: shell runner, injected so the mechanics are testable.
|
|
|
|
Raises:
|
|
SnapshotError: the kind is unknown, or the snapshot cannot be created.
|
|
Removal failure is reported, not raised: a leftover snapshot is a
|
|
cleanup problem and must not discard a generation that is complete.
|
|
"""
|
|
create = _CREATE.get(kind)
|
|
if create is None:
|
|
raise SnapshotError(f"unknown snapshot kind {kind!r}; expected one of {KINDS}")
|
|
|
|
root, remove = create(subject, f"baudolo-{tag}", run)
|
|
try:
|
|
yield _resolver(subject, root)
|
|
finally:
|
|
try:
|
|
run(remove)
|
|
except BackupException as error:
|
|
# Raising here would also mask whatever the body raised.
|
|
print(f"WARNING: {root} could not be removed: {error}", flush=True)
|