mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-08-20 21:22:54 +00:00
feat(manifest)!: record per volume what the run established
A finished generation cannot show whether a volume held a database, nor whether a dump was produced for it: under --only-sql a failed dump falls back to a file copy, and the resulting files/ tree looks like any other copy. The run knows both and threw the knowledge away as a printed warning, leaving every reader to guess from file names. Each generation now carries a manifest.json stating its layout and, per volume, database / dumped / engine. baudolo.generation is the single place those names are spelled; restore/paths.py, backup/db.py and backup/volume.py stop repeating them. It is deliberately import-free so a consumer can read the manifest with nothing but json, on hosts where this package is not installed. BREAKING CHANGE: BackupException is renamed BackupError. The rename is atomic across the ten modules that define or import it, three of which also carry the manifest change, so it lands in this commit rather than a separate one that could not import. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
84
tests/unit/backup/test_app_manifest.py
Normal file
84
tests/unit/backup/test_app_manifest.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""What main() records in the manifest for each volume it touched."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from baudolo.backup import app
|
||||
from baudolo.backup.dumps import VolumeOutcome
|
||||
from baudolo.backup.volume import Backing
|
||||
|
||||
from . import REQUIRED_PAIRS
|
||||
|
||||
ARGV = ["baudolo", *[arg for pair in REQUIRED_PAIRS for arg in pair]]
|
||||
|
||||
|
||||
def drive(argv: list[str], dump_result: VolumeOutcome) -> dict:
|
||||
"""Run main() over one volume and return the manifest's volume section.
|
||||
|
||||
Args:
|
||||
argv: the command line under test.
|
||||
dump_result: what backup_dumps_for_volume reports.
|
||||
"""
|
||||
with (
|
||||
mock.patch("sys.argv", argv),
|
||||
mock.patch.object(app, "get_machine_id", return_value="machine"),
|
||||
mock.patch.object(app, "create_version_directory", return_value="/gen"),
|
||||
mock.patch.object(app, "create_volume_directory", return_value="/gen/vol"),
|
||||
mock.patch.object(app, "load_databases_df", return_value=None),
|
||||
mock.patch.object(app, "docker_volume_names", return_value=["pgdata"]),
|
||||
mock.patch.object(app, "containers_using_volume", return_value=["db"]),
|
||||
mock.patch.object(app, "volume_is_fully_ignored", return_value=False),
|
||||
mock.patch.object(app, "backup_dumps_for_volume", return_value=dump_result),
|
||||
mock.patch.object(app, "inspect_backing", return_value=Backing("/data")),
|
||||
mock.patch.object(app, "write_manifest") as manifest,
|
||||
mock.patch.object(app, "stamp_directory"),
|
||||
mock.patch.object(app, "handle_docker_compose_services"),
|
||||
mock.patch("os.path.isdir", return_value=True),
|
||||
mock.patch.object(app, "backup_volume"),
|
||||
mock.patch.object(app, "filter_stoppable", return_value=[]),
|
||||
mock.patch.object(app, "requires_stop", return_value=False),
|
||||
mock.patch.object(app, "change_containers_status"),
|
||||
):
|
||||
app.main()
|
||||
return manifest.call_args.args[1]
|
||||
|
||||
|
||||
class TestManifest(unittest.TestCase):
|
||||
def test_a_database_volume_without_a_dump_is_recorded_as_undumped(self) -> None:
|
||||
volumes = drive(
|
||||
[*ARGV, "--only-sql"],
|
||||
VolumeOutcome(database=True, dumped=False, engine="postgres"),
|
||||
)
|
||||
self.assertEqual(
|
||||
volumes["pgdata"],
|
||||
VolumeOutcome(database=True, dumped=False, engine="postgres"),
|
||||
)
|
||||
|
||||
def test_a_dumped_database_volume_is_recorded_as_dumped(self) -> None:
|
||||
volumes = drive(
|
||||
[*ARGV, "--only-sql"],
|
||||
VolumeOutcome(database=True, dumped=True, engine="mariadb"),
|
||||
)
|
||||
self.assertEqual(volumes["pgdata"].dumped, True)
|
||||
self.assertEqual(volumes["pgdata"].engine, "mariadb")
|
||||
|
||||
def test_a_plain_volume_is_recorded_as_no_database(self) -> None:
|
||||
volumes = drive(ARGV, VolumeOutcome(database=False, dumped=False))
|
||||
self.assertEqual(volumes["pgdata"].database, False)
|
||||
self.assertIsNone(volumes["pgdata"].engine)
|
||||
|
||||
def test_the_dumped_volume_is_recorded_even_though_the_copy_is_skipped(
|
||||
self,
|
||||
) -> None:
|
||||
"""--only-sql returns to the loop head on success, before the copy."""
|
||||
volumes = drive(
|
||||
[*ARGV, "--only-sql"],
|
||||
VolumeOutcome(database=True, dumped=True, engine="postgres"),
|
||||
)
|
||||
self.assertIn("pgdata", volumes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -34,9 +34,10 @@ def drive(argv: list[str]) -> tuple[list[str], list, list]:
|
||||
mock.patch.object(app, "volume_is_fully_ignored", return_value=False),
|
||||
mock.patch.object(app, "backup_dumps_for_volume") as dumps,
|
||||
mock.patch.object(app, "inspect_backing", return_value=Backing("/data")),
|
||||
mock.patch.object(app, "write_manifest"),
|
||||
mock.patch.object(app, "stamp_directory"),
|
||||
mock.patch.object(app, "handle_docker_compose_services"),
|
||||
mock.patch.object(app.os.path, "isdir", return_value=True),
|
||||
mock.patch("os.path.isdir", return_value=True),
|
||||
mock.patch.object(app, "backup_volume", side_effect=record_backup),
|
||||
mock.patch.object(app, "filter_stoppable", return_value=[]),
|
||||
mock.patch.object(app, "requires_stop", return_value=False),
|
||||
|
||||
@@ -50,9 +50,10 @@ def drive(*, present: bool = True, reason: str | None = None) -> list[dict]:
|
||||
return_value=Backing("/var/lib/docker/volumes/vol/_data"),
|
||||
),
|
||||
mock.patch.object(snapshot_mod, "unsnapshotted", return_value=reason),
|
||||
mock.patch.object(app, "write_manifest"),
|
||||
mock.patch.object(app, "stamp_directory"),
|
||||
mock.patch.object(app, "handle_docker_compose_services"),
|
||||
mock.patch.object(app.os.path, "isdir", return_value=present),
|
||||
mock.patch("os.path.isdir", return_value=present),
|
||||
mock.patch.object(app, "backup_volume", side_effect=record),
|
||||
mock.patch.object(app, "volume_snapshot", stubbed_snapshot),
|
||||
):
|
||||
|
||||
@@ -47,9 +47,10 @@ def drive() -> tuple[list[str], list[str], list[str]]:
|
||||
mock.patch.object(app, "volume_is_fully_ignored", return_value=False),
|
||||
mock.patch.object(app, "backup_dumps_for_volume", return_value=(False, False)),
|
||||
mock.patch.object(app, "inspect_backing", return_value=Backing("/data")),
|
||||
mock.patch.object(app, "write_manifest"),
|
||||
mock.patch.object(app, "stamp_directory"),
|
||||
mock.patch.object(app, "handle_docker_compose_services"),
|
||||
mock.patch.object(app.os.path, "isdir", return_value=True),
|
||||
mock.patch("os.path.isdir", return_value=True),
|
||||
mock.patch.object(app, "backup_volume", side_effect=record_backup),
|
||||
mock.patch.object(app, "filter_stoppable", return_value=[]),
|
||||
mock.patch.object(app, "requires_stop", return_value=False),
|
||||
|
||||
@@ -2,7 +2,7 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from baudolo.backup import docker as docker_mod
|
||||
from baudolo.backup.shell import BackupException
|
||||
from baudolo.backup.shell import BackupError
|
||||
|
||||
|
||||
class TestIsSwarmTask(unittest.TestCase):
|
||||
@@ -21,7 +21,7 @@ class TestIsSwarmTask(unittest.TestCase):
|
||||
@patch.object(
|
||||
docker_mod,
|
||||
"execute_shell_command",
|
||||
side_effect=[BackupException("gone"), []],
|
||||
side_effect=[BackupError("gone"), []],
|
||||
)
|
||||
def test_vanished_container_counts_as_not_stoppable(self, _mock) -> None:
|
||||
# A container removed between listing and inspect must not abort the
|
||||
@@ -32,13 +32,13 @@ class TestIsSwarmTask(unittest.TestCase):
|
||||
@patch.object(
|
||||
docker_mod,
|
||||
"execute_shell_command",
|
||||
side_effect=[BackupException("daemon hiccup"), ["still-here"]],
|
||||
side_effect=[BackupError("daemon hiccup"), ["still-here"]],
|
||||
)
|
||||
def test_inspect_failure_on_existing_container_still_fails(self, _mock) -> None:
|
||||
# If the container still exists, an inspect failure must keep failing
|
||||
# the run: silently skipping the stop would back up a hot volume and
|
||||
# report green without the stop guarantee.
|
||||
with self.assertRaises(BackupException):
|
||||
with self.assertRaises(BackupError):
|
||||
docker_mod.is_swarm_task("still-here")
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from baudolo.backup import docker as docker_mod
|
||||
from baudolo.backup.shell import BackupException
|
||||
from baudolo.backup.shell import BackupError
|
||||
|
||||
|
||||
class TestImageId(unittest.TestCase):
|
||||
@@ -20,7 +20,7 @@ class TestHasTool(unittest.TestCase):
|
||||
|
||||
def test_a_tool_that_exits_non_zero_is_absent(self) -> None:
|
||||
with patch.object(
|
||||
docker_mod, "execute_shell_command", side_effect=BackupException("127")
|
||||
docker_mod, "execute_shell_command", side_effect=BackupError("127")
|
||||
):
|
||||
self.assertFalse(docker_mod.has_tool("c1", "mariadb-dump"))
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import pandas
|
||||
import pandas as pd
|
||||
|
||||
from baudolo.backup import dumps as dumps_mod
|
||||
|
||||
|
||||
def _df(rows):
|
||||
return pandas.DataFrame(
|
||||
rows, columns=["instance", "database", "username", "password"]
|
||||
)
|
||||
return pd.DataFrame(rows, columns=["instance", "database", "username", "password"])
|
||||
|
||||
|
||||
class _Probe:
|
||||
@@ -104,15 +102,16 @@ class TestBackupDispatch(unittest.TestCase):
|
||||
patch.object(dumps_mod, "image_id", probe.image_id),
|
||||
patch.object(dumps_mod, "backup_database", _fake_backup_database),
|
||||
):
|
||||
is_db, dumped = dumps_mod.backup_mariadb_or_postgres(
|
||||
outcome = dumps_mod.backup_mariadb_or_postgres(
|
||||
container="c1",
|
||||
volume_dir="/tmp",
|
||||
databases_df=_df([("c1", "appdb", "u", "p")]),
|
||||
database_containers=["c1"],
|
||||
)
|
||||
|
||||
self.assertTrue(is_db)
|
||||
self.assertTrue(dumped)
|
||||
self.assertTrue(outcome.database)
|
||||
self.assertTrue(outcome.dumped)
|
||||
self.assertEqual(outcome.engine, "mariadb")
|
||||
self.assertEqual(seen["db_type"], "mariadb")
|
||||
self.assertEqual(seen["dump_tool"], "mysqldump")
|
||||
|
||||
@@ -130,7 +129,7 @@ class TestBackupDispatch(unittest.TestCase):
|
||||
databases_df=_df([]),
|
||||
database_containers=[],
|
||||
),
|
||||
(False, False),
|
||||
dumps_mod.VolumeOutcome(database=False, dumped=False, engine=None),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from baudolo.backup import layout as mod
|
||||
from baudolo.backup.shell import BackupException
|
||||
from baudolo.backup.shell import BackupError
|
||||
|
||||
|
||||
class TestVersionDirectory(unittest.TestCase):
|
||||
@@ -21,7 +21,7 @@ class TestVersionDirectory(unittest.TestCase):
|
||||
def test_it_refuses_a_generation_another_run_already_claimed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mod.create_version_directory(tmp, "20260731")
|
||||
with self.assertRaises(BackupException) as caught:
|
||||
with self.assertRaises(BackupError) as caught:
|
||||
mod.create_version_directory(tmp, "20260731")
|
||||
self.assertIn("20260731", str(caught.exception))
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from baudolo.backup.shell import BackupException
|
||||
from baudolo.backup.shell import BackupError
|
||||
from baudolo.backup.snapshot import SnapshotError, volume_snapshot
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ class TestRejections(unittest.TestCase):
|
||||
class Busy(Runner):
|
||||
def __call__(self, command: list[str]) -> list[str]:
|
||||
if command[:3] == ["btrfs", "subvolume", "delete"]:
|
||||
raise BackupException("target is busy")
|
||||
raise BackupError("target is busy")
|
||||
return super().__call__(command)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user