Files
computer-playbook/utils/recovery/databases.py
kevinveenbirkenbachandClaude Opus 5 7057d6647e fix(recover): place a dump by the engine the backup observed, not only by name
engine_by_key merges the generation manifest's volume-to-engine entries and
its docstring promises they win, but replay() called it without the
generation it was already holding, so only repository-derived names resolved.
A dump whose names the application model does not claim aborted the drill:
matrix variant 2 seeds five mautrix bridge databases into the central
postgres, their dumps sit in postgres_data under names that are no entity,
and both Matrix#2 CI jobs died on

    no database service in the repository claims volume 'postgres_data'
    or database 'mautrix_meta_bridge', so its engine is unknown

tearing the stack down mid-cycle and cascading into four more CLI tests.

The drill's marker seeder has passed the generation since dd07275e8, so one
run was resolving engines from two different maps; replay now reads the same
one. A generation without a manifest contributes nothing, exactly as before,
and a corrupt manifest already fails earlier in dumps_of.

The new test pins the shape: a dump only the manifest can place is replayed.
Before the change it raised with the CI message verbatim.

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

380 lines
13 KiB
Python

"""Replay the sql dumps of one backup generation into their databases.
``baudolo-restore --empty`` pre-cleans the schema in one psql session and
replays the dump in the next, so a consumer that boots in between recreates
the schema and the replay dies on its own ``CREATE TABLE``. Hence the refusal
while a consumer is up.
The engine is read from the repository, not guessed from a container image.
Whether a dump is readable by that engine is decided by ``baudolo-restore``
itself, before its pre-clean drops anything.
The generation layout belongs to baudolo, which writes it, so the file names
and subdirectory below are read from the generation's own manifest rather than
restated here. Restating them would let a rename on its side leave the globs
matching nothing, which reads as "this generation holds no databases" rather
than as a failure.
A ``database = '*'`` row dumps a whole instance with ``pg_dumpall``. It goes
back through ``baudolo-restore cluster`` with the instance's superuser; its
``\\connect`` lines name the databases, while the consumers sit in the compose
project of the engine, so both are checked.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple
from baudolo.databases import CLUSTER_ROW, DatabasesCsvError, Row, read_rows
from baudolo.restore.db.cluster import dump_inventory
from utils.cache.applications import get_application_defaults
from utils.recovery import docker as recovery_docker
from utils.recovery import manifest
from utils.recovery.docker import RecoveryError
from utils.roles.applications.services.database import (
RDBMS_SERVICE_KEYS,
resolve_database_service_key,
)
from utils.roles.entity.name import get_entity_name
if TYPE_CHECKING:
from collections.abc import Mapping
from pathlib import Path
RESTORE_BIN = "baudolo-restore"
DEDICATED_VOLUME_SUFFIX = "_database"
CLUSTER_ENGINE = "postgres"
CONTROL_DATABASES = frozenset({"postgres", "template0", "template1"})
class Dump(NamedTuple):
"""One single-database dump inside a generation."""
volume: str
database: str
path: Path
class Cluster(NamedTuple):
"""One ``pg_dumpall`` dump of a whole instance inside a generation."""
volume: str
instance: str
path: Path
class Generation(NamedTuple):
"""A generation directory, decomposed into baudolo-restore's arguments."""
backups_dir: str
machine_hash: str
repo_name: str
name: str
def engine_by_key(
applications: Mapping[str, object] | None = None,
generation_dir: Path | None = None,
) -> dict[str, str]:
"""Map every name a dump can be addressed by to its database engine.
Args:
applications: the materialised application payload; loaded from the
repository when omitted.
generation_dir: a generation whose manifest states, per volume, the
engine the run actually detected. Those entries win: they are
observed, where everything derived from the repository is inferred
from naming. A generation without a manifest contributes nothing.
Returns:
A mapping of docker volume names and database names to ``postgres``
or ``mariadb``. Both volume spellings are emitted per application,
because which one a host used depends on ``shared``, which is
inventory-dependent and therefore unknowable from a checkout - and
both spellings resolve to the same engine anyway.
"""
payload = get_application_defaults() if applications is None else applications
mapping = {key: key for key in RDBMS_SERVICE_KEYS}
for application_id in payload:
engine = resolve_database_service_key(payload, application_id)
if not engine:
continue
entity = get_entity_name(application_id)
mapping[f"{entity}{DEDICATED_VOLUME_SUFFIX}"] = engine
mapping[entity] = engine
if generation_dir is not None:
mapping.update(manifest.engine_by_volume(generation_dir))
return mapping
def generation_of(generation_dir: Path) -> Generation:
"""Decompose <backups>/<machine-hash>/<repo>/<generation> into its parts."""
repo_dir = generation_dir.parent
machine_dir = repo_dir.parent
return Generation(
str(machine_dir.parent),
machine_dir.name,
repo_dir.name,
generation_dir.name,
)
def dumps_of(generation_dir: Path) -> tuple[list[Dump], list[Cluster]]:
"""Split a generation's dumps by the subcommand that replays them.
Returns:
The single-database dumps, and the cluster dumps, which name their
instance in the file name and go through ``baudolo-restore cluster``.
"""
dumps: list[Dump] = []
clusters: list[Cluster] = []
names = manifest.layout_of(generation_dir)
cluster_suffix = names["cluster_suffix"]
dump_suffix = names["dump_suffix"]
_files_glob, dump_glob = manifest.globs_of(names)
for path in sorted(generation_dir.glob(dump_glob)):
volume = path.parent.parent.name
if path.name.endswith(cluster_suffix):
clusters.append(Cluster(volume, path.name[: -len(cluster_suffix)], path))
continue
dumps.append(Dump(volume, path.name[: -len(dump_suffix)], path))
return dumps, clusters
def _rows_of(csv_path: Path) -> list[Row]:
"""The host's databases.csv, read through baudolo's own reader."""
if not csv_path.is_file():
raise RecoveryError(f"{csv_path} does not exist")
try:
return read_rows(str(csv_path))
except DatabasesCsvError as error:
raise RecoveryError(str(error)) from error
def credentials_of(csv_path: Path) -> dict[str, tuple[str, str]]:
"""Read database credentials from the host's databases.csv.
Returns:
Database name to (user, password); the first row of a name wins,
matching the upsert order of the seed.
Raises:
RecoveryError: the file is missing, or a row is short of columns.
"""
credentials: dict[str, tuple[str, str]] = {}
for row in _rows_of(csv_path):
credentials.setdefault(row.database, (row.username, row.password))
return credentials
def cluster_credentials_of(csv_path: Path) -> dict[str, tuple[str, str]]:
"""Read the superuser of every instance that is dumped as a whole.
A cluster row states the cluster marker where a single-database row names
a database, so :func:`credentials_of` would file every instance under the
same key and keep only the first. Cluster credentials are therefore keyed
by instance.
Returns:
Instance name to (user, password).
"""
credentials: dict[str, tuple[str, str]] = {}
for row in _rows_of(csv_path):
if row.is_cluster:
credentials.setdefault(row.instance.strip(), (row.username, row.password))
return credentials
def databases_in(cluster: Cluster) -> list[str]:
"""Name the databases a cluster dump recreates.
The control databases are dropped: they exist in every cluster and are not
deployed as applications, so nothing consumes them.
Args:
cluster: the dump to read.
Returns:
The database names, in the order the dump names them.
"""
dumped, _roles = dump_inventory(str(cluster.path))
return [name for name in dumped if name not in CONTROL_DATABASES]
def engine_of(dump: Dump, engines: Mapping[str, str]) -> str:
"""Name the engine a dump has to be replayed with.
Args:
dump: the dump to place.
engines: the map from :func:`engine_by_key`.
Raises:
RecoveryError: neither the volume nor the database name is known to
the repository, so the engine would have to be guessed.
"""
for key in (dump.volume, dump.database):
engine = engines.get(key)
if engine:
return engine
raise RecoveryError(
f"no database service in the repository claims volume '{dump.volume}' "
f"or database '{dump.database}', so its engine is unknown"
)
def restore_argv(
dump: Dump,
generation: Generation,
engine: str,
container: str,
user: str,
password: str,
) -> list[str]:
"""Build the baudolo-restore call that replays one dump."""
return [
RESTORE_BIN,
engine,
dump.volume,
generation.machine_hash,
generation.name,
"--backups-dir",
generation.backups_dir,
"--repo-name",
generation.repo_name,
"--container",
container,
"--db-name",
dump.database,
"--db-user",
user,
"--db-password",
password,
"--empty",
]
def cluster_argv(
cluster: Cluster,
generation: Generation,
container: str,
user: str,
password: str,
) -> list[str]:
"""Build the baudolo-restore call that replays one instance as a whole.
The dump recreates roles and databases, which an application role may not
do, so the credentials are the instance's superuser rather than an app's.
"""
return [
RESTORE_BIN,
"cluster",
cluster.volume,
generation.machine_hash,
generation.name,
"--backups-dir",
generation.backups_dir,
"--repo-name",
generation.repo_name,
"--container",
container,
"--instance",
cluster.instance,
"--db-user",
user,
"--db-password",
password,
"--empty",
]
def replay(
generation_dir: Path,
csv_path: Path,
*,
docker_host: str | None = None,
engines: Mapping[str, str] | None = None,
) -> int:
"""Replay every database dump of a generation, single and cluster alike.
Whether the dump is readable by the engine it goes into is decided by
``baudolo-restore`` itself, which compares the dump's header against the
running server before its pre-clean drops anything.
Args:
generation_dir: <backups>/<machine-hash>/<repo>/<generation>.
csv_path: the host's databases.csv.
docker_host: remote docker endpoint, or None for this host.
engines: engine map override; read from the repository when omitted.
Returns:
The number of dumps replayed.
Raises:
RecoveryError: a consumer of a database is still running, a dump has
no credentials row, or its engine is unknown to the repository.
"""
if not generation_dir.is_dir():
raise RecoveryError(f"{generation_dir} is not a directory")
generation = generation_of(generation_dir)
dumps, clusters = dumps_of(generation_dir)
if not dumps and not clusters:
print(f"OK: generation {generation.name} carries no database dumps")
return 0
engine_map = (
engine_by_key(generation_dir=generation_dir) if engines is None else engines
)
credentials = credentials_of(csv_path)
cluster_credentials = cluster_credentials_of(csv_path)
for dump in dumps:
if dump.database not in credentials:
raise RecoveryError(f"{csv_path} has no row for database '{dump.database}'")
engine = recovery_docker.container_of_volume(dump.volume, docker_host)
recovery_docker.assert_no_consumers(
dump.database, docker_host, ignore=(engine,)
)
engine_of_cluster: dict[Path, str] = {}
for cluster in clusters:
if cluster.instance not in cluster_credentials:
raise RecoveryError(
f"{csv_path} has no '{CLUSTER_ROW}' row for instance "
f"'{cluster.instance}', so the superuser of the cluster dump "
f"{cluster.path.name} is unknown"
)
engine = recovery_docker.container_of_volume(cluster.volume, docker_host)
engine_of_cluster[cluster.path] = engine
recovery_docker.assert_no_consumers(
recovery_docker.project_of(engine, docker_host),
docker_host,
ignore=(engine,),
)
for database in databases_in(cluster):
recovery_docker.assert_no_consumers(database, docker_host)
for cluster in clusters:
container = engine_of_cluster[cluster.path]
user, password = cluster_credentials[cluster.instance]
print(
recovery_docker._run(
cluster_argv(cluster, generation, container, user, password),
secret=password,
).strip()
)
print(f"OK: replayed cluster dump '{cluster.instance}' into {container}")
for dump in dumps:
engine = engine_of(dump, engine_map)
container = recovery_docker.container_of_volume(dump.volume, docker_host)
user, password = credentials[dump.database]
print(
recovery_docker._run(
restore_argv(dump, generation, engine, container, user, password),
secret=password,
).strip()
)
print(f"OK: replayed {engine} dump '{dump.database}' into {container}")
return len(dumps) + len(clusters)