mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-08-20 21:22:54 +00:00
feat(restore): replay pg_dumpall cluster dumps
A databases.csv row asking for every database of an instance (database = '*') makes the backup side write <instance>.cluster.backup.sql via pg_dumpall, and nothing could read it back: the restore CLI knew files, postgres and mariadb. That dump was stored and unrestorable - a format whose producer had no consumer. Adds `baudolo-restore cluster`. Three properties of a cluster stream shape it, and each one bit during development: - It recreates databases, and CREATE DATABASE cannot run inside a transaction block. So unlike the single-database replay this one must NOT be wrapped in --single-transaction. The unit tests now pin both contracts against each other. - It recreates every role including the one the replay connects as, and the pre-clean cannot drop the role holding its own session. That single CREATE ROLE is filtered out of the stream while its ALTER ROLE is kept, because that is what carries the attributes and the password. Found by running it: the first replay died on `role "postgres" already exists`. - --empty means more than for one database: the cluster's databases go first, then DROP OWNED BY releases what a role still holds in the control database, then the roles themselves. The order is pinned by a phase column because \gexec would otherwise emit them interleaved, and a role cannot be dropped while it still owns a database. Without --empty the replay stops at the first object that already exists. Recreating a cluster over a populated one is a decision, not a default. The e2e test drills the real thing: two databases and their owning role are dropped outright and have to come back with their payload and their ownership intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,11 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from .paths import BackupPaths
|
||||
from .files import restore_volume_files
|
||||
from .db.postgres import restore_postgres_sql
|
||||
from .db.cluster import restore_cluster_sql
|
||||
from .db.mariadb import restore_mariadb_sql
|
||||
from .db.postgres import restore_postgres_sql
|
||||
from .files import restore_volume_files
|
||||
from .paths import BackupPaths
|
||||
|
||||
|
||||
def _add_common_backup_args(p: argparse.ArgumentParser) -> None:
|
||||
@@ -59,6 +60,27 @@ def main(argv: list[str] | None = None) -> int:
|
||||
p_pg.add_argument("--db-password", required=True)
|
||||
p_pg.add_argument("--empty", action="store_true")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# cluster
|
||||
# ------------------------------------------------------------------
|
||||
p_cluster = sub.add_parser(
|
||||
"cluster", help="Restore a full PostgreSQL cluster dump (pg_dumpall)"
|
||||
)
|
||||
_add_common_backup_args(p_cluster)
|
||||
p_cluster.add_argument("--container", required=True)
|
||||
p_cluster.add_argument(
|
||||
"--instance",
|
||||
required=True,
|
||||
help="Instance the dump was taken from; names <instance>.cluster.backup.sql",
|
||||
)
|
||||
p_cluster.add_argument(
|
||||
"--db-user",
|
||||
required=True,
|
||||
help="Superuser of the instance; the dump creates roles and databases",
|
||||
)
|
||||
p_cluster.add_argument("--db-password", required=True)
|
||||
p_cluster.add_argument("--empty", action="store_true")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# mariadb
|
||||
# ------------------------------------------------------------------
|
||||
@@ -111,6 +133,22 @@ def main(argv: list[str] | None = None) -> int:
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.cmd == "cluster":
|
||||
restore_cluster_sql(
|
||||
container=args.container,
|
||||
user=args.db_user,
|
||||
password=args.db_password,
|
||||
sql_path=BackupPaths(
|
||||
args.volume_name,
|
||||
args.backup_hash,
|
||||
args.version,
|
||||
repo_name=args.repo_name,
|
||||
backups_dir=args.backups_dir,
|
||||
).cluster_file(args.instance),
|
||||
empty=args.empty,
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.cmd == "mariadb":
|
||||
user = args.db_user or args.db_name
|
||||
restore_mariadb_sql(
|
||||
|
||||
103
src/baudolo/restore/db/cluster.py
Normal file
103
src/baudolo/restore/db/cluster.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Replay a full PostgreSQL cluster dump produced by ``pg_dumpall``.
|
||||
|
||||
The backup side writes one when a databases.csv row asks for every database of
|
||||
an instance (``database = '*'``, see ``backup/db.py``). Until now nothing read
|
||||
it back, so that dump was stored and unrestorable - a format whose producer has
|
||||
no consumer.
|
||||
|
||||
A cluster stream differs from a single-database one in three ways that decide
|
||||
the implementation:
|
||||
|
||||
* it recreates roles and databases, so it must be replayed against the control
|
||||
database rather than into a target database;
|
||||
* ``CREATE DATABASE`` cannot run inside a transaction block, so unlike
|
||||
:mod:`baudolo.restore.db.postgres` the replay must not be wrapped in
|
||||
``--single-transaction``;
|
||||
* it is replayed as a superuser, so the superuser-only statements that the
|
||||
single-database path filters out are exactly the ones that have to survive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from collections.abc import Iterable, Iterator
|
||||
|
||||
from ..run import docker_exec
|
||||
|
||||
CONTROL_DB = "postgres"
|
||||
_CLUSTER_PRECLEAN_SQL = os.path.join(os.path.dirname(__file__), "cluster_preclean.sql")
|
||||
_CREATE_ROLE = re.compile(rb'^CREATE ROLE "?([^";]+)"?;\s*$')
|
||||
|
||||
|
||||
def _psql(user: str) -> list[str]:
|
||||
"""The replay client: no --single-transaction, CREATE DATABASE forbids it."""
|
||||
return ["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", CONTROL_DB]
|
||||
|
||||
|
||||
def filter_own_role_creation(lines: Iterable[bytes], user: str) -> Iterator[bytes]:
|
||||
"""Drop the ``CREATE ROLE`` of the role holding this session.
|
||||
|
||||
A pg_dumpall stream recreates every role of the cluster, the bootstrap
|
||||
superuser included, and the pre-clean cannot drop the one it is connected
|
||||
as - so that single statement always collides. Its ``ALTER ROLE`` is kept:
|
||||
that is what re-applies the attributes and the password the dump captured.
|
||||
|
||||
Args:
|
||||
lines: dump lines including their trailing newlines.
|
||||
user: the connecting role.
|
||||
|
||||
Yields:
|
||||
Every line except that one CREATE.
|
||||
"""
|
||||
for line in lines:
|
||||
found = _CREATE_ROLE.match(line)
|
||||
if found and found.group(1).decode() == user:
|
||||
continue
|
||||
yield line
|
||||
|
||||
|
||||
def restore_cluster_sql(
|
||||
*,
|
||||
container: str,
|
||||
user: str,
|
||||
password: str,
|
||||
sql_path: str,
|
||||
empty: bool,
|
||||
) -> None:
|
||||
"""Replay a pg_dumpall stream into a running instance.
|
||||
|
||||
Args:
|
||||
container: the running engine to replay into.
|
||||
user: a superuser of that instance; the dump creates roles and
|
||||
databases, which an application role may not do.
|
||||
password: its password, handed to psql through the container's env.
|
||||
sql_path: the ``<instance>.cluster.backup.sql`` of a generation.
|
||||
empty: drop the cluster's databases and roles first. Without it the
|
||||
replay stops at the first object that already exists, which is the
|
||||
honest outcome: recreating a cluster over a populated one is a
|
||||
decision, not a default.
|
||||
"""
|
||||
if not os.path.isfile(sql_path):
|
||||
raise FileNotFoundError(sql_path)
|
||||
|
||||
docker_env = {"PGPASSWORD": password}
|
||||
|
||||
if empty:
|
||||
with open(_CLUSTER_PRECLEAN_SQL, encoding="utf-8") as preclean:
|
||||
drop_sql = preclean.read()
|
||||
docker_exec(
|
||||
container,
|
||||
_psql(user),
|
||||
stdin=drop_sql.encode(),
|
||||
docker_env=docker_env,
|
||||
)
|
||||
|
||||
with open(sql_path, "rb") as src, tempfile.TemporaryFile() as filtered:
|
||||
for line in filter_own_role_creation(src, user):
|
||||
filtered.write(line)
|
||||
filtered.seek(0)
|
||||
docker_exec(container, _psql(user), stdin=filtered, docker_env=docker_env)
|
||||
|
||||
print(f"PostgreSQL cluster restore complete from '{os.path.basename(sql_path)}'.")
|
||||
30
src/baudolo/restore/db/cluster_preclean.sql
Normal file
30
src/baudolo/restore/db/cluster_preclean.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- Pre-clean for `restore cluster --empty`. A pg_dumpall stream recreates roles
|
||||
-- and databases, so replaying it into a populated cluster dies on the first
|
||||
-- CREATE ROLE. Emitted as one DROP per row and run via \gexec so each executes
|
||||
-- as its own top-level statement: DROP DATABASE cannot run inside a
|
||||
-- transaction block, which rules out a single DO block.
|
||||
-- The phase column pins the order: databases must be gone before their owners
|
||||
-- can be dropped, and DROP OWNED BY releases what a role still holds in the
|
||||
-- control database. Template databases, the control database itself, the pg_*
|
||||
-- system roles and the connecting role are kept - the dump does not recreate
|
||||
-- them and dropping them would end the session.
|
||||
SELECT statement
|
||||
FROM (
|
||||
SELECT 1 AS phase,
|
||||
format('DROP DATABASE IF EXISTS %I', datname) AS statement
|
||||
FROM pg_database
|
||||
WHERE NOT datistemplate
|
||||
AND datname <> current_database()
|
||||
UNION ALL
|
||||
SELECT 2, format('DROP OWNED BY %I', rolname)
|
||||
FROM pg_roles
|
||||
WHERE NOT starts_with(rolname, 'pg_')
|
||||
AND rolname <> current_user
|
||||
UNION ALL
|
||||
SELECT 3, format('DROP ROLE IF EXISTS %I', rolname)
|
||||
FROM pg_roles
|
||||
WHERE NOT starts_with(rolname, 'pg_')
|
||||
AND rolname <> current_user
|
||||
) drops
|
||||
ORDER BY phase
|
||||
\gexec
|
||||
@@ -27,3 +27,7 @@ class BackupPaths:
|
||||
|
||||
def sql_file(self, db_name: str) -> str:
|
||||
return os.path.join(self.root(), "sql", f"{db_name}.backup.sql")
|
||||
|
||||
def cluster_file(self, instance: str) -> str:
|
||||
"""The pg_dumpall stream a `database = '*'` row produces."""
|
||||
return os.path.join(self.root(), "sql", f"{instance}.cluster.backup.sql")
|
||||
|
||||
Reference in New Issue
Block a user