mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-08-24 14:54:32 +00:00
The package carried no ruff configuration at all, so it ran on the defaults (E4+E7+E9+F) while infinito-nexus-core, its only consumer, holds itself to a far wider selection. Measured against that selection this tree had 224 findings. It now has none. The selector list is core's verbatim so both repositories answer to one bar. target-version stays py39 rather than core's py311, because requires-python still declares >=3.9 and pyupgrade would otherwise propose syntax the declared minimum cannot run. Every ignore carries its reason: S603/S607 in particular, since running docker and dump binaries from PATH in list form is this tool's whole job and is already injection-safe. Two conversions are judgement rather than mechanics. os.path.join(dir, '') was the rsync idiom for a trailing separator, which Path drops, so it becomes an explicit os.sep. os.path.abspath stays where Path.resolve() would follow symlinks and let a symlinked volume test as inside the snapshot subject. BREAKING CHANGE: VersionMismatch is renamed VersionMismatchError. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
253 lines
8.8 KiB
Python
253 lines
8.8 KiB
Python
"""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 re
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from baudolo.restore.run import docker_exec
|
|
|
|
from .version import guard
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable, Iterator
|
|
|
|
CONTROL_DB = "postgres"
|
|
_CLUSTER_PRECLEAN_SQL = Path(__file__).parent / "cluster_preclean.sql"
|
|
_CREATE_ROLE = re.compile(rb'^CREATE ROLE "?([^";]+)"?;\s*$')
|
|
_CREATE_DATABASE = re.compile(rb"^CREATE DATABASE\s+(.*)$")
|
|
_CREATE_ROLE_LINE = re.compile(rb"^CREATE ROLE\s+(.*)$")
|
|
_CONNECT = re.compile(rb"^\\connect\s+(.*)$")
|
|
_NO_ROWS = "SELECT ''::text WHERE false"
|
|
|
|
|
|
def _first_identifier(rest: str) -> str | None:
|
|
"""The first SQL identifier in *rest*, quoted or bare.
|
|
|
|
A quoted identifier may hold spaces and doubled quotes, so it cannot be
|
|
read with a character class that stops at whitespace - which is how a
|
|
database called ``odd name`` used to leave the inventory as ``odd``.
|
|
"""
|
|
text = rest.strip()
|
|
if not text:
|
|
return None
|
|
if text.startswith('"'):
|
|
out = []
|
|
index = 1
|
|
while index < len(text):
|
|
char = text[index]
|
|
if char == '"':
|
|
if index + 1 < len(text) and text[index + 1] == '"':
|
|
out.append('"')
|
|
index += 2
|
|
continue
|
|
return "".join(out)
|
|
out.append(char)
|
|
index += 1
|
|
return None
|
|
return re.split(r"[\s;(]", text, maxsplit=1)[0] or None
|
|
|
|
|
|
def _connect_target(rest: str) -> str | None:
|
|
"""The database a ``\\connect`` line switches to.
|
|
|
|
psql options precede the name (``\\connect -reuse-previous=on dbname=x``),
|
|
and the name may arrive as a ``dbname=`` assignment rather than bare.
|
|
"""
|
|
for token in rest.strip().split():
|
|
if token.startswith("-"):
|
|
continue
|
|
if token.startswith("dbname="):
|
|
return _first_identifier(token[len("dbname=") :])
|
|
return _first_identifier(rest.strip()[rest.strip().index(token) :])
|
|
return None
|
|
|
|
|
|
def dump_inventory(sql_path: str) -> tuple[list[str], list[str]]:
|
|
"""The databases and roles a cluster dump recreates.
|
|
|
|
Args:
|
|
sql_path: the ``pg_dumpall`` stream.
|
|
|
|
Returns:
|
|
``(databases, roles)``, each in the order the dump names them. The
|
|
pre-clean is scoped to these: everything else in the instance belongs
|
|
to no backup this restore holds, and dropping it would destroy data
|
|
the replay cannot bring back.
|
|
"""
|
|
databases: list[str] = []
|
|
roles: list[str] = []
|
|
with Path(sql_path).open("rb") as handle:
|
|
for raw in handle:
|
|
line = raw.decode("utf-8", "replace")
|
|
for pattern, sink, read in (
|
|
(_CREATE_DATABASE, databases, _first_identifier),
|
|
(_CONNECT, databases, _connect_target),
|
|
(_CREATE_ROLE_LINE, roles, _first_identifier),
|
|
):
|
|
found = pattern.match(raw)
|
|
if not found:
|
|
continue
|
|
name = read(line[found.start(1) :])
|
|
if name and name not in sink:
|
|
sink.append(name)
|
|
return databases, roles
|
|
|
|
|
|
def preclean_sql() -> str:
|
|
"""The catalog-wide pre-clean, safe only behind the instance check."""
|
|
with _CLUSTER_PRECLEAN_SQL.open(encoding="utf-8") as preclean:
|
|
return preclean.read()
|
|
|
|
|
|
def instance_databases(container: str, user: str, docker_env: dict) -> list[str]:
|
|
"""The instance's own databases, templates and control database aside."""
|
|
listed = docker_exec(
|
|
container,
|
|
[
|
|
"psql",
|
|
"-U",
|
|
user,
|
|
"-d",
|
|
CONTROL_DB,
|
|
"-tAc",
|
|
(
|
|
"SELECT datname FROM pg_database "
|
|
"WHERE NOT datistemplate AND datname <> current_database()"
|
|
),
|
|
],
|
|
capture=True,
|
|
docker_env=docker_env,
|
|
).stdout
|
|
text = listed.decode() if isinstance(listed, bytes) else listed
|
|
return [name for name in text.split() if name]
|
|
|
|
|
|
def assert_instance_matches_dump(
|
|
container: str, user: str, sql_path: str, docker_env: dict
|
|
) -> None:
|
|
"""Refuse ``--empty`` on an instance holding anything the dump lacks.
|
|
|
|
The pre-clean is a catalog-wide sweep, so a foreign database would be
|
|
destroyed with no way back. Scoping the sweep instead is not a fix: a
|
|
surviving database that owns or grants to one of the dump's roles pins
|
|
that role in pg_shdepend, and DROP ROLE then fails after the dump's own
|
|
databases are already gone.
|
|
|
|
Raises:
|
|
RuntimeError: the instance carries databases this dump cannot restore.
|
|
"""
|
|
dumped, _roles = dump_inventory(sql_path)
|
|
present = instance_databases(container, user, docker_env)
|
|
foreign = sorted(set(present) - set(dumped))
|
|
if foreign:
|
|
raise RuntimeError(
|
|
f"{container} also holds {', '.join(foreign)}, which "
|
|
f"{Path(sql_path).name} does not carry. --empty wipes the "
|
|
"instance, so those would be destroyed with nothing to restore "
|
|
"them from. Move them off this instance, or drop them yourself if "
|
|
"they are disposable."
|
|
)
|
|
|
|
|
|
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,
|
|
check_version: bool = True,
|
|
) -> 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.
|
|
check_version: refuse a dump from a newer major version than the
|
|
running engine before anything is dropped.
|
|
"""
|
|
if not Path(sql_path).is_file():
|
|
raise FileNotFoundError(sql_path)
|
|
|
|
if check_version:
|
|
guard(
|
|
sql_path=sql_path,
|
|
engine="postgres",
|
|
container=container,
|
|
user=user,
|
|
password=password,
|
|
)
|
|
|
|
docker_env = {"PGPASSWORD": password}
|
|
|
|
if empty:
|
|
assert_instance_matches_dump(container, user, sql_path, docker_env)
|
|
docker_exec(
|
|
container,
|
|
_psql(user),
|
|
stdin=preclean_sql().encode(),
|
|
docker_env=docker_env,
|
|
)
|
|
|
|
with Path(sql_path).open("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 '{Path(sql_path).name}'.")
|