diff --git a/src/baudolo/backup/app.py b/src/baudolo/backup/app.py index 857d3ca..2829a52 100644 --- a/src/baudolo/backup/app.py +++ b/src/baudolo/backup/app.py @@ -52,7 +52,7 @@ def is_image_ignored(container: str, images_no_backup_required: list[str]) -> bo if not images_no_backup_required: return False img = get_image_info(container) - return any(pat in img for pat in images_no_backup_required) + return img in images_no_backup_required def volume_is_fully_ignored( @@ -68,15 +68,15 @@ def volume_is_fully_ignored( def requires_stop(containers: list[str], images_no_stop_required: list[str]) -> bool: """ - Stop is required if ANY stoppable container image is NOT in the - whitelist patterns. Swarm task containers never count: baudolo must + Stop is required if ANY stoppable container image is NOT in the exact + image whitelist. Swarm task containers never count: baudolo must not cycle them (see docker.is_swarm_task). """ for c in containers: if is_swarm_task(c): continue img = get_image_info(c) - if not any(pat in img for pat in images_no_stop_required): + if img not in images_no_stop_required: return True return False @@ -247,6 +247,6 @@ def main() -> int: print("Finished volume backups.", flush=True) print("Handling Docker Compose services...", flush=True) - handle_docker_compose_services(args.compose_dir, args.hard_compose_restart) + handle_docker_compose_services(args.compose_dir, args.hard_restart_projects) return 0 diff --git a/src/baudolo/backup/cli.py b/src/baudolo/backup/cli.py index f004979..44278e7 100644 --- a/src/baudolo/backup/cli.py +++ b/src/baudolo/backup/cli.py @@ -17,7 +17,7 @@ def parse_args() -> argparse.Namespace: help="Path to the parent directory containing docker-compose setups", ) p.add_argument( - "--hard-compose-restart", + "--hard-restart-projects", nargs="*", default=[], help="Compose dir names that require 'docker-compose down && up -d' (default: none; pass e.g. 'mailu' under compose where the DB cannot be backed up hot)", @@ -49,13 +49,13 @@ def parse_args() -> argparse.Namespace: "--images-no-stop-required", nargs="+", required=True, - help="Image name patterns for which containers should not be stopped during file backup", + help="Exact image references (repo:tag, incl. any registry prefix) whose containers must not be stopped during file backup", ) p.add_argument( "--images-no-backup-required", nargs="+", default=[], - help="Image name patterns for which no backup should be performed", + help="Exact image references (repo:tag, incl. any registry prefix) for which no backup should be performed", ) p.add_argument( diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 2d4a609..56e6f4b 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -7,6 +7,14 @@ import time import uuid from pathlib import Path +# SPOT for the database images and their in-container data dirs the e2e +# suite runs against. postgres:alpine tracks latest (18+), which mounts at +# /var/lib/postgresql (not /var/lib/postgresql/data); bump here only. +POSTGRES_IMAGE = "postgres:alpine" +POSTGRES_DATA_DIR = "/var/lib/postgresql" +MARIADB_IMAGE = "mariadb:latest" +MARIADB_DATA_DIR = "/var/lib/mysql" + def run( cmd: list[str], @@ -172,7 +180,7 @@ def backup_run( "baudolo", "--compose-dir", compose_dir, - "--docker-compose-hard-restart-required", + "--hard-restart-projects", "mailu", "--repo-name", repo_name, diff --git a/tests/e2e/test_e2e_dump_only_fallback_to_files.py b/tests/e2e/test_e2e_dump_only_fallback_to_files.py index fa458c1..9f44915 100644 --- a/tests/e2e/test_e2e_dump_only_fallback_to_files.py +++ b/tests/e2e/test_e2e_dump_only_fallback_to_files.py @@ -2,6 +2,8 @@ import unittest from .helpers import ( + POSTGRES_IMAGE, + POSTGRES_DATA_DIR, backup_path, cleanup_docker, create_minimal_compose_dir, @@ -50,8 +52,8 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase): "-e", "POSTGRES_USER=postgres", "-v", - f"{cls.pg_volume}:/var/lib/postgresql/data", - "postgres:16", + f"{cls.pg_volume}:{POSTGRES_DATA_DIR}", + POSTGRES_IMAGE, ] ) wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90) @@ -65,7 +67,7 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase): cls.pg_container, "sh", "-lc", - f"echo '{cls.marker}' > /var/lib/postgresql/data/marker.txt", + f"echo '{cls.marker}' > {POSTGRES_DATA_DIR}/marker.txt", ] ) @@ -79,7 +81,7 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase): "baudolo", "--compose-dir", cls.compose_dir, - "--docker-compose-hard-restart-required", + "--hard-restart-projects", "mailu", "--repo-name", cls.repo_name, @@ -90,10 +92,7 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase): "--database-containers", cls.pg_container, "--images-no-stop-required", - "postgres", - "mariadb", - "mysql", - "alpine", + POSTGRES_IMAGE, "--dump-only-sql", ] cp = run(cmd, capture=True, check=True) @@ -116,8 +115,6 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase): cls.repo_name, "--source-volume", cls.pg_volume, - "--rsync-image", - "ghcr.io/kevinveenbirkenbach/alpine-rsync", ] ) diff --git a/tests/e2e/test_e2e_dump_only_sql_mixed_run.py b/tests/e2e/test_e2e_dump_only_sql_mixed_run.py index ae94df8..fd5be66 100644 --- a/tests/e2e/test_e2e_dump_only_sql_mixed_run.py +++ b/tests/e2e/test_e2e_dump_only_sql_mixed_run.py @@ -1,6 +1,8 @@ import unittest from .helpers import ( + POSTGRES_IMAGE, + POSTGRES_DATA_DIR, backup_path, cleanup_docker, create_minimal_compose_dir, @@ -70,8 +72,8 @@ class TestE2EDumpOnlySqlMixedRun(unittest.TestCase): "-e", f"POSTGRES_PASSWORD={cls.pg_password}", "-v", - f"{cls.db_volume}:/var/lib/postgresql/data", - "postgres:16-alpine", + f"{cls.db_volume}:{POSTGRES_DATA_DIR}", + POSTGRES_IMAGE, ] ) wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90) @@ -122,10 +124,7 @@ class TestE2EDumpOnlySqlMixedRun(unittest.TestCase): "--database-containers", cls.pg_container, "--images-no-stop-required", - "alpine", - "postgres", - "mariadb", - "mysql", + POSTGRES_IMAGE, "--dump-only-sql", "--backups-dir", cls.backups_dir, diff --git a/tests/e2e/test_e2e_files_full.py b/tests/e2e/test_e2e_files_full.py index e40d18b..1223669 100644 --- a/tests/e2e/test_e2e_files_full.py +++ b/tests/e2e/test_e2e_files_full.py @@ -57,7 +57,7 @@ class TestE2EFilesFull(unittest.TestCase): compose_dir=cls.compose_dir, databases_csv=cls.databases_csv, database_containers=["dummy-db"], - images_no_stop_required=["alpine", "postgres", "mariadb", "mysql"], + images_no_stop_required=["alpine:3.20"], ) cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name) @@ -94,8 +94,6 @@ class TestE2EFilesFull(unittest.TestCase): self.repo_name, "--source-volume", self.volume_src, - "--rsync-image", - "ghcr.io/kevinveenbirkenbach/alpine-rsync", ] ) diff --git a/tests/e2e/test_e2e_files_no_copy.py b/tests/e2e/test_e2e_files_no_copy.py index 6f6a892..8ed2216 100644 --- a/tests/e2e/test_e2e_files_no_copy.py +++ b/tests/e2e/test_e2e_files_no_copy.py @@ -55,7 +55,7 @@ class TestE2EFilesNoCopy(unittest.TestCase): compose_dir=cls.compose_dir, databases_csv=cls.databases_csv, database_containers=["dummy-db"], - images_no_stop_required=["alpine", "postgres", "mariadb", "mysql"], + images_no_stop_required=["alpine:3.20"], dump_only_sql=True, ) diff --git a/tests/e2e/test_e2e_images_no_backup_required_early_skip.py b/tests/e2e/test_e2e_images_no_backup_required_early_skip.py index 619d38f..76d54b2 100644 --- a/tests/e2e/test_e2e_images_no_backup_required_early_skip.py +++ b/tests/e2e/test_e2e_images_no_backup_required_early_skip.py @@ -76,7 +76,7 @@ class TestE2EImagesNoBackupRequiredEarlySkip(unittest.TestCase): "baudolo", "--compose-dir", cls.compose_dir, - "--docker-compose-hard-restart-required", + "--hard-restart-projects", "mailu", "--repo-name", cls.repo_name, @@ -87,13 +87,9 @@ class TestE2EImagesNoBackupRequiredEarlySkip(unittest.TestCase): "--database-containers", "dummy-db", "--images-no-stop-required", - "alpine", - "redis", - "postgres", - "mariadb", - "mysql", + "redis:alpine", "--images-no-backup-required", - "redis", + "redis:alpine", ] cp = run(cmd, capture=True, check=True) cls.stdout = cp.stdout or "" diff --git a/tests/e2e/test_e2e_mariadb_anonymous_preemption.py b/tests/e2e/test_e2e_mariadb_anonymous_preemption.py index c16f745..4a9171b 100644 --- a/tests/e2e/test_e2e_mariadb_anonymous_preemption.py +++ b/tests/e2e/test_e2e_mariadb_anonymous_preemption.py @@ -30,6 +30,8 @@ import pandas from baudolo.backup import db as db_mod from .helpers import ( + MARIADB_IMAGE, + MARIADB_DATA_DIR, cleanup_docker, require_docker, run, @@ -69,8 +71,8 @@ class TestE2EMariaDBAnonymousPreemption(unittest.TestCase): "-e", f"MARIADB_ROOT_PASSWORD={cls.root_password}", "-v", - f"{cls.db_volume}:/var/lib/mysql", - "mariadb:12.2", + f"{cls.db_volume}:{MARIADB_DATA_DIR}", + MARIADB_IMAGE, ] ) diff --git a/tests/e2e/test_e2e_mariadb_full.py b/tests/e2e/test_e2e_mariadb_full.py index 3f23b9a..7541ed4 100644 --- a/tests/e2e/test_e2e_mariadb_full.py +++ b/tests/e2e/test_e2e_mariadb_full.py @@ -2,6 +2,8 @@ import unittest from .helpers import ( + MARIADB_IMAGE, + MARIADB_DATA_DIR, backup_run, backup_path, cleanup_docker, @@ -56,8 +58,8 @@ class TestE2EMariaDBFull(unittest.TestCase): "-e", f"MARIADB_PASSWORD={cls.db_password}", "-v", - f"{cls.db_volume}:/var/lib/mysql", - "mariadb:11", + f"{cls.db_volume}:{MARIADB_DATA_DIR}", + MARIADB_IMAGE, ] ) @@ -97,7 +99,7 @@ class TestE2EMariaDBFull(unittest.TestCase): compose_dir=cls.compose_dir, databases_csv=cls.databases_csv, database_containers=[cls.db_container], - images_no_stop_required=["mariadb", "mysql", "alpine", "postgres"], + images_no_stop_required=[MARIADB_IMAGE], ) 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 68a0dca..a0272a8 100644 --- a/tests/e2e/test_e2e_mariadb_no_copy.py +++ b/tests/e2e/test_e2e_mariadb_no_copy.py @@ -2,6 +2,8 @@ import unittest from .helpers import ( + MARIADB_IMAGE, + MARIADB_DATA_DIR, backup_run, backup_path, cleanup_docker, @@ -55,8 +57,8 @@ class TestE2EMariaDBNoCopy(unittest.TestCase): "-e", f"MARIADB_PASSWORD={cls.db_password}", "-v", - f"{cls.db_volume}:/var/lib/mysql", - "mariadb:11", + f"{cls.db_volume}:{MARIADB_DATA_DIR}", + MARIADB_IMAGE, ] ) @@ -94,7 +96,7 @@ class TestE2EMariaDBNoCopy(unittest.TestCase): compose_dir=cls.compose_dir, databases_csv=cls.databases_csv, database_containers=[cls.db_container], - images_no_stop_required=["mariadb", "mysql", "alpine", "postgres"], + images_no_stop_required=[MARIADB_IMAGE], dump_only_sql=True, ) diff --git a/tests/e2e/test_e2e_postgres_full.py b/tests/e2e/test_e2e_postgres_full.py index fde00ad..4124812 100644 --- a/tests/e2e/test_e2e_postgres_full.py +++ b/tests/e2e/test_e2e_postgres_full.py @@ -2,6 +2,8 @@ import unittest from .helpers import ( + POSTGRES_IMAGE, + POSTGRES_DATA_DIR, backup_run, backup_path, cleanup_docker, @@ -47,8 +49,8 @@ class TestE2EPostgresFull(unittest.TestCase): "-e", "POSTGRES_USER=postgres", "-v", - f"{cls.pg_volume}:/var/lib/postgresql/data", - "postgres:16", + f"{cls.pg_volume}:{POSTGRES_DATA_DIR}", + POSTGRES_IMAGE, ] ) wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90) @@ -76,7 +78,7 @@ class TestE2EPostgresFull(unittest.TestCase): compose_dir=cls.compose_dir, databases_csv=cls.databases_csv, database_containers=[cls.pg_container], - images_no_stop_required=["postgres", "mariadb", "mysql", "alpine"], + images_no_stop_required=[POSTGRES_IMAGE], ) cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name) diff --git a/tests/e2e/test_e2e_postgres_no_copy.py b/tests/e2e/test_e2e_postgres_no_copy.py index 28570a8..96961c4 100644 --- a/tests/e2e/test_e2e_postgres_no_copy.py +++ b/tests/e2e/test_e2e_postgres_no_copy.py @@ -2,6 +2,8 @@ import unittest from .helpers import ( + POSTGRES_IMAGE, + POSTGRES_DATA_DIR, backup_run, backup_path, cleanup_docker, @@ -46,8 +48,8 @@ class TestE2EPostgresNoCopy(unittest.TestCase): "-e", "POSTGRES_USER=postgres", "-v", - f"{cls.pg_volume}:/var/lib/postgresql/data", - "postgres:16", + f"{cls.pg_volume}:{POSTGRES_DATA_DIR}", + POSTGRES_IMAGE, ] ) wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90) @@ -74,7 +76,7 @@ class TestE2EPostgresNoCopy(unittest.TestCase): compose_dir=cls.compose_dir, databases_csv=cls.databases_csv, database_containers=[cls.pg_container], - images_no_stop_required=["postgres", "mariadb", "mysql", "alpine"], + images_no_stop_required=[POSTGRES_IMAGE], dump_only_sql=True, ) 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 88a11d7..7a0997f 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 @@ -1,6 +1,8 @@ import unittest from .helpers import ( + POSTGRES_IMAGE, + POSTGRES_DATA_DIR, backup_path, cleanup_docker, create_minimal_compose_dir, @@ -66,8 +68,8 @@ class TestE2ESeedStarAndDbEntriesBackupPostgres(unittest.TestCase): "-e", f"POSTGRES_PASSWORD={cls.pg_password}", "-v", - f"{cls.db_volume}:/var/lib/postgresql/data", - "postgres:16-alpine", + f"{cls.db_volume}:{POSTGRES_DATA_DIR}", + POSTGRES_IMAGE, ] ) wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90) @@ -165,10 +167,7 @@ class TestE2ESeedStarAndDbEntriesBackupPostgres(unittest.TestCase): "--database-containers", cls.pg_container, "--images-no-stop-required", - "alpine", - "postgres", - "mariadb", - "mysql", + POSTGRES_IMAGE, "--dump-only-sql", "--backups-dir", cls.backups_dir, diff --git a/tests/unit/backup/test_compose.py b/tests/unit/backup/test_compose.py index 0eaca18..8f846e1 100644 --- a/tests/unit/backup/test_compose.py +++ b/tests/unit/backup/test_compose.py @@ -279,15 +279,15 @@ class HardRestartArgTests(unittest.TestCase): def test_default_is_empty(self) -> None: args = self._parse([]) - self.assertEqual(args.hard_compose_restart, []) + self.assertEqual(args.hard_restart_projects, []) def test_empty_flag_stays_empty(self) -> None: - args = self._parse(["--hard-compose-restart"]) - self.assertEqual(args.hard_compose_restart, []) + args = self._parse(["--hard-restart-projects"]) + self.assertEqual(args.hard_restart_projects, []) def test_explicit_names_preserved(self) -> None: - args = self._parse(["--hard-compose-restart", "mailu", "foo"]) - self.assertEqual(args.hard_compose_restart, ["mailu", "foo"]) + 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 diff --git a/tests/unit/test_backup.py b/tests/unit/test_backup.py index 9a52ebb..29746e4 100644 --- a/tests/unit/test_backup.py +++ b/tests/unit/test_backup.py @@ -10,13 +10,12 @@ class TestRequiresStop(unittest.TestCase): def test_requires_stop_false_when_all_images_are_whitelisted( self, mock_get_image_info, _mock_is_swarm_task ): - # All containers use images containing allowed substrings mock_get_image_info.side_effect = [ "repo/mastodon:v4", "repo/wordpress:latest", ] containers = ["c1", "c2"] - whitelist = ["mastodon", "wordpress"] + whitelist = ["repo/mastodon:v4", "repo/wordpress:latest"] self.assertFalse(requires_stop(containers, whitelist)) @patch("baudolo.backup.app.get_image_info") @@ -28,9 +27,17 @@ class TestRequiresStop(unittest.TestCase): "repo/nginx:latest", ] containers = ["c1", "c2"] - whitelist = ["mastodon", "wordpress"] + whitelist = ["repo/mastodon:v4", "repo/wordpress:latest"] self.assertTrue(requires_stop(containers, whitelist)) + @patch("baudolo.backup.app.get_image_info") + def test_requires_stop_true_on_substring_only_match( + self, mock_get_image_info, _mock_is_swarm_task + ): + mock_get_image_info.return_value = "reg:5000/repo/mastodon:v4" + self.assertTrue(requires_stop(["c1"], ["mastodon"])) + self.assertTrue(requires_stop(["c1"], ["repo/mastodon:v4"])) + @patch("baudolo.backup.app.get_image_info") def test_requires_stop_true_when_whitelist_empty( self, mock_get_image_info, _mock_is_swarm_task