From c9af131604cea34859fbf54d834697bc7752cb9e Mon Sep 17 00:00:00 2001 From: Aman Sachan Date: Mon, 6 Jul 2026 00:09:05 +0000 Subject: [PATCH] fix: detect images pulled by digest (no RepoTags) (#101) When a container's image was pulled by digest (e.g. via 'image@sha256:...' in a compose file), Docker does not assign any RepoTags, so container.image.tags is an empty list and the container was silently skipped during backup. This was particularly painful for Immich, whose compose file pins the postgres image by digest, so the immich-app/postgres pattern in BACKUP_PROVIDERS never matched. Fix by: - extracting image-reference parsing into _name_from_image_reference (handles name:tag, name@sha256:..., and registry/name:tag@sha256:... forms, plus a missing tag when only the digest is referenced) - falling back to container.attrs['Config']['Image'] when RepoTags is empty - adding tests covering all the new cases --- db-auto-backup.py | 53 +++++++++++++++++++++++++++++------- tests/tests.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/db-auto-backup.py b/db-auto-backup.py index 5b6f1e5..8b66044 100755 --- a/db-auto-backup.py +++ b/db-auto-backup.py @@ -175,17 +175,52 @@ def get_backup_provider(container_names: Iterable[str]) -> Optional[BackupProvid return None -def get_container_names(container: Container) -> Iterable[str]: - names = set() - for tag in container.image.tags: - registry, image = docker.auth.resolve_repository_name(tag) +def _name_from_image_reference(reference: str) -> Optional[str]: + """ + Extract the image name from a tag or digest reference. + + Handles three forms: + - ``name:tag`` (e.g. ``postgres:14-alpine``) + - ``name@digest`` (e.g. ``ghcr.io/immich-app/postgres@sha256:abc...``) + - ``registry/name:tag@digest`` (e.g. ``docker.io/library/postgres:14@sha256:abc...``) + """ + # Strip the digest suffix if present (e.g. @sha256:...). + # We only care about the name part for matching against patterns. + image_ref = reference.split("@", 1)[0] + if not image_ref: + return None + + registry, image = docker.auth.resolve_repository_name(image_ref) - # HACK: Strip "library" from official images - if registry == docker.auth.INDEX_NAME: - image = image.removeprefix("library/") + # HACK: Strip "library" from official images + if registry == docker.auth.INDEX_NAME: + image = image.removeprefix("library/") + + # Tags may be absent when the image was referenced only by digest, so + # `:` may not appear. In that case, the whole string is the name. + if ":" in image: + image, _ = image.split(":", 1) + + return image + + +def get_container_names(container: Container) -> Iterable[str]: + names: set[str] = set() + + # Prefer `RepoTags` for the common case. When the image was pulled by + # digest, this is empty and we fall back to the original image reference + # stored on the container's config, which preserves the digest form. + references: list[str] = list(container.image.tags) + if not references: + config_image = (container.attrs.get("Config") or {}).get("Image") + if config_image: + references.append(config_image) + + for reference in references: + name = _name_from_image_reference(reference) + if name: + names.add(name) - image, tag_name = image.split(":", 1) - names.add(image) return names diff --git a/tests/tests.py b/tests/tests.py index c396149..47b9fc9 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -135,3 +135,72 @@ def test_get_backup_provider(container_name: str, name: str) -> None: assert provider is not None assert provider.name == name + + +@pytest.mark.parametrize( + "reference,name", + [ + ( + "ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:" + "32324a2f41df5de9efe1af166b7008c3f55646f8d0e00d9550c16c9822366b4a", + "immich-app/postgres", + ), + ( + "postgres@sha256:32324a2f41df5de9efe1af166b7008c3f55646f8d0e00d9550c16c9822366b4a", + "postgres", + ), + ( + "docker.io/library/postgres@sha256:32324a2f41df5de9efe1af166b7008c3f55646f8d0e00d9550c16c9822366b4a", + "postgres", + ), + ( + "ghcr.io/realorangeone/db-auto-backup@sha256:32324a2f41df5de9efe1af166b7008c3f55646f8d0e00d9550c16c9822366b4a", + "realorangeone/db-auto-backup", + ), + ], +) +def test_name_from_image_reference_with_digest(reference: str, name: str) -> None: + assert db_auto_backup._name_from_image_reference(reference) == name + + +@pytest.mark.parametrize( + "reference,name", + [ + ("postgres:14-alpine", "postgres"), + ("ghcr.io/realorangeone/db-auto-backup:latest", "realorangeone/db-auto-backup"), + ("postgres", "postgres"), + ("ghcr.io/immich-app/postgres", "immich-app/postgres"), + ], +) +def test_name_from_image_reference_without_digest(reference: str, name: str) -> None: + assert db_auto_backup._name_from_image_reference(reference) == name + + +def test_get_container_names_falls_back_to_config_image() -> None: + """ + Images pulled by digest (e.g. `image@sha256:...`) have an empty + `RepoTags` list. `get_container_names` should fall back to the + container's `Config.Image` so the container is still detected. + """ + container = MagicMock() + container.image.tags = [] + container.attrs = { + "Config": { + "Image": ( + "ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0" + "@sha256:32324a2f41df5de9efe1af166b7008c3f55646f8d0e00d9550c16c9822366b4a" + ) + } + } + assert db_auto_backup.get_container_names(container) == {"immich-app/postgres"} + + +def test_get_container_names_empty_when_no_reference() -> None: + """ + When both `RepoTags` and `Config.Image` are missing or empty, the + container should simply produce no names (not crash). + """ + container = MagicMock() + container.image.tags = [] + container.attrs = {"Config": {}} + assert db_auto_backup.get_container_names(container) == set()