feat(backup)!: exact --images-* matching and --hard-restart-projects

Match --images-no-stop-required and --images-no-backup-required against
the container's exact .Config.Image instead of a substring, so callers
pass full repo:tag references (registry prefix included) and near-miss
image names no longer flip the stop/skip decision. Rename the opt-in
--hard-compose-restart flag to --hard-restart-projects.

The e2e suite pins a SPOT for the DB images and in-container data dirs
(postgres:alpine at /var/lib/postgresql, mariadb:latest at
/var/lib/mysql) and passes exact image refs to the --images-* flags.

BREAKING CHANGE: --images-* now require exact image references, not
substrings; --hard-compose-restart is renamed to --hard-restart-projects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 18:19:20 +02:00
parent 8c1a6cc465
commit f9776ac47a
16 changed files with 78 additions and 64 deletions

View File

@@ -52,7 +52,7 @@ def is_image_ignored(container: str, images_no_backup_required: list[str]) -> bo
if not images_no_backup_required: if not images_no_backup_required:
return False return False
img = get_image_info(container) 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( 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: 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 Stop is required if ANY stoppable container image is NOT in the exact
whitelist patterns. Swarm task containers never count: baudolo must image whitelist. Swarm task containers never count: baudolo must
not cycle them (see docker.is_swarm_task). not cycle them (see docker.is_swarm_task).
""" """
for c in containers: for c in containers:
if is_swarm_task(c): if is_swarm_task(c):
continue continue
img = get_image_info(c) 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 True
return False return False
@@ -247,6 +247,6 @@ def main() -> int:
print("Finished volume backups.", flush=True) print("Finished volume backups.", flush=True)
print("Handling Docker Compose services...", 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 return 0

View File

@@ -17,7 +17,7 @@ def parse_args() -> argparse.Namespace:
help="Path to the parent directory containing docker-compose setups", help="Path to the parent directory containing docker-compose setups",
) )
p.add_argument( p.add_argument(
"--hard-compose-restart", "--hard-restart-projects",
nargs="*", nargs="*",
default=[], 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)", 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", "--images-no-stop-required",
nargs="+", nargs="+",
required=True, 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( p.add_argument(
"--images-no-backup-required", "--images-no-backup-required",
nargs="+", nargs="+",
default=[], 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( p.add_argument(

View File

@@ -7,6 +7,14 @@ import time
import uuid import uuid
from pathlib import Path 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( def run(
cmd: list[str], cmd: list[str],
@@ -172,7 +180,7 @@ def backup_run(
"baudolo", "baudolo",
"--compose-dir", "--compose-dir",
compose_dir, compose_dir,
"--docker-compose-hard-restart-required", "--hard-restart-projects",
"mailu", "mailu",
"--repo-name", "--repo-name",
repo_name, repo_name,

View File

@@ -2,6 +2,8 @@
import unittest import unittest
from .helpers import ( from .helpers import (
POSTGRES_IMAGE,
POSTGRES_DATA_DIR,
backup_path, backup_path,
cleanup_docker, cleanup_docker,
create_minimal_compose_dir, create_minimal_compose_dir,
@@ -50,8 +52,8 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase):
"-e", "-e",
"POSTGRES_USER=postgres", "POSTGRES_USER=postgres",
"-v", "-v",
f"{cls.pg_volume}:/var/lib/postgresql/data", f"{cls.pg_volume}:{POSTGRES_DATA_DIR}",
"postgres:16", POSTGRES_IMAGE,
] ]
) )
wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90) wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90)
@@ -65,7 +67,7 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase):
cls.pg_container, cls.pg_container,
"sh", "sh",
"-lc", "-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", "baudolo",
"--compose-dir", "--compose-dir",
cls.compose_dir, cls.compose_dir,
"--docker-compose-hard-restart-required", "--hard-restart-projects",
"mailu", "mailu",
"--repo-name", "--repo-name",
cls.repo_name, cls.repo_name,
@@ -90,10 +92,7 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase):
"--database-containers", "--database-containers",
cls.pg_container, cls.pg_container,
"--images-no-stop-required", "--images-no-stop-required",
"postgres", POSTGRES_IMAGE,
"mariadb",
"mysql",
"alpine",
"--dump-only-sql", "--dump-only-sql",
] ]
cp = run(cmd, capture=True, check=True) cp = run(cmd, capture=True, check=True)
@@ -116,8 +115,6 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase):
cls.repo_name, cls.repo_name,
"--source-volume", "--source-volume",
cls.pg_volume, cls.pg_volume,
"--rsync-image",
"ghcr.io/kevinveenbirkenbach/alpine-rsync",
] ]
) )

