diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index d2107e3ca..5f32ab39e 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -28,6 +28,7 @@ from langgraph_cli.host_backend import ( HostBackendClient, HostBackendError, ) +from langgraph_cli.image_reference import ImageReference from langgraph_cli.progress import Progress from langgraph_cli.util import warn_non_wolfi_distro @@ -889,8 +890,7 @@ def _resolve_pushed_image_digest( Falls back to ``remote_image`` with a warning if no matching digest is found, rather than failing the deploy. """ - # rsplit preserves ``:port`` in the registry host. - repo_no_tag = remote_image.rsplit(":", 1)[0] + reference = ImageReference.parse(remote_image) args: list[str] = ["docker"] if docker_config_dir: args += ["--config", docker_config_dir] @@ -901,7 +901,7 @@ def _resolve_pushed_image_digest( except json_mod.JSONDecodeError: digests = [] for d in digests: - if isinstance(d, str) and d.startswith(f"{repo_no_tag}@sha256:"): + if isinstance(d, str) and reference.matches_digest(d): return d _get_emitter().warn( f"Could not resolve image digest for {remote_image}; " diff --git a/libs/cli/langgraph_cli/image_reference.py b/libs/cli/langgraph_cli/image_reference.py new file mode 100644 index 000000000..6446f3486 --- /dev/null +++ b/libs/cli/langgraph_cli/image_reference.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace + +DIGEST_SEPARATOR = "@sha256:" +TAG_SEPARATOR = ":" +PATH_SEPARATOR = "/" + + +@dataclass(frozen=True, slots=True) +class ImageReference: + repository: str + tag: str | None = None + + @classmethod + def parse(cls, reference: str) -> ImageReference: + path_start = reference.rfind(PATH_SEPARATOR) + 1 + name, separator, tag = reference[path_start:].partition(TAG_SEPARATOR) + if not separator: + return cls(reference) + return cls(reference[:path_start] + name, tag) + + def with_tag(self, tag: str) -> ImageReference: + return replace(self, tag=tag) + + def matches_digest(self, repo_digest: str) -> bool: + return repo_digest.startswith(f"{self.repository}{DIGEST_SEPARATOR}") + + def __str__(self) -> str: + if self.tag is None: + return self.repository + return f"{self.repository}{TAG_SEPARATOR}{self.tag}" diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index 5577265c1..3c1128dc4 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -611,6 +611,16 @@ class TestResolvePushedImageDigest: ) assert out == "us-central1-docker.pkg.dev/proj/repo@sha256:abc123" + def test_registry_port_without_tag_still_resolves_the_digest(self): + runner = self._runner('["localhost:5000/repo@sha256:abc123"]') + out = _resolve_pushed_image_digest( + runner, + remote_image="localhost:5000/repo", + docker_config_dir=None, + verbose=False, + ) + assert out == "localhost:5000/repo@sha256:abc123" + def test_empty_repodigests_falls_back_with_warning(self, mocker): emitter = mocker.MagicMock() mocker.patch("langgraph_cli.deploy._get_emitter", return_value=emitter) diff --git a/libs/cli/tests/unit_tests/test_image_reference.py b/libs/cli/tests/unit_tests/test_image_reference.py new file mode 100644 index 000000000..1b70db077 --- /dev/null +++ b/libs/cli/tests/unit_tests/test_image_reference.py @@ -0,0 +1,66 @@ +import pytest + +from langgraph_cli.image_reference import ImageReference + + +@pytest.mark.parametrize( + ("reference", "repository", "tag"), + [ + pytest.param( + "registry.example.com/team/app:v1", + "registry.example.com/team/app", + "v1", + id="tag_after_last_slash", + ), + pytest.param( + "registry.example.com/team/app", + "registry.example.com/team/app", + None, + id="no_tag", + ), + pytest.param( + "localhost:5000/app", + "localhost:5000/app", + None, + id="registry_port_is_not_a_tag", + ), + pytest.param( + "localhost:5000/app:latest", + "localhost:5000/app", + "latest", + id="registry_port_with_tag", + ), + pytest.param("app:dev", "app", "dev", id="bare_name_with_tag"), + ], +) +def test_parse_splits_repository_and_tag(reference, repository, tag): + assert ImageReference.parse(reference) == ImageReference(repository, tag) + + +def test_with_tag_replaces_the_tag(): + assert ImageReference("r/app", "v1").with_tag("v2") == ImageReference("r/app", "v2") + + +@pytest.mark.parametrize( + ("reference", "expected"), + [ + pytest.param(ImageReference("r/app", "v1"), "r/app:v1", id="tagged"), + pytest.param(ImageReference("r/app"), "r/app", id="untagged"), + ], +) +def test_str_renders_the_docker_reference(reference, expected): + assert str(reference) == expected + + +@pytest.mark.parametrize( + ("repo_digest", "expected"), + [ + pytest.param("localhost:5000/app@sha256:abc", True, id="same_repository"), + pytest.param("localhost:5000/app-2@sha256:abc", False, id="other_repository"), + pytest.param("mirror.example.com/app@sha256:abc", False, id="other_registry"), + ], +) +def test_matches_digest_only_for_the_same_repository(repo_digest, expected): + assert ImageReference("localhost:5000/app", "v1").matches_digest(repo_digest) is ( + expected + )