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

@@ -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,

View File

@@ -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",
]
)

View File

@@ -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,

View File

@@ -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",
]
)

View File

@@ -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,
)

View File

@@ -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 ""

View File

@@ -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,
]
)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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