mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-11-06 02:57:59 +00:00
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:
1 parent
328520169b
commit
b30344d494
34 files changed
+833
-110
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user