fix(backup): detect the engine by its dump tool, not by the image name

36b2336 matched postgres and mariadb against the image's repository path. That name is not a property of the software: a dedicated Postgres inside an app's own stack is tagged <app>-database or postgis/postgis and carries no engine token at all, so it was never recognised, never dumped, and its data directory was copied as files without a single warning - the fallback notice hangs on found_db, which stayed false.

The container is now asked what it can run. pg_dumpall, mariadb-dump and mysqldump are probed by executing them, not by asking a shell for them, because a distroless image has no shell and would deny every tool it ships. The verdict is cached per image ID rather than per container, so replicas of one image cost a single probe.

Because the probe names the tool it found, the dump uses it instead of the hardcoded /usr/bin/mariadb-dump, which makes an image that ships only mysqldump dumpable rather than silently file-copied.

image_name and has_image are gone with their registry-host and tag stripping; the trap they worked around cannot occur when nothing reads the name. get_image_info stays, since the --images-* lists match exact references.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 13:51:26 +02:00
parent c03329e4ca
commit f791046c02
9 changed files with 448 additions and 85 deletions

View File

@@ -69,12 +69,17 @@ def backup_database(
container: str,
volume_dir: str,
db_type: str,
dump_tool: str,
databases_df: pandas.DataFrame,
database_containers: list[str],
) -> bool:
"""
Backup databases for a given DB container.
Args:
dump_tool: the MariaDB client found in the container, so an image
that ships only mysqldump is dumped with the tool it has.
Returns True if at least one dump was produced.
"""
instance_name = get_instance(container, database_containers)
@@ -114,7 +119,7 @@ def backup_database(
if db_type == "mariadb":
# Force TCP so auth matches '<user>'@'%' instead of socket -> 'localhost'.
cmd = (
f"docker exec {container} /usr/bin/mariadb-dump "
f"docker exec {container} {dump_tool} "
f"-h 127.0.0.1 --protocol=tcp "
f"-u {user} -p{password} {db_name}"
)

View File

@@ -9,29 +9,25 @@ def get_image_info(container: str) -> str:
)[0]
def image_name(container: str) -> str:
"""The image's repository path, without registry host, tag or digest.
def image_id(container: str) -> str:
"""The container's image ID, identical for every replica of one image."""
return execute_shell_command(
f"docker inspect --format '{{{{.Image}}}}' {container}"
)[0].strip()
A swarm node that hosts the local registry puts its own hostname in front
of every pull, so the raw reference of a Postgres container can read
`svc-db-mariadb-swarm-mgr-01:5000/postgres_custom:17-3.5`. Matching the
whole reference finds "mariadb" there and dumps the database with
mariadb-dump, which the Postgres image does not ship (exit 127). Tags bite
the same way: `xwiki_custom:lts-postgres-tomcat`.
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.
"""
reference = get_image_info(container).strip().split("@", 1)[0]
head, _, tail = reference.rpartition("/")
tail = tail.split(":", 1)[0]
if head:
registry = head.split("/", 1)[0]
if "." in registry or ":" in registry or registry == "localhost":
head = head.partition("/")[2]
return f"{head}/{tail}" if head else tail
def has_image(container: str, pattern: str) -> bool:
"""Return True if the container's image name contains the pattern."""
return pattern in image_name(container)
try:
execute_shell_command(f"docker exec {container} {tool} --version")
except BackupException:
return False
return True
def docker_volume_names() -> list[str]:

View File

@@ -8,7 +8,45 @@ import pandas
from pandas.errors import EmptyDataError
from .db import backup_database
from .docker import has_image
from .docker import has_tool, image_id
DUMP_TOOLS: tuple[tuple[str, str], ...] = (
("postgres", "pg_dumpall"),
("mariadb", "mariadb-dump"),
("mariadb", "mysqldump"),
)
_ENGINE_BY_IMAGE: dict[str, tuple[str, str] | None] = {}
def container_engine(container: str) -> tuple[str, str] | None:
"""The (engine, dump tool) a container can serve, or None for neither.
Asks the container what it can run instead of reading its image name. A
dedicated Postgres is tagged `<app>-database` or `postgis/postgis` and
carries no engine token at all, while a swarm registry host such as
`svc-db-mariadb-swarm-mgr-01:5000` carries the wrong one.
Args:
container: must be running - `docker exec` is the probe, and a
stopped container would be cached as "no engine" for its whole
image. The only caller feeds it `docker ps` output.
Returns:
The engine and the tool that dumps it, cached per image ID so that
replicas of one image are probed once.
"""
image = image_id(container)
if image not in _ENGINE_BY_IMAGE:
_ENGINE_BY_IMAGE[image] = next(
(
(engine, tool)
for engine, tool in DUMP_TOOLS
if has_tool(container, tool)
),
None,
)
return _ENGINE_BY_IMAGE[image]
def backup_mariadb_or_postgres(
@@ -21,17 +59,19 @@ def backup_mariadb_or_postgres(
"""
Returns (is_db_container, dumped_any)
"""
for img in ["mariadb", "postgres"]:
if has_image(container, img):
dumped = backup_database(
container=container,
volume_dir=volume_dir,
db_type=img,
databases_df=databases_df,
database_containers=database_containers,
)
return True, dumped
return False, False
engine = container_engine(container)
if engine is None:
return False, False
db_type, dump_tool = engine
dumped = backup_database(
container=container,
volume_dir=volume_dir,
db_type=db_type,
dump_tool=dump_tool,
databases_df=databases_df,
database_containers=database_containers,
)
return True, dumped
def _empty_databases_df() -> pandas.DataFrame: