fix(backup): fallback to file backup in dump-only mode when no DB dump is possible

- Change DB backup helpers to return whether a dump was actually produced
- Detect DB containers without successful dumps in --dump-only mode
- Fallback to file backups with a warning instead of skipping silently
- Refactor DB dump logic to return boolean status
- Add E2E test covering dump-only fallback when databases.csv entry is missing

https://chatgpt.com/share/6951a659-2b0c-800f-aafa-3e89ae1eb697
This commit is contained in:
2025-12-28 22:51:12 +01:00
parent d563dce20f
commit df32671cec
3 changed files with 225 additions and 30 deletions

View File

@@ -72,28 +72,27 @@ def requires_stop(containers: list[str], images_no_stop_required: list[str]) ->
return True
return False
def backup_mariadb_or_postgres(
*,
container: str,
volume_dir: str,
databases_df: "pandas.DataFrame",
database_containers: list[str],
) -> bool:
) -> tuple[bool, bool]:
"""
Returns True if the container is a DB container we handled.
Returns (is_db_container, dumped_any)
"""
for img in ["mariadb", "postgres"]:
if has_image(container, img):
backup_database(
dumped = backup_database(
container=container,
volume_dir=volume_dir,
db_type=img,
databases_df=databases_df,
database_containers=database_containers,
)
return True
return False
return True, dumped
return False, False
def _backup_dumps_for_volume(
@@ -102,21 +101,26 @@ def _backup_dumps_for_volume(
vol_dir: str,
databases_df: "pandas.DataFrame",
database_containers: list[str],
) -> bool:
) -> tuple[bool, bool]:
"""
Create DB dumps for any mariadb/postgres containers attached to this volume.
Returns True if at least one dump was produced.
Returns (found_db_container, dumped_any)
"""
found_db = False
dumped_any = False
for c in containers:
if backup_mariadb_or_postgres(
is_db, dumped = backup_mariadb_or_postgres(
container=c,
volume_dir=vol_dir,
databases_df=databases_df,
database_containers=database_containers,
):
)
if is_db:
found_db = True
if dumped:
dumped_any = True
return dumped_any
return found_db, dumped_any
def main() -> int:
@@ -137,18 +141,26 @@ def main() -> int:
containers = containers_using_volume(volume_name)
vol_dir = create_volume_directory(version_dir, volume_name)
# Old behavior: DB dumps are additional to file backups.
_backup_dumps_for_volume(
found_db, dumped_any = _backup_dumps_for_volume(
containers=containers,
vol_dir=vol_dir,
databases_df=databases_df,
database_containers=args.database_containers,
)
# dump-only: skip ALL file rsync backups
# dump-only logic:
if args.dump_only:
continue
if found_db and not dumped_any:
print(
f"WARNING: dump-only requested but no DB dump was produced for DB volume '{volume_name}'. Falling back to file backup.",
flush=True,
)
# continue to file backup below
else:
# keep old behavior: skip file backups
continue
# skip file backup if all linked containers are ignored
if volume_is_fully_ignored(containers, args.images_no_backup_required):

View File

@@ -3,9 +3,8 @@ from __future__ import annotations
import os
import pathlib
import re
import pandas
import logging
import pandas
from .shell import BackupException, execute_shell_command
@@ -18,9 +17,7 @@ def get_instance(container: str, database_containers: list[str]) -> str:
return re.split(r"(_|-)(database|db|postgres)", container)[0]
def fallback_pg_dumpall(
container: str, username: str, password: str, out_file: str
) -> None:
def fallback_pg_dumpall(container: str, username: str, password: str, out_file: str) -> None:
cmd = (
f"PGPASSWORD={password} docker exec -i {container} "
f"pg_dumpall -U {username} -h localhost > {out_file}"
@@ -35,20 +32,25 @@ def backup_database(
db_type: str,
databases_df: "pandas.DataFrame",
database_containers: list[str],
) -> None:
) -> bool:
"""
Returns True if at least one dump file was produced, else False.
"""
instance_name = get_instance(container, database_containers)
entries = databases_df.loc[databases_df["instance"] == instance_name]
if entries.empty:
log.warning("No entry found for instance '%s'", instance_name)
return
log.warning("No entry found for instance '%s' (skipping DB dump)", instance_name)
return False
out_dir = os.path.join(volume_dir, "sql")
pathlib.Path(out_dir).mkdir(parents=True, exist_ok=True)
for row in entries.iloc:
db_name = row["database"]
user = row["username"]
password = row["password"]
produced = False
for row in entries.itertuples(index=False):
db_name = row.database
user = row.username
password = row.password
dump_file = os.path.join(out_dir, f"{db_name}.backup.sql")
@@ -58,13 +60,15 @@ def backup_database(
f"-u {user} -p{password} {db_name} > {dump_file}"
)
execute_shell_command(cmd)
produced = True
continue
if db_type == "postgres":
cluster_file = os.path.join(out_dir, f"{instance_name}.cluster.backup.sql")
if not db_name:
fallback_pg_dumpall(container, user, password, cluster_file)
return
return True
try:
cmd = (
@@ -72,6 +76,7 @@ def backup_database(
f"pg_dump -U {user} -d {db_name} -h localhost > {dump_file}"
)
execute_shell_command(cmd)
produced = True
except BackupException as e:
print(f"pg_dump failed: {e}", flush=True)
print(
@@ -79,4 +84,7 @@ def backup_database(
flush=True,
)
fallback_pg_dumpall(container, user, password, cluster_file)
produced = True
continue
return produced