feat(manifest)!: record per volume what the run established

A finished generation cannot show whether a volume held a database, nor whether a dump was produced for it: under --only-sql a failed dump falls back to a file copy, and the resulting files/ tree looks like any other copy. The run knows both and threw the knowledge away as a printed warning, leaving every reader to guess from file names.

Each generation now carries a manifest.json stating its layout and, per volume, database / dumped / engine. baudolo.generation is the single place those names are spelled; restore/paths.py, backup/db.py and backup/volume.py stop repeating them. It is deliberately import-free so a consumer can read the manifest with nothing but json, on hosts where this package is not installed.

BREAKING CHANGE: BackupException is renamed BackupError. The rename is atomic across the ten modules that define or import it, three of which also carry the manifest change, so it lands in this commit rather than a separate one that could not import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5 committed 2026-08-18 01:48:09 +02:00
1 parent 03da186a06
commit 94637c32aa
22 files changed
+422 -106

No files matched your search

+14 -12
View File
@@ -1,16 +1,18 @@
from __future__ import annotations
import logging
import os
import pathlib
import re
import pandas
from typing import TYPE_CHECKING
from baudolo.databases import CLUSTER_ROW, validate_database
from baudolo.generation import CLUSTER_SUFFIX, DUMP_SUFFIX, SQL_DIR
from .docker import docker_exec_argv
from .shell import BackupException, execute_to_file
from .shell import BackupError, execute_to_file
if TYPE_CHECKING:
import pandas as pd
log = logging.getLogger(__name__)
@@ -47,7 +49,7 @@ def backup_database(
volume_dir: str,
db_type: str,
dump_tool: str,
databases_df: pandas.DataFrame,
databases_df: pd.DataFrame,
database_containers: list[str],
) -> bool:
"""
@@ -66,8 +68,8 @@ def backup_database(
log.debug("No database entries for instance '%s'", instance_name)
return False
out_dir = os.path.join(volume_dir, "sql")
pathlib.Path(out_dir).mkdir(parents=True, exist_ok=True)
out_dir = pathlib.Path(volume_dir) / SQL_DIR
out_dir.mkdir(parents=True, exist_ok=True)
produced = False
@@ -85,13 +87,13 @@ def backup_database(
f"'{CLUSTER_ROW}' is currently only supported for Postgres."
)
cluster_file = os.path.join(out_dir, f"{instance_name}.cluster.backup.sql")
cluster_file = str(out_dir / f"{instance_name}{CLUSTER_SUFFIX}")
fallback_pg_dumpall(container, user, password, cluster_file)
produced = True
continue
db_name = db_value
dump_file = os.path.join(out_dir, f"{db_name}.backup.sql")
dump_file = str(out_dir / f"{db_name}{DUMP_SUFFIX}")
if db_type == "mariadb":
# Force TCP so auth matches '<user>'@'%' instead of socket -> 'localhost'.
@@ -136,13 +138,13 @@ def backup_database(
env={"PGPASSWORD": password},
)
produced = True
except BackupException as e:
raise BackupException(
except BackupError as e:
raise BackupError(
f"Postgres dump failed for instance '{instance_name}', "
f"database '{db_name}'. This database was explicitly configured "
"and therefore must succeed.\n"
f"{e}"
)
) from e
continue
return produced