mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-09-23 21:23:19 +00:00
Two defects kept dedicated Postgres databases out of the backup. docker exec never forwarded PGPASSWORD. execute_to_file set it on baudolo's own process, but nothing carried it across the container boundary, so an engine whose pg_hba demands a password on TCP loopback refused the dump. forward_env passes a bare `-e NAME`, letting docker copy the value out of this process's environment instead of spelling it into argv, where the host's process list would publish it. get_instance returned the container name unchanged when that name carried no database token, claiming an instance it had never derived. An application container therefore answered the same databases.csv row as its own dedicated engine, and application images often ship the engine's client tools, so the dump command started and wrote a file that looked like a backup and held none of the data. Discourse is the live case: its launcher names the container `discourse`, and the image ships pg_dumpall. The regex stays a normaliser - `<app>-database` from compose and `<app>_database.1.<task>` from swarm still resolve to the same instance. Only the fallthrough changes. BREAKING CHANGE: a database container whose name carries no `database`, `db` or `postgres` token must now be named in --database-containers. Without that declaration its rows no longer match and no dump is written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
144 lines
4.3 KiB
Python
144 lines
4.3 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from .shell import BackupError, execute_shell_command
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
|
|
|
|
def docker_exec_argv(
|
|
container: str,
|
|
argv: Sequence[str],
|
|
*,
|
|
interactive: bool = False,
|
|
forward_env: Sequence[str] = (),
|
|
) -> list[str]:
|
|
"""The argv that runs *argv* inside *container*.
|
|
|
|
Args:
|
|
container: the container to run in.
|
|
argv: the command, already split.
|
|
interactive: keep stdin open, for a command that is fed a dump.
|
|
forward_env: names of environment variables to hand to the container.
|
|
Passed as bare ``-e NAME``, so docker copies the value out of this
|
|
process's own environment; spelling ``-e NAME=value`` instead would
|
|
publish a secret in the host's process list.
|
|
|
|
Returns:
|
|
The argv list.
|
|
"""
|
|
forwarded = [arg for name in forward_env for arg in ("-e", name)]
|
|
return [
|
|
"docker",
|
|
"exec",
|
|
*(["-i"] if interactive else []),
|
|
*forwarded,
|
|
container,
|
|
*argv,
|
|
]
|
|
|
|
|
|
def get_image_info(container: str) -> str:
|
|
return execute_shell_command(
|
|
["docker", "inspect", "--format", "{{.Config.Image}}", container]
|
|
)[0]
|
|
|
|
|
|
def image_id(container: str) -> str:
|
|
"""The container's image ID, identical for every replica of one image."""
|
|
return execute_shell_command(
|
|
["docker", "inspect", "--format", "{{.Image}}", container]
|
|
)[0].strip()
|
|
|
|
|
|
def has_tool(container: str, tool: str) -> bool:
|
|
"""Whether *tool* runs inside the container.
|
|
|
|
Executes the binary rather than asking a shell for it: a distroless image
|
|
has no shell, and `sh -c 'command -v'` would answer "absent" for every
|
|
tool it ships.
|
|
"""
|
|
try:
|
|
execute_shell_command(docker_exec_argv(container, [tool, "--version"]))
|
|
except BackupError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def docker_volume_names() -> list[str]:
|
|
return execute_shell_command(["docker", "volume", "ls", "--format", "{{.Name}}"])
|
|
|
|
|
|
def containers_using_volume(volume_name: str) -> list[str]:
|
|
return execute_shell_command(
|
|
[
|
|
"docker",
|
|
"ps",
|
|
"--filter",
|
|
f"volume={volume_name}",
|
|
"--format",
|
|
"{{.Names}}",
|
|
]
|
|
)
|
|
|
|
|
|
def is_swarm_task(container: str) -> bool:
|
|
"""Swarm-managed task containers must never be stopped or started
|
|
manually: the orchestrator replaces the stopped task and a later
|
|
`docker start` fails on the detached overlay network. A container that
|
|
vanished between listing and inspect (--rm one-shots, task-history GC)
|
|
counts as not stoppable instead of aborting the whole backup run; if the
|
|
container still exists the inspect failure re-raises, so a broken daemon
|
|
keeps failing the run loudly instead of silently skipping the stop."""
|
|
try:
|
|
out = execute_shell_command(
|
|
[
|
|
"docker",
|
|
"inspect",
|
|
"--format",
|
|
'{{index .Config.Labels "com.docker.swarm.task.id"}}',
|
|
container,
|
|
]
|
|
)
|
|
except BackupError:
|
|
still_listed = execute_shell_command(
|
|
[
|
|
"docker",
|
|
"ps",
|
|
"-a",
|
|
"--filter",
|
|
f"name=^{container}$",
|
|
"--format",
|
|
"{{.Names}}",
|
|
]
|
|
)
|
|
if still_listed and still_listed[0].strip():
|
|
raise
|
|
return True
|
|
return bool(out and out[0].strip())
|
|
|
|
|
|
def filter_stoppable(containers: list[str]) -> list[str]:
|
|
"""Containers baudolo may stop/start itself (everything but swarm tasks)."""
|
|
stoppable = []
|
|
for container in containers:
|
|
if is_swarm_task(container):
|
|
print(
|
|
f"Skipping stop/start for swarm task container '{container}'.",
|
|
flush=True,
|
|
)
|
|
continue
|
|
stoppable.append(container)
|
|
return stoppable
|
|
|
|
|
|
def change_containers_status(containers: list[str], status: str) -> None:
|
|
"""Stop or start a list of containers."""
|
|
if not containers:
|
|
print(f"No containers to {status}.", flush=True)
|
|
return
|
|
print(f"{status.capitalize()} containers: {' '.join(containers)}...", flush=True)
|
|
execute_shell_command(["docker", status, *containers])
|