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: ABC

Service-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: NamedTuple

One pg_dumpall dump of a whole instance inside a generation.

instance: str

Alias for field number 1

path: Path

Alias for field number 2

volume: str

Alias for field number 0

class utils.recovery.databases.Dump(volume: str, database: str, path: Path)

Bases: NamedTuple

One single-database dump inside a generation.

database: str

Alias for field number 1

path: Path

Alias for field number 2

volume: str

Alias for field number 0

class utils.recovery.databases.Generation(backups_dir: str, machine_hash: str, repo_name: str, name: str)

Bases: NamedTuple

A 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 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.

utils.recovery.databases.engine_of(dump: Dump, engines: Mapping[str, str]) → str

Name the engine a dump has to be replayed with.

Parameters:
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-restore itself, 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: Exception

A 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.layout apply.

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.

Module contents