From 4ea2701a29c0b86ae9b710fd93e5da4b1f664c91 Mon Sep 17 00:00:00 2001 From: Hugo Durand Date: Tue, 22 Sep 2026 16:29:33 -0400 Subject: [PATCH] fix(cli): refuse a malformed listener instead of dropping it --- libs/cli/langgraph_cli/deploy.py | 9 ++-- .../unit_tests/cli/test_deploy_command.py | 49 ++++++++++++------- .../tests/unit_tests/test_deploy_helpers.py | 14 +++++- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index 525f9d5de..f12dc8d68 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -157,10 +157,12 @@ class Listener: namespaces: tuple[str, ...] @classmethod - def from_resource(cls, resource: Mapping[str, object]) -> "Listener | None": + def from_resource(cls, resource: Mapping[str, object]) -> "Listener": identifier = str(resource.get("id") or "") if not identifier: - return None + raise HostBackendError( + "The control plane returned a listener without an id." + ) compute_config = resource.get("compute_config") namespaces = ( compute_config.get("k8s_namespaces") @@ -1467,8 +1469,7 @@ def _available_listeners(client: HostBackendClient) -> tuple[Listener, ...]: resources = _call_host_backend_with_optional_tenant( client, lambda c: c.list_listeners() ) - listeners = (Listener.from_resource(resource) for resource in resources) - return tuple(listener for listener in listeners if listener is not None) + return tuple(Listener.from_resource(resource) for resource in resources) def _ensure_customer_registry_source(existing: ExistingDeployment) -> None: 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 9e08c7957..318177fbf 100644 --- a/libs/cli/tests/unit_tests/cli/test_deploy_command.py +++ b/libs/cli/tests/unit_tests/cli/test_deploy_command.py @@ -918,24 +918,6 @@ def test_a_truncated_listener_page_says_so(deploy_project: DeployProject) -> Non assert "first 100" in result.output -def test_a_listener_without_an_id_is_ignored(deploy_project: DeployProject) -> None: - deploy_project.control_plane.listeners = [ - {"compute_id": "broken", "compute_config": {"k8s_namespaces": ["agents"]}}, - LISTENER, - ] - - result = deploy_project.run( - "--push-to", PUSH_REPOSITORY, host_url=CLOUD_CONTROL_PLANE_URL - ) - - assert result.exit_code == 0, result.output - assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT]["source_config"] == { - "resource_spec": {}, - "listener_id": "listener-1", - "listener_config": {"k8s_namespace": "agents"}, - } - - def test_a_managed_build_in_a_listener_workspace_points_at_push_to( deploy_project: DeployProject, ) -> None: @@ -949,3 +931,34 @@ def test_a_managed_build_in_a_listener_workspace_points_at_push_to( assert result.exit_code != 0 assert "--push-to" in result.output assert deploy_project.docker.verbs() == [] + + +def test_a_managed_control_plane_without_listeners_creates_as_before( + deploy_project: DeployProject, +) -> None: + result = deploy_project.run( + "--push-to", PUSH_REPOSITORY, host_url=CLOUD_CONTROL_PLANE_URL + ) + + assert result.exit_code == 0, result.output + assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT]["source_config"] == { + "resource_spec": {} + } + assert deploy_project.timeline.count(LIST_LISTENERS) == 1 + + +def test_a_listener_without_an_id_is_reported_rather_than_ignored( + deploy_project: DeployProject, +) -> None: + deploy_project.control_plane.listeners = [ + {"compute_id": "broken", "compute_config": {"k8s_namespaces": ["agents"]}}, + LISTENER, + ] + + result = deploy_project.run( + "--push-to", PUSH_REPOSITORY, host_url=CLOUD_CONTROL_PLANE_URL + ) + + assert result.exit_code != 0 + assert "without an id" in result.output + assert deploy_project.docker.verbs() == [] diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index 3ea3931a9..0da5eaef5 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -960,8 +960,6 @@ class TestListener: Listener("listener-1", "", ()), id="only_an_id", ), - pytest.param({"compute_id": "c"}, None, id="no_id_is_not_a_listener"), - pytest.param({"id": ""}, None, id="empty_id_is_not_a_listener"), ], ) def test_from_resource_reads_the_control_plane_shape(self, resource, expected): @@ -1179,3 +1177,15 @@ def test_a_partial_page_without_a_match_means_the_name_is_free(): ) assert find_deployment_by_name(client, "brand-new-agent") is None + + +@pytest.mark.parametrize( + "resource", + [ + pytest.param({"compute_id": "c"}, id="no_id"), + pytest.param({"id": ""}, id="empty_id"), + ], +) +def test_a_listener_without_an_id_is_refused(resource): + with pytest.raises(HostBackendError, match="without an id"): + Listener.from_resource(resource)