Files
docker-volume-backup/src/baudolo/backup/dumps.py
Kevin Veen-Birkenbach f97efb10c4 fix(backup)!: read the instance from one engine-name set, and trust it
Two shapes fell through the inline regex, which knew `database`, `db` and
`postgres` only. A container named exactly after its engine - what a compose
file writes as `container_name: postgres` - carries no separator before the
token, so it resolved to nothing and 6.0.0 stopped dumping it without saying
so. And a swarm task of a central MariaDB reads `mariadb_mariadb.1.<id>`,
where `_mariadb` was no token at all, so that database has never been dumped
under swarm at all.

ENGINE_NAMES states the set once and serves both readings: carried as a
suffix it makes the rest the instance, being one outright makes the container
its own instance.

backup_mariadb_or_postgres stops calling an application container a database.
container_engine recognises an engine by its client tools, which an
application image often ships, so refusing the dump alone would have recorded
the volume as `database: true, dumped: false` - the exact shape a restore
drill reads as a database that was missed. Without an instance there is no
database to record.

BREAKING CHANGE: `mariadb` and `mysql` join the suffix tokens, so a container
named `<app>-mariadb` resolves to the instance `<app>` rather than to its own
name. A databases.csv keyed on the full container name has to move to the
application name, or name the container in --database-containers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 05:11:46 +02:00

156 lines
4.7 KiB
Python

"""Database dumps taken before a volume's files are copied."""
from __future__ import annotations
import sys
from typing import NamedTuple
import pandas as pd
from pandas.errors import EmptyDataError
from baudolo.databases import COLUMNS, DELIMITER
from .db import backup_database, get_instance
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] = {}
class VolumeOutcome(NamedTuple):
"""What a dump attempt established about one volume.
``database`` says a container serving the volume speaks an engine this
tool can dump; ``dumped`` says a dump was actually written. ``engine`` is
the engine that was detected, or None when none was.
"""
database: bool
dumped: bool
engine: str | None = 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(
*,
container: str,
volume_dir: str,
databases_df: pd.DataFrame,
database_containers: list[str],
) -> VolumeOutcome:
"""What this container contributes to its volume's outcome."""
engine = container_engine(container)
if engine is None:
return VolumeOutcome(database=False, dumped=False)
if get_instance(container, database_containers) is None:
return VolumeOutcome(database=False, dumped=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 VolumeOutcome(database=True, dumped=dumped, engine=db_type)
def _empty_databases_df() -> pd.DataFrame:
"""
Create an empty DataFrame with the expected schema for databases.csv.
This allows the backup to continue without DB dumps when the CSV is missing
or empty (pandas EmptyDataError).
"""
return pd.DataFrame(columns=list(COLUMNS))
def load_databases_df(csv_path: str) -> pd.DataFrame:
"""
Load databases.csv robustly.
- Missing file -> warn, continue with empty df
- Empty file -> warn, continue with empty df
- Valid CSV -> return dataframe
"""
try:
return pd.read_csv(csv_path, sep=DELIMITER, keep_default_na=False, dtype=str)
except FileNotFoundError:
print(
f"WARNING: databases.csv not found: {csv_path}. Continuing without database dumps.",
file=sys.stderr,
flush=True,
)
return _empty_databases_df()
except EmptyDataError:
print(
f"WARNING: databases.csv exists but is empty: {csv_path}. Continuing without database dumps.",
file=sys.stderr,
flush=True,
)
return _empty_databases_df()
def backup_dumps_for_volume(
*,
containers: list[str],
vol_dir: str,
databases_df: pd.DataFrame,
database_containers: list[str],
) -> VolumeOutcome:
"""The volume's outcome across every container that mounts it."""
found_db = False
dumped_any = False
engine: str | None = None
for c in containers:
outcome = backup_mariadb_or_postgres(
container=c,
volume_dir=vol_dir,
databases_df=databases_df,
database_containers=database_containers,
)
if outcome.database:
found_db = True
if outcome.dumped:
dumped_any = True
if engine is None:
engine = outcome.engine
return VolumeOutcome(database=found_db, dumped=dumped_any, engine=engine)