diff --git a/src/baudolo/backup/docker.py b/src/baudolo/backup/docker.py index 8aea523..055b9ac 100644 --- a/src/baudolo/backup/docker.py +++ b/src/baudolo/backup/docker.py @@ -9,9 +9,29 @@ def get_image_info(container: str) -> str: )[0] +def image_name(container: str) -> str: + """The image's repository path, without registry host, tag or digest. + + A swarm node that hosts the local registry puts its own hostname in front + of every pull, so the raw reference of a Postgres container can read + `svc-db-mariadb-swarm-mgr-01:5000/postgres_custom:17-3.5`. Matching the + whole reference finds "mariadb" there and dumps the database with + mariadb-dump, which the Postgres image does not ship (exit 127). Tags bite + the same way: `xwiki_custom:lts-postgres-tomcat`. + """ + reference = get_image_info(container).strip().split("@", 1)[0] + head, _, tail = reference.rpartition("/") + tail = tail.split(":", 1)[0] + if head: + registry = head.split("/", 1)[0] + if "." in registry or ":" in registry or registry == "localhost": + head = head.partition("/")[2] + return f"{head}/{tail}" if head else tail + + def has_image(container: str, pattern: str) -> bool: - """Return True if container's image contains the pattern.""" - return pattern in get_image_info(container) + """Return True if the container's image name contains the pattern.""" + return pattern in image_name(container) def docker_volume_names() -> list[str]: diff --git a/tests/e2e/test_e2e_engine_detection_registry_prefix.py b/tests/e2e/test_e2e_engine_detection_registry_prefix.py new file mode 100644 index 0000000..5b05625 --- /dev/null +++ b/tests/e2e/test_e2e_engine_detection_registry_prefix.py @@ -0,0 +1,105 @@ +import unittest + +from .helpers import ( + POSTGRES_IMAGE, + POSTGRES_DATA_DIR, + backup_run, + backup_path, + cleanup_docker, + create_minimal_compose_dir, + ensure_empty_dir, + latest_version_dir, + require_docker, + unique, + write_databases_csv, + run, + wait_for_postgres, +) + +REGISTRY_HOST = "svc-db-mariadb-swarm-mgr-01:5000" + + +class TestE2EEngineDetectionRegistryPrefix(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + require_docker() + cls.prefix = unique("baudolo-e2e-registry-prefix") + 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.image = f"{REGISTRY_HOST}/postgres_custom:17-3.5" + 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", "pull", POSTGRES_IMAGE]) + run(["docker", "tag", POSTGRES_IMAGE, cls.image]) + 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}", + cls.image, + ] + ) + wait_for_postgres(cls.pg_container, user="postgres", timeout_s=90) + + run( + [ + "docker", + "exec", + cls.pg_container, + "sh", + "-lc", + "psql -U postgres -d appdb -c \"CREATE TABLE t (id int primary key, v text); INSERT INTO t VALUES (1,'ok');\"", + ] + ) + + 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=[cls.image], + ) + + 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) + run(["docker", "rmi", cls.image], check=False) + + def test_the_registry_host_does_not_pick_the_engine(self) -> None: + p = ( + backup_path(self.backups_dir, self.repo_name, self.version, self.pg_volume) + / "sql" + / "appdb.backup.sql" + ) + self.assertTrue(p.is_file(), f"Expected a pg_dump at: {p}") + self.assertIn("Dumped by pg_dump", p.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/backup/test_docker_image_name.py b/tests/unit/backup/test_docker_image_name.py new file mode 100644 index 0000000..b857f30 --- /dev/null +++ b/tests/unit/backup/test_docker_image_name.py @@ -0,0 +1,50 @@ +import unittest +from unittest.mock import patch + +from baudolo.backup import docker as docker_mod + + +def _with_image(reference: str): + return patch.object(docker_mod, "execute_shell_command", return_value=[reference]) + + +class TestImageName(unittest.TestCase): + def test_plain_reference(self) -> None: + with _with_image("postgres:16"): + self.assertEqual(docker_mod.image_name("c1"), "postgres") + + def test_registry_host_is_dropped(self) -> None: + with _with_image("svc-db-mariadb-swarm-mgr-01:5000/postgres_custom:17-3.5"): + self.assertEqual(docker_mod.image_name("c1"), "postgres_custom") + + def test_pull_through_path_is_kept(self) -> None: + with _with_image( + "svc-db-mariadb-swarm-mgr-01:5000/ghcr.io/x/mirror/docker.io/postgres:16" + ): + self.assertEqual( + docker_mod.image_name("c1"), "ghcr.io/x/mirror/docker.io/postgres" + ) + + def test_digest_is_dropped(self) -> None: + with _with_image("registry:5000/postgres@sha256:" + "0" * 64): + self.assertEqual(docker_mod.image_name("c1"), "postgres") + + +class TestHasImage(unittest.TestCase): + def test_registry_hostname_does_not_decide_the_engine(self) -> None: + with _with_image("svc-db-mariadb-swarm-mgr-01:5000/postgres_custom:17-3.5"): + self.assertFalse(docker_mod.has_image("c1", "mariadb")) + with _with_image("svc-db-mariadb-swarm-mgr-01:5000/postgres_custom:17-3.5"): + self.assertTrue(docker_mod.has_image("c1", "postgres")) + + def test_tag_does_not_decide_the_engine(self) -> None: + with _with_image("registry:5000/xwiki_custom:lts-postgres-tomcat"): + self.assertFalse(docker_mod.has_image("c1", "postgres")) + + def test_mirrored_mariadb_still_matches(self) -> None: + with _with_image("registry:5000/ghcr.io/x/mirror/docker.io/mariadb:11"): + self.assertTrue(docker_mod.has_image("c1", "mariadb")) + + +if __name__ == "__main__": + unittest.main()