View File

@@ -1,6 +1,8 @@
import unittest import unittest
from .helpers import ( from .helpers import (
POSTGRES_IMAGE,
POSTGRES_DATA_DIR,
backup_path, backup_path,
cleanup_docker, cleanup_docker,
create_minimal_compose_dir, create_minimal_compose_dir,
@@ -70,8 +72,8 @@ class TestE2EDumpOnlySqlMixedRun(unittest.TestCase):
"-e", "-e",
f"POSTGRES_PASSWORD={cls.pg_password}", f"POSTGRES_PASSWORD={cls.pg_password}",
"-v", "-v",
f"{cls.db_volume}:/var/lib/postgresql/data", f"{cls.db_volume}:{POSTGRES_DATA_DIR}",
"postgres:16-alpine", POSTGRES_IMAGE,
] ]
) )
wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90) wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90)
@@ -122,10 +124,7 @@ class TestE2EDumpOnlySqlMixedRun(unittest.TestCase):
"--database-containers", "--database-containers",
cls.pg_container, cls.pg_container,
"--images-no-stop-required", "--images-no-stop-required",
"alpine", POSTGRES_IMAGE,
"postgres",
"mariadb",
"mysql",
"--dump-only-sql", "--dump-only-sql",
"--backups-dir", "--backups-dir",
cls.backups_dir, cls.backups_dir,

View File

@@ -57,7 +57,7 @@ class TestE2EFilesFull(unittest.TestCase):
compose_dir=cls.compose_dir, compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv, databases_csv=cls.databases_csv,
database_containers=["dummy-db"], 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) cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
@@ -94,8 +94,6 @@ class TestE2EFilesFull(unittest.TestCase):
self.repo_name, self.repo_name,
"--source-volume", "--source-volume",
self.volume_src, self.volume_src,
"--rsync-image",
"ghcr.io/kevinveenbirkenbach/alpine-rsync",
] ]
) )

View File

@@ -55,7 +55,7 @@ class TestE2EFilesNoCopy(unittest.TestCase):
compose_dir=cls.compose_dir, compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv, databases_csv=cls.databases_csv,
database_containers=["dummy-db"], database_containers=["dummy-db"],
images_no_stop_required=["alpine", "postgres", "mariadb", "mysql"], images_no_stop_required=["alpine:3.20"],
dump_only_sql=True, dump_only_sql=True,
) )

View File

@@ -76,7 +76,7 @@ class TestE2EImagesNoBackupRequiredEarlySkip(unittest.TestCase):
"baudolo", "baudolo",
"--compose-dir", "--compose-dir",
cls.compose_dir, cls.compose_dir,
"--docker-compose-hard-restart-required", "--hard-restart-projects",
"mailu", "mailu",
"--repo-name", "--repo-name",
cls.repo_name, cls.repo_name,
@@ -87,13 +87,9 @@ class TestE2EImagesNoBackupRequiredEarlySkip(unittest.TestCase):
"--database-containers", "--database-containers",
"dummy-db", "dummy-db",
"--images-no-stop-required", "--images-no-stop-required",
"alpine", "redis:alpine",
"redis",
"postgres",
"mariadb",
"mysql",
"--images-no-backup-required", "--images-no-backup-required",
"redis", "redis:alpine",
] ]
cp = run(cmd, capture=True, check=True) cp = run(cmd, capture=True, check=True)
cls.stdout = cp.stdout or "" cls.stdout = cp.stdout or ""

View File

@@ -30,6 +30,8 @@ import pandas
from baudolo.backup import db as db_mod from baudolo.backup import db as db_mod
from .helpers import ( from .helpers import (
MARIADB_IMAGE,
MARIADB_DATA_DIR,
cleanup_docker, cleanup_docker,
require_docker, require_docker,
run, run,
@@ -69,8 +71,8 @@ class TestE2EMariaDBAnonymousPreemption(unittest.TestCase):
"-e", "-e",
f"MARIADB_ROOT_PASSWORD={cls.root_password}", f"MARIADB_ROOT_PASSWORD={cls.root_password}",
"-v", "-v",
f"{cls.db_volume}:/var/lib/mysql", f"{cls.db_volume}:{MARIADB_DATA_DIR}",
"mariadb:12.2", MARIADB_IMAGE,
] ]
) )

