mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-10-20 09:45:37 +00:00
refactor(backup)!: run argv lists, never a shell
The backup built command strings and handed them to shell=True, so four interpolated values per dump - user, password, container, database - were each a way out of the command. validate_database covered one of them since the previous commit; now there is nothing to cover: every command is an argv list, and a value can only ever be an argument. execute_to_file absorbs the atomic dump write. The shell redirect into <file>.tmp and the separate mv process become a Python file handle and os.replace, and a failing dump deletes its partial file instead of leaving it. PGPASSWORD moves out of the command string into the child's environment, where a process listing does not show it. docker exec is built in one place, docker_exec_argv; db.py's three hand-built copies and the probe use it. The dead docker_volume_exists goes - never called, and the restore side owns the living twin. The rsync quoting in --link-dest falls away: inside an argv it would have become part of the path. The snapshot module's injected runner changes type with it, which the three e2e drivers implement - the first conversion missed them, btrfs ran with no arguments, and the e2e caught it. Marked breaking for that contract: any external runner injected into volume_snapshot must now accept a list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,23 +48,27 @@ def _resolver(subject: str, root: str) -> Callable[[str], str]:
|
||||
return resolve
|
||||
|
||||
|
||||
def _btrfs(subject: str, name: str, run: Callable[[str], list[str]]) -> tuple[str, str]:
|
||||
def _btrfs(
|
||||
subject: str, name: str, run: Callable[[list[str]], list[str]]
|
||||
) -> tuple[str, 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 = os.path.join(os.path.abspath(subject), f".{name}")
|
||||
run(f"btrfs subvolume snapshot -r {subject} {target}")
|
||||
return target, f"btrfs subvolume delete {target}"
|
||||
run(["btrfs", "subvolume", "snapshot", "-r", subject, target])
|
||||
return target, ["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}")
|
||||
def _zfs(
|
||||
subject: str, name: str, run: Callable[[list[str]], list[str]]
|
||||
) -> tuple[str, 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(f"zfs snapshot {dataset}@{name}")
|
||||
run(["zfs", "snapshot", f"{dataset}@{name}"])
|
||||
root = os.path.join(subject, ".zfs", "snapshot", name)
|
||||
return root, f"zfs destroy {dataset}@{name}"
|
||||
return root, ["zfs", "destroy", f"{dataset}@{name}"]
|
||||
|
||||
|
||||
_CREATE = {"btrfs": _btrfs, "zfs": _zfs}
|
||||
@@ -129,7 +133,7 @@ def volume_snapshot(
|
||||
kind: str,
|
||||
subject: str,
|
||||
tag: str,
|
||||
run: Callable[[str], list[str]] = execute_shell_command,
|
||||
run: Callable[[list[str]], list[str]] = execute_shell_command,
|
||||
) -> Iterator[Callable[[str], str]]:
|
||||
"""Yield a resolver mapping a path under ``subject`` into a snapshot of it.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user