8 Commits

Author SHA1 Message Date
779f297c85 Release version 3.1.0 2026-07-15 03:36:59 +02:00
35a4c355fe feat(cli): make --database-containers and --images-no-stop-required optional
Both default to an empty list so a pure file backup needs no dummy
arguments; an empty stop whitelist keeps the conservative stop-all
behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 03:34:53 +02:00
b0ae1aba54 build(make): run the three test suites concurrently and allow e2e subsets
make test now runs clean and build once, then unit, integration and e2e
via a -j3 sub-make over run-only targets so a second clean cannot race
the build. scripts/test-e2e.sh accepts E2E_TEST_PATTERN to run a subset
of the e2e suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 03:34:37 +02:00
57d75b1e13 fix(restore): run --empty pre-clean per statement and drop non-public schemas
One DO-block ran every DROP in a single transaction and exhausted
max_locks_per_transaction on large schemas (gitlab); emit one DROP per
row and execute via \gexec instead. Also drop user-owned non-public
schemas so a dump that CREATE SCHEMAs (discourse) does not abort on the
existing schema under ON_ERROR_STOP.

Covered by a new DinD e2e test restoring --empty against a fully
populated database with a non-public schema and every object class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 03:34:20 +02:00
bf5f6db7c3 Release version 3.0.0 2026-07-12 18:29:49 +02:00
b4d7e7f396 Merge branch 'feat/exact-image-match'
# Conflicts:
#	tests/e2e/helpers.py
#	tests/e2e/test_e2e_dump_only_fallback_to_files.py
#	tests/e2e/test_e2e_images_no_backup_required_early_skip.py
2026-07-12 18:27:05 +02:00
f9776ac47a 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>
2026-07-12 18:19:20 +02:00
8c1a6cc465 refactor(restore)!: restore volume files via direct host rsync
Drop the alpine-rsync helper image: resolve the volume mountpoint with
docker volume inspect and rsync the backup straight into it. Removes the
--rsync-image flag and the E2E_RSYNC_IMAGE pre-pull; the e2e test
container now mounts /var/lib/docker rw so the direct restore can write.

BREAKING CHANGE: the restore 'files' subcommand no longer accepts
--rsync-image; rsync must be available on the host running baudolo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:18:57 +02:00
21 changed files with 270 additions and 71 deletions

View File

@@ -1,5 +1,33 @@
# Changelog # Changelog
## [3.1.0] - 2026-07-15
- Restore: the postgres *--empty* pre-clean emits one DROP per object and
runs them via *\gexec* instead of a single DO-block, so large schemas
(e.g. gitlab) no longer exhaust *max_locks_per_transaction* in one
transaction. It also drops user-owned non-public schemas, so dumps that
CREATE SCHEMA (e.g. discourse's *discourse_functions*) no longer abort
on the already-existing schema under ON_ERROR_STOP.
- Backup: *--database-containers* and *--images-no-stop-required* are now
optional and default to an empty list, so a pure file backup needs no
dummy arguments; an empty stop whitelist keeps the conservative
stop-all behavior.
- Tests: new e2e test restores *--empty* against a fully populated
database containing a non-public schema and every dropped object class.
*make test* runs the three suites concurrently after a single
clean+build; *E2E_TEST_PATTERN* runs an e2e subset.
## [3.0.0] - 2026-07-12
- Backup: *--images-no-stop-required* and *--images-no-backup-required* now
match a container's exact *.Config.Image* (full *repo:tag*, registry
prefix included) instead of a substring, so a near-miss image name no
longer flips the stop/skip decision. Callers must pass exact image
references. **Breaking.**
- Backup: renamed *--hard-compose-restart* to *--hard-restart-projects*
(its value stays a list of compose project dir names). **Breaking:** the
old flag name is removed.
## [2.0.0] - 2026-07-12 ## [2.0.0] - 2026-07-12
- Backup: renamed *--docker-compose-hard-restart-required* to - Backup: renamed *--docker-compose-hard-restart-required* to

