diff --git a/README.md b/README.md index 7578eed..b70e0f5 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,8 @@ This information is used by `baudolo` to execute ```bash baudolo \ --compose-dir /srv/docker \ + --backups-dir /Backups \ + --repo-name my-repo \ --databases-csv /etc/baudolo/databases.csv \ --database-containers central-postgres central-mariadb \ --images-no-stop-required alpine postgres mariadb mysql \ @@ -133,11 +135,12 @@ baudolo \ | Flag | Description | | --------------- | ------------------------------------------- | -| `--everything` | Always stop containers and re-run rsync | -| `--dump-only-sql`| Skip file backups only for DB volumes when dumps succeed; non-DB volumes are still backed up; fallback to files if no dump. | +| `--only-sql` | Skip file backups only for DB volumes when dumps succeed; non-DB volumes are still backed up; fallback to files if no dump. | +| `--only-files` | Take no dumps at all; every volume is backed up as files. Needs no `--databases-csv`. Mutually exclusive with `--only-sql`. | | `--shutdown` | Do not restart containers after backup | -| `--backups-dir` | Backup root directory (default: `/Backups`) | -| `--repo-name` | Backup namespace under machine hash | +| `--backups-dir` | Backup root directory (required) | +| `--repo-name` | Backup namespace under machine hash (required) | +| `--databases-csv`| Path to `databases.csv` (required) | ## ♻️ Restore Operations diff --git a/src/baudolo/backup/app.py b/src/baudolo/backup/app.py index 5a699e0..356352e 100644 --- a/src/baudolo/backup/app.py +++ b/src/baudolo/backup/app.py @@ -37,7 +37,7 @@ def main() -> int: versions_dir = os.path.join(args.backups_dir, machine_id, args.repo_name) version_dir = create_version_directory(versions_dir, backup_time) - databases_df = load_databases_df(args.databases_csv) + databases_df = None if args.only_files else load_databases_df(args.databases_csv) print("💾 Start volume backups...", flush=True) @@ -69,17 +69,19 @@ def main() -> int: vol_dir = create_volume_directory(version_dir, volume_name) - found_db, dumped_any = backup_dumps_for_volume( - containers=containers, - vol_dir=vol_dir, - databases_df=databases_df, - database_containers=args.database_containers, - ) + found_db = dumped_any = False + if not args.only_files: + found_db, dumped_any = backup_dumps_for_volume( + containers=containers, + vol_dir=vol_dir, + databases_df=databases_df, + database_containers=args.database_containers, + ) - if args.dump_only_sql and found_db: + if args.only_sql and found_db: if not dumped_any: print( - f"WARNING: dump-only-sql requested but no DB dump was produced for DB volume '{volume_name}'. " + f"WARNING: only-sql requested but no DB dump was produced for DB volume '{volume_name}'. " "Falling back to file backup.", flush=True, ) @@ -119,15 +121,6 @@ def main() -> int: copy(authoritative=False) continue - if args.everything: - stoppable = filter_stoppable(containers) - copy(authoritative=False) - change_containers_status(stoppable, "stop") - copy(authoritative=True) - if not args.shutdown: - change_containers_status(stoppable, "start") - continue - copy(authoritative=False) if requires_stop(containers, args.images_no_stop_required): stoppable = filter_stoppable(containers) diff --git a/src/baudolo/backup/cli.py b/src/baudolo/backup/cli.py index db4d3b4..928309d 100644 --- a/src/baudolo/backup/cli.py +++ b/src/baudolo/backup/cli.py @@ -1,13 +1,9 @@ from __future__ import annotations import argparse -import os def parse_args() -> argparse.Namespace: - dirname = os.path.dirname(__file__) - default_databases_csv = os.path.join(dirname, "databases.csv") - p = argparse.ArgumentParser(description="Backup Docker volumes.") p.add_argument( @@ -25,13 +21,12 @@ def parse_args() -> argparse.Namespace: p.add_argument( "--repo-name", - default="backup-docker-to-local", - help="Backup repo folder name under // (default: git repo folder name)", + required=True, + help="Backup repo folder name under //", ) p.add_argument( "--databases-csv", - default=default_databases_csv, - help=f"Path to databases.csv (default: {default_databases_csv})", + help="Path to databases.csv; required unless --only-files is given", ) p.add_argument( "--backups-dir", @@ -75,19 +70,15 @@ def parse_args() -> argparse.Namespace: help="Exact volume names that are never backed up, whatever containers use them. For derived trees a restore cannot reproduce, above all a nested docker data root", ) - p.add_argument( - "--everything", - action="store_true", - help="Force file backup for all volumes and also execute database dumps (like old script)", - ) p.add_argument( "--shutdown", action="store_true", help="Do not restart containers after backup", ) - p.add_argument( - "--dump-only-sql", + scope = p.add_mutually_exclusive_group() + scope.add_argument( + "--only-sql", action="store_true", help=( "Create database dumps only for DB volumes. " @@ -96,7 +87,19 @@ def parse_args() -> argparse.Namespace: "If a DB dump cannot be produced, baudolo falls back to a file backup." ), ) + scope.add_argument( + "--only-files", + action="store_true", + help=( + "Take no database dumps at all and back up every volume as files. " + "For hosts that hold no database credentials. A database's files " + "are only consistent if its containers are stopped for the second " + "pass, so keep its image off --images-no-stop-required." + ), + ) args = p.parse_args() + if not args.only_files and not args.databases_csv: + p.error("--databases-csv is required unless --only-files is given") if bool(args.snapshot) != bool(args.snapshot_subject): p.error("--snapshot and --snapshot-subject must be given together") if args.snapshot and args.shutdown: diff --git a/src/baudolo/restore/__main__.py b/src/baudolo/restore/__main__.py index a2a3d6b..33abf5c 100644 --- a/src/baudolo/restore/__main__.py +++ b/src/baudolo/restore/__main__.py @@ -22,8 +22,8 @@ def _add_common_backup_args(p: argparse.ArgumentParser) -> None: ) p.add_argument( "--repo-name", - default="backup-docker-to-local", - help="Backup repo folder name under // (default: backup-docker-to-local)", + required=True, + help="Backup repo folder name under //", ) diff --git a/tests/e2e/helpers/fixtures.py b/tests/e2e/helpers/fixtures.py index 8c1d88f..59de1c0 100644 --- a/tests/e2e/helpers/fixtures.py +++ b/tests/e2e/helpers/fixtures.py @@ -24,7 +24,7 @@ def backup_run( database_containers: list[str], images_no_stop_required: list[str], images_no_backup_required: list[str] | None = None, - dump_only_sql: bool = False, + only_sql: bool = False, ) -> None: cmd = [ "baudolo", @@ -45,8 +45,8 @@ def backup_run( ] if images_no_backup_required: cmd += ["--images-no-backup-required", *images_no_backup_required] - if dump_only_sql: - cmd += ["--dump-only-sql"] + if only_sql: + cmd += ["--only-sql"] try: run(cmd, capture=True, check=True) diff --git a/tests/e2e/test_e2e_cli_contract_dump_only_sql.py b/tests/e2e/test_e2e_cli_contract_dump_only_sql.py deleted file mode 100644 index 5f7773d..0000000 --- a/tests/e2e/test_e2e_cli_contract_dump_only_sql.py +++ /dev/null @@ -1,29 +0,0 @@ -import unittest - -from .helpers import run - - -class TestE2ECLIContractDumpOnlySql(unittest.TestCase): - def test_help_mentions_new_flag(self) -> None: - cp = run(["baudolo", "--help"], capture=True, check=True) - out = (cp.stdout or "") + "\n" + (cp.stderr or "") - self.assertIn( - "--dump-only-sql", - out, - f"Expected '--dump-only-sql' to appear in --help output. Output:\n{out}", - ) - - def test_old_flag_is_rejected(self) -> None: - cp = run(["baudolo", "--dump-only"], capture=True, check=False) - self.assertEqual( - cp.returncode, - 2, - f"Expected exitcode 2 for unknown args, got {cp.returncode}\n" - f"STDOUT={cp.stdout}\nSTDERR={cp.stderr}", - ) - err = (cp.stderr or "") + "\n" + (cp.stdout or "") - # Argparse typically prints "unrecognized arguments" - self.assertTrue( - ("unrecognized arguments" in err) or ("usage:" in err.lower()), - f"Expected argparse-style error output. Output:\n{err}", - ) diff --git a/tests/e2e/test_e2e_cli_contract_only_sql.py b/tests/e2e/test_e2e_cli_contract_only_sql.py new file mode 100644 index 0000000..93efb5c --- /dev/null +++ b/tests/e2e/test_e2e_cli_contract_only_sql.py @@ -0,0 +1,33 @@ +import unittest + +from .helpers import run + +WITHDRAWN_FLAGS = ["--dump-only", "--dump-only-sql", "--everything"] + + +class TestE2ECLIContractOnlySql(unittest.TestCase): + def test_help_mentions_the_flag(self) -> None: + cp = run(["baudolo", "--help"], capture=True, check=True) + out = (cp.stdout or "") + "\n" + (cp.stderr or "") + self.assertIn( + "--only-sql", + out, + f"Expected '--only-sql' to appear in --help output. Output:\n{out}", + ) + + def test_a_withdrawn_flag_is_rejected(self) -> None: + for flag in WITHDRAWN_FLAGS: + with self.subTest(flag=flag): + cp = run(["baudolo", flag], capture=True, check=False) + self.assertEqual( + cp.returncode, + 2, + f"Expected exitcode 2 for unknown args, got {cp.returncode}\n" + f"STDOUT={cp.stdout}\nSTDERR={cp.stderr}", + ) + err = (cp.stderr or "") + "\n" + (cp.stdout or "") + # Argparse typically prints "unrecognized arguments" + self.assertTrue( + ("unrecognized arguments" in err) or ("usage:" in err.lower()), + f"Expected argparse-style error output. Output:\n{err}", + ) diff --git a/tests/e2e/test_e2e_files_no_copy.py b/tests/e2e/test_e2e_files_no_copy.py index 0298064..5bf8981 100644 --- a/tests/e2e/test_e2e_files_no_copy.py +++ b/tests/e2e/test_e2e_files_no_copy.py @@ -47,7 +47,7 @@ class TestE2EFilesNoCopy(unittest.TestCase): cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv" write_databases_csv(cls.databases_csv, []) - # dump-only-sql => non-DB volumes are STILL backed up as files + # only-sql => non-DB volumes are STILL backed up as files backup_run( backups_dir=cls.backups_dir, repo_name=cls.repo_name, @@ -55,7 +55,7 @@ class TestE2EFilesNoCopy(unittest.TestCase): databases_csv=cls.databases_csv, database_containers=["dummy-db"], images_no_stop_required=["alpine:3.20"], - dump_only_sql=True, + only_sql=True, ) cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name) diff --git a/tests/e2e/test_e2e_mariadb_no_copy.py b/tests/e2e/test_e2e_mariadb_no_copy.py index 181ef6e..27883c0 100644 --- a/tests/e2e/test_e2e_mariadb_no_copy.py +++ b/tests/e2e/test_e2e_mariadb_no_copy.py @@ -89,7 +89,7 @@ class TestE2EMariaDBNoCopy(unittest.TestCase): [(cls.db_container, cls.db_name, cls.db_user, cls.db_password)], ) - # dump-only-sql => no files + # only-sql => no files backup_run( backups_dir=cls.backups_dir, repo_name=cls.repo_name, @@ -97,7 +97,7 @@ class TestE2EMariaDBNoCopy(unittest.TestCase): databases_csv=cls.databases_csv, database_containers=[cls.db_container], images_no_stop_required=[MARIADB_IMAGE], - dump_only_sql=True, + only_sql=True, ) cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name) diff --git a/tests/e2e/test_e2e_only_files.py b/tests/e2e/test_e2e_only_files.py new file mode 100644 index 0000000..91674cf --- /dev/null +++ b/tests/e2e/test_e2e_only_files.py @@ -0,0 +1,116 @@ +"""--only-files backs a database up as a file tree and asks for no credentials. + +The run deliberately passes no --databases-csv at all: a host that only copies +files has no reason to hold database passwords, and requiring the file would +make the flag useless there. +""" + +import unittest + +from .helpers import ( + POSTGRES_DATA_DIR, + POSTGRES_IMAGE, + backup_path, + cleanup_docker, + create_minimal_compose_dir, + ensure_empty_dir, + latest_version_dir, + require_docker, + run, + unique, + wait_for_postgres, +) + +MARKER = "only-files-marker" + + +class TestE2EOnlyFiles(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + require_docker() + cls.prefix = unique("baudolo-e2e-only-files") + cls.backups_dir = f"/tmp/{cls.prefix}/Backups" + ensure_empty_dir(cls.backups_dir) + cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}") + cls.repo_name = cls.prefix + + cls.pg_container = f"{cls.prefix}-pg" + cls.pg_volume = f"{cls.prefix}-pg-vol" + cls.containers = [cls.pg_container] + cls.volumes = [cls.pg_volume] + + run(["docker", "volume", "create", cls.pg_volume]) + run( + [ + "docker", + "run", + "-d", + "--name", + cls.pg_container, + "-e", + "POSTGRES_PASSWORD=pgpw", + "-e", + "POSTGRES_DB=appdb", + "-e", + "POSTGRES_USER=postgres", + "-v", + f"{cls.pg_volume}:{POSTGRES_DATA_DIR}", + POSTGRES_IMAGE, + ] + ) + wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90) + run( + [ + "docker", + "exec", + cls.pg_container, + "sh", + "-lc", + f"echo '{MARKER}' > {POSTGRES_DATA_DIR}/marker.txt", + ] + ) + + cp = run( + [ + "baudolo", + "--compose-dir", + cls.compose_dir, + "--repo-name", + cls.repo_name, + "--backups-dir", + cls.backups_dir, + "--images-no-stop-required", + POSTGRES_IMAGE, + "--only-files", + ], + capture=True, + check=True, + ) + cls.stdout = cp.stdout or "" + cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name) + + @classmethod + def tearDownClass(cls) -> None: + cleanup_docker(containers=cls.containers, volumes=cls.volumes) + + def _volume_dir(self): + return backup_path( + self.backups_dir, self.repo_name, self.version, self.pg_volume + ) + + def test_the_database_volume_is_backed_up_as_files(self) -> None: + marker = self._volume_dir() / "files" / "marker.txt" + self.assertTrue(marker.is_file(), f"expected a file backup at {marker}") + self.assertEqual(marker.read_text(encoding="utf-8").strip(), MARKER) + + def test_no_dump_is_written(self) -> None: + sql_dir = self._volume_dir() / "sql" + dumps = list(sql_dir.glob("*.sql")) if sql_dir.exists() else [] + self.assertEqual(dumps, [], f"did not expect any dump, found: {dumps}") + + def test_the_missing_databases_csv_is_not_reported(self) -> None: + self.assertNotIn("databases.csv", self.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/e2e/test_e2e_dump_only_fallback_to_files.py b/tests/e2e/test_e2e_only_sql_fallback_to_files.py similarity index 93% rename from tests/e2e/test_e2e_dump_only_fallback_to_files.py rename to tests/e2e/test_e2e_only_sql_fallback_to_files.py index 327510e..8c6cb63 100644 --- a/tests/e2e/test_e2e_dump_only_fallback_to_files.py +++ b/tests/e2e/test_e2e_only_sql_fallback_to_files.py @@ -16,11 +16,11 @@ from .helpers import ( ) -class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase): +class TestE2EOnlySqlFallbackToFiles(unittest.TestCase): @classmethod def setUpClass(cls) -> None: require_docker() - cls.prefix = unique("baudolo-e2e-dump-only-sql-fallback") + cls.prefix = unique("baudolo-e2e-only-sql-fallback") cls.backups_dir = f"/tmp/{cls.prefix}/Backups" ensure_empty_dir(cls.backups_dir) @@ -57,7 +57,7 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase): wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90) # Add a deterministic marker file into the volume - cls.marker = "dump-only-sql-fallback-marker" + cls.marker = "only-sql-fallback-marker" run( [ "docker", @@ -73,7 +73,7 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase): cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv" write_databases_csv(cls.databases_csv, []) # empty except header - # Run baudolo with --dump-only-sql and a DB container present: + # Run baudolo with --only-sql and a DB container present: # Expected: WARNING + FALLBACK to file backup (files/ must exist) cmd = [ "baudolo", @@ -91,7 +91,7 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase): cls.pg_container, "--images-no-stop-required", POSTGRES_IMAGE, - "--dump-only-sql", + "--only-sql", ] cp = run(cmd, capture=True, check=True) @@ -122,7 +122,7 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase): def test_warns_about_missing_dump_in_dump_only_mode(self) -> None: self.assertIn( - "WARNING: dump-only-sql requested but no DB dump was produced", + "WARNING: only-sql requested but no DB dump was produced", self.stdout, f"Expected warning in baudolo output. STDOUT:\n{self.stdout}", ) diff --git a/tests/e2e/test_e2e_dump_only_sql_mixed_run.py b/tests/e2e/test_e2e_only_sql_mixed_run.py similarity index 94% rename from tests/e2e/test_e2e_dump_only_sql_mixed_run.py rename to tests/e2e/test_e2e_only_sql_mixed_run.py index d084450..153f086 100644 --- a/tests/e2e/test_e2e_dump_only_sql_mixed_run.py +++ b/tests/e2e/test_e2e_only_sql_mixed_run.py @@ -16,11 +16,11 @@ from .helpers import ( ) -class TestE2EDumpOnlySqlMixedRun(unittest.TestCase): +class TestE2EOnlySqlMixedRun(unittest.TestCase): @classmethod def setUpClass(cls) -> None: require_docker() - cls.prefix = unique("baudolo-e2e-dump-only-sql-mixed-run") + cls.prefix = unique("baudolo-e2e-only-sql-mixed-run") cls.backups_dir = f"/tmp/{cls.prefix}/Backups" ensure_empty_dir(cls.backups_dir) @@ -123,7 +123,7 @@ class TestE2EDumpOnlySqlMixedRun(unittest.TestCase): cls.pg_container, "--images-no-stop-required", POSTGRES_IMAGE, - "--dump-only-sql", + "--only-sql", "--backups-dir", cls.backups_dir, "--repo-name", @@ -170,8 +170,8 @@ class TestE2EDumpOnlySqlMixedRun(unittest.TestCase): f"Expected files dir for non-DB volume at: {files}", ) - def test_dump_only_sql_does_not_disable_non_db_files_backup(self) -> None: - # Regression guard: even with --dump-only-sql, non-DB volumes must still be backed up as files + def test_only_sql_does_not_disable_non_db_files_backup(self) -> None: + # Regression guard: even with --only-sql, non-DB volumes must still be backed up as files base = backup_path( self.backups_dir, self.repo_name, self.version, self.files_volume ) diff --git a/tests/e2e/test_e2e_postgres_no_copy.py b/tests/e2e/test_e2e_postgres_no_copy.py index d2aa7b6..745753f 100644 --- a/tests/e2e/test_e2e_postgres_no_copy.py +++ b/tests/e2e/test_e2e_postgres_no_copy.py @@ -76,7 +76,7 @@ class TestE2EPostgresNoCopy(unittest.TestCase): databases_csv=cls.databases_csv, database_containers=[cls.pg_container], images_no_stop_required=[POSTGRES_IMAGE], - dump_only_sql=True, + only_sql=True, ) cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name) diff --git a/tests/e2e/test_e2e_seed_star_and_db_entries_backup_postgres.py b/tests/e2e/test_e2e_seed_star_and_db_entries_backup_postgres.py index 425fc87..e4f1be3 100644 --- a/tests/e2e/test_e2e_seed_star_and_db_entries_backup_postgres.py +++ b/tests/e2e/test_e2e_seed_star_and_db_entries_backup_postgres.py @@ -157,7 +157,6 @@ class TestE2ESeedStarAndDbEntriesBackupPostgres(unittest.TestCase): ] ) - # --- Run baudolo with dump-only-sql --- cmd = [ "baudolo", "--compose-dir", @@ -168,7 +167,7 @@ class TestE2ESeedStarAndDbEntriesBackupPostgres(unittest.TestCase): cls.pg_container, "--images-no-stop-required", POSTGRES_IMAGE, - "--dump-only-sql", + "--only-sql", "--backups-dir", cls.backups_dir, "--repo-name", @@ -194,7 +193,7 @@ class TestE2ESeedStarAndDbEntriesBackupPostgres(unittest.TestCase): self.assertTrue(sql_dir.exists(), f"Expected sql dir at: {sql_dir}") self.assertFalse( files_dir.exists(), - f"Did not expect files dir for DB volume when dump-only-sql succeeded: {files_dir}", + f"Did not expect files dir for DB volume when only-sql succeeded: {files_dir}", ) # Cluster dump file produced by '*' entry diff --git a/tests/unit/backup/__init__.py b/tests/unit/backup/__init__.py index e69de29..6e919cc 100644 --- a/tests/unit/backup/__init__.py +++ b/tests/unit/backup/__init__.py @@ -0,0 +1,10 @@ +"""The smallest argv the backup CLI accepts, shared by every test that drives it.""" + +REQUIRED_PAIRS = [ + ("--compose-dir", "/compose"), + ("--backups-dir", "/backups"), + ("--repo-name", "stack"), + ("--databases-csv", "/etc/baudolo/databases.csv"), +] +REQUIRED = [arg for pair in REQUIRED_PAIRS for arg in pair] +BASE_ARGV = ["baudolo", *REQUIRED] diff --git a/tests/unit/backup/test_app_only_files.py b/tests/unit/backup/test_app_only_files.py new file mode 100644 index 0000000..4da2ff5 --- /dev/null +++ b/tests/unit/backup/test_app_only_files.py @@ -0,0 +1,65 @@ +"""Contract of --only-files: no dump is attempted, every volume is copied.""" + +from __future__ import annotations + +import unittest +from unittest import mock + +from baudolo.backup import app +from baudolo.backup.volume import Backing + +from . import REQUIRED_PAIRS + +ARGV_WITHOUT_CSV = [ + "baudolo", + *[arg for pair in REQUIRED_PAIRS if pair[0] != "--databases-csv" for arg in pair], + "--only-files", +] + + +def drive(argv: list[str]) -> tuple[list[str], list, list]: + backed_up: list[str] = [] + + def record_backup(versions_dir, volume_name, volume_dir, *, authoritative, source): + backed_up.append(volume_name) + + 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") as load_csv, + 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") as dumps, + mock.patch.object(app, "inspect_backing", return_value=Backing("/data")), + 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.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), + mock.patch.object(app, "change_containers_status"), + ): + app.main() + return backed_up, dumps.mock_calls, load_csv.mock_calls + + +class TestOnlyFiles(unittest.TestCase): + def test_no_dump_is_attempted(self) -> None: + _backed_up, dumps, _load_csv = drive(ARGV_WITHOUT_CSV) + self.assertEqual(dumps, []) + + def test_the_databases_csv_is_never_read(self) -> None: + """It may legitimately be absent, so reading it would abort the run.""" + _backed_up, _dumps, load_csv = drive(ARGV_WITHOUT_CSV) + self.assertEqual(load_csv, []) + + def test_the_volume_is_still_copied(self) -> None: + backed_up, _dumps, _load_csv = drive(ARGV_WITHOUT_CSV) + self.assertEqual(backed_up, ["pgdata"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/backup/test_app_snapshot.py b/tests/unit/backup/test_app_snapshot.py index 19c25dd..dda8749 100644 --- a/tests/unit/backup/test_app_snapshot.py +++ b/tests/unit/backup/test_app_snapshot.py @@ -10,17 +10,15 @@ from baudolo.backup import snapshot as snapshot_mod from baudolo.backup.snapshot import volume_snapshot from baudolo.backup.volume import Backing +from . import BASE_ARGV + def stubbed_snapshot(kind: str, subject: str, tag: str): return volume_snapshot(kind, subject, tag, run=lambda command: []) ARGV = [ - "baudolo", - "--compose-dir", - "/compose", - "--backups-dir", - "/backups", + *BASE_ARGV, "--snapshot", "btrfs", "--snapshot-subject", diff --git a/tests/unit/backup/test_app_volumes_no_backup_required.py b/tests/unit/backup/test_app_volumes_no_backup_required.py index 93a8971..cf64570 100644 --- a/tests/unit/backup/test_app_volumes_no_backup_required.py +++ b/tests/unit/backup/test_app_volumes_no_backup_required.py @@ -9,12 +9,10 @@ from unittest import mock from baudolo.backup import app from baudolo.backup.volume import Backing +from . import BASE_ARGV + ARGV = [ - "baudolo", - "--compose-dir", - "/compose", - "--backups-dir", - "/backups", + *BASE_ARGV, "--volumes-no-backup-required", "derived", ] diff --git a/tests/unit/backup/test_cli.py b/tests/unit/backup/test_cli.py index 07b5cd7..b31f4de 100644 --- a/tests/unit/backup/test_cli.py +++ b/tests/unit/backup/test_cli.py @@ -7,7 +7,7 @@ from unittest import mock from baudolo.backup.cli import parse_args -REQUIRED = ["--compose-dir", "/compose", "--backups-dir", "/backups"] +from . import REQUIRED, REQUIRED_PAIRS def parse(*extra: str): @@ -68,19 +68,42 @@ class TestSnapshotFlags(unittest.TestCase): class TestRequiredFlags(unittest.TestCase): - def test_backups_dir_is_required(self) -> None: - with ( - mock.patch("sys.argv", ["baudolo", "--compose-dir", "/compose"]), - self.assertRaises(SystemExit), - ): - parse_args() + def test_no_flag_falls_back_to_a_default(self) -> None: + for omitted, _ in REQUIRED_PAIRS: + argv = [a for pair in REQUIRED_PAIRS if pair[0] != omitted for a in pair] + with ( + self.subTest(omitted=omitted), + mock.patch("sys.argv", ["baudolo", *argv]), + self.assertRaises(SystemExit), + ): + parse_args() - def test_compose_dir_is_required(self) -> None: - with ( - mock.patch("sys.argv", ["baudolo", "--backups-dir", "/backups"]), - self.assertRaises(SystemExit), - ): - parse_args() + +class TestBackupScope(unittest.TestCase): + """--only-sql and --only-files name the two halves a generation can hold.""" + + def test_both_halves_by_default(self) -> None: + args = parse() + self.assertFalse(args.only_sql) + self.assertFalse(args.only_files) + + def test_either_half_alone_is_accepted(self) -> None: + self.assertTrue(parse("--only-sql").only_sql) + self.assertTrue(parse("--only-files").only_files) + + def test_asking_for_both_halves_alone_is_rejected(self) -> None: + with self.assertRaises(SystemExit): + parse("--only-sql", "--only-files") + + def test_only_files_needs_no_databases_csv(self) -> None: + argv = [ + arg + for pair in REQUIRED_PAIRS + if pair[0] != "--databases-csv" + for arg in pair + ] + with mock.patch("sys.argv", ["baudolo", *argv, "--only-files"]): + self.assertIsNone(parse_args().databases_csv) if __name__ == "__main__": diff --git a/tests/unit/backup/test_compose_hard_restart.py b/tests/unit/backup/test_compose_hard_restart.py index 7533f4d..ac354ea 100644 --- a/tests/unit/backup/test_compose_hard_restart.py +++ b/tests/unit/backup/test_compose_hard_restart.py @@ -3,6 +3,8 @@ from __future__ import annotations import unittest from unittest.mock import patch +from . import BASE_ARGV + class HardRestartArgTests(unittest.TestCase): """The hard-restart list defaults to empty (no compose down/up); callers @@ -16,11 +18,7 @@ class HardRestartArgTests(unittest.TestCase): from baudolo.backup import cli argv = [ - "baudolo", - "--compose-dir", - "/tmp", - "--backups-dir", - "/tmp/backup", + *BASE_ARGV, "--database-containers", "postgres", "--images-no-stop-required", @@ -42,23 +40,6 @@ class HardRestartArgTests(unittest.TestCase): args = self._parse(["--hard-restart-projects", "mailu", "foo"]) self.assertEqual(args.hard_restart_projects, ["mailu", "foo"]) - def test_backups_dir_is_required(self) -> None: - import sys - - from baudolo.backup import cli - - argv = [ - "baudolo", - "--compose-dir", - "/tmp", - "--database-containers", - "postgres", - "--images-no-stop-required", - "redis", - ] - with patch.object(sys, "argv", argv), self.assertRaises(SystemExit): - cli.parse_args() - if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/unit/restore/test_cli_version_flag.py b/tests/unit/restore/test_cli_version_flag.py index 880800f..a849f18 100644 --- a/tests/unit/restore/test_cli_version_flag.py +++ b/tests/unit/restore/test_cli_version_flag.py @@ -18,6 +18,8 @@ class TestVersionFlagReachesEveryEngine(unittest.TestCase): "app_vol", "hash", "20260817000000", + "--repo-name", + "repo", "--container", "db", "--db-password",