2026-07-31 13:01:43 +02:00
|
|
|
"""Database dumps taken before a volume's files are copied."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import sys
|
2026-08-18 01:47:38 +02:00
|
|
|
from typing import NamedTuple
|
2026-07-31 13:01:43 +02:00
|
|
|
|
2026-08-18 01:47:38 +02:00
|
|
|
import pandas as pd
|
2026-07-31 13:01:43 +02:00
|
|
|
from pandas.errors import EmptyDataError
|
|
|
|
|
|
2026-08-17 16:28:15 +02:00
|
|
|
from baudolo.databases import COLUMNS, DELIMITER
|
|
|
|
|
|
2026-08-18 05:11:46 +02:00
|
|
|
from .db import backup_database, get_instance
|
2026-08-17 13:51:26 +02:00
|
|
|
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] = {}
|
|
|
|
|
|
|
|
|
|
|
2026-08-18 01:47:38 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 13:51:26 +02:00
|
|
|
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]
|
2026-07-31 13:01:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def backup_mariadb_or_postgres(
|
|
|
|
|
*,
|
|
|
|
|
container: str,
|
|
|
|
|
volume_dir: str,
|
2026-08-18 01:47:38 +02:00
|
|
|
databases_df: pd.DataFrame,
|
2026-07-31 13:01:43 +02:00
|
|
|
database_containers: list[str],
|
2026-08-18 01:47:38 +02:00
|
|
|
) -> VolumeOutcome:
|
|
|
|
|
"""What this container contributes to its volume's outcome."""
|
2026-08-17 13:51:26 +02:00
|
|
|
engine = container_engine(container)
|
|
|
|
|
if engine is None:
|
2026-08-18 01:47:38 +02:00
|
|
|
return VolumeOutcome(database=False, dumped=False)
|
2026-08-18 05:11:46 +02:00
|
|
|
if get_instance(container, database_containers) is None:
|
|
|
|
|
return VolumeOutcome(database=False, dumped=False)
|
2026-08-17 13:51:26 +02:00
|
|
|
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,
|
|
|
|
|
)
|
2026-08-18 01:47:38 +02:00
|
|
|
return VolumeOutcome(database=True, dumped=dumped, engine=db_type)
|
2026-07-31 13:01:43 +02:00
|
|
|
|
|
|
|
|
|
2026-08-18 01:47:38 +02:00
|
|
|
def _empty_databases_df() -> pd.DataFrame:
|
2026-07-31 13:01:43 +02:00
|
|
|
"""
|
|
|
|
|
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).
|
|
|
|
|
"""
|
2026-08-18 01:47:38 +02:00
|
|
|
return pd.DataFrame(columns=list(COLUMNS))
|
2026-07-31 13:01:43 +02:00
|
|
|
|
|
|
|
|
|
2026-08-18 01:47:38 +02:00
|
|
|
def load_databases_df(csv_path: str) -> pd.DataFrame:
|
2026-07-31 13:01:43 +02:00
|
|
|
"""
|
|
|
|
|
Load databases.csv robustly.
|
|
|
|
|
|
|
|
|
|
- Missing file -> warn, continue with empty df
|
|
|
|
|
- Empty file -> warn, continue with empty df
|
|
|
|
|
- Valid CSV -> return dataframe
|
|
|
|
|
"""
|
|
|
|
|
try:
|
2026-08-18 01:47:38 +02:00
|
|
|
return pd.read_csv(csv_path, sep=DELIMITER, keep_default_na=False, dtype=str)
|
2026-07-31 13:01:43 +02:00
|
|
|
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,
|
2026-08-18 01:47:38 +02:00
|
|
|
databases_df: pd.DataFrame,
|
2026-07-31 13:01:43 +02:00
|
|
|
database_containers: list[str],
|
2026-08-18 01:47:38 +02:00
|
|
|
) -> VolumeOutcome:
|
|
|
|
|
"""The volume's outcome across every container that mounts it."""
|
2026-07-31 13:01:43 +02:00
|
|
|
found_db = False
|
|
|
|
|
dumped_any = False
|
2026-08-18 01:47:38 +02:00
|
|
|
engine: str | None = None
|
2026-07-31 13:01:43 +02:00
|
|
|
|
|
|
|
|
for c in containers:
|
2026-08-18 01:47:38 +02:00
|
|
|
outcome = backup_mariadb_or_postgres(
|
2026-07-31 13:01:43 +02:00
|
|
|
container=c,
|
|
|
|
|
volume_dir=vol_dir,
|
|
|
|
|
databases_df=databases_df,
|
|
|
|
|
database_containers=database_containers,
|
|
|
|
|
)
|
2026-08-18 01:47:38 +02:00
|
|
|
if outcome.database:
|
2026-07-31 13:01:43 +02:00
|
|
|
found_db = True
|
2026-08-18 01:47:38 +02:00
|
|
|
if outcome.dumped:
|
2026-07-31 13:01:43 +02:00
|
|
|
dumped_any = True
|
2026-08-18 01:47:38 +02:00
|
|
|
if engine is None:
|
|
|
|
|
engine = outcome.engine
|
2026-07-31 13:01:43 +02:00
|
|
|
|
2026-08-18 01:47:38 +02:00
|
|
|
return VolumeOutcome(database=found_db, dumped=dumped_any, engine=engine)
|