mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-08-20 21:22:54 +00:00
feat(restore): refuse a dump the target engine cannot read
A restore with --empty destroys before it replays: the pre-clean drops the schema in one session and the dump goes in the next, with no rollback across the two. A dump the engine cannot parse therefore does not fail harmlessly, it leaves an emptied database behind. The version each side is on decides that up front, so the refusal lands before the first session opens. Both engines state their origin in the dump's own header and spell it differently. Postgres names the source server; MariaDB opens with mariadb-dump's own version and names the server further down, so matching the first number would read the tool on one engine and the server on the other. A pg_dumpall stream carries no version line of its own at all - the first belongs to the first database's embedded pg_dump output, arbitrarily far down - hence the deep scan. Restoring forward across a major version stays allowed; that is the upgrade path. Only backward is refused, with --no-version-check as the way out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,20 @@ def _add_common_backup_args(p: argparse.ArgumentParser) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _add_common_engine_args(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument("--container", required=True)
|
||||
p.add_argument("--db-password", required=True)
|
||||
p.add_argument("--empty", action="store_true")
|
||||
p.add_argument(
|
||||
"--no-version-check",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Replay even if the dump comes from a newer engine than the target. "
|
||||
"With --empty this can leave an emptied database behind."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="baudolo-restore",
|
||||
@@ -48,17 +62,15 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
p_pg = sub.add_parser("postgres", help="Restore a single PostgreSQL database dump")
|
||||
_add_common_backup_args(p_pg)
|
||||
p_pg.add_argument("--container", required=True)
|
||||
_add_common_engine_args(p_pg)
|
||||
p_pg.add_argument("--db-name", required=True)
|
||||
p_pg.add_argument("--db-user", default=None, help="Defaults to db-name if omitted")
|
||||
p_pg.add_argument("--db-password", required=True)
|
||||
p_pg.add_argument("--empty", action="store_true")
|
||||
|
||||
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)
|
||||
_add_common_engine_args(p_cluster)
|
||||
p_cluster.add_argument(
|
||||
"--instance",
|
||||
required=True,
|
||||
@@ -69,18 +81,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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")
|
||||
|
||||
p_mdb = sub.add_parser(
|
||||
"mariadb", help="Restore a single MariaDB/MySQL-compatible dump"
|
||||
)
|
||||
_add_common_backup_args(p_mdb)
|
||||
p_mdb.add_argument("--container", required=True)
|
||||
_add_common_engine_args(p_mdb)
|
||||
p_mdb.add_argument("--db-name", required=True)
|
||||
p_mdb.add_argument("--db-user", default=None, help="Defaults to db-name if omitted")
|
||||
p_mdb.add_argument("--db-password", required=True)
|
||||
p_mdb.add_argument("--empty", action="store_true")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
@@ -116,6 +124,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
backups_dir=args.backups_dir,
|
||||
).sql_file(args.db_name),
|
||||
empty=args.empty,
|
||||
check_version=not args.no_version_check,
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -132,6 +141,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
backups_dir=args.backups_dir,
|
||||
).cluster_file(args.instance),
|
||||
empty=args.empty,
|
||||
check_version=not args.no_version_check,
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -150,6 +160,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
backups_dir=args.backups_dir,
|
||||
).sql_file(args.db_name),
|
||||
empty=args.empty,
|
||||
check_version=not args.no_version_check,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import tempfile
|
||||
from collections.abc import Iterable, Iterator
|
||||
|
||||
from ..run import docker_exec
|
||||
from .version import guard
|
||||
|
||||
CONTROL_DB = "postgres"
|
||||
_CLUSTER_PRECLEAN_SQL = os.path.join(os.path.dirname(__file__), "cluster_preclean.sql")
|
||||
@@ -65,6 +66,7 @@ def restore_cluster_sql(
|
||||
password: str,
|
||||
sql_path: str,
|
||||
empty: bool,
|
||||
check_version: bool = True,
|
||||
) -> None:
|
||||
"""Replay a pg_dumpall stream into a running instance.
|
||||
|
||||
@@ -78,10 +80,21 @@ def restore_cluster_sql(
|
||||
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 os.path.isfile(sql_path):
|
||||
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:
|
||||
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
import sys
|
||||
|
||||
from ..run import docker_exec, docker_exec_sh
|
||||
from .version import guard
|
||||
|
||||
|
||||
def _pick_client(container: str) -> str:
|
||||
@@ -37,12 +38,23 @@ def restore_mariadb_sql(
|
||||
password: str,
|
||||
sql_path: str,
|
||||
empty: bool,
|
||||
check_version: bool = True,
|
||||
) -> None:
|
||||
client = _pick_client(container)
|
||||
|
||||
if not os.path.isfile(sql_path):
|
||||
raise FileNotFoundError(sql_path)
|
||||
|
||||
if check_version:
|
||||
guard(
|
||||
sql_path=sql_path,
|
||||
engine="mariadb",
|
||||
container=container,
|
||||
user=user,
|
||||
password=password,
|
||||
client=client,
|
||||
)
|
||||
|
||||
if empty:
|
||||
# Do not hardcode 'mysql': MariaDB 11 images may not ship that binary.
|
||||
result = docker_exec(
|
||||
|
||||
@@ -5,6 +5,7 @@ import tempfile
|
||||
from collections.abc import Iterable, Iterator
|
||||
|
||||
from ..run import docker_exec
|
||||
from .version import guard
|
||||
|
||||
_SUPERUSER_ONLY_PREFIXES = (b"COMMENT ON EXTENSION", b"ALTER DEFAULT PRIVILEGES")
|
||||
_EMPTY_PRECLEAN_SQL = os.path.join(os.path.dirname(__file__), "empty_preclean.sql")
|
||||
@@ -46,10 +47,20 @@ def restore_postgres_sql(
|
||||
password: str,
|
||||
sql_path: str,
|
||||
empty: bool,
|
||||
check_version: bool = True,
|
||||
) -> None:
|
||||
if not os.path.isfile(sql_path):
|
||||
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:
|
||||
|
||||
146
src/baudolo/restore/db/version.py
Normal file
146
src/baudolo/restore/db/version.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Refuse a dump the target engine is too old to read.
|
||||
|
||||
A restore with ``--empty`` destroys before it replays: the pre-clean drops the
|
||||
schema in one session and the dump goes in the next, with no rollback across
|
||||
the two. A dump the engine cannot parse therefore does not fail harmlessly -
|
||||
it leaves an emptied database behind. Comparing the two versions first turns
|
||||
that into a refusal.
|
||||
|
||||
Both engines state their origin in the dump's own header, and they do not
|
||||
state it the same way. Postgres writes ``-- Dumped from database version``
|
||||
around line seven. MariaDB opens line two with ``-- MariaDB dump 10.19-11.8.8``,
|
||||
where the first number is mariadb-dump's own version, and names the server only
|
||||
further down on the tab-separated ``-- Server version`` line. Matching the first
|
||||
number in the header would read the tool on one engine and the server on the
|
||||
other, so each engine gets its own pattern.
|
||||
|
||||
A ``pg_dumpall`` cluster dump has no version line of its own: its header opens
|
||||
with the cluster banner and the roles section, and the first
|
||||
``-- Dumped from database version`` belongs to the first database's embedded
|
||||
``pg_dump`` output, arbitrarily far down. Hence the scan runs to
|
||||
``SCAN_LINES`` rather than to a header-sized handful.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from ..run import docker_exec, stdout_of
|
||||
|
||||
SCAN_LINES = 2000
|
||||
DUMP_VERSION = {
|
||||
"postgres": re.compile(r"^-- Dumped from database version (\S+)"),
|
||||
"mariadb": re.compile(r"^-- Server version\s+(\S+)"),
|
||||
}
|
||||
|
||||
|
||||
class VersionMismatch(Exception):
|
||||
"""The dump cannot be replayed into this engine."""
|
||||
|
||||
|
||||
def major_of(version: str) -> int:
|
||||
"""The major number of an engine version string.
|
||||
|
||||
Args:
|
||||
version: as the engine spells it, e.g. ``17.11`` or
|
||||
``11.8.8-MariaDB-ubu2404``.
|
||||
|
||||
Raises:
|
||||
VersionMismatch: the string does not start with a number.
|
||||
"""
|
||||
leading = re.match(r"(\d+)", version)
|
||||
if not leading:
|
||||
raise VersionMismatch(f"cannot read a major version from '{version}'")
|
||||
return int(leading.group(1))
|
||||
|
||||
|
||||
def dump_version(sql_path: str, engine: str) -> str:
|
||||
"""Read the engine version a dump was taken from, out of its own header.
|
||||
|
||||
Args:
|
||||
sql_path: the dump to read.
|
||||
engine: ``postgres`` or ``mariadb``.
|
||||
|
||||
Returns:
|
||||
The version string as the dump spells it.
|
||||
|
||||
Raises:
|
||||
VersionMismatch: no version line within the first ``SCAN_LINES``.
|
||||
"""
|
||||
pattern = DUMP_VERSION[engine]
|
||||
with open(sql_path, encoding="utf-8", errors="replace") as handle:
|
||||
for _ in range(SCAN_LINES):
|
||||
line = handle.readline()
|
||||
if not line:
|
||||
break
|
||||
found = pattern.search(line)
|
||||
if found:
|
||||
return found.group(1)
|
||||
raise VersionMismatch(
|
||||
f"{sql_path} carries no {engine} version header in its first {SCAN_LINES} lines"
|
||||
)
|
||||
|
||||
|
||||
def server_version(
|
||||
container: str, engine: str, user: str, password: str, client: str = ""
|
||||
) -> str:
|
||||
"""Ask the running engine which version it is."""
|
||||
if engine == "postgres":
|
||||
return stdout_of(
|
||||
docker_exec(
|
||||
container,
|
||||
["psql", "-U", user, "-tAc", "SHOW server_version"],
|
||||
capture=True,
|
||||
docker_env={"PGPASSWORD": password},
|
||||
)
|
||||
)
|
||||
return stdout_of(
|
||||
docker_exec(
|
||||
container,
|
||||
[
|
||||
client or "mariadb",
|
||||
"-u",
|
||||
user,
|
||||
f"--password={password}",
|
||||
"-N",
|
||||
"-B",
|
||||
"-e",
|
||||
"SELECT VERSION()",
|
||||
],
|
||||
capture=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def assert_replayable(sql_path: str, engine: str, dumped: str, serving: str) -> None:
|
||||
"""Refuse a dump from a newer major version than the target engine.
|
||||
|
||||
Restoring forward across a major version is the upgrade path and stays
|
||||
allowed; backward is refused, because a newer dump uses syntax an older
|
||||
server rejects and the pre-clean would already have dropped the schema.
|
||||
|
||||
Raises:
|
||||
VersionMismatch: the dump is newer than the engine.
|
||||
"""
|
||||
if major_of(dumped) > major_of(serving):
|
||||
raise VersionMismatch(
|
||||
f"{sql_path} came from {engine} {dumped} but {serving} is running; "
|
||||
"a newer dump does not replay into an older engine, and --empty "
|
||||
"would drop the schema before finding out"
|
||||
)
|
||||
|
||||
|
||||
def guard(
|
||||
*,
|
||||
sql_path: str,
|
||||
engine: str,
|
||||
container: str,
|
||||
user: str,
|
||||
password: str,
|
||||
client: str = "",
|
||||
) -> None:
|
||||
"""Compare the dump's origin against the running engine before replaying."""
|
||||
dumped = dump_version(sql_path, engine)
|
||||
serving = server_version(container, engine, user, password, client)
|
||||
assert_replayable(sql_path, engine, dumped, serving)
|
||||
print(f"OK: dump is from {engine} {dumped}, {serving} is serving.")
|
||||
@@ -40,6 +40,12 @@ def run(
|
||||
raise
|
||||
|
||||
|
||||
def stdout_of(completed: subprocess.CompletedProcess) -> str:
|
||||
"""The captured stdout as stripped text, whether it came back bytes or str."""
|
||||
raw = completed.stdout or b""
|
||||
return (raw.decode() if isinstance(raw, bytes) else raw).strip()
|
||||
|
||||
|
||||
def docker_exec(
|
||||
container: str,
|
||||
argv: list[str],
|
||||
|
||||
Reference in New Issue
Block a user