feat(backup): write database dumps gzip-compressed

Dumps stream through gzip into <db>.backup.sql.gz; no uncompressed copy touches the disk. Restore reads gzip and plain dumps by their magic bytes, verifies a gzip dump to its end before anything is dropped, and still finds the uncompressed names of older generations. The manifest names the compression.

🎯 Fix: 92 %
💥 Break: 20 %
💸 Cost: –
⏱️ Time: –
🛡️ Security: ~100 %
🏆 Score: 74
🚦 Gate: ⛔ break 20 % > 5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-05 18:37:04 +00:00
1 parent 328520169b
commit b30344d494
34 files changed
+833 -110

No files matched your search

+5 -1
View File
@@ -47,9 +47,13 @@ Backups are stored in a deterministic, fully nested structure:
└── <volume-name>/ └── <volume-name>/
├── files/ ├── files/
└── sql/ └── sql/
└── <database>.backup.sql └── <database>.backup.sql.gz
``` ```
Dumps are gzip-compressed while they are written; no uncompressed copy
touches the disk. Generations written before compression hold
`<database>.backup.sql`; `baudolo-restore` reads both.
### Meaning of each level ### Meaning of each level
* `<machine-hash>` * `<machine-hash>`
+4 -4
View File
@@ -9,7 +9,7 @@ from baudolo.databases import CLUSTER_ROW, validate_database
from baudolo.generation import CLUSTER_SUFFIX, DUMP_SUFFIX, SQL_DIR from baudolo.generation import CLUSTER_SUFFIX, DUMP_SUFFIX, SQL_DIR
from .docker import docker_exec_argv from .docker import docker_exec_argv
from .shell import BackupError, execute_to_file from .shell import BackupError, execute_to_gzip
if TYPE_CHECKING: if TYPE_CHECKING:
import pandas as pd import pandas as pd
@@ -53,7 +53,7 @@ def fallback_pg_dumpall(
""" """
Perform a full Postgres cluster dump using pg_dumpall. Perform a full Postgres cluster dump using pg_dumpall.
""" """
execute_to_file( execute_to_gzip(
docker_exec_argv( docker_exec_argv(
container, container,
["pg_dumpall", "-U", username, "-h", "localhost"], ["pg_dumpall", "-U", username, "-h", "localhost"],
@@ -122,7 +122,7 @@ def backup_database(
if db_type == "mariadb": if db_type == "mariadb":
# Force TCP so auth matches '<user>'@'%' instead of socket -> 'localhost'. # Force TCP so auth matches '<user>'@'%' instead of socket -> 'localhost'.
execute_to_file( execute_to_gzip(
docker_exec_argv( docker_exec_argv(
container, container,
[ [
@@ -144,7 +144,7 @@ def backup_database(
if db_type == "postgres": if db_type == "postgres":
try: try:
execute_to_file( execute_to_gzip(
docker_exec_argv( docker_exec_argv(
container, container,
[ [
+65 -9
View File
@@ -7,14 +7,22 @@ string handed to ``shell=True`` allowed.
from __future__ import annotations from __future__ import annotations
import gzip
import os import os
import subprocess import subprocess
import tempfile
import zlib
from functools import partial
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
GZIP_LEVEL = 6
READ_BYTES = 1 << 20
SYNC_BYTES = 1 << 16
class BackupError(Exception): class BackupError(Exception):
"""Generic exception for backup errors.""" """Generic exception for backup errors."""
@@ -53,23 +61,71 @@ def execute_shell_command(
return [line.decode("utf-8") for line in out.splitlines()] return [line.decode("utf-8") for line in out.splitlines()]
def execute_to_file( def is_sync_point(piece: bytes) -> bool:
"""
Args:
piece: a line of the dump, or a READ_BYTES slice of a longer one.
Returns:
True when a full flush follows *piece*, decided by its bytes alone, on
average once per SYNC_BYTES of input.
"""
return zlib.crc32(piece) % max(1, SYNC_BYTES // len(piece)) == 0
def _abort(process: subprocess.Popen, tmp: Path) -> None:
process.kill()
process.wait()
tmp.unlink(missing_ok=True)
def execute_to_gzip(
command: Sequence[str], out_file: str, *, env: Mapping[str, str] | None = None command: Sequence[str], out_file: str, *, env: Mapping[str, str] | None = None
) -> None: ) -> None:
"""Run *command*, writing its stdout to *out_file* only once it succeeded. """
Args:
command: argv, the program first; its stdout is the dump.
out_file: the final gzip path. The stream is written to
``<out_file>.tmp`` and renamed only after the command exited 0.
env: variables added to the child's environment.
The output goes to a sibling temporary file first, so a partial or empty Raises:
stream from a failing dump never takes the place of a valid backup. BackupError: the command failed, or the file could not be written.
""" """
command = list(command) command = list(command)
print(" ".join(command), flush=True) print(" ".join(command), flush=True)
tmp = Path(f"{out_file}.tmp") tmp = Path(f"{out_file}.tmp")
with tmp.open("wb") as handle: with tempfile.TemporaryFile() as err_sink:
process = subprocess.Popen( process = subprocess.Popen(
command, stdout=handle, stderr=subprocess.PIPE, env=_child_env(env) command, stdout=subprocess.PIPE, stderr=err_sink, env=_child_env(env)
) )
_, err = process.communicate() try:
if process.returncode != 0: with (
tmp.open("wb") as raw,
gzip.GzipFile(
filename="",
mode="wb",
fileobj=raw,
compresslevel=GZIP_LEVEL,
mtime=0,
) as packed,
):
for piece in iter(partial(process.stdout.readline, READ_BYTES), b""):
packed.write(piece)
if is_sync_point(piece):
packed.flush(zlib.Z_FULL_FLUSH)
except OSError as error:
_abort(process, tmp)
raise BackupError(f"Error writing {out_file}: {error}") from error
except BaseException:
_abort(process, tmp)
raise
finally:
process.stdout.close()
returncode = process.wait()
err_sink.seek(0)
err = err_sink.read()
if returncode != 0:
tmp.unlink() tmp.unlink()
_fail(command, process.returncode, b"", err) _fail(command, returncode, b"", err)
tmp.replace(out_file) tmp.replace(out_file)
+7 -2
View File
@@ -18,8 +18,12 @@ from __future__ import annotations
FILES_DIR = "files" FILES_DIR = "files"
SQL_DIR = "sql" SQL_DIR = "sql"
DUMP_SUFFIX = ".backup.sql" LEGACY_DUMP_SUFFIX = ".backup.sql"
CLUSTER_SUFFIX = ".cluster.backup.sql" LEGACY_CLUSTER_SUFFIX = ".cluster.backup.sql"
COMPRESSED_SUFFIX = ".gz"
DUMP_COMPRESSION = "gzip"
DUMP_SUFFIX = LEGACY_DUMP_SUFFIX + COMPRESSED_SUFFIX
CLUSTER_SUFFIX = LEGACY_CLUSTER_SUFFIX + COMPRESSED_SUFFIX
MANIFEST_FILE = "manifest.json" MANIFEST_FILE = "manifest.json"
MANIFEST_SCHEMA = 1 MANIFEST_SCHEMA = 1
@@ -42,6 +46,7 @@ def manifest_document(volumes: dict[str, object]) -> dict[str, object]:
"sql_dir": SQL_DIR, "sql_dir": SQL_DIR,
"dump_suffix": DUMP_SUFFIX, "dump_suffix": DUMP_SUFFIX,
"cluster_suffix": CLUSTER_SUFFIX, "cluster_suffix": CLUSTER_SUFFIX,
"dump_compression": DUMP_COMPRESSION,
}, },
"volumes": { "volumes": {
name: { name: {
+29 -22
View File
@@ -6,6 +6,7 @@ import sys
from .db.cluster import restore_cluster_sql from .db.cluster import restore_cluster_sql
from .db.mariadb import restore_mariadb_sql from .db.mariadb import restore_mariadb_sql
from .db.postgres import restore_postgres_sql from .db.postgres import restore_postgres_sql
from .dumpfile import locate
from .files import restore_volume_files from .files import restore_volume_files
from .paths import BackupPaths from .paths import BackupPaths
@@ -106,7 +107,7 @@ def main(argv: list[str] | None = None) -> int:
p_cluster.add_argument( p_cluster.add_argument(
"--instance", "--instance",
required=True, required=True,
help="Instance the dump was taken from; names <instance>.cluster.backup.sql", help="Instance the dump was taken from; names <instance>.cluster.backup.sql.gz",
) )
p_cluster.add_argument( p_cluster.add_argument(
"--db-user", "--db-user",
@@ -149,13 +150,15 @@ def main(argv: list[str] | None = None) -> int:
db_name=args.db_name, db_name=args.db_name,
user=user, user=user,
password=password, password=password,
sql_path=BackupPaths( sql_path=locate(
args.volume_name, BackupPaths(
args.backup_hash, args.volume_name,
args.version, args.backup_hash,
repo_name=args.repo_name, args.version,
backups_dir=args.backups_dir, repo_name=args.repo_name,
).sql_file(args.db_name), backups_dir=args.backups_dir,
).sql_file(args.db_name)
),
empty=args.empty, empty=args.empty,
check_version=not args.no_version_check, check_version=not args.no_version_check,
) )
@@ -166,13 +169,15 @@ def main(argv: list[str] | None = None) -> int:
container=args.container, container=args.container,
user=args.db_user, user=args.db_user,
password=password, password=password,
sql_path=BackupPaths( sql_path=locate(
args.volume_name, BackupPaths(
args.backup_hash, args.volume_name,
args.version, args.backup_hash,
repo_name=args.repo_name, args.version,
backups_dir=args.backups_dir, repo_name=args.repo_name,
).cluster_file(args.instance), backups_dir=args.backups_dir,
).cluster_file(args.instance)
),
empty=args.empty, empty=args.empty,
check_version=not args.no_version_check, check_version=not args.no_version_check,
) )
@@ -185,13 +190,15 @@ def main(argv: list[str] | None = None) -> int:
db_name=args.db_name, db_name=args.db_name,
user=user, user=user,
password=password, password=password,
sql_path=BackupPaths( sql_path=locate(
args.volume_name, BackupPaths(
args.backup_hash, args.volume_name,
args.version, args.backup_hash,
repo_name=args.repo_name, args.version,
backups_dir=args.backups_dir, repo_name=args.repo_name,
).sql_file(args.db_name), backups_dir=args.backups_dir,
).sql_file(args.db_name)
),
empty=args.empty, empty=args.empty,
check_version=not args.no_version_check, check_version=not args.no_version_check,
) )
+5 -3
View File
@@ -24,6 +24,7 @@ import tempfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from baudolo.restore.dumpfile import open_dump
from baudolo.restore.run import docker_exec from baudolo.restore.run import docker_exec
from .version import guard from .version import guard
@@ -96,7 +97,7 @@ def dump_inventory(sql_path: str) -> tuple[list[str], list[str]]:
""" """
databases: list[str] = [] databases: list[str] = []
roles: list[str] = [] roles: list[str] = []
with Path(sql_path).open("rb") as handle: with open_dump(sql_path) as handle:
for raw in handle: for raw in handle:
line = raw.decode("utf-8", "replace") line = raw.decode("utf-8", "replace")
for pattern, sink, read in ( for pattern, sink, read in (
@@ -212,7 +213,8 @@ def restore_cluster_sql(
user: a superuser of that instance; the dump creates roles and user: a superuser of that instance; the dump creates roles and
databases, which an application role may not do. databases, which an application role may not do.
password: its password, handed to psql through the container's env. password: its password, handed to psql through the container's env.
sql_path: the ``<instance>.cluster.backup.sql`` of a generation. sql_path: the ``<instance>.cluster.backup.sql.gz`` of a generation, or
the uncompressed ``<instance>.cluster.backup.sql`` of an older one.
empty: drop the cluster's databases and roles first. Without it the empty: drop the cluster's databases and roles first. Without it the
replay stops at the first object that already exists, which is the replay stops at the first object that already exists, which is the
honest outcome: recreating a cluster over a populated one is a honest outcome: recreating a cluster over a populated one is a
@@ -243,7 +245,7 @@ def restore_cluster_sql(
docker_env=docker_env, docker_env=docker_env,
) )
with Path(sql_path).open("rb") as src, tempfile.TemporaryFile() as filtered: with open_dump(sql_path) as src, tempfile.TemporaryFile() as filtered:
for line in filter_own_role_creation(src, user): for line in filter_own_role_creation(src, user):
filtered.write(line) filtered.write(line)
filtered.seek(0) filtered.seek(0)
+9 -4
View File
@@ -3,7 +3,8 @@ from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
from baudolo.restore.run import docker_exec, docker_exec_sh from baudolo.restore.dumpfile import open_dump, verify
from baudolo.restore.run import docker_exec, docker_exec_sh, docker_exec_stream
from .version import guard from .version import guard
@@ -47,6 +48,7 @@ def restore_mariadb_sql(
if not Path(sql_path).is_file(): if not Path(sql_path).is_file():
raise FileNotFoundError(sql_path) raise FileNotFoundError(sql_path)
verify(sql_path)
docker_env = {"MYSQL_PWD": password} docker_env = {"MYSQL_PWD": password}
if check_version: if check_version:
@@ -98,9 +100,12 @@ def restore_mariadb_sql(
docker_env=docker_env, docker_env=docker_env,
) )
with Path(sql_path).open("rb") as f: with open_dump(sql_path) as source:
docker_exec( docker_exec_stream(
container, [client, "-u", user, db_name], stdin=f, docker_env=docker_env container,
[client, "-u", user, db_name],
source=source,
docker_env=docker_env,
) )
print(f"MariaDB/MySQL restore complete for db '{db_name}'.") print(f"MariaDB/MySQL restore complete for db '{db_name}'.")
+11 -11
View File
@@ -4,6 +4,7 @@ import tempfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from baudolo.restore.dumpfile import open_dump
from baudolo.restore.run import docker_exec from baudolo.restore.run import docker_exec
from .version import guard from .version import guard
@@ -67,23 +68,22 @@ def restore_postgres_sql(
docker_env = {"PGPASSWORD": password} docker_env = {"PGPASSWORD": password}
if empty:
with _EMPTY_PRECLEAN_SQL.open(encoding="utf-8") as preclean:
drop_sql = preclean.read()
docker_exec(
container,
["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", db_name],
stdin=drop_sql.encode(),
docker_env=docker_env,
)
# Filter into a spooled temp file instead of building the whole dump in # Filter into a spooled temp file instead of building the whole dump in
# memory: production dumps reach many GB and the previous read/splitlines/ # memory: production dumps reach many GB and the previous read/splitlines/
# join needed roughly three times the dump size in RSS. # join needed roughly three times the dump size in RSS.
with Path(sql_path).open("rb") as src, tempfile.TemporaryFile() as filtered: with open_dump(sql_path) as src, tempfile.TemporaryFile() as filtered:
for line in filter_superuser_only_lines(src): for line in filter_superuser_only_lines(src):
filtered.write(line) filtered.write(line)
filtered.seek(0) filtered.seek(0)
if empty:
with _EMPTY_PRECLEAN_SQL.open(encoding="utf-8") as preclean:
drop_sql = preclean.read()
docker_exec(
container,
["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", db_name],
stdin=drop_sql.encode(),
docker_env=docker_env,
)
docker_exec( docker_exec(
container, container,
[ [
+5 -2
View File
@@ -23,9 +23,10 @@ with the cluster banner and the roles section, and the first
from __future__ import annotations from __future__ import annotations
import io
import re import re
from pathlib import Path
from baudolo.restore.dumpfile import open_dump
from baudolo.restore.run import docker_exec, stdout_of from baudolo.restore.run import docker_exec, stdout_of
SCAN_LINES = 2000 SCAN_LINES = 2000
@@ -69,7 +70,9 @@ def dump_version(sql_path: str, engine: str) -> str:
VersionMismatchError: no version line within the first ``SCAN_LINES``. VersionMismatchError: no version line within the first ``SCAN_LINES``.
""" """
pattern = DUMP_VERSION[engine] pattern = DUMP_VERSION[engine]
with Path(sql_path).open(encoding="utf-8", errors="replace") as handle: with io.TextIOWrapper(
open_dump(sql_path), encoding="utf-8", errors="replace"
) as handle:
for _ in range(SCAN_LINES): for _ in range(SCAN_LINES):
line = handle.readline() line = handle.readline()
if not line: if not line:
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import gzip
from pathlib import Path
from typing import BinaryIO
from baudolo.generation import COMPRESSED_SUFFIX
GZIP_MAGIC = b"\x1f\x8b"
VERIFY_BYTES = 1 << 20
def is_gzip(path: str) -> bool:
"""
Args:
path: a dump file.
Returns:
True when its first bytes are the gzip magic, whatever its name says.
"""
with Path(path).open("rb") as handle:
return handle.read(len(GZIP_MAGIC)) == GZIP_MAGIC
def open_dump(path: str) -> BinaryIO:
"""
Args:
path: a dump, gzip-compressed or plain.
Returns:
A binary reader yielding the SQL text either way.
"""
if is_gzip(path):
return gzip.open(path, "rb")
return Path(path).open("rb")
def verify(path: str) -> None:
"""
Args:
path: a dump; a gzip one is decompressed to its end, a plain one is
left alone.
Raises:
EOFError: the gzip stream is truncated.
gzip.BadGzipFile: its header, CRC or length is wrong.
zlib.error: its deflate data is corrupt.
"""
if not is_gzip(path):
return
with gzip.open(path, "rb") as handle:
while handle.read(VERIFY_BYTES):
pass
def locate(path: str) -> str:
"""
Args:
path: where this version writes the dump, ending in COMPRESSED_SUFFIX.
Returns:
*path*, or the uncompressed name earlier versions wrote, whichever
exists.
Raises:
FileNotFoundError: neither exists.
FileExistsError: both exist.
"""
legacy = path.removesuffix(COMPRESSED_SUFFIX)
found = [
candidate
for candidate in dict.fromkeys((path, legacy))
if Path(candidate).is_file()
]
if len(found) > 1:
raise FileExistsError(
f"both {path} and {legacy} exist; remove the one that must not be replayed"
)
if not found:
raise FileNotFoundError(f"neither {path} nor {legacy} exists")
return found[0]
+42
View File
@@ -1,8 +1,13 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import os import os
import shutil
import subprocess import subprocess
import sys import sys
from typing import BinaryIO
STREAM_BYTES = 1 << 20
def run( def run(
@@ -96,6 +101,43 @@ def docker_exec(
) )
def docker_exec_stream(
container: str,
argv: list[str],
*,
source: BinaryIO,
docker_env: dict[str, str] | None = None,
) -> None:
"""
Args:
container: the container to run in.
argv: the command, fed *source* on stdin.
source: a binary reader, copied in STREAM_BYTES chunks, so a reader
without a file descriptor of its own, such as a gzip one, works.
docker_env: as for :func:`docker_exec`.
Raises:
subprocess.CalledProcessError: the command exited non-zero.
"""
cmd = docker_exec_cmd(container, argv, docker_env)
process = subprocess.Popen(
cmd, stdin=subprocess.PIPE, env=child_env(None, docker_env)
)
try:
with contextlib.suppress(BrokenPipeError):
shutil.copyfileobj(source, process.stdin, STREAM_BYTES)
with contextlib.suppress(BrokenPipeError):
process.stdin.close()
except BaseException:
process.kill()
process.wait()
raise
returncode = process.wait()
if returncode != 0:
print(f"ERROR: command failed ({returncode}): {' '.join(cmd)}", file=sys.stderr)
raise subprocess.CalledProcessError(returncode, cmd)
def docker_exec_sh( def docker_exec_sh(
container: str, container: str,
script: str, script: str,
+55
View File
@@ -2,10 +2,14 @@
from __future__ import annotations from __future__ import annotations
import gzip
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from baudolo.generation import DUMP_SUFFIX, LEGACY_DUMP_SUFFIX
from baudolo.restore.dumpfile import open_dump
from .process import machine_hash, run from .process import machine_hash, run
# postgres 18+ mounts at /var/lib/postgresql, not /var/lib/postgresql/data. # postgres 18+ mounts at /var/lib/postgresql, not /var/lib/postgresql/data.
@@ -112,3 +116,54 @@ def ensure_empty_dir(path: str) -> None:
if p.exists(): if p.exists():
shutil.rmtree(p) shutil.rmtree(p)
p.mkdir(parents=True, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
def dump_text(path: Path | str) -> str:
"""
Args:
path: a dump, gzip-compressed or plain.
Returns:
Its SQL text.
"""
with open_dump(str(path)) as handle:
return handle.read().decode("utf-8", errors="replace")
def write_dump_text(path: Path | str, text: str) -> None:
"""
Args:
path: a gzip dump to overwrite.
text: the SQL text it carries afterwards.
"""
with gzip.open(str(path), "wb") as handle:
handle.write(text.encode("utf-8"))
def dumps_in(sql_dir: Path) -> list[Path]:
"""
Args:
sql_dir: a volume's sql directory, which may be absent.
Returns:
Every dump in it, compressed or plain, single-database or cluster.
"""
if not sql_dir.is_dir():
return []
return sorted(
{*sql_dir.glob(f"*{DUMP_SUFFIX}"), *sql_dir.glob(f"*{LEGACY_DUMP_SUFFIX}")}
)
def assert_dumps_in_sees_both_suffixes(case, scratch: str) -> None:
"""
Args:
case: the running ``unittest.TestCase``.
scratch: an empty directory to place the probe dumps in.
"""
probe = Path(scratch) / "dumps-in-probe"
probe.mkdir(parents=True, exist_ok=True)
expected = [probe / f"a{DUMP_SUFFIX}", probe / f"b{LEGACY_DUMP_SUFFIX}"]
for path in expected:
path.write_bytes(b"")
case.assertEqual(dumps_in(probe), expected)
@@ -26,6 +26,7 @@ from .helpers import (
backup_run, backup_run,
cleanup_docker, cleanup_docker,
create_minimal_compose_dir, create_minimal_compose_dir,
dump_text,
ensure_empty_dir, ensure_empty_dir,
latest_version_dir, latest_version_dir,
require_docker, require_docker,
@@ -140,7 +141,7 @@ class TestE2EAppContainerShipsClientTools(unittest.TestCase):
def test_the_engine_volume_was_dumped(self) -> None: def test_the_engine_volume_was_dumped(self) -> None:
dump = self.volume_dir(self.engine_volume) / SQL_DIR / f"shopdb{DUMP_SUFFIX}" dump = self.volume_dir(self.engine_volume) / SQL_DIR / f"shopdb{DUMP_SUFFIX}"
self.assertTrue(dump.is_file(), f"expected a dump at {dump}") self.assertTrue(dump.is_file(), f"expected a dump at {dump}")
self.assertIn(PAYLOAD, dump.read_text(encoding="utf-8")) self.assertIn(PAYLOAD, dump_text(dump))
def test_the_application_volume_produced_no_dump(self) -> None: def test_the_application_volume_produced_no_dump(self) -> None:
"""The collision this module exists for: the application container """The collision this module exists for: the application container
+12 -4
View File
@@ -13,6 +13,8 @@ inside Alpine and takes the whole run with it.
import unittest import unittest
from baudolo.generation import DUMP_SUFFIX, SQL_DIR
from .helpers import ( from .helpers import (
POSTGRES_DATA_DIR, POSTGRES_DATA_DIR,
POSTGRES_IMAGE, POSTGRES_IMAGE,
@@ -20,6 +22,8 @@ from .helpers import (
backup_run, backup_run,
cleanup_docker, cleanup_docker,
create_minimal_compose_dir, create_minimal_compose_dir,
dump_text,
dumps_in,
ensure_empty_dir, ensure_empty_dir,
latest_version_dir, latest_version_dir,
require_docker, require_docker,
@@ -142,16 +146,20 @@ class TestE2EEngineDetectionByTool(unittest.TestCase):
return backup_path(self.backups_dir, self.repo_name, self.version, volume) return backup_path(self.backups_dir, self.repo_name, self.version, volume)
def test_an_engine_without_an_engine_name_is_still_dumped(self) -> None: def test_an_engine_without_an_engine_name_is_still_dumped(self) -> None:
dump = self._volume_dir(self.engine_volume) / "sql" / "appdb.backup.sql" dump = self._volume_dir(self.engine_volume) / SQL_DIR / f"appdb{DUMP_SUFFIX}"
self.assertTrue( self.assertTrue(
dump.is_file(), dump.is_file(),
f"a Postgres tagged '{self.engine_image}' produced no dump at {dump}", f"a Postgres tagged '{self.engine_image}' produced no dump at {dump}",
) )
self.assertIn("Dumped by pg_dump", dump.read_text(encoding="utf-8")) self.assertIn("Dumped by pg_dump", dump_text(dump))
def test_an_engine_name_without_an_engine_is_not_dumped(self) -> None: def test_an_engine_name_without_an_engine_is_not_dumped(self) -> None:
sql_dir = self._volume_dir(self.impostor_volume) / "sql" self.assertEqual(
dumps = list(sql_dir.glob("*.sql")) if sql_dir.exists() else [] dumps_in(self._volume_dir(self.engine_volume) / SQL_DIR),
[self._volume_dir(self.engine_volume) / SQL_DIR / f"appdb{DUMP_SUFFIX}"],
"the glob must see the real engine's dump, or the check below is vacuous",
)
dumps = dumps_in(self._volume_dir(self.impostor_volume) / SQL_DIR)
self.assertEqual( self.assertEqual(
dumps, dumps,
[], [],
@@ -1,5 +1,7 @@
import unittest import unittest
from baudolo.generation import DUMP_SUFFIX, SQL_DIR
from .helpers import ( from .helpers import (
POSTGRES_DATA_DIR, POSTGRES_DATA_DIR,
POSTGRES_IMAGE, POSTGRES_IMAGE,
@@ -7,6 +9,7 @@ from .helpers import (
backup_run, backup_run,
cleanup_docker, cleanup_docker,
create_minimal_compose_dir, create_minimal_compose_dir,
dump_text,
ensure_empty_dir, ensure_empty_dir,
latest_version_dir, latest_version_dir,
require_docker, require_docker,
@@ -94,11 +97,11 @@ class TestE2EEngineDetectionRegistryPrefix(unittest.TestCase):
def test_the_registry_host_does_not_pick_the_engine(self) -> None: def test_the_registry_host_does_not_pick_the_engine(self) -> None:
p = ( p = (
backup_path(self.backups_dir, self.repo_name, self.version, self.pg_volume) backup_path(self.backups_dir, self.repo_name, self.version, self.pg_volume)
/ "sql" / SQL_DIR
/ "appdb.backup.sql" / f"appdb{DUMP_SUFFIX}"
) )
self.assertTrue(p.is_file(), f"Expected a pg_dump at: {p}") self.assertTrue(p.is_file(), f"Expected a pg_dump at: {p}")
self.assertIn("Dumped by pg_dump", p.read_text(encoding="utf-8")) self.assertIn("Dumped by pg_dump", dump_text(p))
if __name__ == "__main__": if __name__ == "__main__":
@@ -34,6 +34,7 @@ from .helpers import (
MARIADB_DATA_DIR, MARIADB_DATA_DIR,
MARIADB_IMAGE, MARIADB_IMAGE,
cleanup_docker, cleanup_docker,
dump_text,
require_docker, require_docker,
run, run,
unique, unique,
@@ -159,8 +160,7 @@ class TestE2EMariaDBAnonymousPreemption(unittest.TestCase):
self.assertTrue(produced, "backup_database did not produce a dump") self.assertTrue(produced, "backup_database did not produce a dump")
dump_path = Path(volume_dir) / SQL_DIR / f"{self.db_name}{DUMP_SUFFIX}" dump_path = Path(volume_dir) / SQL_DIR / f"{self.db_name}{DUMP_SUFFIX}"
self.assertTrue(dump_path.is_file(), f"expected dump at {dump_path}") self.assertTrue(dump_path.is_file(), f"expected dump at {dump_path}")
with dump_path.open(encoding="utf-8", errors="replace") as f: content = dump_text(dump_path)
content = f.read()
self.assertIn("INSERT INTO", content) self.assertIn("INSERT INTO", content)
self.assertIn("'ok'", content) self.assertIn("'ok'", content)
+6 -2
View File
@@ -1,5 +1,7 @@
import unittest import unittest
from baudolo.generation import DUMP_SUFFIX, SQL_DIR
from .helpers import ( from .helpers import (
MARIADB_DATA_DIR, MARIADB_DATA_DIR,
MARIADB_IMAGE, MARIADB_IMAGE,
@@ -7,6 +9,7 @@ from .helpers import (
backup_run, backup_run,
cleanup_docker, cleanup_docker,
create_minimal_compose_dir, create_minimal_compose_dir,
dump_text,
ensure_empty_dir, ensure_empty_dir,
latest_version_dir, latest_version_dir,
require_docker, require_docker,
@@ -150,10 +153,11 @@ class TestE2EMariaDBFull(unittest.TestCase):
def test_dump_file_exists(self) -> None: def test_dump_file_exists(self) -> None:
p = ( p = (
backup_path(self.backups_dir, self.repo_name, self.version, self.db_volume) backup_path(self.backups_dir, self.repo_name, self.version, self.db_volume)
/ "sql" / SQL_DIR
/ f"{self.db_name}.backup.sql" / f"{self.db_name}{DUMP_SUFFIX}"
) )
self.assertTrue(p.is_file(), f"Expected dump file at: {p}") self.assertTrue(p.is_file(), f"Expected dump file at: {p}")
self.assertIn("INSERT INTO", dump_text(p))
def test_data_restored(self) -> None: def test_data_restored(self) -> None:
p = run( p = run(
+6 -2
View File
@@ -7,12 +7,16 @@ make the flag useless there.
import unittest import unittest
from baudolo.generation import SQL_DIR
from .helpers import ( from .helpers import (
POSTGRES_DATA_DIR, POSTGRES_DATA_DIR,
POSTGRES_IMAGE, POSTGRES_IMAGE,
assert_dumps_in_sees_both_suffixes,
backup_path, backup_path,
cleanup_docker, cleanup_docker,
create_minimal_compose_dir, create_minimal_compose_dir,
dumps_in,
ensure_empty_dir, ensure_empty_dir,
latest_version_dir, latest_version_dir,
require_docker, require_docker,
@@ -104,8 +108,8 @@ class TestE2EOnlyFiles(unittest.TestCase):
self.assertEqual(marker.read_text(encoding="utf-8").strip(), MARKER) self.assertEqual(marker.read_text(encoding="utf-8").strip(), MARKER)
def test_no_dump_is_written(self) -> None: def test_no_dump_is_written(self) -> None:
sql_dir = self._volume_dir() / "sql" assert_dumps_in_sees_both_suffixes(self, f"/tmp/{self.prefix}")
dumps = list(sql_dir.glob("*.sql")) if sql_dir.exists() else [] dumps = dumps_in(self._volume_dir() / SQL_DIR)
self.assertEqual(dumps, [], f"did not expect any dump, found: {dumps}") self.assertEqual(dumps, [], f"did not expect any dump, found: {dumps}")
def test_the_missing_databases_csv_is_not_reported(self) -> None: def test_the_missing_databases_csv_is_not_reported(self) -> None:
@@ -1,14 +1,24 @@
import json import json
import unittest import unittest
from baudolo.generation import FILES_DIR, MANIFEST_FILE, MANIFEST_SCHEMA, SQL_DIR from baudolo.generation import (
CLUSTER_SUFFIX,
DUMP_COMPRESSION,
DUMP_SUFFIX,
FILES_DIR,
MANIFEST_FILE,
MANIFEST_SCHEMA,
SQL_DIR,
)
from .helpers import ( from .helpers import (
POSTGRES_DATA_DIR, POSTGRES_DATA_DIR,
POSTGRES_IMAGE, POSTGRES_IMAGE,
assert_dumps_in_sees_both_suffixes,
backup_path, backup_path,
cleanup_docker, cleanup_docker,
create_minimal_compose_dir, create_minimal_compose_dir,
dumps_in,
ensure_empty_dir, ensure_empty_dir,
latest_version_dir, latest_version_dir,
require_docker, require_docker,
@@ -143,7 +153,7 @@ class TestE2EOnlySqlFallbackToFiles(unittest.TestCase):
self.assertTrue(p.is_dir(), f"Expected files backup dir at: {p}") self.assertTrue(p.is_dir(), f"Expected files backup dir at: {p}")
def test_sql_dump_not_present(self) -> None: def test_sql_dump_not_present(self) -> None:
# There should be no sql dumps because databases.csv had no matching entry. assert_dumps_in_sees_both_suffixes(self, f"/tmp/{self.prefix}")
sql_dir = ( sql_dir = (
backup_path( backup_path(
self.backups_dir, self.backups_dir,
@@ -151,16 +161,10 @@ class TestE2EOnlySqlFallbackToFiles(unittest.TestCase):
self.version, self.version,
self.pg_volume, self.pg_volume,
) )
/ "sql" / SQL_DIR
) )
# Could exist (dir created) in some edge cases, but should contain no *.sql dumps. dumps = dumps_in(sql_dir)
if sql_dir.exists(): self.assertEqual(dumps, [], f"Did not expect SQL dump files, found: {dumps}")
dumps = list(sql_dir.glob("*.sql"))
self.assertEqual(
len(dumps),
0,
f"Did not expect SQL dump files, found: {dumps}",
)
def manifest(self) -> dict: def manifest(self) -> dict:
generation = backup_path( generation = backup_path(
@@ -183,6 +187,9 @@ class TestE2EOnlySqlFallbackToFiles(unittest.TestCase):
self.assertTrue((volume_dir / layout["files_dir"]).is_dir()) self.assertTrue((volume_dir / layout["files_dir"]).is_dir())
self.assertEqual(layout["files_dir"], FILES_DIR) self.assertEqual(layout["files_dir"], FILES_DIR)
self.assertEqual(layout["sql_dir"], SQL_DIR) self.assertEqual(layout["sql_dir"], SQL_DIR)
self.assertEqual(layout["dump_suffix"], DUMP_SUFFIX)
self.assertEqual(layout["cluster_suffix"], CLUSTER_SUFFIX)
self.assertEqual(layout["dump_compression"], DUMP_COMPRESSION)
def test_the_manifest_states_a_schema_a_reader_can_check(self) -> None: def test_the_manifest_states_a_schema_a_reader_can_check(self) -> None:
self.assertEqual(self.manifest()["schema"], MANIFEST_SCHEMA) self.assertEqual(self.manifest()["schema"], MANIFEST_SCHEMA)
@@ -1,5 +1,7 @@
import unittest import unittest
from baudolo.generation import CLUSTER_SUFFIX, SQL_DIR
from .helpers import ( from .helpers import (
POSTGRES_DATA_DIR, POSTGRES_DATA_DIR,
POSTGRES_IMAGE, POSTGRES_IMAGE,
@@ -85,8 +87,8 @@ class TestE2EPostgresClusterRestore(unittest.TestCase):
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name) cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
cls.dump = ( cls.dump = (
backup_path(cls.backups_dir, cls.repo_name, cls.version, cls.pg_volume) backup_path(cls.backups_dir, cls.repo_name, cls.version, cls.pg_volume)
/ "sql" / SQL_DIR
/ f"{cls.pg_container}.cluster.backup.sql" / f"{cls.pg_container}{CLUSTER_SUFFIX}"
) )
run(cls._restore_argv(), stdin_text="pgpw") run(cls._restore_argv(), stdin_text="pgpw")
+6 -2
View File
@@ -1,5 +1,7 @@
import unittest import unittest
from baudolo.generation import DUMP_SUFFIX, SQL_DIR
from .helpers import ( from .helpers import (
POSTGRES_DATA_DIR, POSTGRES_DATA_DIR,
POSTGRES_IMAGE, POSTGRES_IMAGE,
@@ -7,6 +9,7 @@ from .helpers import (
backup_run, backup_run,
cleanup_docker, cleanup_docker,
create_minimal_compose_dir, create_minimal_compose_dir,
dump_text,
ensure_empty_dir, ensure_empty_dir,
latest_version_dir, latest_version_dir,
require_docker, require_docker,
@@ -124,10 +127,11 @@ class TestE2EPostgresFull(unittest.TestCase):
def test_dump_file_exists(self) -> None: def test_dump_file_exists(self) -> None:
p = ( p = (
backup_path(self.backups_dir, self.repo_name, self.version, self.pg_volume) backup_path(self.backups_dir, self.repo_name, self.version, self.pg_volume)
/ "sql" / SQL_DIR
/ "appdb.backup.sql" / f"appdb{DUMP_SUFFIX}"
) )
self.assertTrue(p.is_file(), f"Expected dump file at: {p}") self.assertTrue(p.is_file(), f"Expected dump file at: {p}")
self.assertIn("Dumped by pg_dump", dump_text(p))
def test_the_deprecated_password_argument_still_restores_but_warns(self) -> None: def test_the_deprecated_password_argument_still_restores_but_warns(self) -> None:
self.assertIn("visible in the process list", self.restored.stderr) self.assertIn("visible in the process list", self.restored.stderr)
@@ -19,6 +19,7 @@ from .helpers import (
backup_run, backup_run,
cleanup_docker, cleanup_docker,
create_minimal_compose_dir, create_minimal_compose_dir,
dump_text,
ensure_empty_dir, ensure_empty_dir,
latest_version_dir, latest_version_dir,
require_docker, require_docker,
@@ -137,19 +138,19 @@ class TestE2EPostgresPasswordRequired(unittest.TestCase):
def test_the_configured_database_was_dumped(self) -> None: def test_the_configured_database_was_dumped(self) -> None:
dump = self.volume_dir() / SQL_DIR / f"appdb{DUMP_SUFFIX}" dump = self.volume_dir() / SQL_DIR / f"appdb{DUMP_SUFFIX}"
self.assertTrue(dump.is_file(), f"expected a dump at {dump}") self.assertTrue(dump.is_file(), f"expected a dump at {dump}")
self.assertIn("Dumped by pg_dump", dump.read_text(encoding="utf-8")) self.assertIn("Dumped by pg_dump", dump_text(dump))
def test_the_dump_carries_the_payload(self) -> None: def test_the_dump_carries_the_payload(self) -> None:
"""pg_dump emits table data as COPY ... FROM stdin, so the row reads as """pg_dump emits table data as COPY ... FROM stdin, so the row reads as
tab-separated values rather than as an INSERT literal.""" tab-separated values rather than as an INSERT literal."""
dump = self.volume_dir() / SQL_DIR / f"appdb{DUMP_SUFFIX}" text = dump_text(self.volume_dir() / SQL_DIR / f"appdb{DUMP_SUFFIX}")
self.assertIn("COPY public.t (id, v) FROM stdin;", dump.read_text("utf-8")) self.assertIn("COPY public.t (id, v) FROM stdin;", text)
self.assertIn("1\tok", dump.read_text(encoding="utf-8")) self.assertIn("1\tok", text)
def test_the_cluster_row_was_dumped_too(self) -> None: def test_the_cluster_row_was_dumped_too(self) -> None:
cluster = self.volume_dir() / SQL_DIR / f"{self.pg_container}{CLUSTER_SUFFIX}" cluster = self.volume_dir() / SQL_DIR / f"{self.pg_container}{CLUSTER_SUFFIX}"
self.assertTrue(cluster.is_file(), f"expected a cluster dump at {cluster}") self.assertTrue(cluster.is_file(), f"expected a cluster dump at {cluster}")
self.assertIn("CREATE DATABASE", cluster.read_text(encoding="utf-8")) self.assertIn("CREATE DATABASE", dump_text(cluster))
def test_only_sql_left_no_file_copy_behind(self) -> None: def test_only_sql_left_no_file_copy_behind(self) -> None:
self.assertFalse((self.volume_dir() / FILES_DIR).exists()) self.assertFalse((self.volume_dir() / FILES_DIR).exists())
@@ -1,11 +1,14 @@
import unittest import unittest
from baudolo.generation import CLUSTER_SUFFIX, DUMP_SUFFIX
from .helpers import ( from .helpers import (
POSTGRES_DATA_DIR, POSTGRES_DATA_DIR,
POSTGRES_IMAGE, POSTGRES_IMAGE,
backup_path, backup_path,
cleanup_docker, cleanup_docker,
create_minimal_compose_dir, create_minimal_compose_dir,
dump_text,
ensure_empty_dir, ensure_empty_dir,
latest_version_dir, latest_version_dir,
require_docker, require_docker,
@@ -197,15 +200,15 @@ class TestE2ESeedStarAndDbEntriesBackupPostgres(unittest.TestCase):
) )
# Cluster dump file produced by '*' entry # Cluster dump file produced by '*' entry
cluster = sql_dir / f"{self.pg_container}.cluster.backup.sql" cluster = sql_dir / f"{self.pg_container}{CLUSTER_SUFFIX}"
self.assertTrue(cluster.is_file(), f"Expected cluster dump file at: {cluster}") self.assertTrue(cluster.is_file(), f"Expected cluster dump file at: {cluster}")
# Concrete DB dump produced by normal entry # Concrete DB dump produced by normal entry
db1 = sql_dir / f"{self.pg_db1}.backup.sql" db1 = sql_dir / f"{self.pg_db1}{DUMP_SUFFIX}"
self.assertTrue(db1.is_file(), f"Expected db dump file at: {db1}") self.assertTrue(db1.is_file(), f"Expected db dump file at: {db1}")
# Basic sanity: cluster dump usually contains CREATE DATABASE statements # Basic sanity: cluster dump usually contains CREATE DATABASE statements
txt = cluster.read_text(encoding="utf-8", errors="ignore") txt = dump_text(cluster)
self.assertIn( self.assertIn(
"CREATE DATABASE", "CREATE DATABASE",
txt, txt,
+9 -5
View File
@@ -10,6 +10,8 @@ import re
import unittest import unittest
from pathlib import Path from pathlib import Path
from baudolo.generation import DUMP_SUFFIX, SQL_DIR
from .helpers import ( from .helpers import (
MARIADB_DATA_DIR, MARIADB_DATA_DIR,
MARIADB_IMAGE, MARIADB_IMAGE,
@@ -19,6 +21,7 @@ from .helpers import (
backup_run, backup_run,
cleanup_docker, cleanup_docker,
create_minimal_compose_dir, create_minimal_compose_dir,
dump_text,
ensure_empty_dir, ensure_empty_dir,
latest_version_dir, latest_version_dir,
require_docker, require_docker,
@@ -28,6 +31,7 @@ from .helpers import (
wait_for_mariadb_sql, wait_for_mariadb_sql,
wait_for_postgres, wait_for_postgres,
write_databases_csv, write_databases_csv,
write_dump_text,
) )
PAYLOAD = "gate-payload" PAYLOAD = "gate-payload"
@@ -36,14 +40,14 @@ FUTURE = "99.0"
def rewrite_version(dump: Path, pattern: str, version: str) -> str: def rewrite_version(dump: Path, pattern: str, version: str) -> str:
"""Make the dump claim ``version``; return what it claimed before.""" """Make the dump claim ``version``; return what it claimed before."""
text = dump.read_text(encoding="utf-8", errors="replace") text = dump_text(dump)
found = re.search(pattern, text) found = re.search(pattern, text)
if not found: if not found:
raise AssertionError(f"{dump} carries no version header matching {pattern}") raise AssertionError(f"{dump} carries no version header matching {pattern}")
claimed = found.group(1) claimed = found.group(1)
dump.write_text( write_dump_text(
dump,
text.replace(found.group(0), found.group(0).replace(claimed, version), 1), text.replace(found.group(0), found.group(0).replace(claimed, version), 1),
encoding="utf-8",
) )
return claimed return claimed
@@ -103,8 +107,8 @@ class GateCase:
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name) cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
cls.dump = ( cls.dump = (
backup_path(cls.backups_dir, cls.repo_name, cls.version, cls.volume) backup_path(cls.backups_dir, cls.repo_name, cls.version, cls.volume)
/ "sql" / SQL_DIR
/ f"{cls.db_name}.backup.sql" / f"{cls.db_name}{DUMP_SUFFIX}"
) )
cls.truthful_version = rewrite_version(cls.dump, cls.pattern, FUTURE) cls.truthful_version = rewrite_version(cls.dump, cls.pattern, FUTURE)
+15 -1
View File
@@ -20,7 +20,7 @@ def _capture_dumps(*, db_type, rows, container, dump_tool="mariadb-dump"):
with ( with (
tempfile.TemporaryDirectory() as td, tempfile.TemporaryDirectory() as td,
patch.object(db_mod, "execute_to_file", side_effect=_capture), patch.object(db_mod, "execute_to_gzip", side_effect=_capture),
): ):
db_mod.backup_database( db_mod.backup_database(
container=container, container=container,
@@ -94,6 +94,20 @@ class TestMariaDBDumpUsesTCP(unittest.TestCase):
f"password reached the argv: {argv}", f"password reached the argv: {argv}",
) )
def test_every_dump_is_written_gzip_named(self):
for db_type, container, database, suffix in (
("postgres", "pg", "appdb", ".backup.sql.gz"),
("mariadb", "mariadb", "appdb", ".backup.sql.gz"),
("postgres", "pg", "*", ".cluster.backup.sql.gz"),
):
with self.subTest(engine=db_type, database=database):
_argv, _env, out = _capture_dumps(
db_type=db_type,
rows=[(container, database, "appuser", "s3cret")],
container=container,
)[0]
self.assertTrue(out.endswith(suffix), out)
def dump_tool_of(argv): def dump_tool_of(argv):
return next(arg for arg in argv if arg in ("mariadb-dump", "mysqldump")) return next(arg for arg in argv if arg in ("mariadb-dump", "mysqldump"))
+3 -3
View File
@@ -44,12 +44,12 @@ class TestPostgresDumpCarriesThePassword(unittest.TestCase):
import baudolo.backup.db as db import baudolo.backup.db as db
original = db.execute_to_file original = db.execute_to_gzip
db.execute_to_file = fake db.execute_to_gzip = fake
try: try:
fallback_pg_dumpall("pg", "user", "secret", "/tmp/out.sql") fallback_pg_dumpall("pg", "user", "secret", "/tmp/out.sql")
finally: finally:
db.execute_to_file = original db.execute_to_gzip = original
self.assertIn("-e", seen["command"]) self.assertIn("-e", seen["command"])
self.assertEqual(seen["command"][seen["command"].index("-e") + 1], "PGPASSWORD") self.assertEqual(seen["command"][seen["command"].index("-e") + 1], "PGPASSWORD")
+126
View File
@@ -0,0 +1,126 @@
import contextlib
import gzip
import io
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from baudolo.backup import shell
from baudolo.backup.shell import BackupError, execute_to_gzip
CAT = (
"import shutil, sys; shutil.copyfileobj(open(sys.argv[1], 'rb'), sys.stdout.buffer)"
)
def sql_lines(count: int, changed: int = -1) -> bytes:
return b"".join(
(
b"INSERT INTO t VALUES (%d, 'changed');\n" % i
if i == changed
else b"INSERT INTO t VALUES (%d, 'name-%d');\n" % (i, i * 7919)
)
for i in range(count)
)
def common_suffix(a: bytes, b: bytes) -> int:
n = 0
while n < min(len(a), len(b)) and a[-1 - n] == b[-1 - n]:
n += 1
return n
class GzipDumpCase(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
def dump(self, payload: bytes, name: str = "db.backup.sql.gz") -> bytes:
src = self.dir / f"{name}.src"
src.write_bytes(payload)
out = self.dir / name
with contextlib.redirect_stdout(io.StringIO()):
execute_to_gzip([sys.executable, "-c", CAT, str(src)], str(out))
return out.read_bytes()
class TestRoundTrip(GzipDumpCase):
def test_the_dump_round_trips_through_gzip(self):
payload = sql_lines(5000) + b"-- " + b"x" * (3 * shell.READ_BYTES) + b"\n"
packed = self.dump(payload)
self.assertEqual(packed[:2], b"\x1f\x8b")
self.assertEqual(gzip.decompress(packed), payload)
def test_an_empty_dump_is_a_valid_empty_gzip(self):
self.assertEqual(gzip.decompress(self.dump(b"")), b"")
def test_no_temporary_file_is_left_behind(self):
self.dump(sql_lines(10))
self.assertEqual(sorted(p.suffix for p in self.dir.iterdir()), [".gz", ".src"])
class TestDeterminism(GzipDumpCase):
def test_identical_dumps_give_identical_bytes(self):
payload = sql_lines(20000)
self.assertEqual(self.dump(payload, "a.gz"), self.dump(payload, "b.gz"))
def test_the_header_carries_neither_a_name_nor_a_time(self):
packed = self.dump(sql_lines(10))
self.assertEqual(packed[3] & 0x08, 0, "FNAME flag set")
self.assertEqual(packed[4:8], b"\x00\x00\x00\x00", "MTIME set")
class TestResync(GzipDumpCase):
def test_a_change_near_the_start_leaves_the_tail_identical(self):
before = sql_lines(100000)
after = sql_lines(100000, changed=5)
ours = common_suffix(
self.dump(before, "a.gz")[:-8], self.dump(after, "b.gz")[:-8]
)
plain = common_suffix(gzip.compress(before)[:-8], gzip.compress(after)[:-8])
self.assertLess(plain, 1024, "control: plain gzip must not resync")
self.assertGreater(ours, len(gzip.compress(before)) // 2)
class TestFailure(GzipDumpCase):
def test_a_failing_command_leaves_no_file(self):
out = self.dir / "db.backup.sql.gz"
script = "import sys; sys.stdout.write('partial'); sys.exit(3)"
with (
contextlib.redirect_stdout(io.StringIO()),
self.assertRaises(BackupError) as caught,
):
execute_to_gzip([sys.executable, "-c", script], str(out))
self.assertIn("Exit code: 3", str(caught.exception))
self.assertEqual(list(self.dir.iterdir()), [])
def test_a_write_error_kills_the_dump_and_leaves_no_file(self):
src = self.dir / "src"
src.write_bytes(sql_lines(200000))
out = self.dir / "db.backup.sql.gz"
with (
patch.object(
gzip.GzipFile, "write", side_effect=OSError(28, "No space left")
),
contextlib.redirect_stdout(io.StringIO()),
self.assertRaises(BackupError),
):
execute_to_gzip([sys.executable, "-c", CAT, str(src)], str(out))
self.assertEqual(sorted(p.name for p in self.dir.iterdir()), ["src"])
def test_env_reaches_the_child_without_the_argv(self):
out = self.dir / "env.gz"
script = "import os, sys; sys.stdout.write(os.environ['MYSQL_PWD'])"
printed = io.StringIO()
with contextlib.redirect_stdout(printed):
execute_to_gzip(
[sys.executable, "-c", script], str(out), env={"MYSQL_PWD": "s3cret"}
)
self.assertEqual(gzip.decompress(out.read_bytes()), b"s3cret")
self.assertNotIn("s3cret", printed.getvalue())
if __name__ == "__main__":
unittest.main()
@@ -24,6 +24,7 @@ class TestRestorePasswordChannel(unittest.TestCase):
stderr = io.StringIO() stderr = io.StringIO()
with ( with (
patch.object(cli, "restore_postgres_sql") as restore, patch.object(cli, "restore_postgres_sql") as restore,
patch.object(cli, "locate", side_effect=lambda path: path),
patch("sys.stdin", io.StringIO(stdin)), patch("sys.stdin", io.StringIO(stdin)),
contextlib.redirect_stderr(stderr), contextlib.redirect_stderr(stderr),
): ):
+4 -1
View File
@@ -27,7 +27,10 @@ class TestVersionFlagReachesEveryEngine(unittest.TestCase):
*required, *required,
*extra, *extra,
] ]
with patch.object(cli, target) as restore: with (
patch.object(cli, target) as restore,
patch.object(cli, "locate", side_effect=lambda path: path),
):
self.assertEqual(cli.main(argv), 0) self.assertEqual(cli.main(argv), 0)
return restore.call_args.kwargs return restore.call_args.kwargs
+1 -1
View File
@@ -117,7 +117,7 @@ class TestClusterReplay(unittest.TestCase):
paths = BackupPaths("vol", "hash", "v1", repo_name="repo", backups_dir="/B") paths = BackupPaths("vol", "hash", "v1", repo_name="repo", backups_dir="/B")
self.assertEqual( self.assertEqual(
paths.cluster_file("bigbluebutton"), paths.cluster_file("bigbluebutton"),
"/B/hash/repo/v1/vol/sql/bigbluebutton.cluster.backup.sql", "/B/hash/repo/v1/vol/sql/bigbluebutton.cluster.backup.sql.gz",
) )
@@ -0,0 +1,242 @@
import gzip
import sys
import tempfile
import unittest
import zlib
from pathlib import Path
from unittest.mock import MagicMock, patch
from baudolo.restore import run as run_mod
from baudolo.restore.db import cluster as cluster_mod
from baudolo.restore.db import mariadb as mdb_mod
from baudolo.restore.db import postgres as pg_mod
from baudolo.restore.db import version as ver
from baudolo.restore.dumpfile import is_gzip, locate, open_dump, verify
from baudolo.restore.paths import BackupPaths
from .test_version_gate import MARIADB_HEADER, POSTGRES_HEADER, cluster_header
SQL = (
POSTGRES_HEADER + "CREATE TABLE t (id int);\nINSERT INTO t VALUES (1);\n"
).encode()
def write(directory: Path, name: str, data: bytes) -> str:
path = directory / name
path.write_bytes(data)
return str(path)
class DumpCase(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
class TestOpenDump(DumpCase):
def test_gzip_and_plain_read_the_same_sql(self):
packed = write(self.dir, "a.backup.sql.gz", gzip.compress(SQL))
plain = write(self.dir, "b.backup.sql", SQL)
for path in (packed, plain):
with self.subTest(path=path), open_dump(path) as handle:
self.assertEqual(handle.read(), SQL)
def test_the_magic_decides_not_the_name(self):
misnamed = write(self.dir, "c.backup.sql", gzip.compress(SQL))
self.assertTrue(is_gzip(misnamed))
with open_dump(misnamed) as handle:
self.assertEqual(handle.read(), SQL)
class TestLocate(DumpCase):
def test_the_compressed_name_wins_when_alone(self):
path = write(self.dir, "db.backup.sql.gz", gzip.compress(SQL))
self.assertEqual(locate(path), path)
def test_an_old_uncompressed_dump_is_found(self):
legacy = write(self.dir, "db.backup.sql", SQL)
self.assertEqual(locate(str(self.dir / "db.backup.sql.gz")), legacy)
def test_both_present_is_refused(self):
write(self.dir, "db.backup.sql", SQL)
path = write(self.dir, "db.backup.sql.gz", gzip.compress(SQL))
with self.assertRaises(FileExistsError):
locate(path)
def test_neither_present_names_both(self):
with self.assertRaises(FileNotFoundError) as caught:
locate(str(self.dir / "db.backup.sql.gz"))
self.assertIn("db.backup.sql.gz", str(caught.exception))
self.assertIn("db.backup.sql ", str(caught.exception) + " ")
def test_the_path_helper_names_what_this_version_writes(self):
paths = BackupPaths("vol", "h", "v", repo_name="r", backups_dir="/B")
self.assertEqual(paths.sql_file("app"), "/B/h/r/v/vol/sql/app.backup.sql.gz")
class TestVerify(DumpCase):
def test_a_sound_gzip_and_a_plain_file_pass(self):
verify(write(self.dir, "a.gz", gzip.compress(SQL)))
verify(write(self.dir, "b.sql", SQL[:10]))
def test_a_truncated_gzip_is_refused(self):
packed = gzip.compress(SQL * 50)
with self.assertRaises(EOFError):
verify(write(self.dir, "t.gz", packed[: len(packed) // 2]))
def test_a_corrupt_gzip_is_refused(self):
packed = bytearray(gzip.compress(SQL * 50))
packed[-6] ^= 0xFF
with self.assertRaises((gzip.BadGzipFile, zlib.error, EOFError)):
verify(write(self.dir, "c.gz", bytes(packed)))
class TestReadersAcceptGzip(DumpCase):
def test_the_version_header_is_read_through_gzip(self):
for header, engine, expected in (
(POSTGRES_HEADER, "postgres", "17.11"),
(MARIADB_HEADER, "mariadb", "11.8.8-MariaDB-ubu2404"),
):
with self.subTest(engine=engine):
path = write(self.dir, f"{engine}.gz", gzip.compress(header.encode()))
self.assertEqual(ver.dump_version(path, engine), expected)
def test_the_cluster_inventory_is_read_through_gzip(self):
data = (cluster_header(roles=2) + "CREATE DATABASE app2;\n").encode()
path = write(self.dir, "c.cluster.backup.sql.gz", gzip.compress(data))
databases, roles = cluster_mod.dump_inventory(path)
self.assertIn("app2", databases)
self.assertEqual(roles, ["app0", "app1"])
class TestReplayFeedsSql(DumpCase):
def test_postgres_replays_decompressed_sql(self):
fed = []
def capture(container, argv, **kwargs):
stdin = kwargs.get("stdin")
fed.append(stdin if isinstance(stdin, bytes) else stdin.read())
return MagicMock()
for name, data in (
("new.backup.sql.gz", gzip.compress(SQL)),
("old.backup.sql", SQL),
):
fed.clear()
path = write(self.dir, name, data)
with (
self.subTest(name=name),
patch.object(pg_mod, "docker_exec", side_effect=capture),
):
pg_mod.restore_postgres_sql(
container="db",
db_name="app",
user="app",
password="pw",
sql_path=path,
empty=False,
check_version=False,
)
self.assertEqual(fed, [SQL])
def test_mariadb_streams_decompressed_sql(self):
fed = []
def capture(container, argv, *, source, docker_env):
fed.append(source.read())
for name, data in (
("new.backup.sql.gz", gzip.compress(SQL)),
("old.backup.sql", SQL),
):
fed.clear()
path = write(self.dir, name, data)
with (
self.subTest(name=name),
patch.object(mdb_mod, "_pick_client", return_value="mariadb"),
patch.object(mdb_mod, "docker_exec_stream", side_effect=capture),
):
mdb_mod.restore_mariadb_sql(
container="db",
db_name="app",
user="app",
password="pw",
sql_path=path,
empty=False,
check_version=False,
)
self.assertEqual(fed, [SQL])
def test_the_stream_reaches_the_child_decompressed(self):
out = self.dir / "received.sql"
sink = "import shutil, sys; shutil.copyfileobj(sys.stdin.buffer, open(sys.argv[1], 'wb'))"
path = write(self.dir, "s.gz", gzip.compress(SQL * 2000))
with (
patch.object(
run_mod,
"docker_exec_cmd",
return_value=[sys.executable, "-c", sink, str(out)],
),
open_dump(path) as source,
):
run_mod.docker_exec_stream("db", ["ignored"], source=source)
self.assertEqual(out.read_bytes(), SQL * 2000)
def test_a_child_that_dies_early_is_reported_by_its_exit_code(self):
path = write(self.dir, "s.gz", gzip.compress(SQL * 20000))
with (
patch.object(
run_mod,
"docker_exec_cmd",
return_value=[sys.executable, "-c", "import sys; sys.exit(3)"],
),
open_dump(path) as source,
patch("sys.stderr"),
self.assertRaises(run_mod.subprocess.CalledProcessError) as caught,
):
run_mod.docker_exec_stream("db", ["ignored"], source=source)
self.assertEqual(caught.exception.returncode, 3)
class TestCorruptionStopsBeforeDestroying(DumpCase):
def truncated(self) -> str:
packed = gzip.compress(SQL * 500)
return write(self.dir, "x.backup.sql.gz", packed[: len(packed) // 2])
def test_postgres_runs_no_preclean_on_a_truncated_dump(self):
with (
patch.object(pg_mod, "docker_exec") as exec_,
self.assertRaises(EOFError),
):
pg_mod.restore_postgres_sql(
container="db",
db_name="app",
user="app",
password="pw",
sql_path=self.truncated(),
empty=True,
check_version=False,
)
exec_.assert_not_called()
def test_mariadb_drops_nothing_on_a_truncated_dump(self):
with (
patch.object(mdb_mod, "_pick_client", return_value="mariadb"),
patch.object(mdb_mod, "docker_exec") as exec_,
patch.object(mdb_mod, "docker_exec_stream") as stream,
self.assertRaises(EOFError),
):
mdb_mod.restore_mariadb_sql(
container="db",
db_name="app",
user="app",
password="pw",
sql_path=self.truncated(),
empty=True,
check_version=False,
)
exec_.assert_not_called()
stream.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -20,6 +20,7 @@ class TestMariadbEmptyDrop(unittest.TestCase):
sql.flush() sql.flush()
with ( with (
patch.object(mariadb_mod, "docker_exec", side_effect=_capture), patch.object(mariadb_mod, "docker_exec", side_effect=_capture),
patch.object(mariadb_mod, "docker_exec_stream"),
patch.object(mariadb_mod, "_pick_client", return_value="mariadb"), patch.object(mariadb_mod, "_pick_client", return_value="mariadb"),
): ):
mariadb_mod.restore_mariadb_sql( mariadb_mod.restore_mariadb_sql(
+27 -4
View File
@@ -1,3 +1,4 @@
import gzip
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -39,6 +40,20 @@ class TestDockerExecForwardsByName(unittest.TestCase):
self.assertIsNone(run.call_args.kwargs["env"]) self.assertIsNone(run.call_args.kwargs["env"])
self.assertEqual(run.call_args.args[0], ["docker", "exec", "-i", "db", "true"]) self.assertEqual(run.call_args.args[0], ["docker", "exec", "-i", "db", "true"])
def test_the_stream_forwards_by_name_too(self):
with patch.object(run_mod.subprocess, "Popen") as popen:
popen.return_value.wait.return_value = 0
run_mod.docker_exec_stream(
"db",
["mariadb"],
source=MagicMock(read=MagicMock(return_value=b"")),
docker_env={"MYSQL_PWD": SECRET},
)
cmd = popen.call_args.args[0]
self.assertFalse([arg for arg in cmd if SECRET in arg])
self.assertIn("MYSQL_PWD", cmd)
self.assertEqual(popen.call_args.kwargs["env"]["MYSQL_PWD"], SECRET)
class TestNoRestoreArgvCarriesThePassword(unittest.TestCase): class TestNoRestoreArgvCarriesThePassword(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -54,12 +69,20 @@ class TestNoRestoreArgvCarriesThePassword(unittest.TestCase):
stdout = b"11.8.8-MariaDB\n" stdout = b"11.8.8-MariaDB\n"
return MagicMock(stdout=stdout) return MagicMock(stdout=stdout)
def fake_popen(cmd, *, stdin=None, env=None):
self.argvs.append(cmd)
self.envs.append(env or {})
process = MagicMock()
process.wait.return_value = 0
return process
patch.object(run_mod, "run", side_effect=fake_run).start() patch.object(run_mod, "run", side_effect=fake_run).start()
patch.object(run_mod.subprocess, "Popen", side_effect=fake_popen).start()
self.addCleanup(patch.stopall) self.addCleanup(patch.stopall)
def dump(self, name: str, text: str) -> str: def dump(self, name: str, text: str) -> str:
path = self.dir / name path = self.dir / name
path.write_text(text, encoding="utf-8") path.write_bytes(gzip.compress(text.encode()))
return str(path) return str(path)
def assert_clean(self, variable: str): def assert_clean(self, variable: str):
@@ -74,7 +97,7 @@ class TestNoRestoreArgvCarriesThePassword(unittest.TestCase):
db_name="app", db_name="app",
user="app", user="app",
password=SECRET, password=SECRET,
sql_path=self.dump("app.backup.sql", POSTGRES_HEADER), sql_path=self.dump("app.backup.sql.gz", POSTGRES_HEADER),
empty=True, empty=True,
) )
self.assert_clean("PGPASSWORD") self.assert_clean("PGPASSWORD")
@@ -85,7 +108,7 @@ class TestNoRestoreArgvCarriesThePassword(unittest.TestCase):
container="db", container="db",
user="postgres", user="postgres",
password=SECRET, password=SECRET,
sql_path=self.dump("c.cluster.backup.sql", cluster_header(roles=1)), sql_path=self.dump("c.cluster.backup.sql.gz", cluster_header(roles=1)),
empty=True, empty=True,
) )
self.assert_clean("PGPASSWORD") self.assert_clean("PGPASSWORD")
@@ -97,7 +120,7 @@ class TestNoRestoreArgvCarriesThePassword(unittest.TestCase):
db_name="app", db_name="app",
user="app", user="app",
password=SECRET, password=SECRET,
sql_path=self.dump("app.backup.sql", MARIADB_HEADER), sql_path=self.dump("app.backup.sql.gz", MARIADB_HEADER),
empty=True, empty=True,
) )
self.assert_clean("MYSQL_PWD") self.assert_clean("MYSQL_PWD")
+12
View File
@@ -13,6 +13,8 @@ from baudolo.generation import (
CLUSTER_SUFFIX, CLUSTER_SUFFIX,
DUMP_SUFFIX, DUMP_SUFFIX,
FILES_DIR, FILES_DIR,
LEGACY_CLUSTER_SUFFIX,
LEGACY_DUMP_SUFFIX,
MANIFEST_FILE, MANIFEST_FILE,
MANIFEST_SCHEMA, MANIFEST_SCHEMA,
SQL_DIR, SQL_DIR,
@@ -30,9 +32,19 @@ class TestManifestDocument(unittest.TestCase):
"sql_dir": SQL_DIR, "sql_dir": SQL_DIR,
"dump_suffix": DUMP_SUFFIX, "dump_suffix": DUMP_SUFFIX,
"cluster_suffix": CLUSTER_SUFFIX, "cluster_suffix": CLUSTER_SUFFIX,
"dump_compression": "gzip",
}, },
) )
def test_dumps_are_named_as_gzip_and_the_legacy_names_stay_known(self) -> None:
self.assertEqual(DUMP_SUFFIX, ".backup.sql.gz")
self.assertEqual(CLUSTER_SUFFIX, ".cluster.backup.sql.gz")
self.assertEqual(LEGACY_DUMP_SUFFIX, ".backup.sql")
self.assertEqual(LEGACY_CLUSTER_SUFFIX, ".cluster.backup.sql")
def test_the_schema_stays_one_so_existing_readers_keep_reading(self) -> None:
self.assertEqual(MANIFEST_SCHEMA, 1)
def test_it_carries_a_schema_so_a_reader_can_refuse_a_newer_one(self) -> None: def test_it_carries_a_schema_so_a_reader_can_refuse_a_newer_one(self) -> None:
self.assertEqual(manifest_document({})["schema"], MANIFEST_SCHEMA) self.assertEqual(manifest_document({})["schema"], MANIFEST_SCHEMA)