diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index 0ae032c36..2908fd515 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -1117,7 +1117,7 @@ def _run_local_build( "--password-stdin", registry_host, input=token_input, - verbose=verbose, + verbose=False, ) ) step += 1 @@ -1440,12 +1440,19 @@ class CustomerRegistrySource: raise +def _require_local_docker() -> None: + supported, error = can_build_locally() + if not supported: + raise click.UsageError(error or "Unable to build locally.") + + def _push_reference(push_to: str, tag: str | None) -> ImageReference: - if "@" in push_to: + try: + reference = ImageReference.parse(push_to) + except ValueError: raise click.UsageError( "--push-to takes a repository with an optional tag, not a digest." - ) - reference = ImageReference.parse(push_to) + ) from None if reference.tag is not None and tag is not None: raise click.UsageError( "--push-to already includes a tag; do not combine it with --tag." @@ -1466,9 +1473,10 @@ def _select_source( if push_to is not None: if remote_build_flag is True: raise click.UsageError("--push-to cannot be combined with --remote.") - return CustomerRegistrySource( - _push_reference(push_to, tag), prebuilt_image=image - ) + reference = _push_reference(push_to, tag) + if image is None: + _require_local_docker() + return CustomerRegistrySource(reference, prebuilt_image=image) if image and remote_build_flag is True: raise click.UsageError("--image cannot be combined with --remote builds.") use_remote_build, local_build_error = _resolve_build_mode( diff --git a/libs/cli/langgraph_cli/host_backend.py b/libs/cli/langgraph_cli/host_backend.py index fe85bf955..fb3b7e729 100644 --- a/libs/cli/langgraph_cli/host_backend.py +++ b/libs/cli/langgraph_cli/host_backend.py @@ -62,7 +62,7 @@ def _is_cloud_host(hostname: str) -> bool: def _cloud_control_plane_host_for(langsmith_api_host: str) -> str: - if langsmith_api_host.endswith(CLOUD_API_HOST): + if langsmith_api_host.endswith(f".{CLOUD_API_HOST}"): region = langsmith_api_host[: -len(CLOUD_API_HOST)] return f"{region}{CLOUD_CONTROL_PLANE_HOST}" return CLOUD_CONTROL_PLANE_HOST diff --git a/libs/cli/langgraph_cli/image_reference.py b/libs/cli/langgraph_cli/image_reference.py index 6446f3486..803ea3d4c 100644 --- a/libs/cli/langgraph_cli/image_reference.py +++ b/libs/cli/langgraph_cli/image_reference.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, replace DIGEST_SEPARATOR = "@sha256:" +DIGEST_MARKER = "@" TAG_SEPARATOR = ":" PATH_SEPARATOR = "/" @@ -14,6 +15,8 @@ class ImageReference: @classmethod def parse(cls, reference: str) -> ImageReference: + if DIGEST_MARKER in reference: + raise ValueError(f"{reference!r} carries a digest and cannot be tagged") path_start = reference.rfind(PATH_SEPARATOR) + 1 name, separator, tag = reference[path_start:].partition(TAG_SEPARATOR) if not separator: diff --git a/libs/cli/tests/unit_tests/cli/test_deploy_command.py b/libs/cli/tests/unit_tests/cli/test_deploy_command.py index 3064796a2..12e57bb0f 100644 --- a/libs/cli/tests/unit_tests/cli/test_deploy_command.py +++ b/libs/cli/tests/unit_tests/cli/test_deploy_command.py @@ -514,8 +514,9 @@ def test_push_to_builds_pushes_then_creates_an_external_deployment( def test_push_to_builds_directly_with_the_push_reference( deploy_project: DeployProject, ) -> None: - deploy_project.run("--push-to", PUSH_REPOSITORY) + result = deploy_project.run("--push-to", PUSH_REPOSITORY) + assert result.exit_code == 0, result.output assert deploy_project.docker.builds[0]["tag"] == EXTERNAL_IMAGE assert deploy_project.docker.command("push").args == ( "docker", @@ -525,11 +526,30 @@ def test_push_to_builds_directly_with_the_push_reference( def test_push_to_composes_with_the_tag_flag(deploy_project: DeployProject) -> None: - deploy_project.run("--push-to", PUSH_REPOSITORY, "--tag", "v1") + result = deploy_project.run("--push-to", PUSH_REPOSITORY, "--tag", "v1") + assert result.exit_code == 0, result.output assert deploy_project.docker.command("push").args[-1] == f"{PUSH_REPOSITORY}:v1" +def test_push_to_with_a_failing_push_creates_no_deployment( + deploy_project: DeployProject, +) -> None: + deploy_project.docker.failing_pushes = 3 + + result = deploy_project.run("--push-to", PUSH_REPOSITORY) + + assert result.exit_code != 0 + assert CREATE_DEPLOYMENT not in deploy_project.timeline + + +def test_verbose_never_echoes_the_push_token(deploy_project: DeployProject) -> None: + result = deploy_project.run("--no-remote", "--verbose") + + assert result.exit_code == 0, result.output + assert deploy_project.docker.command("login").kwargs["verbose"] is False + + def test_push_to_retags_a_prebuilt_image_instead_of_building( deploy_project: DeployProject, ) -> None: diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index 1097d2a08..3c19ee9dc 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -692,6 +692,14 @@ class TestSelectSource: assert _select_source(**{**self.OPTIONS, **flags}) == expected + def test_push_to_build_requires_local_docker(self, monkeypatch): + monkeypatch.setattr( + deploy_mod, "can_build_locally", lambda: (False, "Docker is required") + ) + + with pytest.raises(click.UsageError, match="Docker is required"): + _select_source(**{**self.OPTIONS, "push_to": self.REPOSITORY}) + @pytest.mark.parametrize( ("flags", "message"), [ @@ -717,7 +725,9 @@ class TestSelectSource: ), ], ) - def test_conflicting_flags_are_rejected(self, flags, message): + def test_conflicting_flags_are_rejected(self, monkeypatch, flags, message): + monkeypatch.setattr(deploy_mod, "can_build_locally", lambda: (True, None)) + with pytest.raises(click.UsageError, match=message): _select_source(**{**self.OPTIONS, **flags}) diff --git a/libs/cli/tests/unit_tests/test_host_backend.py b/libs/cli/tests/unit_tests/test_host_backend.py index 42d1de6a4..6fb7e9a0d 100644 --- a/libs/cli/tests/unit_tests/test_host_backend.py +++ b/libs/cli/tests/unit_tests/test_host_backend.py @@ -493,6 +493,12 @@ CLOUD = ("https://api.host.langchain.com", "https://smith.langchain.com") pytest.param( None, "https://api.langchain.com", CLOUD, id="cloud_langchain_api_alias" ), + pytest.param( + None, + "https://xapi.smith.langchain.com", + CLOUD, + id="lookalike_cloud_host_is_not_rewritten_into_a_control_plane", + ), pytest.param( None, "https://eu.api.smith.langchain.com", diff --git a/libs/cli/tests/unit_tests/test_image_reference.py b/libs/cli/tests/unit_tests/test_image_reference.py index 1b70db077..901555940 100644 --- a/libs/cli/tests/unit_tests/test_image_reference.py +++ b/libs/cli/tests/unit_tests/test_image_reference.py @@ -64,3 +64,8 @@ def test_matches_digest_only_for_the_same_repository(repo_digest, expected): assert ImageReference("localhost:5000/app", "v1").matches_digest(repo_digest) is ( expected ) + + +def test_parse_rejects_a_digest_reference(): + with pytest.raises(ValueError, match="digest"): + ImageReference.parse("registry.example.com/app@sha256:abc")