mirror of
https://github.com/ansible-collections/community.docker.git
synced 2025-12-15 11:32:05 +00:00
When using combined tag@digest references (e.g., nginx:1.21@sha256:abc...), Docker does NOT store the tag in RepoTags. It only stores the digest in RepoDigests. The previous implementation required BOTH to match, which always failed because RepoTags was empty. This caused docker_container to pull the image on every run even when the image with the correct digest already existed locally, breaking idempotency. The fix: When a digest is specified, match by digest only since it's the authoritative identifier. The tag is informational for human readability. Real-world example from docker image inspect: "RepoTags": [], # Empty when pulled by digest\! "RepoDigests": ["portainer/portainer-ee@sha256:7ecf2008..."] Updated tests to reflect the correct behavior: - test_empty_repo_tags_matches_by_digest (the critical fix case) - test_combined_tag_digest_matches_even_if_tag_differs - test_multiple_tags_irrelevant_for_combined
713 lines
20 KiB
Python
713 lines
20 KiB
Python
# Copyright (c) Ansible Project
|
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
from __future__ import annotations
|
|
|
|
import typing as t
|
|
|
|
import pytest
|
|
|
|
from ansible_collections.community.docker.plugins.module_utils._util import (
|
|
compare_dict_allow_more_present,
|
|
compare_generic,
|
|
convert_duration_to_nanosecond,
|
|
filter_images_by_tag,
|
|
parse_healthcheck,
|
|
)
|
|
|
|
|
|
if t.TYPE_CHECKING:
|
|
|
|
class DAMSpec(t.TypedDict):
|
|
av: dict[str, t.Any]
|
|
bv: dict[str, t.Any]
|
|
result: bool
|
|
|
|
class Spec(t.TypedDict):
|
|
a: t.Any
|
|
b: t.Any
|
|
method: t.Literal["strict", "ignore", "allow_more_present"]
|
|
type: t.Literal["value", "list", "set", "set(dict)", "dict"]
|
|
result: bool
|
|
|
|
|
|
DICT_ALLOW_MORE_PRESENT: list[DAMSpec] = [
|
|
{"av": {}, "bv": {"a": 1}, "result": True},
|
|
{"av": {"a": 1}, "bv": {"a": 1, "b": 2}, "result": True},
|
|
{"av": {"a": 1}, "bv": {"b": 2}, "result": False},
|
|
{"av": {"a": 1}, "bv": {"a": None, "b": 1}, "result": False},
|
|
{"av": {"a": None}, "bv": {"b": 1}, "result": False},
|
|
]
|
|
|
|
DICT_ALLOW_MORE_PRESENT_SPECS: list[Spec] = [
|
|
{
|
|
"a": entry["av"],
|
|
"b": entry["bv"],
|
|
"method": "allow_more_present",
|
|
"type": "dict",
|
|
"result": entry["result"],
|
|
}
|
|
for entry in DICT_ALLOW_MORE_PRESENT
|
|
]
|
|
|
|
COMPARE_GENERIC: list[Spec] = [
|
|
########################################################################################
|
|
# value
|
|
{"a": 1, "b": 2, "method": "strict", "type": "value", "result": False},
|
|
{"a": "hello", "b": "hello", "method": "strict", "type": "value", "result": True},
|
|
{"a": None, "b": "hello", "method": "strict", "type": "value", "result": False},
|
|
{"a": None, "b": None, "method": "strict", "type": "value", "result": True},
|
|
{"a": 1, "b": 2, "method": "ignore", "type": "value", "result": True},
|
|
{"a": None, "b": 2, "method": "ignore", "type": "value", "result": True},
|
|
########################################################################################
|
|
# list
|
|
{
|
|
"a": [
|
|
"x",
|
|
],
|
|
"b": [
|
|
"y",
|
|
],
|
|
"method": "strict",
|
|
"type": "list",
|
|
"result": False,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
],
|
|
"b": [
|
|
"x",
|
|
"x",
|
|
],
|
|
"method": "strict",
|
|
"type": "list",
|
|
"result": False,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
"y",
|
|
],
|
|
"b": [
|
|
"x",
|
|
"y",
|
|
],
|
|
"method": "strict",
|
|
"type": "list",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
"y",
|
|
],
|
|
"b": [
|
|
"y",
|
|
"x",
|
|
],
|
|
"method": "strict",
|
|
"type": "list",
|
|
"result": False,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
"y",
|
|
],
|
|
"b": [
|
|
"x",
|
|
],
|
|
"method": "allow_more_present",
|
|
"type": "list",
|
|
"result": False,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
],
|
|
"b": [
|
|
"x",
|
|
"y",
|
|
],
|
|
"method": "allow_more_present",
|
|
"type": "list",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
"x",
|
|
"y",
|
|
],
|
|
"b": [
|
|
"x",
|
|
"y",
|
|
],
|
|
"method": "allow_more_present",
|
|
"type": "list",
|
|
"result": False,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
"z",
|
|
],
|
|
"b": [
|
|
"x",
|
|
"y",
|
|
"x",
|
|
"z",
|
|
],
|
|
"method": "allow_more_present",
|
|
"type": "list",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
"y",
|
|
],
|
|
"b": [
|
|
"y",
|
|
"x",
|
|
],
|
|
"method": "ignore",
|
|
"type": "list",
|
|
"result": True,
|
|
},
|
|
########################################################################################
|
|
# set
|
|
{
|
|
"a": [
|
|
"x",
|
|
],
|
|
"b": [
|
|
"y",
|
|
],
|
|
"method": "strict",
|
|
"type": "set",
|
|
"result": False,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
],
|
|
"b": [
|
|
"x",
|
|
"x",
|
|
],
|
|
"method": "strict",
|
|
"type": "set",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
"y",
|
|
],
|
|
"b": [
|
|
"x",
|
|
"y",
|
|
],
|
|
"method": "strict",
|
|
"type": "set",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
"y",
|
|
],
|
|
"b": [
|
|
"y",
|
|
"x",
|
|
],
|
|
"method": "strict",
|
|
"type": "set",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
"y",
|
|
],
|
|
"b": [
|
|
"x",
|
|
],
|
|
"method": "allow_more_present",
|
|
"type": "set",
|
|
"result": False,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
],
|
|
"b": [
|
|
"x",
|
|
"y",
|
|
],
|
|
"method": "allow_more_present",
|
|
"type": "set",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
"x",
|
|
"y",
|
|
],
|
|
"b": [
|
|
"x",
|
|
"y",
|
|
],
|
|
"method": "allow_more_present",
|
|
"type": "set",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
"z",
|
|
],
|
|
"b": [
|
|
"x",
|
|
"y",
|
|
"x",
|
|
"z",
|
|
],
|
|
"method": "allow_more_present",
|
|
"type": "set",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
"x",
|
|
"a",
|
|
],
|
|
"b": [
|
|
"y",
|
|
"z",
|
|
],
|
|
"method": "ignore",
|
|
"type": "set",
|
|
"result": True,
|
|
},
|
|
########################################################################################
|
|
# set(dict)
|
|
{
|
|
"a": [
|
|
{"x": 1},
|
|
],
|
|
"b": [
|
|
{"y": 1},
|
|
],
|
|
"method": "strict",
|
|
"type": "set(dict)",
|
|
"result": False,
|
|
},
|
|
{
|
|
"a": [
|
|
{"x": 1},
|
|
],
|
|
"b": [
|
|
{"x": 1},
|
|
],
|
|
"method": "strict",
|
|
"type": "set(dict)",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
{"x": 1},
|
|
],
|
|
"b": [
|
|
{"x": 1, "y": 2},
|
|
],
|
|
"method": "strict",
|
|
"type": "set(dict)",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
{"x": 1},
|
|
{"x": 2, "y": 3},
|
|
],
|
|
"b": [
|
|
{"x": 1},
|
|
{"x": 2, "y": 3},
|
|
],
|
|
"method": "strict",
|
|
"type": "set(dict)",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
{"x": 1},
|
|
],
|
|
"b": [
|
|
{"x": 1, "z": 2},
|
|
{"x": 2, "y": 3},
|
|
],
|
|
"method": "allow_more_present",
|
|
"type": "set(dict)",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
{"x": 1, "y": 2},
|
|
],
|
|
"b": [
|
|
{"x": 1},
|
|
{"x": 2, "y": 3},
|
|
],
|
|
"method": "allow_more_present",
|
|
"type": "set(dict)",
|
|
"result": False,
|
|
},
|
|
{
|
|
"a": [
|
|
{"x": 1, "y": 3},
|
|
],
|
|
"b": [
|
|
{"x": 1},
|
|
{"x": 1, "y": 3, "z": 4},
|
|
],
|
|
"method": "allow_more_present",
|
|
"type": "set(dict)",
|
|
"result": True,
|
|
},
|
|
{
|
|
"a": [
|
|
{"x": 1},
|
|
{"x": 2, "y": 3},
|
|
],
|
|
"b": [
|
|
{"x": 1},
|
|
],
|
|
"method": "ignore",
|
|
"type": "set(dict)",
|
|
"result": True,
|
|
},
|
|
########################################################################################
|
|
# dict
|
|
{"a": {"x": 1}, "b": {"y": 1}, "method": "strict", "type": "dict", "result": False},
|
|
{
|
|
"a": {"x": 1},
|
|
"b": {"x": 1, "y": 2},
|
|
"method": "strict",
|
|
"type": "dict",
|
|
"result": False,
|
|
},
|
|
{"a": {"x": 1}, "b": {"x": 1}, "method": "strict", "type": "dict", "result": True},
|
|
{
|
|
"a": {"x": 1, "z": 2},
|
|
"b": {"x": 1, "y": 2},
|
|
"method": "strict",
|
|
"type": "dict",
|
|
"result": False,
|
|
},
|
|
{
|
|
"a": {"x": 1, "z": 2},
|
|
"b": {"x": 1, "y": 2},
|
|
"method": "ignore",
|
|
"type": "dict",
|
|
"result": True,
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("entry", DICT_ALLOW_MORE_PRESENT)
|
|
def test_dict_allow_more_present(entry: DAMSpec) -> None:
|
|
assert compare_dict_allow_more_present(entry["av"], entry["bv"]) == entry["result"]
|
|
|
|
|
|
@pytest.mark.parametrize("entry", COMPARE_GENERIC + DICT_ALLOW_MORE_PRESENT_SPECS)
|
|
def test_compare_generic(entry: Spec) -> None:
|
|
assert (
|
|
compare_generic(entry["a"], entry["b"], entry["method"], entry["type"])
|
|
== entry["result"]
|
|
)
|
|
|
|
|
|
def test_convert_duration_to_nanosecond() -> None:
|
|
nanoseconds = convert_duration_to_nanosecond("5s")
|
|
assert nanoseconds == 5000000000
|
|
nanoseconds = convert_duration_to_nanosecond("1m5s")
|
|
assert nanoseconds == 65000000000
|
|
with pytest.raises(ValueError):
|
|
convert_duration_to_nanosecond([1, 2, 3]) # type: ignore
|
|
with pytest.raises(ValueError):
|
|
convert_duration_to_nanosecond("10x")
|
|
|
|
|
|
def test_parse_healthcheck() -> None:
|
|
result, disabled = parse_healthcheck(
|
|
{
|
|
"test": "sleep 1",
|
|
"interval": "1s",
|
|
}
|
|
)
|
|
assert disabled is False
|
|
assert result == {"test": ["CMD-SHELL", "sleep 1"], "interval": 1000000000}
|
|
|
|
result, disabled = parse_healthcheck(
|
|
{
|
|
"test": ["NONE"],
|
|
}
|
|
)
|
|
assert result is None
|
|
assert disabled
|
|
|
|
result, disabled = parse_healthcheck({"test": "sleep 1", "interval": "1s423ms"})
|
|
assert result == {"test": ["CMD-SHELL", "sleep 1"], "interval": 1423000000}
|
|
assert disabled is False
|
|
|
|
result, disabled = parse_healthcheck(
|
|
{"test": "sleep 1", "interval": "1h1m2s3ms4us"}
|
|
)
|
|
assert result == {"test": ["CMD-SHELL", "sleep 1"], "interval": 3662003004000}
|
|
assert disabled is False
|
|
|
|
|
|
# ========== filter_images_by_tag tests ==========
|
|
# Docker stores RepoTags and RepoDigests separately, never combined.
|
|
# This function handles tag-only, digest-only, and combined tag@digest formats.
|
|
|
|
SHA = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
|
|
|
|
def _create_mock_image(
|
|
repo_tags: list[str] | None, repo_digests: list[str] | None
|
|
) -> dict[str, t.Any]:
|
|
"""Helper to create mock image data as returned by Docker API."""
|
|
return {
|
|
"Id": "sha256:abc123",
|
|
"RepoTags": repo_tags,
|
|
"RepoDigests": repo_digests,
|
|
}
|
|
|
|
|
|
class TestFilterImagesByTag:
|
|
"""Test cases for filter_images_by_tag function."""
|
|
|
|
# ========== Tag-only tests ==========
|
|
|
|
def test_tag_only_matches_repo_tags(self) -> None:
|
|
"""Tag-only reference should match in RepoTags."""
|
|
image = _create_mock_image(
|
|
repo_tags=["nginx:1.21"],
|
|
repo_digests=["nginx@sha256:" + SHA],
|
|
)
|
|
result = filter_images_by_tag("nginx", "1.21", [image])
|
|
assert len(result) == 1
|
|
assert result[0]["Id"] == "sha256:abc123"
|
|
|
|
def test_tag_only_no_match(self) -> None:
|
|
"""Tag-only reference should not match if tag is different."""
|
|
image = _create_mock_image(
|
|
repo_tags=["nginx:1.20"],
|
|
repo_digests=["nginx@sha256:" + SHA],
|
|
)
|
|
result = filter_images_by_tag("nginx", "1.21", [image])
|
|
assert len(result) == 0
|
|
|
|
def test_tag_only_with_registry(self) -> None:
|
|
"""Tag-only with registry prefix should match correctly."""
|
|
image = _create_mock_image(
|
|
repo_tags=["ghcr.io/user/repo:v1.0"],
|
|
repo_digests=["ghcr.io/user/repo@sha256:" + SHA],
|
|
)
|
|
result = filter_images_by_tag("ghcr.io/user/repo", "v1.0", [image])
|
|
assert len(result) == 1
|
|
|
|
# ========== Digest-only tests ==========
|
|
|
|
def test_digest_only_matches_repo_digests(self) -> None:
|
|
"""Digest-only reference should match in RepoDigests."""
|
|
image = _create_mock_image(
|
|
repo_tags=["nginx:1.21"],
|
|
repo_digests=["nginx@sha256:" + SHA],
|
|
)
|
|
result = filter_images_by_tag("nginx", f"sha256:{SHA}", [image])
|
|
assert len(result) == 1
|
|
|
|
def test_digest_only_no_match(self) -> None:
|
|
"""Digest-only reference should not match if digest is different."""
|
|
other_sha = "a" * 64
|
|
image = _create_mock_image(
|
|
repo_tags=["nginx:1.21"],
|
|
repo_digests=["nginx@sha256:" + SHA],
|
|
)
|
|
result = filter_images_by_tag("nginx", f"sha256:{other_sha}", [image])
|
|
assert len(result) == 0
|
|
|
|
# ========== Combined tag@digest tests ==========
|
|
# When a digest is specified, we match by digest ONLY because Docker doesn't
|
|
# preserve tags in RepoTags when pulling by digest. The tag is informational.
|
|
|
|
def test_combined_tag_digest_matches_by_digest(self) -> None:
|
|
"""Combined tag@digest should match when digest matches (tag is informational)."""
|
|
image = _create_mock_image(
|
|
repo_tags=["nginx:1.21"],
|
|
repo_digests=["nginx@sha256:" + SHA],
|
|
)
|
|
result = filter_images_by_tag("nginx", f"1.21@sha256:{SHA}", [image])
|
|
assert len(result) == 1
|
|
assert result[0]["Id"] == "sha256:abc123"
|
|
|
|
def test_combined_tag_digest_with_registry(self) -> None:
|
|
"""Combined tag@digest with registry should match correctly."""
|
|
image = _create_mock_image(
|
|
repo_tags=["ghcr.io/gethomepage/homepage:v1.7"],
|
|
repo_digests=["ghcr.io/gethomepage/homepage@sha256:" + SHA],
|
|
)
|
|
result = filter_images_by_tag(
|
|
"ghcr.io/gethomepage/homepage", f"v1.7@sha256:{SHA}", [image]
|
|
)
|
|
assert len(result) == 1
|
|
|
|
def test_combined_tag_digest_matches_even_if_tag_differs(self) -> None:
|
|
"""Combined tag@digest SHOULD match even if tag differs - digest is authoritative.
|
|
|
|
Docker doesn't store tags when pulling by digest, so requiring tag match
|
|
would cause lookups to always fail. The digest is the authoritative identifier.
|
|
"""
|
|
image = _create_mock_image(
|
|
repo_tags=["nginx:1.20"], # Different tag - doesn't matter
|
|
repo_digests=["nginx@sha256:" + SHA],
|
|
)
|
|
result = filter_images_by_tag("nginx", f"1.21@sha256:{SHA}", [image])
|
|
assert len(result) == 1 # Should match by digest
|
|
|
|
def test_combined_tag_digest_fails_if_digest_mismatch(self) -> None:
|
|
"""Combined tag@digest should NOT match if digest doesn't match."""
|
|
other_sha = "a" * 64
|
|
image = _create_mock_image(
|
|
repo_tags=["nginx:1.21"],
|
|
repo_digests=["nginx@sha256:" + other_sha], # Different digest
|
|
)
|
|
result = filter_images_by_tag("nginx", f"1.21@sha256:{SHA}", [image])
|
|
assert len(result) == 0
|
|
|
|
def test_combined_tag_digest_fails_if_digest_mismatch_tag_irrelevant(self) -> None:
|
|
"""Combined tag@digest should NOT match if digest doesn't match, regardless of tag."""
|
|
other_sha = "a" * 64
|
|
image = _create_mock_image(
|
|
repo_tags=["nginx:1.20"], # Different tag - irrelevant
|
|
repo_digests=["nginx@sha256:" + other_sha], # Different digest - this matters
|
|
)
|
|
result = filter_images_by_tag("nginx", f"1.21@sha256:{SHA}", [image])
|
|
assert len(result) == 0
|
|
|
|
# ========== Edge cases ==========
|
|
|
|
def test_empty_repo_tags_matches_by_digest(self) -> None:
|
|
"""Handle images where RepoTags is empty or None - should still match by digest.
|
|
|
|
This is the critical real-world case: Docker doesn't store tags when pulling
|
|
by digest, so RepoTags is often empty. We must match by digest only.
|
|
"""
|
|
image = _create_mock_image(
|
|
repo_tags=None, # Empty - typical when pulled by digest
|
|
repo_digests=["nginx@sha256:" + SHA],
|
|
)
|
|
# Combined format SHOULD match because digest matches
|
|
result = filter_images_by_tag("nginx", f"1.21@sha256:{SHA}", [image])
|
|
assert len(result) == 1
|
|
|
|
def test_empty_repo_tags_list_matches_by_digest(self) -> None:
|
|
"""Empty list RepoTags should still match by digest."""
|
|
image = _create_mock_image(
|
|
repo_tags=[], # Empty list - typical when pulled by digest
|
|
repo_digests=["nginx@sha256:" + SHA],
|
|
)
|
|
result = filter_images_by_tag("nginx", f"1.21@sha256:{SHA}", [image])
|
|
assert len(result) == 1
|
|
|
|
def test_empty_repo_digests(self) -> None:
|
|
"""Handle images where RepoDigests is empty or None."""
|
|
image = _create_mock_image(
|
|
repo_tags=["nginx:1.21"],
|
|
repo_digests=None,
|
|
)
|
|
# Combined format should not match if RepoDigests is empty (digest is authoritative)
|
|
result = filter_images_by_tag("nginx", f"1.21@sha256:{SHA}", [image])
|
|
assert len(result) == 0
|
|
|
|
def test_multiple_tags_irrelevant_for_combined(self) -> None:
|
|
"""For combined tag@digest, tags are irrelevant - digest is authoritative."""
|
|
image = _create_mock_image(
|
|
repo_tags=["nginx:1.21", "nginx:latest", "nginx:stable"],
|
|
repo_digests=["nginx@sha256:" + SHA],
|
|
)
|
|
# All of these should match because the digest matches
|
|
result = filter_images_by_tag("nginx", f"1.21@sha256:{SHA}", [image])
|
|
assert len(result) == 1
|
|
result = filter_images_by_tag("nginx", f"latest@sha256:{SHA}", [image])
|
|
assert len(result) == 1
|
|
# Even a non-existent tag should match because digest matches
|
|
result = filter_images_by_tag("nginx", f"nonexistent@sha256:{SHA}", [image])
|
|
assert len(result) == 1
|
|
|
|
def test_multiple_digests(self) -> None:
|
|
"""Image with multiple digests should match on any of them."""
|
|
other_sha = "b" * 64
|
|
image = _create_mock_image(
|
|
repo_tags=["nginx:1.21"],
|
|
repo_digests=["nginx@sha256:" + SHA, "nginx@sha256:" + other_sha],
|
|
)
|
|
# Should match on first digest (tag is irrelevant)
|
|
result = filter_images_by_tag("nginx", f"1.21@sha256:{SHA}", [image])
|
|
assert len(result) == 1
|
|
# Should also match on second digest
|
|
result = filter_images_by_tag("nginx", f"1.21@sha256:{other_sha}", [image])
|
|
assert len(result) == 1
|
|
# Should match even with different tag
|
|
result = filter_images_by_tag("nginx", f"different@sha256:{SHA}", [image])
|
|
assert len(result) == 1
|
|
|
|
def test_port_in_registry_name(self) -> None:
|
|
"""Registry with port number should not be confused with tag."""
|
|
image = _create_mock_image(
|
|
repo_tags=["localhost:5000/myapp:v2.0"],
|
|
repo_digests=["localhost:5000/myapp@sha256:" + SHA],
|
|
)
|
|
result = filter_images_by_tag(
|
|
"localhost:5000/myapp", f"v2.0@sha256:{SHA}", [image]
|
|
)
|
|
assert len(result) == 1
|
|
|
|
def test_no_tag_returns_all_images(self) -> None:
|
|
"""When tag is None, all images should be returned."""
|
|
images = [
|
|
_create_mock_image(["nginx:1.21"], ["nginx@sha256:" + SHA]),
|
|
_create_mock_image(["nginx:1.20"], ["nginx@sha256:" + "a" * 64]),
|
|
]
|
|
result = filter_images_by_tag("nginx", None, images)
|
|
assert len(result) == 2
|
|
|
|
# ========== Additional edge cases ==========
|
|
|
|
def test_multiple_at_symbols_in_digest(self) -> None:
|
|
"""Handle edge case where digest has extra content after sha."""
|
|
# Using split("@", 1) should handle this correctly
|
|
image = _create_mock_image(
|
|
repo_tags=["nginx:1.21"],
|
|
repo_digests=["nginx@sha256:" + SHA],
|
|
)
|
|
# The sha256:... part should be treated as the digest
|
|
result = filter_images_by_tag("nginx", f"1.21@sha256:{SHA}", [image])
|
|
assert len(result) == 1
|
|
|
|
def test_empty_tag_part_in_combined_format(self) -> None:
|
|
"""Handle edge case where tag part is empty like ':@sha256:...'."""
|
|
image = _create_mock_image(
|
|
repo_tags=["nginx:"], # Empty tag
|
|
repo_digests=["nginx@sha256:" + SHA],
|
|
)
|
|
# Should construct lookup_tag as "nginx:" which matches
|
|
result = filter_images_by_tag("nginx", f"@sha256:{SHA}", [image])
|
|
assert len(result) == 1
|