View File

@@ -2,6 +2,8 @@
import unittest import unittest
from .helpers import ( from .helpers import (
MARIADB_IMAGE,
MARIADB_DATA_DIR,
backup_run, backup_run,
backup_path, backup_path,
cleanup_docker, cleanup_docker,
@@ -56,8 +58,8 @@ class TestE2EMariaDBFull(unittest.TestCase):
"-e", "-e",
f"MARIADB_PASSWORD={cls.db_password}", f"MARIADB_PASSWORD={cls.db_password}",
"-v", "-v",
f"{cls.db_volume}:/var/lib/mysql", f"{cls.db_volume}:{MARIADB_DATA_DIR}",
"mariadb:11", MARIADB_IMAGE,
] ]
) )
@@ -97,7 +99,7 @@ class TestE2EMariaDBFull(unittest.TestCase):
compose_dir=cls.compose_dir, compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv, databases_csv=cls.databases_csv,
database_containers=[cls.db_container], 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) cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)

View File

@@ -2,6 +2,8 @@
import unittest import unittest
from .helpers import ( from .helpers import (
MARIADB_IMAGE,
MARIADB_DATA_DIR,
backup_run, backup_run,
backup_path, backup_path,
cleanup_docker, cleanup_docker,
@@ -55,8 +57,8 @@ class TestE2EMariaDBNoCopy(unittest.TestCase):
"-e", "-e",
f"MARIADB_PASSWORD={cls.db_password}", f"MARIADB_PASSWORD={cls.db_password}",
"-v", "-v",
f"{cls.db_volume}:/var/lib/mysql", f"{cls.db_volume}:{MARIADB_DATA_DIR}",
"mariadb:11", MARIADB_IMAGE,
] ]
) )
@@ -94,7 +96,7 @@ class TestE2EMariaDBNoCopy(unittest.TestCase):
compose_dir=cls.compose_dir, compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv, databases_csv=cls.databases_csv,
database_containers=[cls.db_container], database_containers=[cls.db_container],
images_no_stop_required=["mariadb", "mysql", "alpine", "postgres"], images_no_stop_required=[MARIADB_IMAGE],
dump_only_sql=True, dump_only_sql=True,
) )

View File

@@ -2,6 +2,8 @@
import unittest import unittest
from .helpers import ( from .helpers import (
POSTGRES_IMAGE,
POSTGRES_DATA_DIR,
backup_run, backup_run,
backup_path, backup_path,
cleanup_docker, cleanup_docker,
@@ -47,8 +49,8 @@ class TestE2EPostgresFull(unittest.TestCase):
"-e", "-e",
"POSTGRES_USER=postgres", "POSTGRES_USER=postgres",
"-v", "-v",
f"{cls.pg_volume}:/var/lib/postgresql/data", f"{cls.pg_volume}:{POSTGRES_DATA_DIR}",
"postgres:16", POSTGRES_IMAGE,
] ]
) )
wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90) wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90)
@@ -76,7 +78,7 @@ class TestE2EPostgresFull(unittest.TestCase):
compose_dir=cls.compose_dir, compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv, databases_csv=cls.databases_csv,
database_containers=[cls.pg_container], 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) cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)

View File

@@ -2,6 +2,8 @@
import unittest import unittest
from .helpers import ( from .helpers import (
POSTGRES_IMAGE,
POSTGRES_DATA_DIR,
backup_run, backup_run,
backup_path, backup_path,
cleanup_docker, cleanup_docker,
@@ -46,8 +48,8 @@ class TestE2EPostgresNoCopy(unittest.TestCase):
"-e", "-e",
"POSTGRES_USER=postgres", "POSTGRES_USER=postgres",
"-v", "-v",
f"{cls.pg_volume}:/var/lib/postgresql/data", f"{cls.pg_volume}:{POSTGRES_DATA_DIR}",
"postgres:16", POSTGRES_IMAGE,
] ]
) )
wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90) wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90)
@@ -74,7 +76,7 @@ class TestE2EPostgresNoCopy(unittest.TestCase):
compose_dir=cls.compose_dir, compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv, databases_csv=cls.databases_csv,
database_containers=[cls.pg_container], database_containers=[cls.pg_container],
images_no_stop_required=["postgres", "mariadb", "mysql", "alpine"], images_no_stop_required=[POSTGRES_IMAGE],
dump_only_sql=True, dump_only_sql=True,
) )