View File

@@ -1,5 +1,6 @@
.PHONY: install build \ .PHONY: install build clean \
test-e2e test test-unit test-integration test test-unit test-integration test-e2e \
test-unit-run test-integration-run test-e2e-run
# Default python if no venv is active # Default python if no venv is active
PY_DEFAULT ?= python3 PY_DEFAULT ?= python3
@@ -33,25 +34,32 @@ build:
clean: clean:
git clean -fdX . git clean -fdX .
# ------------------------------------------------------------ # clean + build run once and in order, then the three suites run concurrently
# Run E2E tests inside the container (Docker socket required) # via -j3; the *-run targets carry no clean/build prereq so the sub-make cannot
# ------------------------------------------------------------ # race a second clean against build.
# E2E via isolated Docker-in-Docker (DinD) test:
# - depends on local image build @$(MAKE) clean
# - starts a DinD daemon container on a dedicated network @$(MAKE) build
# - loads the freshly built image into DinD @$(MAKE) -j3 test-unit-run test-integration-run test-e2e-run
# - runs the unittest suite inside a container that talks to DinD via DOCKER_HOST
test-e2e: clean build
@bash scripts/test-e2e.sh
test: test-unit test-integration test-e2e test-unit: clean build test-unit-run
test-unit: clean build test-integration: clean build test-integration-run
test-e2e: clean build test-e2e-run
test-unit-run:
@echo ">> Running unit tests" @echo ">> Running unit tests"
@docker run --rm -t $(IMAGE) \ @docker run --rm -t $(IMAGE) \
bash -lc 'python -m unittest discover -t . -s tests/unit -p "test_*.py" -v' bash -lc 'python -m unittest discover -t . -s tests/unit -p "test_*.py" -v'
test-integration: clean build test-integration-run:
@echo ">> Running integration tests" @echo ">> Running integration tests"
@docker run --rm -t $(IMAGE) \ @docker run --rm -t $(IMAGE) \
bash -lc 'python -m unittest discover -t . -s tests/integration -p "test_*.py" -v' bash -lc 'python -m unittest discover -t . -s tests/integration -p "test_*.py" -v'
# E2E via isolated Docker-in-Docker (DinD): starts a DinD daemon on a dedicated
# network, loads the freshly built image into it, and runs tests/e2e inside a
# container that talks to DinD via DOCKER_HOST.
test-e2e-run:
@bash scripts/test-e2e.sh

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "backup-docker-to-local" name = "backup-docker-to-local"
version = "2.0.0" version = "3.1.0"
description = "Backup Docker volumes to local with rsync and optional DB dumps." description = "Backup Docker volumes to local with rsync and optional DB dumps."
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"

View File

