mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-08-01 12:34:50 +00:00
Backing up a live volume with rsync copies a moving target: a database
written to mid-copy lands on disk in a state no engine ever committed.
Stopping the container avoids that at the cost of downtime.
A snapshot removes both. `--snapshot {btrfs,zfs}` with `--snapshot-subject`
freezes the docker root once per run, and every volume copy is then read
from that frozen tree while the containers keep serving. A restore of such
a copy is an ordinary crash recovery, which every supported engine performs
on its own at startup.
An unsupported filesystem or an unknown snapshot kind fails loudly rather
than degrading to a live copy, since a silent fallback would return exactly
the torn backup the mode exists to prevent. `--shutdown` is rejected
alongside `--snapshot` instead of being ignored: under a snapshot no
container is ever stopped, so accepting the flag would promise downtime
semantics the run does not deliver.
Copies out of a snapshot skip rsync's --checksum verification. The source
is immutable for the lifetime of the copy, so size-and-mtime cannot race,
and dropping the second full read roughly halves the I/O per volume.
backup/app.py grew past what one module could carry and is split into
layout, policy and dumps along the lines it already had internally.
Tests: unit coverage for the new snapshot, layout, policy, volume and cli
units; e2e cases drive real btrfs, zfs and ext4 filesystems on loop devices
in a privileged container, including a MariaDB that is written to across
the snapshot and must recover from the restored copy without losing a
committed row. CI installs zfs and sets E2E_REQUIRE_FILESYSTEMS so a
missing kernel module fails the build instead of silently skipping a
filesystem.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
78 lines
2.7 KiB
Python
78 lines
2.7 KiB
Python
import io
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import redirect_stderr
|
|
|
|
import pandas as pd
|
|
|
|
# Adjust if your package name/import path differs.
|
|
from baudolo.backup.dumps import load_databases_df
|
|
|
|
|
|
EXPECTED_COLUMNS = ["instance", "database", "username", "password"]
|
|
|
|
|
|
class TestLoadDatabasesDf(unittest.TestCase):
|
|
def test_missing_csv_is_handled_with_warning_and_empty_df(self) -> None:
|
|
with tempfile.TemporaryDirectory() as td:
|
|
missing_path = os.path.join(td, "does-not-exist.csv")
|
|
|
|
buf = io.StringIO()
|
|
with redirect_stderr(buf):
|
|
df = load_databases_df(missing_path)
|
|
|
|
stderr = buf.getvalue()
|
|
self.assertIn("WARNING:", stderr)
|
|
self.assertIn("databases.csv not found", stderr)
|
|
|
|
self.assertIsInstance(df, pd.DataFrame)
|
|
self.assertListEqual(list(df.columns), EXPECTED_COLUMNS)
|
|
self.assertTrue(df.empty)
|
|
|
|
def test_empty_csv_is_handled_with_warning_and_empty_df(self) -> None:
|
|
with tempfile.TemporaryDirectory() as td:
|
|
empty_path = os.path.join(td, "databases.csv")
|
|
# Create an empty file (0 bytes)
|
|
with open(empty_path, "w", encoding="utf-8") as f:
|
|
f.write("")
|
|
|
|
buf = io.StringIO()
|
|
with redirect_stderr(buf):
|
|
df = load_databases_df(empty_path)
|
|
|
|
stderr = buf.getvalue()
|
|
self.assertIn("WARNING:", stderr)
|
|
self.assertIn("exists but is empty", stderr)
|
|
|
|
self.assertIsInstance(df, pd.DataFrame)
|
|
self.assertListEqual(list(df.columns), EXPECTED_COLUMNS)
|
|
self.assertTrue(df.empty)
|
|
|
|
def test_valid_csv_loads_without_warning(self) -> None:
|
|
with tempfile.TemporaryDirectory() as td:
|
|
csv_path = os.path.join(td, "databases.csv")
|
|
|
|
content = "instance;database;username;password\nmyapp;*;dbuser;secret\n"
|
|
with open(csv_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
buf = io.StringIO()
|
|
with redirect_stderr(buf):
|
|
df = load_databases_df(csv_path)
|
|
|
|
stderr = buf.getvalue()
|
|
self.assertEqual(stderr, "") # no warning expected
|
|
|
|
self.assertIsInstance(df, pd.DataFrame)
|
|
self.assertListEqual(list(df.columns), EXPECTED_COLUMNS)
|
|
self.assertEqual(len(df), 1)
|
|
self.assertEqual(df.loc[0, "instance"], "myapp")
|
|
self.assertEqual(df.loc[0, "database"], "*")
|
|
self.assertEqual(df.loc[0, "username"], "dbuser")
|
|
self.assertEqual(df.loc[0, "password"], "secret")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|