View File

@@ -1,6 +1,8 @@
import unittest import unittest
from .helpers import ( from .helpers import (
POSTGRES_IMAGE,
POSTGRES_DATA_DIR,
backup_path, backup_path,
cleanup_docker, cleanup_docker,
create_minimal_compose_dir, create_minimal_compose_dir,
@@ -66,8 +68,8 @@ class TestE2ESeedStarAndDbEntriesBackupPostgres(unittest.TestCase):
"-e", "-e",
f"POSTGRES_PASSWORD={cls.pg_password}", f"POSTGRES_PASSWORD={cls.pg_password}",
"-v", "-v",
f"{cls.db_volume}:/var/lib/postgresql/data", f"{cls.db_volume}:{POSTGRES_DATA_DIR}",
"postgres:16-alpine", POSTGRES_IMAGE,
] ]
) )
wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90) wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90)
@@ -165,10 +167,7 @@ class TestE2ESeedStarAndDbEntriesBackupPostgres(unittest.TestCase):
"--database-containers", "--database-containers",
cls.pg_container, cls.pg_container,
"--images-no-stop-required", "--images-no-stop-required",
"alpine", POSTGRES_IMAGE,
"postgres",
"mariadb",
"mysql",
"--dump-only-sql", "--dump-only-sql",
"--backups-dir", "--backups-dir",
cls.backups_dir, cls.backups_dir,

View File

@@ -279,15 +279,15 @@ class HardRestartArgTests(unittest.TestCase):
def test_default_is_empty(self) -> None: def test_default_is_empty(self) -> None:
args = self._parse([]) args = self._parse([])
self.assertEqual(args.hard_compose_restart, []) self.assertEqual(args.hard_restart_projects, [])
def test_empty_flag_stays_empty(self) -> None: def test_empty_flag_stays_empty(self) -> None:
args = self._parse(["--hard-compose-restart"]) args = self._parse(["--hard-restart-projects"])
self.assertEqual(args.hard_compose_restart, []) self.assertEqual(args.hard_restart_projects, [])
def test_explicit_names_preserved(self) -> None: def test_explicit_names_preserved(self) -> None:
args = self._parse(["--hard-compose-restart", "mailu", "foo"]) args = self._parse(["--hard-restart-projects", "mailu", "foo"])
self.assertEqual(args.hard_compose_restart, ["mailu", "foo"]) self.assertEqual(args.hard_restart_projects, ["mailu", "foo"])
def test_backups_dir_is_required(self) -> None: def test_backups_dir_is_required(self) -> None:
import sys import sys

View File

@@ -10,13 +10,12 @@ class TestRequiresStop(unittest.TestCase):
def test_requires_stop_false_when_all_images_are_whitelisted( def test_requires_stop_false_when_all_images_are_whitelisted(
self, mock_get_image_info, _mock_is_swarm_task self, mock_get_image_info, _mock_is_swarm_task
): ):
# All containers use images containing allowed substrings
mock_get_image_info.side_effect = [ mock_get_image_info.side_effect = [
"repo/mastodon:v4", "repo/mastodon:v4",
"repo/wordpress:latest", "repo/wordpress:latest",
] ]
containers = ["c1", "c2"] containers = ["c1", "c2"]
whitelist = ["mastodon", "wordpress"] whitelist = ["repo/mastodon:v4", "repo/wordpress:latest"]
self.assertFalse(requires_stop(containers, whitelist)) self.assertFalse(requires_stop(containers, whitelist))
@patch("baudolo.backup.app.get_image_info") @patch("baudolo.backup.app.get_image_info")
@@ -28,9 +27,17 @@ class TestRequiresStop(unittest.TestCase):
"repo/nginx:latest", "repo/nginx:latest",
] ]
containers = ["c1", "c2"] containers = ["c1", "c2"]
whitelist = ["mastodon", "wordpress"] whitelist = ["repo/mastodon:v4", "repo/wordpress:latest"]
self.assertTrue(requires_stop(containers, whitelist)) 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") @patch("baudolo.backup.app.get_image_info")
def test_requires_stop_true_when_whitelist_empty( def test_requires_stop_true_when_whitelist_empty(
self, mock_get_image_info, _mock_is_swarm_task self, mock_get_image_info, _mock_is_swarm_task