@@ -37,6 +37,9 @@ KEEP_ON_FAIL="${E2E_KEEP_ON_FAIL:-0}"
KEEP_VOLUMES="${E2E_KEEP_VOLUMES:-0}" KEEP_VOLUMES="${E2E_KEEP_VOLUMES:-0}"
DEBUG_SHELL="${E2E_DEBUG_SHELL:-0}" DEBUG_SHELL="${E2E_DEBUG_SHELL:-0}"
# Override to run a subset, e.g. E2E_TEST_PATTERN=test_e2e_postgres_empty_drop_hard.py
TEST_PATTERN="${E2E_TEST_PATTERN:-test_*.py}"
FAILED=0 FAILED=0
TS="$(date +%Y%m%d%H%M%S)" TS="$(date +%Y%m%d%H%M%S)"
@@ -194,6 +197,7 @@ else
docker run --rm \ docker run --rm \
--network "${NET}" \ --network "${NET}" \
-e DOCKER_HOST="${DIND_HOST_IN_NET}" \ -e DOCKER_HOST="${DIND_HOST_IN_NET}" \
-e E2E_TEST_PATTERN="${TEST_PATTERN}" \
-v "${DIND_VOL}:/var/lib/docker" \ -v "${DIND_VOL}:/var/lib/docker" \
-v "${E2E_TMP_VOL}:/tmp" \ -v "${E2E_TMP_VOL}:/tmp" \
"${IMG}" \ "${IMG}" \
@@ -209,7 +213,7 @@ else
cat /proc/sys/kernel/random/uuid > /etc/machine-id cat /proc/sys/kernel/random/uuid > /etc/machine-id
fi fi
python -m unittest discover -t . -s tests/e2e -p "test_*.py" -v -f python -m unittest discover -t . -s tests/e2e -p "${E2E_TEST_PATTERN}" -v -f
' '
rc=$? rc=$?
fi fi

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)",
@@ -42,20 +42,20 @@ def parse_args() -> argparse.Namespace:
p.add_argument( p.add_argument(
"--database-containers", "--database-containers",
nargs="+", nargs="+",
required=True, default=[],
help="Container names treated as special instances for database backups", help="Container names treated as special instances for database backups",
) )
p.add_argument( p.add_argument(
"--images-no-stop-required", "--images-no-stop-required",
nargs="+", nargs="+",
required=True, default=[],
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

@@ -53,11 +53,15 @@ def restore_postgres_sql(
docker_env = {"PGPASSWORD": password} docker_env = {"PGPASSWORD": password}
if empty: if empty:
# Owner-filtered: extension members (pg_trgm's set_limit) are superuser-owned; IF EXISTS absorbs CASCADE fallout. # Owner-filtered pre-clean emitted as one DROP per row and run via \gexec so each
# executes as its own top-level statement: a single DO-block runs every DROP in one
# transaction and exhausts max_locks_per_transaction on large schemas (e.g. gitlab).
# Also drop user-owned non-public schemas so a dump that CREATE SCHEMAs (e.g.
# discourse's discourse_functions) does not fail on an already-existing schema.
# Extension members (pg_trgm's set_limit) are superuser-owned; IF EXISTS absorbs CASCADE fallout.
drop_sql = r""" drop_sql = r"""
DO $$ DECLARE r RECORD; SELECT format('DROP %s IF EXISTS public.%I CASCADE', obj.type, obj.name)
BEGIN FROM (
FOR r IN (
SELECT c.relname AS name, SELECT c.relname AS name,
CASE c.relkind CASE c.relkind
WHEN 'v' THEN 'VIEW' WHEN 'v' THEN 'VIEW'
@@ -93,10 +97,14 @@ BEGIN
FROM pg_collation col JOIN pg_namespace n ON n.oid = col.collnamespace FROM pg_collation col JOIN pg_namespace n ON n.oid = col.collnamespace
WHERE n.nspname = 'public' WHERE n.nspname = 'public'
AND pg_get_userbyid(col.collowner) = current_user AND pg_get_userbyid(col.collowner) = current_user
) LOOP ) obj
EXECUTE format('DROP %s IF EXISTS public.%I CASCADE', r.type, r.name); UNION ALL
END LOOP; SELECT format('DROP SCHEMA IF EXISTS %I CASCADE', n.nspname)
END $$; FROM pg_namespace n
WHERE NOT starts_with(n.nspname, 'pg_')
AND n.nspname NOT IN ('public', 'information_schema')
AND pg_get_userbyid(n.nspowner) = current_user
\gexec
""" """
docker_exec( docker_exec(
container, container,

View File

@@ -180,7 +180,7 @@ def backup_run(
"baudolo", "baudolo",
"--compose-dir", "--compose-dir",
compose_dir, compose_dir,
"--hard-compose-restart", "--hard-restart-projects",
"mailu", "mailu",
"--repo-name", "--repo-name",
repo_name, repo_name,

View File

@@ -81,7 +81,7 @@ class TestE2EDumpOnlyFallbackToFiles(unittest.TestCase):
"baudolo", "baudolo",
"--compose-dir", "--compose-dir",
cls.compose_dir, cls.compose_dir,
"--hard-compose-restart", "--hard-restart-projects",
"mailu", "mailu",
"--repo-name", "--repo-name",
cls.repo_name, cls.repo_name,
@@ -92,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)

View File

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

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,
"--hard-compose-restart", "--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

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

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

@@ -0,0 +1,157 @@
# tests/e2e/test_e2e_postgres_empty_drop_hard.py
import unittest
from .helpers import (
POSTGRES_IMAGE,
POSTGRES_DATA_DIR,
backup_run,
cleanup_docker,
create_minimal_compose_dir,
ensure_empty_dir,
latest_version_dir,
require_docker,
run,
unique,
wait_for_postgres,
write_databases_csv,
)
# The scenario the --empty pre-clean must survive: a non-public user schema
# plus one object of every class the discovery SELECT enumerates. Restore
# --empty runs against the still-populated DB (no wipe), so the pre-clean must
# drop discourse_functions too or the dump's CREATE SCHEMA aborts the replay
# under ON_ERROR_STOP; the old public-only DROP left it and broke discourse.
SCENARIO_SQL = (
"CREATE SCHEMA discourse_functions;"
"CREATE TABLE discourse_functions.helper (id int);"
"INSERT INTO discourse_functions.helper VALUES (1);"
"CREATE TABLE public.t (id int primary key, v text);"
"INSERT INTO public.t VALUES (1, 'ok');"
"CREATE VIEW public.t_view AS SELECT * FROM public.t;"
"CREATE SEQUENCE public.s;"
"CREATE TYPE public.mood AS ENUM ('ok', 'bad');"
"CREATE FUNCTION public.f() RETURNS int LANGUAGE sql AS 'SELECT 1';"
"CREATE COLLATION public.c (locale = 'C');"
)
class TestE2EPostgresEmptyDropHard(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
require_docker()
cls.prefix = unique("baudolo-e2e-postgres-empty-drop-hard")
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'psql -U postgres -d appdb -v ON_ERROR_STOP=1 -c "{SCENARIO_SQL}"',
]
)
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
write_databases_csv(
cls.databases_csv, [(cls.pg_container, "appdb", "postgres", "pgpw")]
)
backup_run(
backups_dir=cls.backups_dir,
repo_name=cls.repo_name,
compose_dir=cls.compose_dir,
databases_csv=cls.databases_csv,
database_containers=[cls.pg_container],
images_no_stop_required=[POSTGRES_IMAGE],
)
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
# No wipe: restore --empty must pre-clean the fully-populated DB
# (incl. the non-public schema) before replaying the dump.
run(
[
"baudolo-restore",
"postgres",
cls.pg_volume,
cls.hash,
cls.version,
"--backups-dir",
cls.backups_dir,
"--repo-name",
cls.repo_name,
"--container",
cls.pg_container,
"--db-name",
"appdb",
"--db-user",
"postgres",
"--db-password",
"pgpw",
"--empty",
]
)
@classmethod
def tearDownClass(cls) -> None:
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
def _scalar(self, sql: str) -> str:
p = run(
[
"docker",
"exec",
self.pg_container,
"sh",
"-lc",
f'psql -U postgres -d appdb -t -A -c "{sql}"',
]
)
return (p.stdout or "").strip()
def test_public_data_restored(self) -> None:
self.assertEqual(self._scalar("SELECT v FROM public.t WHERE id=1;"), "ok")
def test_view_restored(self) -> None:
self.assertEqual(self._scalar("SELECT count(*) FROM public.t_view;"), "1")
def test_non_public_schema_restored(self) -> None:
self.assertEqual(
self._scalar(
"SELECT count(*) FROM pg_namespace WHERE nspname='discourse_functions';"
),
"1",
)
if __name__ == "__main__":
unittest.main()

View File

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

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

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