mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-08-20 13:12:48 +00:00
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>
72 lines
2.3 KiB
Python
72 lines
2.3 KiB
Python
"""Running external commands without a shell.
|
|
|
|
Every command is an argv list. A database name, a password or a container name
|
|
therefore cannot close a quote and start a second command, which a formatted
|
|
string handed to ``shell=True`` allowed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
from collections.abc import Mapping, Sequence
|
|
|
|
|
|
class BackupException(Exception):
|
|
"""Generic exception for backup errors."""
|
|
|
|
|
|
def _child_env(env: Mapping[str, str] | None) -> dict[str, str] | None:
|
|
return None if env is None else {**os.environ, **env}
|
|
|
|
|
|
def _fail(command: Sequence[str], returncode: int, out: bytes, err: bytes) -> None:
|
|
raise BackupException(
|
|
f"Error in command: {' '.join(command)}\n"
|
|
f"Output: {out}\nError: {err}\n"
|
|
f"Exit code: {returncode}"
|
|
)
|
|
|
|
|
|
def execute_shell_command(
|
|
command: Sequence[str], *, env: Mapping[str, str] | None = None
|
|
) -> list[str]:
|
|
"""Run *command* and return its stdout lines.
|
|
|
|
Args:
|
|
command: argv, the program first.
|
|
env: variables added to the child's environment, for values that must
|
|
not appear in the argv of a process listing.
|
|
"""
|
|
command = list(command)
|
|
print(" ".join(command), flush=True)
|
|
process = subprocess.Popen(
|
|
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=_child_env(env)
|
|
)
|
|
out, err = process.communicate()
|
|
if process.returncode != 0:
|
|
_fail(command, process.returncode, out, err)
|
|
return [line.decode("utf-8") for line in out.splitlines()]
|
|
|
|
|
|
def execute_to_file(
|
|
command: Sequence[str], out_file: str, *, env: Mapping[str, str] | None = None
|
|
) -> None:
|
|
"""Run *command*, writing its stdout to *out_file* only once it succeeded.
|
|
|
|
The output goes to a sibling temporary file first, so a partial or empty
|
|
stream from a failing dump never takes the place of a valid backup.
|
|
"""
|
|
command = list(command)
|
|
print(" ".join(command), flush=True)
|
|
tmp = f"{out_file}.tmp"
|
|
with open(tmp, "wb") as handle:
|
|
process = subprocess.Popen(
|
|
command, stdout=handle, stderr=subprocess.PIPE, env=_child_env(env)
|
|
)
|
|
_, err = process.communicate()
|
|
if process.returncode != 0:
|
|
os.unlink(tmp)
|
|
_fail(command, process.returncode, b"", err)
|
|
os.replace(tmp, out_file)
|