diff --git a/src/pyinfra/operations/docker.py b/src/pyinfra/operations/docker.py index 4f5d59998..33c0824a1 100644 --- a/src/pyinfra/operations/docker.py +++ b/src/pyinfra/operations/docker.py @@ -8,9 +8,15 @@ from pyinfra import host from pyinfra.api import operation -from pyinfra.facts.docker import DockerContainer, DockerNetwork, DockerPlugin, DockerVolume +from pyinfra.facts.docker import ( + DockerContainer, + DockerImage, + DockerNetwork, + DockerPlugin, + DockerVolume, +) -from .util.docker import ContainerSpec, handle_docker +from .util.docker import ContainerSpec, handle_docker, parse_image_reference @operation() @@ -127,13 +133,14 @@ def container( ) -@operation(is_idempotent=False) -def image(image, present=True): +@operation() +def image(image: str, present: bool = True, force: bool = False): """ Manage Docker images + image: Image and tag ex: nginx:alpine + present: whether the Docker image should exist + + force: always pull the image if present is True **Examples:** @@ -153,20 +160,55 @@ def image(image, present=True): present=False, ) """ - + image_info = parse_image_reference(image) if present: - yield handle_docker( - resource="image", - command="pull", - image=image, - ) - + if force: + # always pull the image if force is True + yield handle_docker( + resource="image", + command="pull", + image=image, + ) + return + else: + existent_image = host.get_fact(DockerImage, object_id=image) + if image_info.digest: + # If a digest is specified, we must ensure the exact image is present + if existent_image: + host.noop(f"Image with digest {image_info.digest} already exists!") + else: + yield handle_docker( + resource="image", + command="pull", + image=image, + ) + elif image_info.tag == "latest" or not image_info.tag: + # If the tag is 'latest' or not specified, always pull to ensure freshness + yield handle_docker( + resource="image", + command="pull", + image=image, + ) + else: + # For other tags, check if the image exists + if existent_image: + host.noop(f"Image with tag {image_info.tag} already exists!") + else: + yield handle_docker( + resource="image", + command="pull", + image=image, + ) else: - yield handle_docker( - resource="image", - command="remove", - image=image, - ) + existent_image = host.get_fact(DockerImage, object_id=image) + if existent_image: + yield handle_docker( + resource="image", + command="remove", + image=image, + ) + else: + host.noop("There is no {0} image!".format(image)) @operation() diff --git a/src/pyinfra/operations/util/docker.py b/src/pyinfra/operations/util/docker.py index c362ff03d..761142186 100644 --- a/src/pyinfra/operations/util/docker.py +++ b/src/pyinfra/operations/util/docker.py @@ -1,16 +1,168 @@ -import dataclasses -from typing import Any, Dict, List +from dataclasses import dataclass, field +from typing import Any from pyinfra.api import OperationError -@dataclasses.dataclass +@dataclass +class ImageReference: + """Represents a parsed Docker image reference.""" + + repository: str + namespace: str | None = None + tag: str | None = None + digest: str | None = None + registry_host: str | None = None + registry_port: int | None = None + + @property + def registry(self) -> str | None: + """Get the full registry address (host:port).""" + if not self.registry_host: + return None + if self.registry_port: + return f"{self.registry_host}:{self.registry_port}" + return self.registry_host + + @property + def name(self) -> str: + """Get the full image name without tag or digest.""" + parts = [] + if self.registry: + parts.append(self.registry) + if self.namespace: + parts.append(self.namespace) + parts.append(self.repository) + return "/".join(parts) + + @property + def full_reference(self) -> str: + """Get the complete image reference string.""" + ref = self.name + if self.tag: + ref += f":{self.tag}" + if self.digest: + ref += f"@{self.digest}" + return ref + + +def parse_registry(registry: str) -> tuple[str, int | None]: + """ + Parse a registry string into host and port components. + + Args: + registry: String like "registry.io:5000" or "registry.io" + + Returns: + tuple: (host, port) where port is None if not specified + + Raises: + ValueError: If port is specified but not a valid integer + """ + if ":" in registry: + host, port_str = registry.rsplit(":", 1) + if port_str: # Only try to parse if port_str is not empty + try: + port = int(port_str) + if port < 0 or port > 65535: + raise ValueError( + f"Invalid port number: {port}. Port must be between 0 and 65535" + ) + return host, port + except ValueError as e: + if "invalid literal" in str(e): + raise ValueError( + f"Invalid port in registry '{registry}': '{port_str}' is not a valid port number" + ) + raise # Re-raise port range error + else: + # Empty port (e.g., "registry.io:") + raise ValueError(f"Invalid registry format '{registry}': port cannot be empty") + else: + return registry, None + + +def parse_image_reference(image: str) -> ImageReference: + """ + Parse a Docker image reference into components. + + Format: [HOST[:PORT]/]NAMESPACE/REPOSITORY[:TAG][@DIGEST] + + Raises: + ValueError: If the image reference is empty or invalid + """ + if not image or not image.strip(): + raise ValueError("Image reference cannot be empty") + + original = image.strip() + registry_host = None + registry_port = None + namespace = None + repository = None + tag = None + digest = None + + # Extract digest first (format: name@digest) + if "@" in original: + original, digest = original.rsplit("@", 1) + + # Extract tag (format: name:tag) + if ":" in original: + parts = original.split(":") + if len(parts) >= 2: + potential_tag = parts[-1] + # Tag cannot contain '/' - if it does, the colon is part of the registry, separating host and port + if "/" not in potential_tag: + original = ":".join(parts[:-1]) + tag = potential_tag + + # Split by '/' to separate registry/namespace/repository + parts = original.split("/") + + if len(parts) == 1: + # Just repository name (e.g., "nginx") + repository = parts[0] + elif len(parts) == 2: + # Could be namespace/repository or registry/repository + if "." in parts[0] or ":" in parts[0]: + # Likely a registry (registry.io:5000/repo or registry.io/repo) + registry_host, registry_port = parse_registry(parts[0]) + repository = parts[1] + else: + # Likely namespace/repository + namespace = parts[0] + repository = parts[1] + elif len(parts) >= 3: + # registry/namespace/repository or registry/nested/namespace/repository + registry_host, registry_port = parse_registry(parts[0]) + namespace = "/".join(parts[1:-1]) + repository = parts[-1] + + # Validate that we found a repository + if not repository: + raise ValueError(f"Invalid image reference: no repository found in '{image}'") + + # Default tag to 'latest' if neither tag nor digest specified. This is Docker's default behavior. + if tag is None and digest is None: + tag = "latest" + + return ImageReference( + repository=repository, + namespace=namespace, + tag=tag, + digest=digest, + registry_host=registry_host, + registry_port=registry_port, + ) + + +@dataclass class ContainerSpec: image: str = "" - ports: List[str] = dataclasses.field(default_factory=list) - networks: List[str] = dataclasses.field(default_factory=list) - volumes: List[str] = dataclasses.field(default_factory=list) - env_vars: List[str] = dataclasses.field(default_factory=list) + ports: list[str] = field(default_factory=list) + networks: list[str] = field(default_factory=list) + volumes: list[str] = field(default_factory=list) + env_vars: list[str] = field(default_factory=list) pull_always: bool = False def container_create_args(self): @@ -34,7 +186,7 @@ def container_create_args(self): return args - def diff_from_inspect(self, inspect_dict: Dict[str, Any]) -> List[str]: + def diff_from_inspect(self, inspect_dict: dict[str, Any]) -> list[str]: # TODO(@minor-fixes): Diff output of "docker inspect" against this spec # to determine if the container needs to be recreated. Currently, this # function will never recreate when attributes change, which is diff --git a/tests/operations/docker.image/force_pull_image.json b/tests/operations/docker.image/force_pull_image.json new file mode 100644 index 000000000..d6da86c35 --- /dev/null +++ b/tests/operations/docker.image/force_pull_image.json @@ -0,0 +1,27 @@ +{ + "kwargs": { + "image": "nginx:alpine", + "present": true, + "force": true + }, + "facts": { + "docker.DockerImage": { + "object_id=nginx:alpine": [ + { + "Id": "sha256:e784f4560448b14a66f55c26e1b4dad2c2877cc73d001b7cd0b18e24a700a070", + "RepoTags": [ + "nginx:alpine" + ], + "RepoDigests": [ + "nginx@sha256:abcd1234" + ], + "Created": "2024-05-26T22:01:24.10525839Z", + "Size": 41390752 + } + ] + } + }, + "commands": [ + "docker image pull nginx:alpine" + ] +} diff --git a/tests/operations/docker.image/image_from_github_registry_exists.json b/tests/operations/docker.image/image_from_github_registry_exists.json new file mode 100644 index 000000000..b3ba57fb8 --- /dev/null +++ b/tests/operations/docker.image/image_from_github_registry_exists.json @@ -0,0 +1,26 @@ +{ + "kwargs": { + "image": "ghcr.io/owner/repo:v2.0", + "present": true, + "force": false + }, + "facts": { + "docker.DockerImage": { + "object_id=ghcr.io/owner/repo:v2.0": [ + { + "Id": "sha256:e784f4560448b14a66f55c26e1b4dad2c2877cc73d001b7cd0b18e24a700a070", + "RepoTags": [ + "ghcr.io/owner/repo:v2.0" + ], + "RepoDigests": [ + "ghcr.io/owner/repo@sha256:abcd1234" + ], + "Created": "2024-05-26T22:01:24.10525839Z", + "Size": 41390752 + } + ] + } + }, + "commands": [], + "noop_description": "Image with tag v2.0 already exists!" +} diff --git a/tests/operations/docker.image/image_with_digest_exists.json b/tests/operations/docker.image/image_with_digest_exists.json new file mode 100644 index 000000000..344020930 --- /dev/null +++ b/tests/operations/docker.image/image_with_digest_exists.json @@ -0,0 +1,24 @@ +{ + "kwargs": { + "image": "nginx@sha256:abcd1234567890", + "present": true, + "force": false + }, + "facts": { + "docker.DockerImage": { + "object_id=nginx@sha256:abcd1234567890": [ + { + "Id": "sha256:e784f4560448b14a66f55c26e1b4dad2c2877cc73d001b7cd0b18e24a700a070", + "RepoTags": [], + "RepoDigests": [ + "nginx@sha256:abcd1234567890" + ], + "Created": "2024-05-26T22:01:24.10525839Z", + "Size": 41390752 + } + ] + } + }, + "commands": [], + "noop_description": "Image with digest sha256:abcd1234567890 already exists!" +} diff --git a/tests/operations/docker.image/image_with_digest_not_exists.json b/tests/operations/docker.image/image_with_digest_not_exists.json new file mode 100644 index 000000000..7497b718d --- /dev/null +++ b/tests/operations/docker.image/image_with_digest_not_exists.json @@ -0,0 +1,15 @@ +{ + "kwargs": { + "image": "nginx@sha256:abcd1234567890", + "present": true, + "force": false + }, + "facts": { + "docker.DockerImage": { + "object_id=nginx@sha256:abcd1234567890": [] + } + }, + "commands": [ + "docker image pull nginx@sha256:abcd1234567890" + ] +} diff --git a/tests/operations/docker.image/image_with_latest_tag_always_pull.json b/tests/operations/docker.image/image_with_latest_tag_always_pull.json new file mode 100644 index 000000000..91b9f2872 --- /dev/null +++ b/tests/operations/docker.image/image_with_latest_tag_always_pull.json @@ -0,0 +1,27 @@ +{ + "kwargs": { + "image": "nginx:latest", + "present": true, + "force": false + }, + "facts": { + "docker.DockerImage": { + "object_id=nginx:latest": [ + { + "Id": "sha256:e784f4560448b14a66f55c26e1b4dad2c2877cc73d001b7cd0b18e24a700a070", + "RepoTags": [ + "nginx:latest" + ], + "RepoDigests": [ + "nginx@sha256:abcd1234" + ], + "Created": "2024-05-26T22:01:24.10525839Z", + "Size": 41390752 + } + ] + } + }, + "commands": [ + "docker image pull nginx:latest" + ] +} diff --git a/tests/operations/docker.image/image_with_specific_tag_exists.json b/tests/operations/docker.image/image_with_specific_tag_exists.json new file mode 100644 index 000000000..d80324c1f --- /dev/null +++ b/tests/operations/docker.image/image_with_specific_tag_exists.json @@ -0,0 +1,26 @@ +{ + "kwargs": { + "image": "nginx:1.21", + "present": true, + "force": false + }, + "facts": { + "docker.DockerImage": { + "object_id=nginx:1.21": [ + { + "Id": "sha256:e784f4560448b14a66f55c26e1b4dad2c2877cc73d001b7cd0b18e24a700a070", + "RepoTags": [ + "nginx:1.21" + ], + "RepoDigests": [ + "nginx@sha256:abcd1234" + ], + "Created": "2024-05-26T22:01:24.10525839Z", + "Size": 41390752 + } + ] + } + }, + "commands": [], + "noop_description": "Image with tag 1.21 already exists!" +} diff --git a/tests/operations/docker.image/image_with_specific_tag_not_exists.json b/tests/operations/docker.image/image_with_specific_tag_not_exists.json new file mode 100644 index 000000000..cf9fd0653 --- /dev/null +++ b/tests/operations/docker.image/image_with_specific_tag_not_exists.json @@ -0,0 +1,15 @@ +{ + "kwargs": { + "image": "nginx:1.21", + "present": true, + "force": false + }, + "facts": { + "docker.DockerImage": { + "object_id=nginx:1.21": [] + } + }, + "commands": [ + "docker image pull nginx:1.21" + ] +} diff --git a/tests/operations/docker.image/image_with_tag_and_digest.json b/tests/operations/docker.image/image_with_tag_and_digest.json new file mode 100644 index 000000000..4dfcf6181 --- /dev/null +++ b/tests/operations/docker.image/image_with_tag_and_digest.json @@ -0,0 +1,16 @@ +{ + "kwargs": { + "image": "nginx:1.21@sha256:abcd1234567890", + "present": true, + "force": false + }, + "facts": { + "docker.DockerImage": { + "object_id=nginx:1.21@sha256:abcd1234567890": [] + } + }, + "commands": [ + "docker image pull nginx:1.21@sha256:abcd1234567890" + ] +} + diff --git a/tests/operations/docker.image/image_with_tag_and_digest_different_digest.json b/tests/operations/docker.image/image_with_tag_and_digest_different_digest.json new file mode 100644 index 000000000..5cf4ce543 --- /dev/null +++ b/tests/operations/docker.image/image_with_tag_and_digest_different_digest.json @@ -0,0 +1,15 @@ +{ + "kwargs": { + "image": "nginx:1.21@sha256:newdigest123", + "present": true, + "force": false + }, + "facts": { + "docker.DockerImage": { + "object_id=nginx:1.21@sha256:newdigest123": [] + } + }, + "commands": [ + "docker image pull nginx:1.21@sha256:newdigest123" + ] +} diff --git a/tests/operations/docker.image/image_with_tag_and_digest_different_tag.json b/tests/operations/docker.image/image_with_tag_and_digest_different_tag.json new file mode 100644 index 000000000..c43115eca --- /dev/null +++ b/tests/operations/docker.image/image_with_tag_and_digest_different_tag.json @@ -0,0 +1,26 @@ +{ + "kwargs": { + "image": "nginx:1.21@sha256:correctdigest789", + "present": true, + "force": false + }, + "facts": { + "docker.DockerImage": { + "object_id=nginx:1.21@sha256:correctdigest789": [ + { + "Id": "sha256:e784f4560448b14a66f55c26e1b4dad2c2877cc73d001b7cd0b18e24a700a070", + "RepoTags": [ + "nginx:1.22" + ], + "RepoDigests": [ + "nginx@sha256:correctdigest789" + ], + "Created": "2024-05-26T22:01:24.10525839Z", + "Size": 41390752 + } + ] + } + }, + "commands": [], + "noop_description": "Image with digest sha256:correctdigest789 already exists!" +} diff --git a/tests/operations/docker.image/image_without_tag_always_pull.json b/tests/operations/docker.image/image_without_tag_always_pull.json new file mode 100644 index 000000000..47be03c58 --- /dev/null +++ b/tests/operations/docker.image/image_without_tag_always_pull.json @@ -0,0 +1,27 @@ +{ + "kwargs": { + "image": "nginx", + "present": true, + "force": false + }, + "facts": { + "docker.DockerImage": { + "object_id=nginx": [ + { + "Id": "sha256:e784f4560448b14a66f55c26e1b4dad2c2877cc73d001b7cd0b18e24a700a070", + "RepoTags": [ + "nginx:latest" + ], + "RepoDigests": [ + "nginx@sha256:abcd1234" + ], + "Created": "2024-05-26T22:01:24.10525839Z", + "Size": 41390752 + } + ] + } + }, + "commands": [ + "docker image pull nginx" + ] +} diff --git a/tests/operations/docker.image/pull_image.json b/tests/operations/docker.image/pull_image.json deleted file mode 100644 index 4540c2e07..000000000 --- a/tests/operations/docker.image/pull_image.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "kwargs": { - "image": "nginx:alpine", - "present": true - }, - "facts": { - "docker.DockerImages": [] - }, - "commands": [ - "docker image pull nginx:alpine" - ] -} \ No newline at end of file diff --git a/tests/operations/docker.image/pull_image_from_private_registry.json b/tests/operations/docker.image/pull_image_from_private_registry.json new file mode 100644 index 000000000..197d7efe8 --- /dev/null +++ b/tests/operations/docker.image/pull_image_from_private_registry.json @@ -0,0 +1,15 @@ +{ + "kwargs": { + "image": "myregistry.io:5000/myapp:v1.0", + "present": true, + "force": false + }, + "facts": { + "docker.DockerImage": { + "object_id=myregistry.io:5000/myapp:v1.0": [] + } + }, + "commands": [ + "docker image pull myregistry.io:5000/myapp:v1.0" + ] +} diff --git a/tests/operations/docker.image/remove_existing_image.json b/tests/operations/docker.image/remove_existing_image.json new file mode 100644 index 000000000..278fb058d --- /dev/null +++ b/tests/operations/docker.image/remove_existing_image.json @@ -0,0 +1,26 @@ +{ + "kwargs": { + "image": "nginx:alpine", + "present": false + }, + "facts": { + "docker.DockerImage": { + "object_id=nginx:alpine": [ + { + "Id": "sha256:e784f4560448b14a66f55c26e1b4dad2c2877cc73d001b7cd0b18e24a700a070", + "RepoTags": [ + "nginx:alpine" + ], + "RepoDigests": [ + "nginx@sha256:abcd1234" + ], + "Created": "2024-05-26T22:01:24.10525839Z", + "Size": 41390752 + } + ] + } + }, + "commands": [ + "docker image rm nginx:alpine" + ] +} diff --git a/tests/operations/docker.image/remove_image.json b/tests/operations/docker.image/remove_image.json deleted file mode 100644 index dcdf82b7e..000000000 --- a/tests/operations/docker.image/remove_image.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "kwargs": { - "image": "nginx:alpine", - "present": false - }, - "facts": { - "docker.DockerImages": [] - }, - "commands": [ - "docker image rm nginx:alpine" - ] -} \ No newline at end of file diff --git a/tests/operations/docker.image/remove_nonexistent_image.json b/tests/operations/docker.image/remove_nonexistent_image.json new file mode 100644 index 000000000..b51118459 --- /dev/null +++ b/tests/operations/docker.image/remove_nonexistent_image.json @@ -0,0 +1,13 @@ +{ + "kwargs": { + "image": "nginx:alpine", + "present": false + }, + "facts": { + "docker.DockerImage": { + "object_id=nginx:alpine": [] + } + }, + "commands": [], + "noop_description": "There is no nginx:alpine image!" +} diff --git a/tests/test_operations_utils.py b/tests/test_operations_utils.py index f32463deb..9478b2abf 100644 --- a/tests/test_operations_utils.py +++ b/tests/test_operations_utils.py @@ -1,5 +1,8 @@ from unittest import TestCase +import pytest + +from pyinfra.operations.util.docker import parse_image_reference, parse_registry from pyinfra.operations.util.files import unix_path_join @@ -15,3 +18,280 @@ def test_multiple_slash_path(self): def test_end_slash_path(self): assert unix_path_join("/", "home", "pyinfra/") == "/home/pyinfra/" + + +class TestParseRegistry(TestCase): + def test_registry_with_port(self): + """Test parsing registry with valid port number.""" + host, port = parse_registry("registry.io:5000") + assert host == "registry.io" + assert port == 5000 + + def test_registry_without_port(self): + """Test parsing registry without port.""" + host, port = parse_registry("registry.io") + assert host == "registry.io" + assert port is None + + def test_localhost_with_port(self): + """Test parsing localhost with port.""" + host, port = parse_registry("localhost:8080") + assert host == "localhost" + assert port == 8080 + + def test_ip_address_with_port(self): + """Test parsing IP address with port.""" + host, port = parse_registry("192.168.1.100:5000") + assert host == "192.168.1.100" + assert port == 5000 + + def test_invalid_port_raises_error(self): + """Test that non-numeric port raises ValueError.""" + with pytest.raises(ValueError, match="Invalid port.*'abc' is not a valid port number"): + parse_registry("registry.io:abc") + + def test_empty_port_raises_error(self): + """Test that empty port raises ValueError.""" + with pytest.raises(ValueError, match="port cannot be empty"): + parse_registry("registry.io:") + + def test_negative_port_raises_error(self): + """Test that negative port raises ValueError.""" + with pytest.raises(ValueError, match="Invalid port number.*must be between 0 and 65535"): + parse_registry("registry.io:-1") + + def test_port_too_large_raises_error(self): + """Test that port > 65535 raises ValueError.""" + with pytest.raises(ValueError, match="Invalid port number.*must be between 0 and 65535"): + parse_registry("registry.io:65536") + + def test_float_port_raises_error(self): + """Test that float port raises ValueError.""" + with pytest.raises(ValueError, match="Invalid port.*'5000.5' is not a valid port number"): + parse_registry("registry.io:5000.5") + + +class TestParseImageReference(TestCase): + def test_simple_repository(self): + """Test parsing simple repository name.""" + ref = parse_image_reference("nginx") + assert ref.repository == "nginx" + assert ref.tag == "latest" + assert ref.namespace is None + assert ref.registry_host is None + assert ref.registry_port is None + assert ref.digest is None + + def test_repository_with_tag(self): + """Test parsing repository with tag.""" + ref = parse_image_reference("nginx:1.21") + assert ref.repository == "nginx" + assert ref.tag == "1.21" + assert ref.namespace is None + assert ref.registry_host is None + assert ref.registry_port is None + assert ref.digest is None + + def test_repository_with_digest(self): + """Test parsing repository with digest.""" + ref = parse_image_reference("nginx@sha256:abc123") + assert ref.repository == "nginx" + assert ref.digest == "sha256:abc123" + assert ref.namespace is None + assert ref.registry_host is None + assert ref.registry_port is None + assert ref.tag is None + + def test_repository_with_tag_and_digest(self): + """Test parsing repository with both tag and digest.""" + ref = parse_image_reference("nginx:1.21@sha256:abc123") + assert ref.repository == "nginx" + assert ref.tag == "1.21" + assert ref.digest == "sha256:abc123" + assert ref.namespace is None + assert ref.registry_host is None + assert ref.registry_port is None + + def test_namespace_repository(self): + """Test parsing namespace/repository.""" + ref = parse_image_reference("library/nginx") + assert ref.repository == "nginx" + assert ref.namespace == "library" + assert ref.tag == "latest" + assert ref.registry_host is None + assert ref.registry_port is None + assert ref.digest is None + + def test_namespace_repository_with_tag(self): + """Test parsing namespace/repository:tag.""" + ref = parse_image_reference("library/nginx:1.21") + assert ref.repository == "nginx" + assert ref.namespace == "library" + assert ref.tag == "1.21" + assert ref.registry_host is None + assert ref.registry_port is None + assert ref.digest is None + + def test_registry_repository(self): + """Test parsing registry.io/repository.""" + ref = parse_image_reference("registry.io/nginx") + assert ref.repository == "nginx" + assert ref.registry_host == "registry.io" + assert ref.tag == "latest" + assert ref.namespace is None + assert ref.registry_port is None + assert ref.digest is None + + def test_registry_with_port_repository(self): + """Test parsing registry.io:5000/repository.""" + ref = parse_image_reference("registry.io:5000/nginx") + assert ref.repository == "nginx" + assert ref.registry_host == "registry.io" + assert ref.registry_port == 5000 + assert ref.tag == "latest" + assert ref.namespace is None + assert ref.digest is None + + def test_registry_namespace_repository(self): + """Test parsing registry.io/namespace/repository.""" + ref = parse_image_reference("registry.io/library/nginx") + assert ref.repository == "nginx" + assert ref.namespace == "library" + assert ref.registry_host == "registry.io" + assert ref.tag == "latest" + assert ref.registry_port is None + assert ref.digest is None + + def test_registry_with_port_namespace_repository(self): + """Test parsing registry.io:5000/namespace/repository:tag.""" + ref = parse_image_reference("registry.io:5000/library/nginx:1.21") + assert ref.repository == "nginx" + assert ref.namespace == "library" + assert ref.registry_host == "registry.io" + assert ref.registry_port == 5000 + assert ref.tag == "1.21" + assert ref.digest is None + + def test_nested_namespace(self): + """Test parsing with nested namespace.""" + ref = parse_image_reference("registry.io/org/team/app:v1.0") + assert ref.repository == "app" + assert ref.namespace == "org/team" + assert ref.registry_host == "registry.io" + assert ref.tag == "v1.0" + assert ref.registry_port is None + assert ref.digest is None + + def test_localhost_registry(self): + """Test parsing localhost registry.""" + ref = parse_image_reference("localhost:5000/myapp") + assert ref.repository == "myapp" + assert ref.registry_host == "localhost" + assert ref.registry_port == 5000 + assert ref.tag == "latest" + assert ref.namespace is None + assert ref.digest is None + + def test_ip_address_registry(self): + """Test parsing IP address registry.""" + ref = parse_image_reference("192.168.1.100:5000/myapp:latest") + assert ref.repository == "myapp" + assert ref.registry_host == "192.168.1.100" + assert ref.registry_port == 5000 + assert ref.tag == "latest" + assert ref.namespace is None + assert ref.digest is None + + def test_complex_tag_with_colon_in_registry(self): + """Test that colon in registry doesn't interfere with tag parsing.""" + ref = parse_image_reference("registry.io:5000/nginx:alpine-3.14") + assert ref.repository == "nginx" + assert ref.registry_host == "registry.io" + assert ref.registry_port == 5000 + assert ref.tag == "alpine-3.14" + assert ref.namespace is None + assert ref.digest is None + + def test_property_name(self): + """Test the name property.""" + ref = parse_image_reference("registry.io:5000/library/nginx:1.21") + assert ref.repository == "nginx" + assert ref.namespace == "library" + assert ref.registry_host == "registry.io" + assert ref.registry_port == 5000 + assert ref.tag == "1.21" + assert ref.name == "registry.io:5000/library/nginx" + assert ref.digest is None + + def test_property_registry(self): + """Test the registry property.""" + ref = parse_image_reference("registry.io:5000/nginx") + assert ref.repository == "nginx" + assert ref.registry_host == "registry.io" + assert ref.registry_port == 5000 + assert ref.tag == "latest" + assert ref.registry == "registry.io:5000" + assert ref.namespace is None + assert ref.digest is None + + def test_property_registry_without_port(self): + """Test the registry property without port.""" + ref = parse_image_reference("registry.io/nginx") + assert ref.repository == "nginx" + assert ref.registry_host == "registry.io" + assert ref.tag == "latest" + assert ref.registry == "registry.io" + assert ref.namespace is None + assert ref.registry_port is None + assert ref.digest is None + + def test_property_full_reference(self): + """Test the full_reference property.""" + ref = parse_image_reference("registry.io:5000/library/nginx:1.21@sha256:abc123") + assert ref.repository == "nginx" + assert ref.namespace == "library" + assert ref.registry_host == "registry.io" + assert ref.registry_port == 5000 + assert ref.tag == "1.21" + assert ref.digest == "sha256:abc123" + assert ref.full_reference == "registry.io:5000/library/nginx:1.21@sha256:abc123" + + def test_empty_image_raises_error(self): + """Test that empty image raises ValueError.""" + with pytest.raises(ValueError, match="Image reference cannot be empty"): + parse_image_reference("") + + def test_whitespace_only_image_raises_error(self): + """Test that whitespace-only image raises ValueError.""" + with pytest.raises(ValueError, match="Image reference cannot be empty"): + parse_image_reference(" ") + + def test_none_image_raises_error(self): + """Test that None image raises ValueError.""" + with pytest.raises(ValueError, match="Image reference cannot be empty"): + parse_image_reference(None) + + def test_invalid_registry_port_raises_error(self): + """Test that invalid registry port raises ValueError.""" + with pytest.raises(ValueError, match="Invalid port.*'abc' is not a valid port number"): + parse_image_reference("registry.io:abc/nginx") + + def test_whitespace_trimmed(self): + """Test that whitespace is trimmed from input.""" + ref = parse_image_reference(" nginx:latest ") + assert ref.repository == "nginx" + assert ref.tag == "latest" + assert ref.namespace is None + assert ref.registry_host is None + assert ref.registry_port is None + assert ref.digest is None + + def test_github_container_registry_image(self): + """Test parsing GitHub Container Registry image.""" + ref = parse_image_reference("ghcr.io/owner/myapp:v1.2.3") + assert ref.repository == "myapp" + assert ref.namespace == "owner" + assert ref.registry_host == "ghcr.io" + assert ref.tag == "v1.2.3" + assert ref.registry_port is None + assert ref.digest is None