utils.recovery package¶
Submodules¶
utils.recovery.base module¶
Abstract recover flow shared by every svc-bkp-* role.
A recover mirrors a snapshot into a live target directory
(rsync -aHAX --delete), so the target’s current state would be lost
without a net. The flow therefore first runs the role’s deployed backup
service (systemctl start on the role’s unit), which applies the
usual differential backup logic to the live data before anything is
overwritten. Roles subclass DirectoryRecovery, set
unit_pattern and ship a thin files/python/recover.py CLI around it
(enforced by tests/lint/ansible/roles/test_bkp_roles_have_recover.py).
Host-agnostic: source and target may be a local path or a remote
[user@]host:/path (rsync pushes/pulls over ssh). A remote target
skips the local pre-recover safety backup, since that host’s live data
is the remote’s concern.
- class utils.recovery.base.DirectoryRecovery(source_dir: str, target_dir: str, *, service_backup: bool = True)¶
Bases:
ABCService-backed recovery for a directory target.
- Parameters:
source_dir – snapshot directory to restore from.
target_dir – live directory to restore into.
service_backup – run the role’s backup unit first (default); disable only when the target holds nothing worth saving.
- backup_target() None¶
Run the role’s deployed backup unit against the live data.
- restore() None¶
- rsync_extra_args: tuple[str, ...] = ()¶
- run() int¶
- unit_pattern: str¶
- utils.recovery.base.is_remote(location: str) bool¶
A location not starting with
/is a remote[user@]host:/path.
utils.recovery.databases module¶
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.
- class utils.recovery.databases.Cluster(volume: str, instance: str, path: Path)¶
Bases:
NamedTupleOne
pg_dumpalldump of a whole instance inside a generation.- instance: str¶
Alias for field number 1
- volume: str¶
Alias for field number 0
- class utils.recovery.databases.Dump(volume: str, database: str, path: Path)¶
Bases:
NamedTupleOne single-database dump inside a generation.
- database: str¶
Alias for field number 1
- volume: str¶
Alias for field number 0
- class utils.recovery.databases.Generation(backups_dir: str, machine_hash: str, repo_name: str, name: str)¶
Bases:
NamedTupleA generation directory, decomposed into baudolo-restore’s arguments.
- backups_dir: str¶
Alias for field number 0
- machine_hash: str¶
Alias for field number 1
- name: str¶
Alias for field number 3
- repo_name: str¶
Alias for field number 2
- utils.recovery.databases.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.
- utils.recovery.databases.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
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).
- utils.recovery.databases.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.
- utils.recovery.databases.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.
- Parameters:
cluster – the dump to read.
- Returns:
The database names, in the order the dump names them.
- utils.recovery.databases.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.
- utils.recovery.databases.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.
- Parameters:
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
postgresormariadb. Both volume spellings are emitted per application, because which one a host used depends onshared, which is inventory-dependent and therefore unknowable from a checkout - and both spellings resolve to the same engine anyway.
- utils.recovery.databases.engine_of(dump: Dump, engines: Mapping[str, str]) str¶
Name the engine a dump has to be replayed with.
- Parameters:
dump – the dump to place.
engines – the map from
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.
- utils.recovery.databases.generation_of(generation_dir: Path) Generation¶
Decompose <backups>/<machine-hash>/<repo>/<generation> into its parts.
- utils.recovery.databases.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-restoreitself, which compares the dump’s header against the running server before its pre-clean drops anything.- Parameters:
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.
- utils.recovery.databases.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.
utils.recovery.docker module¶
Ask the docker daemon what a replay is allowed to touch.
Split out of utils.recovery.databases so the replay module carries the
generation’s layout and the credentials, and this one the probing: which
container serves a volume, which compose project it belongs to, and who is
still running in it.
- exception utils.recovery.docker.RecoveryError¶
Bases:
ExceptionA condition that makes the replay unprovable.
- utils.recovery.docker.assert_no_consumers(project: str, docker_host: str | None = None, ignore: tuple[str, ...] = ()) None¶
Refuse to replay into a database whose application is still up.
- Parameters:
project – the compose project to check.
docker_host – remote docker endpoint, or None for this host.
ignore – containers that are not consumers - the engine itself, when the project checked is the one the engine lives in.
- Raises:
RecoveryError – the compose project still runs containers. A booting consumer recreates the pre-cleaned schema under the replay.
- utils.recovery.docker.consumers_running(project: str, docker_host: str | None = None) list[str]¶
List the running containers of one compose project.
- utils.recovery.docker.container_of_volume(volume: str, docker_host: str | None = None) str¶
Name the running container that mounts a volume.
- Raises:
RecoveryError – nothing is running to replay into. A dump reaches its database through
docker exec, so unlike a file tree it cannot be restored onto a bare host - the engine has to be up. The two ways to get there differ, so they are reported apart.
- utils.recovery.docker.project_of(container: str, docker_host: str | None = None) str¶
The compose project a container belongs to, empty when it has none.
utils.recovery.layout module¶
Fallback shape for a generation that cannot state its own.
baudolo owns these values and writes them into every generation’s manifest, so
utils.recovery.manifest.layout_of() is what callers ask. These literals
answer only for generations written before the manifest existed - those cannot
describe themselves, and nothing else can tell a reader what they used.
Literals rather than an import because the code that reads a generation runs on
swarm nodes, the NFS server and bare rescue hosts, which carry a git checkout
and no baudolo; this module must stay import-free for that reason. The
agreement is enforced by
tests/lint/repository/dependencies/test_baudolo_pin.py.
utils.recovery.manifest module¶
Read the manifest a baudolo generation carries, with nothing but json.
Import-light for the same reason as utils.recovery.layout: this runs on
hosts that have no baudolo.
Generations written before the manifest existed carry none, so read()
reports absence rather than an empty verdict and callers can fall back.
Exit codes as a module: 0 with one <volume>\t<engine> line per undumped
database volume, 2 when the generation carries no manifest, 64 on bad usage.
- utils.recovery.manifest.engine_by_volume(generation_dir: Path | str) dict[str, str]¶
The engine each database volume was dumped from, as the run detected it.
- utils.recovery.manifest.globs_of(names: dict[str, str]) tuple[str, str]¶
(files_glob, dump_glob)for the layout names describe.Takes the names rather than the directory so a caller that needs both reads the manifest once.
- utils.recovery.manifest.layout_of(generation_dir: Path | str) dict[str, str]¶
The names payload is stored under in THIS generation.
Read from the generation’s own manifest, so a tree written by any version is walked with the names that version used. Generations older than the manifest cannot say, and only for those does the fallback in
utils.recovery.layoutapply.
- utils.recovery.manifest.main(argv: list[str] | None = None) int¶
- utils.recovery.manifest.read(generation_dir: Path | str) dict | None¶
The manifest document, or None when the generation carries none.
- Parameters:
generation_dir – the
<backups>/<hash>/<repo>/<generation>directory.- Raises:
ValueError – the manifest is unreadable, or states a schema this code does not know. A newer schema must not be guessed at: the fields this reader relies on may mean something else in it.
- utils.recovery.manifest.undumped(generation_dir: Path | str) list[tuple[str, str]]¶
(volume, engine)for every database volume that carries no dump.
- utils.recovery.manifest.volumes(generation_dir: Path | str) dict[str, dict]¶
Every volume the generation recorded, empty for an old generation.