diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index f12dc8d68..1080761f4 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -1534,11 +1534,10 @@ class RemoteBuildSource: class CustomerRegistrySource: reference: ImageReference prebuilt_image: str | None - placement: RequestedPlacement + requested_placement: RequestedPlacement def run(self, ctx: DeployContext) -> DeployOutcome: if isinstance(ctx.selector, ById): - self.placement.ensure_not_requested(ctx.selector.deployment_id) existing, step = _fetch_deployment(ctx.client, 1, ctx.selector) return self._update(ctx, existing, step) found, step = _find_deployment( @@ -1555,7 +1554,7 @@ class CustomerRegistrySource: self, ctx: DeployContext, existing: ExistingDeployment, step: int ) -> DeployOutcome: _ensure_customer_registry_source(existing) - self.placement.ensure_not_requested(existing.id) + self.requested_placement.ensure_not_requested(existing.id) image_uri, step = self._publish(ctx, step) _log_deploy_step(step, f"Updating deployment {existing.id}") updated = ctx.client.update_deployment( @@ -1569,17 +1568,22 @@ class CustomerRegistrySource: existing.id, _image_revision_result(updated, "Deployment updated") ) - def _placement(self, ctx: DeployContext) -> Placement: - places_on_a_listener = ctx.endpoints.is_cloud or self.placement.requested - if not places_on_a_listener: + def _resolve_placement(self, ctx: DeployContext) -> Placement: + if not (ctx.endpoints.is_cloud or self.requested_placement.requested): return Unplaced() - placement = self.placement.resolve(_available_listeners(ctx.client)) - if placement.summary: - _get_emitter().info(placement.summary) - return placement + return self.requested_placement.resolve(_available_listeners(ctx.client)) + + def _announce(self, placement: Placement) -> None: + if isinstance(placement, OnListener): + _get_emitter().info( + placement.summary, + listener_id=placement.listener_id, + k8s_namespace=placement.k8s_namespace, + ) def _create(self, ctx: DeployContext, name: str, step: int) -> DeployOutcome: - placement = self._placement(ctx) + placement = self._resolve_placement(ctx) + self._announce(placement) image_uri, step = self._publish(ctx, step) try: created, _ = _create_deployment( @@ -1661,12 +1665,19 @@ def _select_source( tag: str | None, remote_build_flag: bool | None, placement: RequestedPlacement, + selector: DeploymentSelector, ) -> DeploymentSource: if push_to is None and placement.requested: raise click.UsageError( "--listener-id and --k8s-namespace only apply when creating a " "deployment with --push-to." ) + if placement.requested and isinstance(selector, ById): + raise click.UsageError( + "Listener and namespace are fixed when a deployment is created, so " + "they cannot be set for an existing --deployment-id. Drop them, or " + "create a new deployment with --name." + ) if push_to is not None: if remote_build_flag is True: raise click.UsageError("--push-to cannot be combined with --remote.") @@ -1674,7 +1685,9 @@ def _select_source( if image is None: _require_local_docker() return CustomerRegistrySource( - reference=reference, prebuilt_image=image, placement=placement + reference=reference, + prebuilt_image=image, + requested_placement=placement, ) if image and remote_build_flag is True: raise click.UsageError("--image cannot be combined with --remote builds.") @@ -1781,9 +1794,7 @@ def _call_host_backend_with_optional_tenant( prompted_for_tenant = True continue if err.status_code == 403 and "not enabled" in err.message.lower(): - smith_base = ControlPlaneEndpoints.from_control_plane_url( - client.base_url - ).dashboard_url + smith_base = client.endpoints.dashboard_url raise HostBackendError( "LangSmith Deployment is not enabled for this organization. " f"Enable it at {smith_base}/host/deployments" @@ -2144,6 +2155,7 @@ def _deploy_cmd( secrets = _secrets_from_env(_env_without_deployment_name(env_vars)) + selector = deployment_selector(deployment_id, name) source = _select_source( push_to=push_to, image=image, @@ -2151,10 +2163,10 @@ def _deploy_cmd( tag=tag, remote_build_flag=remote_build_flag, placement=RequestedPlacement(listener_id, k8s_namespace), + selector=selector, ) client = _create_host_backend_client(host_url, api_key, env_vars=env_vars) - endpoints = ControlPlaneEndpoints.from_control_plane_url(client.base_url) try: tracked_packages = find_tracked_packages(config, config_json) or None except Exception as exc: @@ -2164,7 +2176,7 @@ def _deploy_cmd( outcome = source.run( DeployContext( client=client, - endpoints=endpoints, + endpoints=client.endpoints, spec=BuildSpec( config=config, config_json=config_json, @@ -2176,7 +2188,7 @@ def _deploy_cmd( build_command=build_command, ), verbose=verbose, - selector=deployment_selector(deployment_id, name), + selector=selector, deployment_type=deployment_type, secrets=secrets, tracked_packages=tracked_packages, @@ -2185,7 +2197,7 @@ def _deploy_cmd( dep_status_url = _emit_deployment_status_url( outcome.build_result.updated, outcome.deployment_id, - endpoints, + client.endpoints, ) if no_wait: diff --git a/libs/cli/langgraph_cli/host_backend.py b/libs/cli/langgraph_cli/host_backend.py index b15325587..05cb5cfe6 100644 --- a/libs/cli/langgraph_cli/host_backend.py +++ b/libs/cli/langgraph_cli/host_backend.py @@ -142,7 +142,8 @@ class HostBackendClient: } if tenant_id: headers["X-Tenant-ID"] = tenant_id - self._base_url = base_url.rstrip("/") + self._endpoints = ControlPlaneEndpoints.from_control_plane_url(base_url) + self._base_url = self._endpoints.control_plane_url self._client = httpx.Client( base_url=self._base_url, headers=headers, @@ -154,6 +155,10 @@ class HostBackendClient: def base_url(self) -> str: return self._base_url + @property + def endpoints(self) -> ControlPlaneEndpoints: + return self._endpoints + def set_tenant(self, tenant_id: str) -> None: self._client.headers["X-Tenant-ID"] = tenant_id 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 318177fbf..ffd423474 100644 --- a/libs/cli/tests/unit_tests/cli/test_deploy_command.py +++ b/libs/cli/tests/unit_tests/cli/test_deploy_command.py @@ -692,6 +692,7 @@ def test_push_to_places_a_new_deployment_on_the_only_listener( "listener_id": "listener-1", "listener_config": {"k8s_namespace": "agents"}, } + assert "Deploying through listener listener-1 in namespace agents" in result.output def test_push_to_places_a_new_deployment_on_the_chosen_listener( @@ -840,18 +841,6 @@ def test_listener_flags_are_refused_on_an_existing_deployment( assert deploy_project.docker.verbs() == [] -def test_automatic_placement_is_announced(deploy_project: DeployProject) -> None: - deploy_project.control_plane.listeners = [LISTENER] - - result = deploy_project.run( - "--push-to", PUSH_REPOSITORY, host_url=CLOUD_CONTROL_PLANE_URL - ) - - assert result.exit_code == 0, result.output - assert "listener-1" in result.output - assert "agents" in result.output - - def test_a_deployment_without_a_listener_announces_nothing( 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 0da5eaef5..e2e522e75 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -13,6 +13,8 @@ import pytest import langgraph_cli.deploy as deploy_mod from langgraph_cli.deploy import ( + ById, + ByName, CustomerRegistrySource, DockerBuildCommand, ExistingDeployment, @@ -616,6 +618,7 @@ class TestSelectSource: "tag": None, "remote_build_flag": None, "placement": RequestedPlacement(), + "selector": ByName("my-app"), } REPOSITORY = "registry.example.com/app" @@ -628,7 +631,7 @@ class TestSelectSource: CustomerRegistrySource( reference=ImageReference(REPOSITORY, "latest"), prebuilt_image=None, - placement=RequestedPlacement(), + requested_placement=RequestedPlacement(), ), id="push_to_selects_the_external_source_with_the_default_tag", ), @@ -638,7 +641,7 @@ class TestSelectSource: CustomerRegistrySource( reference=ImageReference(REPOSITORY, "v2"), prebuilt_image=None, - placement=RequestedPlacement(), + requested_placement=RequestedPlacement(), ), id="push_to_keeps_a_tag_given_in_the_reference", ), @@ -648,7 +651,7 @@ class TestSelectSource: CustomerRegistrySource( reference=ImageReference(REPOSITORY, "v3"), prebuilt_image=None, - placement=RequestedPlacement(), + requested_placement=RequestedPlacement(), ), id="tag_flag_composes_with_push_to", ), @@ -658,7 +661,7 @@ class TestSelectSource: CustomerRegistrySource( reference=ImageReference(REPOSITORY, "latest"), prebuilt_image="app:dev", - placement=RequestedPlacement(), + requested_placement=RequestedPlacement(), ), id="prebuilt_image_is_retagged_for_push_to_without_docker_checks", ), @@ -671,7 +674,7 @@ class TestSelectSource: CustomerRegistrySource( reference=ImageReference(REPOSITORY, "latest"), prebuilt_image=None, - placement=RequestedPlacement("listener-1", "agents"), + requested_placement=RequestedPlacement("listener-1", "agents"), ), id="push_to_carries_the_requested_placement", ), @@ -1137,18 +1140,6 @@ def test_a_server_that_ignores_the_exact_name_filter_never_matches_another_deplo assert find_deployment_by_name(client, "brand-new-agent") is None -def test_finding_a_deployment_by_name_returns_none_when_the_server_has_no_match(): - client = HostBackendClient( - "https://api.example.com", - "key", - transport=httpx.MockTransport( - lambda req: httpx.Response(200, json={"resources": []}) - ), - ) - - assert find_deployment_by_name(client, "agent") is None - - def test_a_full_page_without_a_match_refuses_to_claim_the_name_is_free(): page = [ {"id": f"dep-{index}", "name": f"other-agent-{index}"} for index in range(100) @@ -1189,3 +1180,23 @@ def test_a_partial_page_without_a_match_means_the_name_is_free(): def test_a_listener_without_an_id_is_refused(resource): with pytest.raises(HostBackendError, match="without an id"): Listener.from_resource(resource) + + +def test_a_deployment_id_with_listener_flags_is_refused_without_probing_docker( + monkeypatch, +): + def explode() -> tuple[bool, str | None]: + raise AssertionError("docker must not be probed for an argv-only conflict") + + monkeypatch.setattr(deploy_mod, "can_build_locally", explode) + + with pytest.raises(click.UsageError, match="--deployment-id"): + _select_source( + push_to="registry.example.com/app", + image=None, + image_name=None, + tag=None, + remote_build_flag=None, + placement=RequestedPlacement(listener_id="listener-1"), + selector=ById("dep-1"), + ) diff --git a/libs/cli/tests/unit_tests/test_host_backend.py b/libs/cli/tests/unit_tests/test_host_backend.py index c6216b1d1..bf9f922f7 100644 --- a/libs/cli/tests/unit_tests/test_host_backend.py +++ b/libs/cli/tests/unit_tests/test_host_backend.py @@ -79,18 +79,6 @@ def test_request_transport_error_raises(): c._request("GET", "/test") -def test_list_deployments_sends_query_params(): - def handler(req: httpx.Request) -> httpx.Response: - assert req.url.path == "/v2/deployments" - assert req.url.params["name_contains"] == "my app" - return httpx.Response(200, json={"ok": True}) - - c = HostBackendClient( - "https://api.example.com", "test-key", transport=httpx.MockTransport(handler) - ) - assert c.list_deployments(name_contains="my app") == [] - - def _capturing_client(captured: dict) -> HostBackendClient: def handler(req: httpx.Request) -> httpx.Response: captured["body"] = req.read()