mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-08-20 21:22:54 +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:
@@ -1,26 +1,71 @@
|
||||
"""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 execute_shell_command(command: str) -> list[str]:
|
||||
"""Execute a shell command and return its output lines."""
|
||||
print(command, flush=True)
|
||||
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,
|
||||
shell=True,
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=_child_env(env)
|
||||
)
|
||||
out, err = process.communicate()
|
||||
if process.returncode != 0:
|
||||
raise BackupException(
|
||||
f"Error in command: {command}\n"
|
||||
f"Output: {out}\nError: {err}\n"
|
||||
f"Exit code: {process.returncode}"
|
||||
)
|
||||
_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)
|
||||
|
||||
Reference in New Issue
Block a user