fix(backup)!: dump the container that holds the database, with its password

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>
This commit is contained in:
2026-08-18 04:26:24 +02:00
parent 8dbd5e89ea
commit b1ee8f5fac
6 changed files with 501 additions and 7 deletions

View File

@@ -17,13 +17,27 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
def get_instance(container: str, database_containers: list[str]) -> str:
"""
Derive a stable instance name from the container name.
def get_instance(container: str, database_containers: list[str]) -> str | None:
"""The databases.csv instance a container serves, or None for no database.
A declared container is its own instance. Every other name is normalised by
stripping a database suffix token, which maps both `<app>-database` from
compose and `<app>_database.1.<task>` from swarm onto the same instance.
Args:
container: the running container's name.
database_containers: names passed via --database-containers, taken as
declared engines whatever they are called.
Returns:
The instance name, or None when the name carries no database token: an
application container is not an engine, even when it ships the client
tools that would let a dump command start.
"""
if container in database_containers:
return container
return re.split(r"(_|-)(database|db|postgres)", container)[0]
parts = re.split(r"(_|-)(database|db|postgres)", container)
return parts[0] if len(parts) > 1 else None
def fallback_pg_dumpall(
@@ -37,6 +51,7 @@ def fallback_pg_dumpall(
container,
["pg_dumpall", "-U", username, "-h", "localhost"],
interactive=True,
forward_env=["PGPASSWORD"],
),
out_file,
env={"PGPASSWORD": password},
@@ -62,6 +77,9 @@ def backup_database(
Returns True if at least one dump was produced.
"""
instance_name = get_instance(container, database_containers)
if instance_name is None:
log.debug("Container '%s' carries no database token", container)
return False
entries = databases_df[databases_df["instance"] == instance_name]
if entries.empty:
@@ -133,6 +151,7 @@ def backup_database(
"--no-privileges",
],
interactive=True,
forward_env=["PGPASSWORD"],
),
dump_file,
env={"PGPASSWORD": password},

View File

@@ -9,10 +9,35 @@ if TYPE_CHECKING:
def docker_exec_argv(
container: str, argv: Sequence[str], *, interactive: bool = False
container: str,
argv: Sequence[str],
*,
interactive: bool = False,
forward_env: Sequence[str] = (),
) -> list[str]:
"""The argv that runs *argv* inside *container*."""
return ["docker", "exec", *(["-i"] if interactive else []), container, *argv]
"""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: